Files
Proxmox-AIS-Server/provisioner/static/form-controls.js
T
BartelLuis 7b979e6243
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
feat(ui): replace configuration editors with graphical forms
2026-09-14 21:52:39 +02:00

133 lines
13 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/* 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>`;
}