1 Commits
Author SHA1 Message Date
BartelLuis 7b979e6243 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
2026-09-14 21:52:39 +02:00
22 changed files with 1603 additions and 81 deletions
+4 -1
View File
@@ -62,7 +62,10 @@ jobs:
node-version: "24"
package-manager-cache: false
- name: Check JavaScript syntax
run: node --check provisioner/static/app.js
run: |
for script in provisioner/static/*.js; do
node --check "$script"
done
container-policy:
runs-on: ${{ vars.AIS_RUNNER_LABEL || 'ubuntu-latest' }}
+20 -2
View File
@@ -116,14 +116,20 @@ Die Installation auf dem Zielserver folgt der [Deployment-Anleitung](docs/deploy
## Erster Installationslauf
Die [Anleitung zur Weboberfläche](docs/web-interface.md) führt durch die
grafischen Formulare. Profile, Host-Einstellungen, Modulparameter und
Schrittfolgen benötigen keine JSON-Eingabe. Für eigene Skripte stehen
Vorlagen und Dateiupload zur Verfügung.
1. Als Administrator weitere Benutzer anlegen. Rollen sind `reader`, `operator`,
`author`, `admin` und `developer`.
2. Standortbezogenen Installer-Gruppentoken erzeugen und den einmal angezeigten
Token sicher speichern. Er ist Bestandteil des Installationsmediums.
3. ISO-Build, SHA-256, Assistant-Version und Zertifikatsfingerprint erfassen.
Das Medium erst nach bestandenem Labortest freigeben.
4. Root-Zugang als Geheimnis speichern, Installationsprofil und
Postinstallationsprofil anlegen. Module enthalten `check`, `apply` und
4. Root-Zugang als Geheimnis speichern; ein eingegebenes Root-Passwort wird
automatisch in den Installer-Hash umgewandelt. Installationsprofil und
Postinstallationsprofil über die Formulare anlegen. Module enthalten `check`, `apply` und
`verify`; Veröffentlichungen benötigen einen Testnachweis. Standardmäßig
muss eine andere Person als der Autor veröffentlichen.
5. Host mit UUID, Seriennummer und MAC-Adressen, FQDN, Standort, Profilversionen
@@ -149,6 +155,18 @@ Unter PowerShell entsprechend `.\.venv\Scripts\python.exe -m pytest` und
`.\.venv\Scripts\proxmox-ais.exe backup .\backups\first-snapshot` verwenden.
Der [Testbericht](docs/test-report.md) beschreibt die ausgeführten Prüfungen.
Die optionalen Browserprüfungen testen die grafischen Formulare mit Chromium
und einer eigenen lokalen Testdatenbank:
```bash
uv pip install --python .venv/bin/python -e ".[dev,browser]"
.venv/bin/python -m playwright install chromium
.venv/bin/python tests/browser_forms.py
```
Unter Windows `.venv/Scripts/python.exe` als Python-Pfad verwenden.
Screenshots und Testdaten liegen im ignorierten Verzeichnis `.cache/ui-browser/`.
Die Sicherung verwendet die SQLite-Backup-API und enthält Artefakte, verschlüsselte
Geheimnisse, ausgewählte Betriebseinstellungen und SHA-256-Prüfsummen. Den
Entschlüsselungsschlüssel separat sichern. Restore funktioniert ausschließlich
+3 -2
View File
@@ -48,8 +48,9 @@ stattdessen ohne Seriennummernfilter und Gerätenamen angegeben werden:
}
```
In der Weboberfläche trägt **ZFS (RAID0) für Einzelplatte** diese Einstellung
im Profilformular ein. Die übrigen Profilparameter werden dabei beibehalten.
In der Weboberfläche werden dafür **ZFS**, **RAID0 / Einzelplatte** und die
automatische Plattenwahl im Profilformular ausgewählt. JSON-Eingaben sind dort
nicht erforderlich; die übrigen Profilparameter besitzen eigene Formularfelder.
`selection: "all"` verwendet alle vom Proxmox-Installer erkannten Zielplatten;
es prüft nicht, ob tatsächlich nur eine Platte vorhanden ist. Die native Antwort
nutzt dafür `filter.DEVTYPE = "disk"`. CD-ROM- und ISO9660-Installationsmedien
+10 -1
View File
@@ -3,7 +3,7 @@
"info": {
"title": "Proxmox AIS",
"description": "Kontrollierte Proxmox-Installation und wiederaufnehmbare Nachkonfiguration.",
"version": "0.9.2"
"version": "0.9.3"
},
"paths": {
"/health/live": {
@@ -2371,6 +2371,15 @@
"minLength": 1,
"title": "Name"
},
"kind": {
"type": "string",
"enum": [
"value",
"root_password"
],
"title": "Kind",
"default": "value"
},
"value": {
"type": "string",
"maxLength": 16384,
+60
View File
@@ -0,0 +1,60 @@
# Bedienung über die Weboberfläche
Profile, Server, Schritte und Modulparameter werden über Formularfelder,
Auswahllisten sowie Schaltflächen zum Hinzufügen, Entfernen und Sortieren
bearbeitet. Die Anwendung erzeugt die API-Konfiguration intern.
## Root-Zugang anlegen
Unter **Einstellungen → Geheimnisse** einen Zugang mit einer Bezeichnung
anlegen. Als Verwendung **Root-Passwort für die Proxmox-Installation** auswählen
und ein Passwort mit mindestens zwölf Zeichen eingeben. Das Tool erzeugt
den SHA512-crypt-Hash und speichert ihn verschlüsselt. Das eingegebene Passwort
und der Hash werden nicht erneut angezeigt. Andere Geheimnisse oder vorhandene
Hashes lassen sich über die zweite Verwendung unverändert hinterlegen.
## Installationsprofil
Unter **Installationsprofile → Profil erstellen** den Namen und die geprüften
Zielbuilds eintragen. Weitere Builds werden mit **Eintrag hinzufügen** ergänzt.
Sprache, Zeitzone, E-Mail, Netzwerk und Datenträger stehen in eigenen Bereichen.
Den Root-Zugang nach seinem Namen auswählen; es muss keine Referenz-ID kopiert werden.
FQDN und Management-IP können später vom zugeordneten Server stammen.
Für Server mit immer genau einer Zielplatte **ZFS**, **RAID0 / Einzelplatte**
und die automatische Plattenwahl verwenden. Dabei werden alle vom Installer
erkannten Zielplatten verwendet; die Anwendung zählt die tatsächlich eingebauten
Platten nicht. Für eine ausdrückliche Auswahl stehen Seriennummern und WWNs zur
Verfügung. Erweiterte ZFS- und LVM-Optionen besitzen Zahlen- und Auswahlfelder.
Eine neue Version übernimmt die vorhandenen Werte. Leere optionale Felder
entfernen die jeweilige Vorgabe. **Entwurf speichern** speichert das Profil;
die Veröffentlichung mit Testnachweis bleibt ein eigener Schritt.
## Module und Nachinstallation
Ein Modul aus dem Vorlagenkatalog übernehmen oder im Modulformular eine Vorlage
auswählen. Eigene vorhandene Skripte lassen sich als Datei hochladen; die
Skriptansicht ist schreibgeschützt. Parameter werden als Felder mit Typ,
Beschreibung, Pflichtstatus, Vorgaben und Grenzen angelegt.
Im Postinstallationsprofil einen Schritt hinzufügen und eine veröffentlichte
Modulversion nach Namen auswählen. Die Parameter erscheinen als passende
Eingaben, Listen, Checkboxen und Auswahllisten. Benötigte Geheimnisse werden
ebenfalls nach Namen zugeordnet. Schritte können entfernt oder mit den
Pfeiltasten umgeordnet werden. Die bestehenden Schrittkennungen bleiben dabei
erhalten. Mindestens ein verpflichtender Prüfschritt ist erforderlich.
## Server erfassen
Hostname und Standort eintragen, Hardware-Kennungen über getrennte Typ- und
Wertfelder ergänzen und Profile sowie Medium auswählen. Für mehrere Server
**Mehrere Server erfassen** verwenden: Jede Karte enthält einen Server;
Standort und Profilzuordnungen gelten gemeinsam.
Abweichende Einstellungen eines Hosts stehen in einem aufklappbaren Bereich.
Nicht aktivierte Bereiche übernehmen die Profilwerte. Vor einer Installation
die aufgelöste Vorschau prüfen und die Freigabe erteilen.
Die Erstellung des eigentlichen ISO-Mediums erfolgt weiterhin über den offiziellen
Assistant auf einer Build-Maschine; siehe [ISO-Vorbereitung](iso-preparation.md).
+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>
+3 -1
View File
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "proxmox-ais-server"
version = "0.9.2"
version = "0.9.3"
description = "Controlled Proxmox automated installation and resumable post-installation"
readme = "README.md"
requires-python = ">=3.12"
@@ -15,6 +15,7 @@ dependencies = [
"jinja2==3.1.6",
"python-multipart==0.0.32",
"cryptography==50.0.1",
"passlib==1.7.4",
"tomli-w==1.2.0",
"jsonschema==4.26.0",
"tzdata==2026.4",
@@ -22,6 +23,7 @@ dependencies = [
[project.optional-dependencies]
dev = ["pytest==9.1.1", "httpx==0.28.1"]
browser = ["playwright==1.62.0"]
[project.scripts]
proxmox-ais = "provisioner.cli:main"
+302
View File
@@ -0,0 +1,302 @@
"""Optional real-browser regression checks, outside default pytest collection.
From the repository root after installing the normal development dependencies:
uv pip install --python .venv/Scripts/python.exe -e ".[dev,browser]"
.venv/Scripts/python.exe -m playwright install chromium
.venv/Scripts/python.exe tests/browser_forms.py
On Linux/macOS use .venv/bin/python in place of .venv/Scripts/python.exe.
The suite starts isolated loopback servers; no deployment or real data is used.
Screenshots, fixture databases, and logs are in .cache/ui-browser/ (gitignored).
"""
from copy import deepcopy
import json
from browser_support import local_browser
PUBLICATION = {"test_evidence": "Synthetic browser acceptance fixture", "reason": "Browser fixture publication"}
def graphical_workflows():
results = []
with local_browser() as (page, api, ctx):
page.set_default_timeout(8000)
modal = page.locator("#modal")
def route(name):
page.goto(ctx["base"] + "/#/" + name)
page.wait_for_selector("#main .page-header")
def fill(name, value):
modal.locator(f'[name="{name}"]').fill(str(value))
def select(name, value):
modal.locator(f'[name="{name}"]').select_option(value)
def builds(name):
editor = modal.locator(f'[data-editor="{name}"]')
editor.get_by_role("button", name="Eintrag hinzufügen", exact=True).click()
editor.locator("textarea[data-scalar-value]").last.fill("9.1-1")
def submit(label="Entwurf speichern"):
modal.get_by_role("button", name=label, exact=True).click()
try:
modal.wait_for(state="hidden", timeout=12000)
except Exception:
print("FORM_FAILURE", modal.inner_text(), flush=True)
raise
def no_code():
modal.wait_for(state="visible")
assert modal.locator("textarea.code:not([readonly])").count() == 0
assert "(JSON)" not in modal.inner_text()
# New root password: no hash, command or source needed.
route("settings")
page.get_by_role("tab", name="Geheimnisse", exact=True).click()
page.locator('[data-action="create-secret"]').first.click()
fill("name", "Browser root password")
fill("value", "Browser-root-password-2026!")
assert modal.locator('[name="kind"]').input_value() == "root_password"
submit("Verschlüsselt speichern")
secret = next(s for s in api("/secrets") if s["name"] == "Browser root password")
results.append("Root password entered and saved through graphical UI")
# Module template selection and schema preservation.
route("modules")
page.locator('[data-action="create-module"]').first.click()
select("module_template", "prerequisites")
builds("module-builds")
no_code()
submit()
prerequisites = next(m for m in api("/modules") if m["name"] == "Voraussetzungen")
catalog = {m["id"]: m for m in api("/modules/builtin")}
assert prerequisites["source"] == catalog["prerequisites"]["source"].strip()
assert prerequisites["parameters_schema"] == catalog["prerequisites"]["parameters_schema"], (prerequisites["parameters_schema"],catalog["prerequisites"]["parameters_schema"])
api(f'/modules/{prerequisites["id"]}/publish', PUBLICATION)
template = catalog["final-verification"]
final_module = api("/modules", {key: deepcopy(template[key]) for key in ["name", "source", "parameters_schema", "dependencies", "timeout_seconds", "retry_safe"]} | {"target_builds": ["9.1-1"]})
api(f'/modules/{final_module["id"]}/publish', PUBLICATION)
results.append("Template module created with unchanged source/schema")
# Single-disk installation profile, entirely filled through normal controls.
route("installation")
page.locator('[data-action="create-profile"]').first.click()
fill("name", "Browser graphical installation")
builds("profile-builds")
fill("installation.global.mailto", "admin@example.net")
select("installation.root_secret_id", secret["id"])
fill("installation.network.gateway", "192.0.2.1")
fill("installation.network.dns", "192.0.2.53")
select("installation.interface-key", "INTERFACE")
fill("installation.interface-value", "eno1")
assert modal.locator('[name="installation.disk.mode"]').input_value() == "all"
assert modal.locator('[name="installation.disk.zfs.raid"]').input_value() == "raid0"
no_code()
page.screenshot(path=str(ctx["artifacts"] / "installation-desktop.png"), full_page=True)
page.set_viewport_size({"width":390,"height":844})
page.screenshot(path=str(ctx["artifacts"] / "installation-mobile.png"), full_page=True)
dimensions = modal.evaluate("node=>({width:node.clientWidth,scroll:node.scrollWidth,body:node.querySelector('#modal-body').scrollWidth})")
assert dimensions["scroll"] <= dimensions["width"] + 1, dimensions
page.set_viewport_size({"width":1440,"height":1000})
submit()
install = next(p for p in api("/profiles") if p["name"] == "Browser graphical installation")
assert install["values"]["disk_setup"] == {"filesystem":"zfs", "selection":"all", "zfs":{"raid":"raid0"}}
api(f'/profiles/{install["id"]}/publish', PUBLICATION)
results.append("Single-disk ZFS RAID0 installation saved without filter/code; 390px layout fits")
# Postinstallation step creation, ordering, parameter boolean and integer zero.
route("postinstall")
page.locator('[data-action="create-profile"]').first.click()
fill("name", "Browser graphical postinstall")
builds("profile-builds")
modal.get_by_role("button", name="Schritt hinzufügen", exact=True).click()
first = modal.locator('[data-postinstall-step]').nth(0)
first.locator('select[name$="-module"]').select_option(prerequisites["id"])
modal.get_by_role("button", name="Schritt hinzufügen", exact=True).click()
second = modal.locator('[data-postinstall-step]').nth(1)
second.locator('select[name$="-module"]').select_option(final_module["id"])
second.locator('[data-new-property]').select_option("require_time_sync")
second.get_by_role("button", name="Feld hinzufügen", exact=True).click()
checkbox = second.locator('[data-scalar-value][type="checkbox"]')
checkbox.uncheck()
second.get_by_role("button", name="↑ Nach oben", exact=True).click()
modal.locator('[data-postinstall-step]').nth(0).get_by_role("button", name="↓ Nach unten", exact=True).click()
fill("postinstall-reboot-budget", 0)
no_code()
page.screenshot(path=str(ctx["artifacts"] / "postinstall-desktop.png"), full_page=True)
submit()
postinstall = next(p for p in api("/profiles") if p["name"] == "Browser graphical postinstall")
assert [s["module_id"] for s in postinstall["steps"]] == [prerequisites["id"],final_module["id"]]
assert postinstall["steps"][1]["parameters"] == {"require_time_sync":False}
assert postinstall["reboot_budget"] == 0
api(f'/profiles/{postinstall["id"]}/publish', PUBLICATION)
results.append("Postinstall step add/reorder/boolean false and zero restart budget preserved")
# A complete host preview proves controls generated backend-supported configuration.
group = api("/groups", {"name":"browser-lab", "site":"lab", "valid_hours":1})
iso = api("/iso-records", {"name":"Synthetic browser ISO", "build":"9.1-1", "sha256":"1"*64,"assistant_version":"test-only","fingerprint":"2"*64,"group_id":group["id"],"native_token_support":True,"test_status":"passed","test_evidence":"Synthetic browser fixture only"})
route("hosts")
page.locator('[data-action="create-host"]').first.click()
fill("fqdn", "browser.lab.example.net")
fill("site", "lab")
fill("management_ip", "192.0.2.10/24")
fill("identity_value", "BROWSER-SERVER-01")
modal.get_by_role("button", name="Kennung hinzufügen", exact=True).click()
identity = modal.locator('[data-identity-row]').nth(1)
identity.locator('[name="identity_kind"]').select_option("mac")
identity.locator('[name="identity_value"]').fill("02:00:00:00:00:01")
select("installation_profile_id", install["id"])
select("postinstall_profile_id", postinstall["id"])
select("iso_id", iso["id"])
no_code()
submit("Server anlegen")
host = next(h for h in api("/hosts") if h["fqdn"] == "browser.lab.example.net")
assert len(host["identities"]) == 2 and host["overrides"] == {}
preview = api(f'/hosts/{host["id"]}/preview')
assert preview["resolved"]["disk_setup"] == install["values"]["disk_setup"]
assert preview["resolved"]["global"]["fqdn"] == host["fqdn"]
assert preview["resolved"]["network"]["cidr"] == "192.0.2.10/24"
route("hosts/" + host["id"])
page.locator('[data-action="preview-host"]').click()
no_code()
assert "raid0" in modal.inner_text().lower(), modal.inner_text()
modal.get_by_role("button", name="Dialog schließen").click()
page.locator('[data-action="edit-host"]').click()
submit("Änderungen speichern")
assert api(f'/hosts/{host["id"]}')["version"] == host["version"]
results.append("Host identity rows, assignments, no-change edit and full resolved preview passed")
# Multiple host entry uses repeatable rows, retains optional management IP.
route("hosts")
page.locator('[data-action="import-hosts"]').click()
fill("site", "batch")
rows = modal.locator('[data-import-host]')
rows.nth(0).locator('[name="import_fqdn"]').fill("batch1.lab.example.net")
rows.nth(0).locator('[name="identity_value"]').fill("BROWSER-BATCH-1")
modal.get_by_role("button", name="Weiteren Server hinzufügen", exact=True).click()
rows.nth(1).locator('[name="import_fqdn"]').fill("batch2.lab.example.net")
rows.nth(1).locator('[name="identity_value"]').fill("BROWSER-BATCH-2")
no_code()
submit("Server anlegen")
batch = [h for h in api("/hosts") if h["site"] == "batch"]
assert len(batch) == 2 and all(h["management_ip"] is None for h in batch)
results.append("Multiple host entry saved two rows with optional management IP")
(ctx["artifacts"] / "acceptance-results.json").write_text(json.dumps({"passed":results,"errors":ctx["errors"]},indent=2),encoding="utf-8")
print(json.dumps({"passed":results,"errors":ctx["errors"]},indent=2), flush=True)
PUB = PUBLICATION
def normalized_schema(value):
if isinstance(value,dict):
return {key:sorted(item) if key=='required' and isinstance(item,list) else normalized_schema(item) for key,item in value.items()}
if isinstance(value,list):
return [normalized_schema(item) for item in value]
return value
def preservation_checks():
with local_browser() as (page,api,ctx):
page.set_default_timeout(8000)
modal=page.locator("#modal")
results=[]
def route(path):
page.goto(ctx["base"]+"/#/"+path)
page.wait_for_selector("#main .page-header")
def submit():
modal.get_by_role("button",name="Entwurf speichern",exact=True).click()
try: modal.wait_for(state="hidden")
except Exception:
print(modal.inner_text(),flush=True)
raise
secret=api("/secrets",{"name":"fixture","value":"$6$fixture$"+"A"*86})
values={
"global":{"keyboard":"de-ch","country":"ch","timezone":"Europe/Zurich","mailto":"admin@example.net","fqdn":"template.example.net","root-ssh-keys":[],"reboot-on-error":False},
"network":{"source":"from-answer","cidr":"192.0.2.10/24","gateway":"192.0.2.1","dns":"192.0.2.53","filter":{"INTERFACE":"eno1","CUSTOM_MARKER":"firmware-slot"}},
"disk_setup":{"filesystem":"zfs","filter":{"ID_SERIAL":"DISK-*"},"filter_match":"any","expected_count":2,"expected_serials":["DISK-A","DISK-B"],"inventory_evidence":"Inventory fixture reference","zfs":{"raid":"raid1","ashift":12,"arc-max":2048,"copies":2,"hdsize":123.5,"checksum":"sha256","compress":"off"}},
"root_secret_id":secret["id"],
}
profile=api("/profiles",{"name":"Detailed existing profile","kind":"installation","values":values,"target_builds":["9.1-1"],"locked_fields":["disk_setup","network.gateway"]})
route("installation")
page.locator(f'[data-action="version-profile"][data-id="{profile["id"]}"]').click()
submit()
versions=sorted([p for p in api("/profiles") if p["name"]==profile["name"]],key=lambda p:p["version"])
assert versions[-1]["values"]==values,(versions[-1]["values"],values)
assert versions[-1]["locked_fields"]==profile["locked_fields"]
results.append("Detailed ZFS/network/global config and custom locked fields roundtrip unchanged")
# Switching filesystem removes incompatible settings and retains a zero swap.
page.locator(f'[data-action="version-profile"][data-id="{versions[-1]["id"]}"]').click()
modal.locator('[name="installation.disk.filesystem"]').select_option("ext4")
serial=modal.locator('[data-editor="installation.disk.expected_serials"]')
serial.locator('[data-edit-action="remove"]').last.click()
modal.locator('[name="installation.disk.expected_count"]').fill("1")
modal.locator('[name="installation.disk.lvm.swapsize"]').fill("0")
submit()
latest=max([p for p in api("/profiles") if p["name"]==profile["name"]],key=lambda p:p["version"])
assert "zfs" not in latest["values"]["disk_setup"]
assert latest["values"]["disk_setup"]["lvm"]["swapsize"]==0
page.locator(f'[data-action="version-profile"][data-id="{latest["id"]}"]').click()
modal.locator('[name="installation.disk.filesystem"]').select_option("zfs")
modal.locator('[name="installation.disk.mode"]').select_option("all")
modal.locator('[name="installation.disk.zfs.raid"]').select_option("raid0")
submit()
latest=max([p for p in api("/profiles") if p["name"]==profile["name"]],key=lambda p:p["version"])
disks=latest["values"]["disk_setup"]
assert disks["selection"]=="all" and disks["zfs"]["raid"]=="raid0"
assert not set(disks).intersection({"lvm","filter","filter_match","expected_count","expected_serials"})
results.append("RAID1 to ext4 to automatic RAID0 strips incompatible fields and preserves numeric zero")
# Sparse host override editing must not replace inherited configuration.
overrides={"global":{"reboot-on-error":False},"network":{"dns":"192.0.2.54"},"root_secret_id":secret["id"]}
host=api("/hosts",{"fqdn":"preserve.example.net","site":"lab","identities":[{"kind":"serial","value":"PRESERVE-HOST"}],"overrides":overrides})
route("hosts/"+host["id"])
page.locator('[data-action="edit-host"]').click()
modal.locator('[name="site"]').fill("new-lab")
modal.get_by_role("button",name="Änderungen speichern",exact=True).click()
modal.wait_for(state="hidden")
assert api('/hosts/'+host['id'])['overrides']==overrides
results.append("Sparse host overrides preserve inherited values and explicit false")
# Nested catalog schemas and nonstandard module fields preserve their rules.
catalog={m["id"]:m for m in api("/modules/builtin")}
modules=[]
for key in ["prerequisites","ssh"]:
template=catalog[key]
body={k:deepcopy(template[k]) for k in ["name","source","parameters_schema","dependencies","timeout_seconds","retry_safe"]}
body['target_builds']=['9.1-1']
module=api('/modules',body)
api('/modules/'+module['id']+'/publish',PUB)
modules.append(module)
route('modules')
page.locator(f'[data-action="version-module"][data-id="{modules[1]["id"]}"]').click()
submit()
ssh_versions=[m for m in api('/modules') if m['name']==modules[1]['name']]
assert normalized_schema(max(ssh_versions,key=lambda m:m['version'])['parameters_schema'])==normalized_schema(modules[1]['parameters_schema'])
parameters={"users":[{"name":"root","authorized_keys":["ssh-ed25519 AAAATEST original"]}]}
steps=[{"id":"fixed-first","module_id":modules[0]['id'],"parameters":{},"secret_refs":{},"required":True},{"id":"fixed-ssh","module_id":modules[1]['id'],"parameters":parameters,"secret_refs":{"TEST_KEY":secret['id']},"required":False}]
post=api('/profiles',{"name":"Nested existing post","kind":"postinstall","target_builds":["9.1-1"],"steps":steps,"values":{"custom":{"enabled":False,"zero":0,"empty":[],"missing":None}},"reboot_budget":0})
route('postinstall')
page.locator(f'[data-action="version-profile"][data-id="{post["id"]}"]').click()
second=modal.locator('[data-postinstall-step]').nth(1)
keys=second.locator('[data-entry]:has(> .data-key-label > [data-entry-key][value="authorized_keys"]) > .data-entry-value > [data-value-node]')
keys.locator(':scope > .data-node-content > .data-add > button').click()
keys.locator('textarea[data-scalar-value]').last.fill("ssh-ed25519 AAAATEST second")
page.set_viewport_size({"width":390,"height":844})
second.scroll_into_view_if_needed()
page.screenshot(path=str(ctx["artifacts"]/'postinstall-nested-mobile.png'),full_page=True)
size=modal.evaluate('n=>({width:n.clientWidth,scroll:n.scrollWidth})')
assert size['scroll']<=size['width']+1,size
page.set_viewport_size({"width":1440,"height":1000})
submit()
updated=max([p for p in api('/profiles') if p['name']==post['name']],key=lambda p:p['version'])
assert updated['values']==post['values']
expected=deepcopy(steps)
expected[1]['parameters']['users'][0]['authorized_keys'].append('ssh-ed25519 AAAATEST second')
assert updated['steps']==expected,(updated['steps'],expected)
results.append('Nested SSH key add preserves fixed step IDs, references, false required flag, custom null/empty/false/zero values; mobile layout fits')
print(json.dumps({'passed':results,'errors':ctx['errors']},indent=2),flush=True)
if __name__ == "__main__":
graphical_workflows()
preservation_checks()
+98
View File
@@ -0,0 +1,98 @@
"""Isolated localhost application and Chromium support for browser_forms.py.
Only generated test credentials/data are used. Fixture databases, server logs,
and screenshots are written below the repository's ignored .cache/ui-browser.
The server process and browser are always terminated after each scenario group.
"""
from contextlib import contextmanager
import json
import os
from pathlib import Path
import socket
import subprocess
import sys
import tempfile
import time
import httpx
from playwright.sync_api import sync_playwright
ROOT = Path(__file__).resolve().parents[1]
TEST_PASSWORD = "Browser-fixture-admin-2026!"
@contextmanager
def local_browser(viewport=None):
with socket.socket() as listener:
listener.bind(("127.0.0.1", 0))
port = listener.getsockname()[1]
base = f"http://127.0.0.1:{port}"
artifacts = ROOT / ".cache" / "ui-browser"
artifacts.mkdir(parents=True, exist_ok=True)
tempdir = Path(tempfile.mkdtemp(prefix="fixture-", dir=artifacts))
env = {
**os.environ,
"PYTHONPATH": str(ROOT),
"DATA_DIR": str(tempdir / "data"),
"MASTER_KEY_FILE": str(tempdir / "keys" / "master.key"),
"PUBLIC_URL": base,
"SECURE_COOKIES": "false",
"BOOTSTRAP_USERNAME": "admin",
"BOOTSTRAP_PASSWORD": TEST_PASSWORD,
"TESTING": "true",
"FOUR_EYES": "false",
}
log = (tempdir / "server.log").open("w", encoding="utf-8")
server = subprocess.Popen(
[sys.executable, "-m", "provisioner.cli", "serve", "--host", "127.0.0.1", "--port", str(port)],
cwd=ROOT, env=env, stdout=log, stderr=subprocess.STDOUT,
creationflags=subprocess.CREATE_NO_WINDOW if os.name == "nt" else 0,
)
try:
for _ in range(80):
if server.poll() is not None:
raise RuntimeError((tempdir / "server.log").read_text(encoding="utf-8"))
try:
if httpx.get(base + "/health/ready", timeout=1).status_code == 200:
break
except httpx.HTTPError:
pass
time.sleep(0.15)
else:
raise TimeoutError("Local application did not become ready")
with sync_playwright() as playwright:
browser = playwright.chromium.launch(headless=True)
context = browser.new_context(viewport=viewport or {"width": 1440, "height": 1000})
page = context.new_page()
errors = []
page.on("pageerror", lambda error: errors.append(str(error)))
page.goto(base + "/")
page.get_by_label("Benutzername").fill("admin")
page.get_by_label("Passwort", exact=True).fill(TEST_PASSWORD)
page.get_by_role("button", name="Anmelden").click()
page.wait_for_selector("#main .page-header")
csrf = context.request.get(base + "/api/v1/me").json()["csrf_token"]
def api(path, body=None):
response = context.request.get(base + "/api/v1" + path) if body is None else context.request.post(
base + "/api/v1" + path, data=body, headers={"X-CSRF-Token": csrf})
assert response.ok, f"{path}: {response.status} {response.text()}"
return response.json()
yield page, api, {"base": base, "artifacts": artifacts, "fixture": tempdir, "errors": errors}
assert not errors, errors
context.close()
browser.close()
finally:
server.terminate()
try:
server.wait(timeout=5)
except subprocess.TimeoutExpired:
server.kill()
server.wait(timeout=5)
log.close()
if __name__ == "__main__":
with local_browser() as (page, api, context):
print(json.dumps({"title": page.title(), "url": page.url, "me": api("/me")["username"], "errors": context["errors"]}))
+9 -3
View File
@@ -7,6 +7,7 @@ from http.cookiejar import CookieJar
import json
import os
from pathlib import Path
import re
import secrets
import socket
import ssl
@@ -77,13 +78,18 @@ def main():
form = urllib.parse.urlencode({"username":"smoke-admin","password":password}).encode()
with opener.open(urllib.request.Request(base+"/auth/login",data=form,headers={"Origin":base}),timeout=5) as response:
assert response.status == 200
assert b"/static/app.js" in response.read()
page = response.read()
assert b"/static/app.js" in page
with request("/api/v1/me") as response:
csrf = json.load(response)["csrf_token"]
with request("/api/v1/hosts",{"fqdn":"smoke.example.net","site":"isolated-test","management_ip":"192.0.2.11/24","identities":[{"kind":"serial","value":"SMOKE-ONLY"}]},csrf) as response:
host_id = json.load(response)["id"]
with opener.open(base+"/static/app.js",timeout=5) as response:
assert response.status == 200 and len(response.read())>1000
assets = set(re.findall(rb'(?:src|href)="(/static/[^\"]+)"', page))
assert b"/static/installation-form.js" in assets
assert b"/static/module-forms.js" in assets
for asset in assets:
with opener.open(base+asset.decode(),timeout=5) as response:
assert response.status == 200 and response.read(), asset
process.terminate()
process.wait(timeout=15)
process = start()
+76
View File
@@ -0,0 +1,76 @@
"""Root passwords entered through the UI become encrypted installer hashes."""
import json
from passlib.hash import sha512_crypt
import pytest
from provisioner.security import Security
from test_acceptance import PASSWORD, environment, login, post
def test_root_password_secret_is_hashed_encrypted_and_never_returned(environment):
app, client, csrf = environment
password = " Installer-password-2026! "
created = post(client, "/api/v1/secrets", {
"name": "Installer root password", "kind": "root_password", "value": password,
}, csrf)
assert set(created) == {"id", "name"}
with app.state.db.connection() as connection:
row = connection.execute("SELECT * FROM secrets WHERE id=?", (created["id"],)).fetchone()
audit_data = json.dumps([dict(entry) for entry in connection.execute("SELECT * FROM audit")])
stored = app.state.security.decrypt(row["ciphertext"])
assert stored.startswith("$6$rounds=656000$")
assert sha512_crypt.verify(password, stored)
assert not sha512_crypt.verify(password.strip(), stored)
assert password not in row["ciphertext"] and stored not in row["ciphertext"]
public_data = json.dumps(created) + client.get("/api/v1/secrets").text + audit_data
assert password not in public_data and stored not in public_data
@pytest.mark.parametrize("password", ["short", "x" * 1025, "Valid-password\x00invalid"])
def test_root_password_secret_rejects_invalid_password_without_echo(environment, password):
app, client, csrf = environment
response = client.post("/api/v1/secrets", json={
"name": "Invalid root password", "kind": "root_password", "value": password,
}, headers=csrf)
assert response.status_code == 422
assert password not in response.text
with app.state.db.connection() as connection:
assert connection.execute("SELECT COUNT(*) FROM secrets").fetchone()[0] == 0
def test_raw_secret_default_preserves_existing_behavior(environment):
app, client, csrf = environment
created = post(client, "/api/v1/secrets", {"name": "Raw value", "value": " unchanged-value "}, csrf)
with app.state.db.connection() as connection:
row = connection.execute("SELECT ciphertext FROM secrets WHERE id=?", (created["id"],)).fetchone()
assert app.state.security.decrypt(row["ciphertext"]) == "unchanged-value"
assert client.post("/api/v1/secrets", json={"name": "Empty raw value", "value": " "}, headers=csrf).status_code == 422
@pytest.mark.parametrize("role, metadata_status", [("author", 200), ("operator", 200), ("reader", 403)])
def test_secret_metadata_roles_cannot_create_or_read_secret_values(environment, role, metadata_status):
app, client, csrf = environment
created = post(client, "/api/v1/secrets", {"name": "Private root reference", "value": "private-value"}, csrf)
post(client, "/api/v1/users", {"username": role, "password": PASSWORD, "role": role}, csrf)
user_csrf = login(client, username=role)
response = client.get("/api/v1/secrets")
assert response.status_code == metadata_status
if metadata_status == 200:
assert response.json()[0]["id"] == created["id"]
assert set(response.json()[0]) == {"id", "name", "created_at"}
assert "private-value" not in response.text
denied = client.post("/api/v1/secrets", json={
"name": "Denied password", "kind": "root_password", "value": "Forbidden-password-2026!",
}, headers=user_csrf)
assert denied.status_code == 403
assert "Forbidden-password" not in denied.text
def test_installer_password_hash_uses_random_salt():
password = "Installer-password-2026!"
first = Security.hash_installer_password(password)
second = Security.hash_installer_password(password)
assert first != second
assert sha512_crypt.verify(password, first)
assert sha512_crypt.verify(password, second)