Files
Onyscribe-Admin/app/static/tenant.js

612 lines
64 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.
/* Tenant-Detailseite: eine Navigation (links) · Formular (Mitte) · Vorschau/Agent (rechts) */
let S = null;
let SCHEMA = null;
const ui = {sec: 'overview', mode: 'edit', open: true, pvKind: 'web', pvSrc: 'draft', pvDev: '', agentSrc: null};
const SECTIONS = [
['overview', 'Übersicht', null, false],
['brand', 'Marke & Farben', 'Gestaltung', true], ['texts', 'Absender & PDF-Texte', 'Gestaltung', true], ['pdf', 'PDF-Layout', 'Gestaltung', true],
['prompt', 'Prompt', 'Inhalt', true], ['files', 'Dateien', 'Inhalt', true],
['func', 'Funktion & STT', 'Verhalten', true], ['code', 'Hook-Code', 'Verhalten', true], ['adv', 'tenant.json', 'Verhalten', true],
['domain', 'Domain & Login', 'Betrieb', false], ['secrets', 'Secrets', 'Betrieb', false], ['deploy', 'Deploy', 'Betrieb', false], ['versions', 'Verlauf', 'Betrieb', false],
];
const A = p => `/api/tenants/${TID}${p}`;
const $ = s => document.querySelector(s);
const getPath = (o, p) => p.split('.').reduce((a, k) => (a == null ? a : a[k]), o);
function setPath(o, p, v){ const ks = p.split('.'); let c = o; ks.slice(0,-1).forEach(k => c = (c[k] ??= {})); c[ks.at(-1)] = v; }
function unsetPath(o, p){ const ks = p.split('.'); const chain = [o]; let c = o; for(const k of ks.slice(0,-1)){ c = c?.[k]; if(c == null) return; chain.push(c); } delete c[ks.at(-1)]; for(let i = ks.length - 2; i >= 0; i--){ if(Object.keys(chain[i+1]).length === 0) delete chain[i][ks[i]]; else break; } }
function debounce(fn, ms){ let t; return (...a) => { clearTimeout(t); t = setTimeout(() => fn(...a), ms); }; }
function shade(hex, f){ const n=parseInt(hex.slice(1),16); const c=[n>>16,(n>>8)&255,n&255].map(x=>Math.max(0,Math.min(255,Math.round(x*f)))); return '#'+c.map(x=>x.toString(16).padStart(2,'0')).join('').toUpperCase(); }
function tint(hex, a){ const n=parseInt(hex.slice(1),16); const c=[n>>16,(n>>8)&255,n&255].map(x=>Math.round(x+(255-x)*a)); return '#'+c.map(x=>x.toString(16).padStart(2,'0')).join('').toUpperCase(); }
const copyText = t => navigator.clipboard.writeText(t).then(()=>toast('Kopiert'));
const secDef = k => SECTIONS.find(s => s[0] === k);
const kindLabel = {'dry-run':'Dry-Run','deploy':'Deploy','offboard-dry':'Offboard (Dry-Run)','offboard':'Offboard'};
/* ───────── Laden & Rahmen ───────── */
async function loadSchema(){ try{ const c = await api('GET', '/api/contract'); SCHEMA = c.schema; }catch(e){ SCHEMA = null; } }
async function load(keep){
S = await api('GET', A('/state'));
renderHead(); renderNav();
if(!keep) route();
}
function route(){
const h = (location.hash||'').slice(1);
if(secDef(h)) ui.sec = h;
renderSection();
}
window.addEventListener('hashchange', route);
function renderHead(){
$('#h-name').textContent = S.name;
const st = S.status !== 'active' ? `<span class="badge warn">${S.status}</span>` : '';
$('#h-badges').innerHTML = `${S.has_draft?'<span class="dirty">Entwurf</span>':''}${st}<span class="badge dark">v${S.current_version}</span>
<span class="badge ${S.deployed_version===S.current_version?'ok':'warn'}">deployt v${S.deployed_version??'–'}</span>
<span class="badge ${S.source==='admin'?'cyan':''}">Quelle: ${S.source==='admin'?'Admin':'Git'}</span>`;
$('#h-actions').innerHTML = S.has_draft
? `<button class="btn sm" onclick="discardDraft()">Verwerfen</button><button class="btn sm cyan" onclick="openPublish()">Prüfen &amp; veröffentlichen</button>` : '';
document.querySelectorAll('#h-panel button').forEach(b => b.classList.toggle('on', b.dataset.p === ui.mode));
}
function renderNav(extra){
let g = null, out = '';
const nfiles = Object.keys(S.files).length;
const badges = {files: nfiles ? `<span class="n muted">${nfiles}</span>` : '', versions: `<span class="n muted">${S.current_version}</span>`,
secrets: extra?.missing ? `<span class="n"><span class="dotc err" style="margin:0"></span></span>` : '', code: S.files['overrides.py'] ? '<span class="n"><span class="dotc warn" style="margin:0"></span></span>' : '',
deploy: S.deployed_version !== S.current_version ? '<span class="n"><span class="dotc warn" style="margin:0"></span></span>' : ''};
for(const [k, l, grp] of SECTIONS){
if(grp !== g){ g = grp; if(grp) out += `<h6>${grp}</h6>`; }
out += `<button class="${ui.sec===k&&ui.mode==='edit'?'on':''}" onclick="gotoSec('${k}')">${l}${badges[k]||''}</button>`;
}
$('#snav').innerHTML = out;
}
function gotoSec(k){ ui.mode = 'edit'; if(location.hash === '#'+k) route(); else location.hash = k; }
/* Layout: Mitte = Formular ODER Agent-Chat; die Live-Vorschau rechts bleibt in beiden Fällen sichtbar (Betriebsbereiche ohne Vorschau). */
function layout(){
ui.open = secDef(ui.sec)[3] || ui.mode === 'agent';
$('#tl').classList.toggle('open', ui.open);
$('#pane').classList.toggle('hide', ui.mode === 'agent');
$('#ag-wrap').classList.toggle('hide', ui.mode !== 'agent');
document.querySelectorAll('#h-panel button').forEach(b => b.classList.toggle('on', b.dataset.p === ui.mode));
if(ui.open) refreshPreview();
}
$('#h-panel').addEventListener('click', e => {
const b = e.target.closest('button'); if(!b) return; ui.mode = b.dataset.p;
if(ui.mode === 'edit') renderSection(); // Formular NEU aufbauen: der Agent kann den Entwurf inzwischen geändert haben (sonst alte Werte sichtbar)
else { renderNav(); layout(); $('#chat-text').focus(); }
});
function renderSectionContent(){
({overview: sOverview, brand: sBrand, texts: sTexts, pdf: sPdf, prompt: sPrompt, files: sFiles, func: sFunc, code: sCode, adv: sAdv,
domain: sDomain, secrets: sSecrets, deploy: sDeploy, versions: sVersions})[ui.sec]();
}
function renderSection(){ renderNav(); layout(); renderSectionContent(); }
/* ───────── Speichern (Entwurf) ───────── */
const saveState = txt => { const e = $('#save-ind'); if(e) e.textContent = txt; };
function afterSave(r){
S.has_draft = r.has_draft; renderHead(); saveState(r.has_draft ? 'Entwurf gespeichert' : 'Entspricht der veröffentlichten Version');
refreshPreviewSoon();
}
const saveConfigNow = async () => { try { saveState('Speichere …'); afterSave(await api('PUT', A('/config'), S.config)); } catch(e){ toast(e.message,1); saveState('Fehler'); } };
const saveConfig = debounce(saveConfigNow, 500);
async function saveFileNow(path, content){ try { saveState('Speichere …'); afterSave(await api('PUT', A('/files/'+path), {content})); } catch(e){ toast(e.message,1); saveState(e.message); } }
const saveFile = debounce(saveFileNow, 600);
async function deleteFile(path){ afterSave(await api('DELETE', A('/files/'+path))); await load(true); renderSection(); }
async function uploadFile(input, path){
const f = input.files[0]; if(!f) return;
const fd = new FormData(); fd.append('file', f);
const r = await fetch(A('/upload?path='+encodeURIComponent(path||f.name)), {method:'POST', body: fd});
if(!r.ok){ toast((await r.json()).detail||'Upload fehlgeschlagen',1); return; }
await load(true); renderSection(); refreshPreviewSoon(); toast('Hochgeladen'); headLogo(); renderHead();
}
function headLogo(){ const n = S.files['logo.svg']?'logo.svg':S.files['logo.png']?'logo.png':null; $('#h-logo').innerHTML = n?`<img src="${A('/files/'+n)}?source=draft&t=${Date.now()}" alt="">`:''; }
async function discardDraft(){
if(!confirm('Alle Änderungen im Entwurf verwerfen?')) return;
await api('POST', A('/draft/discard')); await load(true); renderSection(); headLogo(); refreshPreview(true); toast('Entwurf verworfen');
}
const saveInd = () => `<span class="hint" id="save-ind">${S.has_draft?'Entwurf vorhanden':'Entspricht der veröffentlichten Version'}</span>`;
const title = (t, sub) => `<div class="row" style="margin-bottom:8px"><h2 style="font-size:16px" class="grow">${t}${sub?` <span class="muted" style="font-weight:400;font-size:12px">${sub}</span>`:''}</h2>${saveInd()}</div>`;
/* ───────── Live-Vorschau ───────── */
let pvSeq = 0;
async function refreshPreview(force){
if(!ui.open) return;
const seq = ++pvSeq, kind = ui.pvKind;
$('#f-web').style.display = kind==='web'?'':'none'; $('#f-pdf').style.display = kind==='pdf'?'':'none';
$('#pv-frame').className = 'pv-frame ' + (kind==='web'?ui.pvDev:'');
$('#pv-dev').style.display = kind==='web'?'':'none';
$('#pv-load').classList.add('on'); $('#pv-err').classList.remove('on');
const t0 = performance.now();
try {
const src = ui.pvSrc === 'agent' ? (ui.agentSrc || 'draft') : ui.pvSrc;
const r = await fetch(`/preview/${TID}/${kind}?source=${encodeURIComponent(src)}&_=${force?Date.now():''}`);
if(seq !== pvSeq) return;
if(!r.ok){ $('#pv-err').textContent = await r.text(); $('#pv-err').classList.add('on'); }
else if(kind==='web'){ $('#f-web').srcdoc = await r.text(); }
else { const b = await r.blob(); $('#f-pdf').src = URL.createObjectURL(b) + '#toolbar=0&navpanes=0'; }
$('#pv-info').textContent = `Core ${S.core_version} · ${Math.round(performance.now()-t0)} ms`;
} catch(e){ $('#pv-err').textContent = String(e); $('#pv-err').classList.add('on'); }
$('#pv-load').classList.remove('on');
}
const refreshPreviewSoon = debounce(() => refreshPreview(), 900);
const refreshPreviewLive = debounce(() => refreshPreview(), 250);
function setPvSrc(src){
ui.pvSrc = src; $('#pv-agent').classList.toggle('hide', !ui.agentSrc);
document.querySelectorAll('#pv-src button').forEach(x => x.classList.toggle('on', x.dataset.s === src));
}
document.addEventListener('click', e => {
const b = e.target.closest('#pv-kind button, #pv-dev button, #pv-src button'); if(!b) return;
const g = b.parentElement.id;
b.parentElement.querySelectorAll('button').forEach(x => x.classList.toggle('on', x===b));
if(g==='pv-kind') ui.pvKind = b.dataset.k; if(g==='pv-dev') ui.pvDev = b.dataset.d; if(g==='pv-src') ui.pvSrc = b.dataset.s;
refreshPreview();
});
/* ───────── Übersicht ───────── */
async function sOverview(){
const c = S.config, col = c.colors||{};
$('#pane').innerHTML = `
<div class="grid2">
<div class="card"><h3>Status</h3>
<dl class="kv">
<dt>URL</dt><dd><a href="https://${esc(S.host)}" target="_blank">${esc(S.host)}</a> <span class="badge ${S.domain_status==='verified'?'ok':'warn'}">${S.domain_status==='verified'?'Domain ok':S.domain_status==='pending'?'DNS offen':'nicht eingerichtet'}</span></dd>
<dt>Version</dt><dd>v${S.current_version} veröffentlicht · v${S.deployed_version??'–'} deployt ${S.deployed_version!==S.current_version?'<span class="badge warn">veraltet</span>':''}</dd>
<dt>Quelle</dt><dd>${S.source==='admin'?'Admin (Bundle zur Laufzeit)':'Git – ins Image gebacken'}</dd>
<dt>Core</dt><dd><span class="mono">:${esc(String(S.core.tag).slice(0,12))}</span> ${S.core.pin?'<span class="badge warn">gepinnt</span>':'<span class="badge">folgt Rollouts</span>'} <span class="hint">· Admin-Vorschau: ${esc(S.core.sha)}</span>
<span id="run-state"></span> <button class="btn sm" onclick="probeMe()">Läuft?</button></dd>
<dt>STT</dt><dd>${esc((c.stt||{}).provider||'mistral')} ${esc((c.stt||{}).model||'')}</dd>
<dt>Dateien</dt><dd>${Object.keys(S.files).map(f=>`<span class="badge">${esc(f)}</span>`).join(' ')||'–'}</dd>
<dt>Marke</dt><dd class="swatches" style="gap:3px">${Object.entries(col).map(([k,v])=>`<i title="${esc(S.color_labels[k]||k)} ${esc(v)}" style="width:16px;height:16px;background:${esc(v)}"></i>`).join('')}</dd>
</dl>
<div class="row" style="margin-top:10px"><a class="btn sm" href="/tenants/${TID}/bundle.tar.gz">Bundle (.tar.gz)</a><button class="btn sm" onclick="runValidate()">Validieren</button></div>
<div id="val" style="margin-top:8px"></div>
</div>
<div class="card"><h3>Cut-over auf die Admin-Plattform</h3>
<p class="sub">Bis alles grün ist, bleibt der Tenant auf <b>Git</b>. Umschalten = <span class="mono">TENANT_SOURCE=admin</span> beim nächsten Deploy.</p>
<div id="cut"><div class="muted">Prüfe …</div></div>
</div>
</div>
<div class="grid2" style="margin-top:12px">
<div class="card"><h3>Betrieb</h3>
<div class="fgrid c3"><label class="f">CPU<input type="number" step="0.25" min="0.1" max="8" id="m-cpu" value="${S.cpu}"></label>
<label class="f">RAM<input type="text" id="m-mem" value="${esc(S.memory)}"></label>
<label class="f">Status<select id="m-status">${['active','paused','archived'].map(x=>`<option ${S.status===x?'selected':''}>${x}</option>`).join('')}</select></label></div>
<div class="row" style="margin-top:8px"><button class="btn sm" onclick="saveMeta()">Speichern</button><span class="hint">Ressourcen wirken beim nächsten Deploy. „paused/archived“ nimmt den Tenant aus „Alle neu deployen“.</span></div>
</div>
<div class="card"><h3>Notizen</h3>
<textarea id="m-notes" rows="4" placeholder="Ansprechpartner, Besonderheiten, Vereinbarungen …">${esc(S.notes||'')}</textarea>
<div class="row" style="margin-top:6px"><button class="btn sm" onclick="saveMeta()">Speichern</button></div>
</div>
</div>
<div class="card" style="margin-top:12px"><h3>Core-Version</h3><p class="sub">Neue Core-Releases rollt die Admin-Plattform zentral aus (Menü „Core“). Ein <b>Pin</b> hält diesen Tenant auf einem festen Tag – er nimmt dann an Rollouts nicht teil.</p>
<div class="row"><input type="text" id="m-pin" value="${esc(S.core.pin||'')}" placeholder="kein Pin (folgt dem aktiven Core)" style="width:260px"><button class="btn sm" onclick="savePin()">Pin setzen / aufheben</button></div></div>
<details class="fold"><summary>Gefahrenbereich</summary><div class="in">
<p class="hint">Offboard entfernt Service, Volume und Ingress aus dem Mittwald-Stack (Dry-Run zeigt vorher, was passiert). Löschen entfernt nur die Daten in dieser Datenbank – und nur für archivierte Tenants.</p>
<div class="row"><button class="btn sm" onclick="offboard(true)">Offboard-Dry-Run</button><button class="btn sm danger ${S.live?'':'disabled'}" id="b-off" onclick="offboard(false)">Offboard (live)</button>
<button class="btn sm danger" onclick="deleteOidcApp()">OIDC-Client im Login löschen …</button><button class="btn sm danger" onclick="deleteTenant()">Tenant in der DB löschen …</button></div><div class="console hide" id="con-off" style="margin-top:8px"></div></div></details>`;
const [cut, mode, sec] = await Promise.all([api('GET', A('/cutover')), api('GET', '/api/deploy/mode'), api('GET', A('/secrets'))]);
renderNav({missing: sec.some(s => s.required && !s.set)});
$('#b-off')?.classList.toggle('disabled', !mode.live);
const ok = cut.checks.every(x=>x.ok);
$('#cut').innerHTML = cut.checks.map(x=>`<div class="check"><span class="dot ${x.ok?'ok':'no'}">${x.ok?'✓':'!'}</span><span>${esc(x.label)}</span></div>`).join('') +
`<div class="row" style="margin-top:10px">${cut.source==='admin'
? `<span class="badge cyan">Quelle: Admin</span><button class="btn sm" onclick="setSource('git')">Zurück auf Git</button>`
: `<button class="btn sm cyan" ${ok?'':'disabled'} onclick="setSource('admin')">Auf Admin-Quelle umstellen</button>${ok?'':'<span class="hint">noch nicht alle Bedingungen erfüllt</span>'}`}</div>`;
}
async function savePin(){ try{ await api('PUT', A('/core-pin'), {pin: $('#m-pin').value}); await load(true); toast('Core-Pin gespeichert – wirksam beim nächsten Deploy'); sOverview(); }catch(e){ toast(e.message,1) } }
async function probeMe(){
$('#run-state').innerHTML = '<span class="muted">prüfe …</span>';
try{ const r = (await api('GET', '/api/status?refresh=1'))[TID]; $('#run-state').innerHTML = !r ? '' : r.reachable && !r.drift.length ? `<span class="badge ok">läuft: Core ${esc((r.core?.version||'?').slice(0,10))}${r.bundle?.source==='admin'?' · Bundle v'+r.bundle.version:''}</span>` : `<span class="badge err">${esc(r.drift.join(' · ')||'nicht erreichbar')}</span>`; }
catch(e){ $('#run-state').innerHTML = `<span class="badge err">${esc(e.message)}</span>`; }
}
async function saveMeta(){ try{ await api('PUT', A('/meta'), {cpu:$('#m-cpu').value, memory:$('#m-mem').value, status:$('#m-status').value, notes:$('#m-notes').value}); await load(true); toast('Gespeichert'); sOverview(); }catch(e){ toast(e.message,1) } }
async function setSource(s){ await api('PUT', A('/source'), {source:s}); await load(true); sOverview(); toast('TENANT_SOURCE='+s+' – wirksam beim nächsten Deploy'); }
async function runValidate(){ $('#val').innerHTML='<span class="muted">Validiere mit dem echten Core …</span>'; $('#val').innerHTML = valHtml(await api('POST', A('/validate?source=current'))); }
const valHtml = r => `<div class="banner ${r.ok?'ok':'err'}" style="margin:0">${r.ok?'✔ Validierung bestanden':'✘ Fehler'}${(r.errors||[]).map(e=>`<div class="mono" style="white-space:pre-wrap">${esc(e)}</div>`).join('')}${(r.warnings||[]).map(e=>`<div>⚠ ${esc(e)}</div>`).join('')}</div>`;
async function offboard(dry){
if(!dry && !confirm('Service, Volume und Ingress dieses Tenants wirklich vom Mittwald-Stack entfernen?')) return;
try{ const r = await api('POST', A('/offboard'), {dry_run:dry}); followDeploy(r.id, $('#con-off')); }catch(e){ toast(e.message,1) }
}
async function deleteOidcApp(){
const id = prompt('OIDC-Client dieses Tenants im Login-System LÖSCHEN (nicht umkehrbar, beendet alle Logins der App). Zur Bestätigung die Tenant-ID eingeben:'); if(id===null) return;
try{ await api('DELETE', A(`/oidc/app?confirm=${encodeURIComponent(id)}`)); toast('OIDC-Client gelöscht'); await load(true); sOverview(); }catch(e){ toast(e.message,1) }
}
async function deleteTenant(){
const id = prompt('Alle Daten dieses Tenants in der Datenbank löschen (Versionen, Secrets, Chat). Zur Bestätigung die ID eingeben:'); if(id===null) return;
try{ await api('DELETE', `/api/tenants/${TID}?confirm=${encodeURIComponent(id)}`); location.href='/'; }catch(e){ toast(e.message,1) }
}
/* ───────── Formular-Helfer ───────── */
function bindCfg(root){
root.querySelectorAll('[data-cfg]').forEach(el => {
el.addEventListener('input', () => {
const p = el.dataset.cfg;
const v = el.type==='checkbox' ? el.checked : el.type==='number' ? (el.value === '' ? '' : Number(el.value)) : el.value;
if(v === '' && !['name'].includes(p) && !p.startsWith('office.') && !p.startsWith('colors.')) unsetPath(S.config, p); else setPath(S.config, p, v);
if(el.type==='color'){ const t = root.querySelector(`[data-cfg="${p}"][type=text]`); if(t) t.value = v; }
if(el.type==='text' && el.dataset.color){ const c = root.querySelector(`[data-cfg="${p}"][type=color]`); if(c && /^#[0-9a-f]{6}$/i.test(v)) c.value = v; }
saveConfig();
});
});
}
const fld = (label, path, o={}) => `<label class="f ${o.full?'full':''}">${label}<input type="${o.type||'text'}" data-cfg="${path}" value="${esc(getPath(S.config,path)??'')}" ${o.ph?`placeholder="${esc(o.ph)}"`:''}></label>`;
/* ───────── Gestaltung ───────── */
function sBrand(){
const col = S.config.colors||{};
const logo = S.files['logo.svg']?'logo.svg':S.files['logo.png']?'logo.png':null;
$('#pane').innerHTML = title('Marke & Farben') + `
<div class="card"><h3>Name &amp; Titel</h3><div class="fgrid">${fld('Name','name')}${fld('Seitentitel (Browser-Tab)','title')}${fld('Beschreibung','description',{full:1})}</div></div>
<div class="card"><h3>Logo &amp; Favicon</h3><div class="row" style="align-items:flex-start;gap:20px">
<div><div class="logo-box lg">${logo?`<img src="${A('/files/'+logo)}?source=draft&t=${Date.now()}">`:'<span class="muted">kein Logo</span>'}</div>
<label class="f" style="margin-top:6px">Logo (SVG/PNG)<input type="file" accept=".svg,.png" onchange="uploadFile(this, this.files[0].name.toLowerCase().endsWith('.svg')?'logo.svg':'logo.png')"></label>
<div class="hint">Word-Export braucht zusätzlich <span class="mono">logo.png</span>.</div></div>
<div><div class="logo-box lg" style="width:56px">${S.files['favicon.svg']?`<img src="${A('/files/favicon.svg')}?source=draft&t=${Date.now()}">`:'<span class="muted">–</span>'}</div>
<label class="f" style="margin-top:6px">Favicon (SVG)<input type="file" accept=".svg" onchange="uploadFile(this,'favicon.svg')"></label></div></div></div>
<div class="card"><div class="row" style="margin-bottom:8px"><h3 style="margin:0" class="grow">Farben</h3><button class="btn sm" onclick="deriveFromAccent()" title="dunkel / mittel / hell / extra hell aus dem Akzent berechnen">Töne aus Akzent ableiten</button></div>
<div class="colors">${Object.keys(S.color_labels).map(k=>`<div class="colorf"><input type="color" data-cfg="colors.${k}" value="${esc(col[k]||'#000000')}"><span title="${esc(k)}">${esc(S.color_labels[k])}</span><input type="text" data-color="1" data-cfg="colors.${k}" value="${esc(col[k]||'')}"></div>`).join('')}</div></div>`;
bindCfg($('#pane'));
}
function deriveFromAccent(){
const a = (S.config.colors||{}).accent; if(!/^#[0-9a-f]{6}$/i.test(a)) return toast('Akzent muss #RRGGBB sein',1);
Object.assign(S.config.colors, {accent_dark:shade(a,.72), accent_mid:shade(a,1.12), accent_light:tint(a,.88), accent_xlight:tint(a,.94)});
saveConfigNow(); sBrand();
}
function sTexts(){
$('#pane').innerHTML = title('Absender & PDF-Texte') + `
<div class="card"><h3>Absender / Kopf- &amp; Fußzeile</h3><p class="sub">Erscheint im PDF und im Word-Export.</p><div class="fgrid">
${fld('Name','office.name')}${fld('Zusatz (Rechtsform)','office.subtitle')}${fld('Adresse','office.address',{full:1})}${fld('Telefon','office.tel')}${fld('E-Mail','office.email')}${fld('Website','office.website',{full:1})}</div></div>
<div class="card"><h3>PDF-Beschriftungen</h3><div class="fgrid">${fld('Bezeichnung „Projekt“ (z. B. „Thema“)','pdf_projekt_label',{ph:'Projekt'})}${fld('Fußzeilen-Hinweis','pdf_footer_note',{ph:'Standard des Cores'})}</div></div>`;
bindCfg($('#pane'));
}
/* Text-Datei-Editor mit „Standard des Cores“ */
async function fileEditor(path, o){
const has = !!S.files[path]; let body = '';
try { const r = await fetch(A('/files/'+path)+'?source=draft'); if(r.ok) body = await r.text(); } catch(e){}
const w = document.createElement('div'); w.className = 'card';
w.innerHTML = `<div class="row" style="margin-bottom:6px"><h3 style="margin:0" class="grow">${esc(o.title)}</h3>
${has ? `<span class="badge cyan">eigene Datei</span><button class="btn sm danger" data-act="reset">Auf Core-Standard</button>` : `<span class="badge">Core-Standard</span><button class="btn sm" data-act="custom">Eigene Version anlegen</button>`}</div>
${o.sub?`<p class="sub">${o.sub}</p>`:''}
<textarea class="code" ${has?'':'readonly'} spellcheck="false" style="min-height:${o.h||300}px;${has?'':'opacity:.7'}">${esc(body)}</textarea>`;
const ta = w.querySelector('textarea');
ta.addEventListener('input', () => saveFile(path, ta.value));
ta.addEventListener('keydown', e => { if(e.key==='Tab'){ e.preventDefault(); ta.setRangeText(' ', ta.selectionStart, ta.selectionEnd, 'end'); ta.dispatchEvent(new Event('input')); } });
w.querySelector('[data-act=custom]')?.addEventListener('click', async () => { await saveFileNow(path, body); await load(true); renderSection(); });
w.querySelector('[data-act=reset]')?.addEventListener('click', async () => { if(confirm('Eigene Datei löschen und den Core-Standard verwenden?')) await deleteFile(path); });
return w;
}
async function sPdf(){
$('#pane').innerHTML = title('PDF-Layout') + `<div class="banner info">HTML + CSS (WeasyPrint). Datenfelder bleiben gleich, nur das Aussehen ist frei. Die Vorschau nutzt das Beispielprotokoll unten.</div><div id="e1"></div><div id="e2"></div>
<details class="fold"><summary>Beispielprotokoll für die Vorschau</summary><div class="in"><textarea class="code" id="sample" style="min-height:200px">${esc(S.sample)}</textarea>
<div class="row" style="margin-top:6px"><button class="btn sm" onclick="saveSample()">Speichern &amp; neu rendern</button><span class="hint">Format wie vom LLM geliefert; gilt nur für die Vorschau.</span></div></div></details>`;
$('#e1').appendChild(await fileEditor('protocol.html', {title:'protocol.html', sub:'Jinja-Template des PDFs.', h:300}));
$('#e2').appendChild(await fileEditor('protocol.css', {title:'protocol.css', sub:'Farben kommen als Jinja-Variablen aus der Marke.', h:300}));
}
async function saveSample(){ await api('PUT', A('/sample'), {sample: $('#sample').value}); S.sample = $('#sample').value; toast('Beispiel gespeichert'); ui.pvKind='pdf'; document.querySelectorAll('#pv-kind button').forEach(b=>b.classList.toggle('on',b.dataset.k==='pdf')); refreshPreview(true); }
/* ───────── Inhalt ───────── */
async function sPrompt(){
$('#pane').innerHTML = title('Prompt') + `
<div class="card"><h3>Selbstbeschreibung</h3><p class="sub">Ersetzt <span class="mono">@@ORG@@ · @@ORG_SHORT@@ · @@ORG_PROFILE@@</span> im Standard-Prompt.</p><div class="fgrid">
${fld('Organisation (voll)','ai_detect_org')}${fld('Kurzname im Protokolltext','org_short')}${fld('Ein Satz: was die Organisation macht','org_profile',{full:1})}</div></div><div id="ed"></div>
<details class="fold" id="ptest"><summary>Prompt testen (echter LLM-Lauf) <span class="badge">kostet Cortecs-Guthaben</span></summary><div class="in">
${S.llm_ready ? '' : '<div class="banner warn">Kein Cortecs-Key hinterlegt – unter Einstellungen → Geteilte Secrets setzen.</div>'}
<div class="fgrid"><label class="f">Termin-Art (optional)<input type="text" id="pt-art" placeholder="aus Gespräch erkennen"></label><label class="f">Projektname (optional)<input type="text" id="pt-proj"></label>
<label class="f full">Beispieltranskript<textarea id="pt-tr" rows="6" class="code" style="min-height:120px">${esc(S.sample_transcript)}</textarea></label></div>
<div class="row" style="margin:8px 0"><button class="btn sm cyan" id="pt-go" onclick="promptTest()" ${S.llm_ready?'':'disabled'}>LLM-Lauf mit Entwurfs-Prompt</button><button class="btn sm" onclick="showFinalPrompt()">Finalen Prompt anzeigen</button><span class="hint" id="pt-info"></span></div>
<div id="pt-out"></div></div></details>`;
bindCfg($('#pane'));
$('#ed').appendChild(await fileEditor('prompt.txt', {title:'prompt.txt', sub:'Ohne eigene Datei gilt der Standard-Prompt des Cores. Hook-Code kann den Prompt zusätzlich erweitern (siehe „Finalen Prompt anzeigen“).', h:420}));
}
async function promptTest(){
$('#pt-go').disabled = true; $('#pt-info').textContent = 'LLM läuft … (bis zu 1–2 Minuten)'; $('#pt-out').innerHTML = '';
try{
const r = await api('POST', A('/prompt-test'), {transcript:$('#pt-tr').value, terminart:$('#pt-art').value, projekt:$('#pt-proj').value});
$('#pt-info').textContent = `${r.model} · ${r.input_tokens}→${r.output_tokens} Tokens · Prompt ${r.prompt_chars} Zeichen${r.truncated?' · ⚠ abgeschnitten (max_tokens)':''}`;
window._ptText = r.text;
$('#pt-out').innerHTML = `<pre class="json" style="white-space:pre-wrap;max-height:340px;background:#fbfaf8;color:#222;border:1px solid var(--border)">${esc(r.text)}</pre><div class="row" style="margin-top:6px"><button class="btn sm" onclick="usePtAsSample()">Als PDF-Beispiel übernehmen &amp; ansehen</button></div>`;
}catch(e){ $('#pt-info').textContent = ''; $('#pt-out').innerHTML = `<div class="banner err">${esc(e.message)}</div>`; }
$('#pt-go').disabled = !S.llm_ready;
}
async function usePtAsSample(){ await api('PUT', A('/sample'), {sample: window._ptText}); S.sample = window._ptText; ui.pvKind='pdf'; document.querySelectorAll('#pv-kind button').forEach(b=>b.classList.toggle('on',b.dataset.k==='pdf')); setPvSrc('draft'); refreshPreview(true); toast('Als Beispiel gesetzt'); }
async function showFinalPrompt(){
try{ const r = await api('GET', A('/final-prompt')); $('#pt-out').innerHTML = `<div class="hint">${r.chars} Zeichen – so geht der Prompt (nach Platzhaltern und Hooks) an das LLM:</div><pre class="json" style="white-space:pre-wrap;max-height:380px">${esc(r.prompt)}</pre>`; }
catch(e){ $('#pt-out').innerHTML = `<div class="banner err">${esc(e.message)}</div>`; }
}
function sFiles(){
const rows = Object.entries(S.files).map(([p,m]) => `<tr><td class="mono">${esc(p)} ${m.personal?'<span class="badge warn" title="Für den KI-Agenten gesperrt">personenbezogen</span>':''}</td><td>${(m.size/1024).toFixed(1)} KB</td><td>${m.text?'Text':'Binär'}</td><td style="text-align:right" class="nowrap">${m.text&&!['prompt.txt','protocol.html','protocol.css','overrides.py'].includes(p)?`<button class="btn sm" onclick="openTextFile('${esc(p)}')">Bearbeiten</button> `:''}<button class="btn sm danger" onclick="if(confirm('Datei löschen?'))deleteFile('${esc(p)}')">Löschen</button></td></tr>`).join('');
$('#pane').innerHTML = title('Dateien') + `<div class="card"><table class="t"><thead><tr><th>Pfad</th><th>Größe</th><th>Typ</th><th></th></tr></thead><tbody>${rows||'<tr><td colspan=4 class="empty">Keine Dateien</td></tr>'}</tbody></table>
<div class="row" style="margin-top:10px"><label class="btn sm">+ Datei hochladen<input type="file" hidden onchange="uploadFile(this)"></label><span class="hint">z. B. <span class="mono">namensliste.txt</span> – lesbar für Hook-Code im Container. Namenslisten sind für den KI-Agenten automatisch gesperrt.</span></div><div id="tf" style="margin-top:10px"></div></div>`;
}
async function openTextFile(p){ const w = await fileEditor(p, {title:p, h:260}); $('#tf').innerHTML=''; $('#tf').appendChild(w); }
/* ───────── Verhalten ───────── */
/* Felder, die der Core im Schema deklariert, aber keine eigene Maske haben, erscheinen automatisch hier (neue Core-Optionen ohne Admin-Änderung). */
const BESPOKE_PATHS = ['name','title','description','pdf_projekt_label','pdf_footer_note','ai_detect_org','org_short','org_profile'];
const isBespoke = p => BESPOKE_PATHS.includes(p) || p.startsWith('colors.') || p.startsWith('office.');
function schemaField(f){
const raw = getPath(S.config, f.path), cur = raw === undefined ? f.default : raw;
const help = f.help ? ` <span class="hint">${esc(f.help)}</span>` : '';
if(f.type === 'bool') return `<label class="chk"><input type="checkbox" data-cfg="${f.path}" ${cur?'checked':''}><span><b>${esc(f.label)}</b>${help}</span></label>`;
if(f.type === 'enum') return `<label class="f">${esc(f.label)}<select data-cfg="${f.path}"><option value="" ${!raw?'selected':''}>Standard (${esc(f.default ?? 'Core')})</option>${(f.options||[]).map(o=>`<option ${raw===o?'selected':''}>${esc(o)}</option>`).join('')}</select>${f.help?`<span class="hint" style="text-transform:none;letter-spacing:0;font-weight:400">${esc(f.help)}</span>`:''}</label>`;
if(f.type === 'color') return `<label class="f">${esc(f.label)}<input type="text" data-cfg="${f.path}" value="${esc(raw ?? '')}" placeholder="${esc(f.default ?? '')}"></label>`;
if(f.type === 'text') return `<label class="f full">${esc(f.label)}<textarea rows="3" data-cfg="${f.path}" placeholder="${esc(f.default ?? '')}">${esc(raw ?? '')}</textarea>${f.help?`<span class="hint" style="text-transform:none;letter-spacing:0;font-weight:400">${esc(f.help)}</span>`:''}</label>`;
return `<label class="f">${esc(f.label)}<input type="${f.type==='number'?'number':'text'}" step="any" data-cfg="${f.path}" value="${esc(raw ?? '')}" placeholder="${esc(f.default ?? 'Standard')}">${f.help?`<span class="hint" style="text-transform:none;letter-spacing:0;font-weight:400">${esc(f.help)}</span>`:''}</label>`;
}
function sFunc(){
if(!SCHEMA) return sFuncLegacy();
const groups = {};
SCHEMA.fields.filter(f => !isBespoke(f.path)).forEach(f => (groups[f.group] ??= []).push(f));
const order = Object.keys(groups);
const known = ['Funktion', 'Spracherkennung'];
const card = (g) => { const fs = groups[g], basic = fs.filter(f=>!f.advanced), adv = fs.filter(f=>f.advanced);
const grid = list => `<div class="${list.every(f=>f.type==='bool')?'':'fgrid'}">${list.map(schemaField).join('')}</div>`;
return `<div class="card"><h3>${esc(g)}${known.includes(g)?'':' <span class="badge cyan">neu im Core</span>'}</h3>${basic.length?grid(basic):''}${adv.length?`<details class="fold"><summary>Erweitert</summary><div class="in">${grid(adv)}</div></details>`:''}
${g==='Spracherkennung'?'<p class="hint" style="margin-top:6px">Bei „cortecs“ wird der geteilte CORTECS_API_KEY genutzt – ein eigener Mistral-Key ist nicht nötig.</p>':''}</div>`; };
$('#pane').innerHTML = title('Funktion & STT') + `<p class="hint" style="margin:-4px 0 8px">Aus dem Schema des Cores (Version ${esc(S.core?.sha||'')}) erzeugt – neue Optionen erscheinen hier automatisch.</p>` + order.map(card).join('');
bindCfg($('#pane'));
}
function sFuncLegacy(){
const stt = S.config.stt||{};
const chk = (path, label, hint, def) => { const v = getPath(S.config,path); return `<label class="chk"><input type="checkbox" data-cfg="${path}" ${(v===undefined?def:v)?'checked':''}><span><b>${label}</b> <span class="hint">${hint}</span></span></label>`; };
$('#pane').innerHTML = title('Funktion & STT') + `<div class="banner warn">Dieser Core liefert kein Schema (Vertrag) – feste Maske.</div>
<div class="card"><h3>Funktionen (Flags)</h3>
${chk('flags.ui_kontakt_field','Feld „Ansprechpartner Kunde“','Aus: Feld in der Web-UI ausgeblendet (z. B. Vereine).',true)}
${chk('flags.multi_audio','Mehrere Audiodateien (bis 5)','Aus: nur eine Datei pro Protokoll.',true)}</div>
<div class="card"><h3>Spracherkennung (STT)</h3><div class="fgrid">
<label class="f">Provider<select data-cfg="stt.provider"><option value="" ${!stt.provider?'selected':''}>Standard (mistral)</option><option ${stt.provider==='cortecs'?'selected':''}>cortecs</option><option ${stt.provider==='mistral'?'selected':''}>mistral</option></select></label>
${fld('Modell','stt.model',{ph:'voxtral-mini-transcribe-2602'})}</div></div>`;
bindCfg($('#pane'));
}
async function sCode(){
const has = !!S.files['overrides.py'];
$('#pane').innerHTML = title('Hook-Code') + `<div class="banner warn"><b>Code läuft im Tenant-Container</b> mit dessen Secrets. In der Vorschau läuft er nur in einer Sandbox ohne Secrets (CPU-/Datei-Limits); Veröffentlichen verlangt eine ausdrückliche Freigabe. Erst Flags/Dateien nutzen; Wiederkehrendes gehört in den Core.</div>
<div class="card"><h3>Verfügbare Hooks</h3><dl class="kv" style="grid-template-columns:150px 1fr"><dt>build_prompt</dt><dd>Prompt vor dem LLM-Aufruf anpassen</dd><dt>transform_transcript</dt><dd>Transkript nachbearbeiten</dd><dt>pdf_context</dt><dd>Daten fürs PDF-Template ändern</dd><dt>extra_routes</dt><dd>zusätzliche FastAPI-Routen</dd></dl></div><div id="ed"></div>`;
if(has) $('#ed').appendChild(await fileEditor('overrides.py', {title:'overrides.py', sub:'Syntax wird beim Speichern geprüft.', h:460}));
else $('#ed').innerHTML = `<div class="card empty">Dieser Tenant nutzt keinen Hook-Code.<br><br><button class="btn" onclick="createHook()">overrides.py anlegen</button></div>`;
}
async function createHook(){ await saveFileNow('overrides.py', `"""Tenant-Hooks für ${S.id}."""\nfrom core.hooks import TenantHooks\n\n\nclass Hooks(TenantHooks):\n def build_prompt(self, base_prompt: str, ctx: dict) -> str:\n return base_prompt\n`); await load(true); renderSection(); }
function sAdv(){
$('#pane').innerHTML = title('tenant.json') + `<div class="card"><p class="sub">Alle Felder, auch solche ohne Formular. Gültiges JSON wird automatisch übernommen. Der <span class="mono">deploy</span>-Block ist nicht Teil des Bundles (Domain/Ressourcen verwaltet die Plattform).</p>
<textarea class="code" id="rawjson" style="min-height:460px">${esc(JSON.stringify(S.config,null,2))}</textarea><div class="hint" id="rawerr" style="color:var(--err)"></div></div>`;
$('#rawjson').addEventListener('input', e => { try { S.config = JSON.parse(e.target.value); $('#rawerr').textContent=''; saveConfig(); } catch(x){ $('#rawerr').textContent = 'Ungültiges JSON: '+x.message; } });
}
/* ───────── Veröffentlichen ───────── */
async function openPublish(){
modal('m-pub', true); $('#pub-body').innerHTML = '<div class="muted">Validiere (echter Core) und berechne Änderungen …</div>'; $('#pub-go').disabled = true;
const [val, diff] = await Promise.all([api('POST', A('/validate?source=draft')), api('GET', A('/diff?a=current&b=draft'))]);
const code = diff.items.some(i => i.path==='overrides.py');
$('#pub-body').innerHTML = valHtml(val) + `<h3 style="margin:12px 0 4px">${diff.items.length} geänderte Datei(en)</h3>` + diff.items.map(diffHtml).join('') +
(code ? `<label class="chk banner warn"><input type="checkbox" id="codeok"><span><b>Freigabe Hook-Code:</b> Ich habe die Änderungen an <span class="mono">overrides.py</span> geprüft.</span></label>` : '') +
`<label class="f" style="margin-top:10px">Commit-Nachricht<input type="text" id="pubmsg" placeholder="Was wurde geändert?"></label>`;
const upd = () => { $('#pub-go').disabled = !val.ok || (code && !$('#codeok').checked); };
$('#codeok')?.addEventListener('change', upd); upd();
}
const diffHtml = i => `<div style="margin-top:8px"><div class="row"><b class="mono">${esc(i.path)}</b><span class="badge ${i.kind==='neu'?'ok':i.kind==='gelöscht'?'err':'warn'}">${i.kind}</span>${i.added!=null?`<span class="hint">+${i.added} −${i.removed}</span>`:''}</div>${i.diff?`<div class="diff">${i.diff.map(l=>`<div class="${l.startsWith('+')&&!l.startsWith('+++')?'a':l.startsWith('-')&&!l.startsWith('---')?'r':l.startsWith('@@')?'h':''}">${esc(l)||' '}</div>`).join('')}</div>`:'<div class="hint">Binärdatei</div>'}</div>`;
async function publish(){
try { const r = await api('POST', A('/publish'), {message: $('#pubmsg').value}); modal('m-pub', false); await load(true); renderSection(); toast('Version v'+r.version+' veröffentlicht'); }
catch(e){ toast(e.message, 1); }
}
/* ───────── Agent ───────── */
const CHIPS = ['Setze die Akzentfarbe auf #0B6E4F','Füge dem Prompt hinzu: Nenne Beträge immer in Euro mit Tausenderpunkt.','Lege ein eigenes PDF-CSS an und mache die Überschriften größer.','Prüfe den Tenant auf Probleme.'];
async function initAgent(){
$('#ag-chips').innerHTML = CHIPS.map(s=>`<span class="chip" onclick="sendChat(this.textContent)">${esc(s)}</span>`).join('');
$('#chat-text').addEventListener('keydown', e => { if(e.key==='Enter' && !e.shiftKey){ e.preventDefault(); sendChat(); } });
const st = await api('GET', '/api/llm/status?tenant=' + TID);
const src = {shared:'geteiltes Secret', env:'Env/.env', tenant:'Key des Tenants'}[st.source];
$('#ag-mode').textContent = st.configured ? `${st.model} · ${st.api}` : 'Demo-Modus – kein Key gefunden';
$('#ag-mode').className = 'badge ' + (st.configured ? 'cyan' : 'warn');
$('#ag-mode').title = st.configured ? 'Cortecs-Key aus: ' + src : (st.error || 'Key unter Einstellungen → Geteilte Secrets setzen oder Env CORTECS_API_KEY');
if(st.error) $('#ag-usage').textContent = '⚠ ' + st.error;
const h = await api('GET', A('/chat')); const box = $('#msgs');
if(!h.length) box.innerHTML = '<div class="empty">Der Agent arbeitet auf einem Entwurf und schlägt Änderungen nur vor – du gibst sie frei.</div>';
else h.forEach(m => box.appendChild(msgEl(m))); box.scrollTop = 1e9;
}
function stepsHtml(steps){ return (steps||[]).map(s=>`<div class="step ${s.error?'err':''}"><b>${esc(s.tool)}</b><span class="mono">${esc(s.arg)}</span> → ${esc(s.result)}</div>`).join(''); }
function propHtml(m){
if(!m.proposal) return '';
const items = (m.proposal.items||[]).map(i=>`<span class="badge ${i.kind==='neu'?'ok':i.kind==='gelöscht'?'err':'warn'}">${esc(i.path)}</span>`).join(' ');
return `<div class="prop"><b>Vorschlag:</b> ${esc(m.proposal.summary)}<div style="margin:5px 0">${items}</div><div class="row">${m.proposal_status==='open'
? `<button class="btn sm" onclick="showProposalDiff(${m.id})">Diff ansehen</button><button class="btn sm cyan" onclick="decide(${m.id},'apply')">In Entwurf übernehmen</button><button class="btn sm" onclick="decide(${m.id},'reject')">Verwerfen</button>`
: `<span class="badge ${m.proposal_status==='applied'?'ok':''}">${m.proposal_status==='applied'?'übernommen – prüfen & veröffentlichen':'verworfen'}</span>`}</div></div>`;
}
function msgEl(m){
const d = document.createElement('div'); d.className = 'bub '+m.role; d.id = 'm'+(m.id||'');
const u = m.usage && m.usage.in ? `<div class="hint" style="margin-top:5px">${esc(m.usage.model||'')} · ${m.usage.in}→${m.usage.out} Tokens</div>` : '';
d.innerHTML = (m.steps&&m.steps.length?`<div class="steps">${stepsHtml(m.steps)}</div>`:'') + esc(m.content) + propHtml(m) + u; return d;
}
async function sendChat(text){
const ta = $('#chat-text'); text = text || ta.value.trim(); if(!text) return; ta.value = '';
ui.mode = 'agent'; layout();
const box = $('#msgs'); box.querySelector('.empty')?.remove();
box.appendChild(msgEl({role:'user', content:text}));
const live = document.createElement('div'); live.className = 'bub assistant'; live.innerHTML = '<div class="steps"></div><div class="typing">Agent arbeitet …</div>'; box.appendChild(live); box.scrollTop = 1e9;
$('#chat-send').disabled = true;
try {
const r = await fetch(A('/chat'), {method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify({message:text})});
if(!r.ok) throw new Error((await r.json()).detail || 'Fehler '+r.status);
const rd = r.body.getReader(), dec = new TextDecoder(); let buf = '';
while(true){
const {value, done} = await rd.read(); if(done) break; buf += dec.decode(value, {stream:true});
let i; while((i = buf.indexOf('\n\n')) >= 0){
const ev = JSON.parse(buf.slice(6, i)); buf = buf.slice(i+2);
if(ev.type==='mode'){ ui.agentSrc = 'ws:' + ev.run; $('#pv-agent').textContent = 'Agent-Stand (live)'; setPvSrc('agent'); }
else if(ev.type==='changed'){ showPdfIfRelevant(ev.paths); refreshPreviewLive(); }
else if(ev.type==='step') live.querySelector('.steps').insertAdjacentHTML('beforeend', stepsHtml([ev]));
else if(ev.type==='thought') live.querySelector('.steps').insertAdjacentHTML('beforeend', `<div class="thought">${esc(ev.text)}</div>`);
else if(ev.type==='error') live.querySelector('.typing').textContent = '⚠ ' + ev.text;
else if(ev.type==='done'){ live.replaceWith(msgEl(ev.message));
if(ev.message.proposal){ showPdfIfRelevant((ev.message.proposal.items||[]).map(i=>i.path)); ui.agentSrc = 'proposal:' + ev.message.id; $('#pv-agent').textContent = 'Agent-Vorschlag'; setPvSrc('agent'); } else { ui.agentSrc = null; setPvSrc('draft'); }
refreshPreview(true); }
box.scrollTop = 1e9;
}
}
} catch(e){ live.remove(); toast(e.message, 1); }
$('#chat-send').disabled = false;
}
const PDF_PATH = /^(protocol\.|pdf_|office\.|preview_sample)/;
function showPdfIfRelevant(paths){
if(!(paths||[]).some(p => PDF_PATH.test(p))) return;
if(ui.pvKind !== 'pdf'){ ui.pvKind = 'pdf'; document.querySelectorAll('#pv-kind button').forEach(b => b.classList.toggle('on', b.dataset.k === 'pdf')); }
}
async function decide(id, d){
const m = await api('POST', A(`/chat/${id}/${d}`)); $('#m'+id).replaceWith(msgEl(m));
ui.agentSrc = null; setPvSrc('draft');
if(d==='apply'){ await load(true); renderNav(); headLogo(); try{ renderSectionContent(); }catch(e){} toast('Im Entwurf – Vorschau zeigt jetzt den Entwurf'); }
refreshPreview(true);
}
async function showProposalDiff(id){ const d = await api('GET', A(`/chat/${id}/diff`)); $('#diff-title').textContent = 'Vorschlag des Agenten'; $('#diff-body').innerHTML = d.items.length ? d.items.map(diffHtml).join('') : '<div class="empty">Keine Unterschiede zum Entwurf.</div>'; modal('m-diff', true); }
async function clearChat(){ if(!confirm('Chatverlauf dieses Tenants löschen?')) return; await api('DELETE', A('/chat')); $('#msgs').innerHTML = '<div class="empty">Verlauf geleert.</div>'; }
/* ───────── Betrieb ───────── */
async function sDomain(){
const dnsTarget = `${S.subdomain}.${S.base_domain}`;
const o = await api('GET', A('/oidc')); window._oidc = o;
const logout = `https://${S.host}/`;
const modeHelp = {off:'Nichts im Login anfassen (Client wird manuell gepflegt).', adopt:'Bestehenden Client übernehmen: fehlende Redirect-URIs ergänzen, sonst nichts ändern.', managed:'Client vollständig aus dieser Konfiguration verwalten (anlegen, Redirect-URIs, auth/totp, Freigaben).'};
$('#pane').innerHTML = `<div class="row" style="margin-bottom:8px"><h2 style="font-size:16px">Domain & Login</h2></div>
<div class="grid2">
<div class="card"><h3>Domain</h3>
<div class="fgrid"><label class="f">Subdomain<input type="text" id="d-sub" value="${esc(S.subdomain)}"></label><label class="f">Basis-Domain<input type="text" value="${esc(S.base_domain)}" disabled></label>
<label class="f full">Eigene Domain (optional)<input type="text" id="d-cust" value="${esc(S.custom_domain)}" placeholder="protokoll.kunde.de"></label></div>
<div class="row" style="margin-top:8px"><button class="btn sm" onclick="saveDomain()">Speichern</button><button class="btn sm" onclick="checkDomain()">DNS prüfen</button>
<span class="badge ${S.domain_status==='verified'?'ok':'warn'}">${S.domain_status==='verified'?'verifiziert':S.domain_status==='pending'?'DNS ausstehend':'nicht eingerichtet'}</span></div>
<div id="dns-msg" class="hint" style="margin-top:6px"></div>
${S.custom_domain?`<table class="t" style="margin-top:10px"><thead><tr><th>Typ</th><th>Name</th><th>Wert</th></tr></thead><tbody><tr><td>CNAME</td><td class="mono">${esc(S.custom_domain)}</td><td class="mono copy" onclick="copyText('${esc(dnsTarget)}')">${esc(dnsTarget)}</td></tr></tbody></table><p class="hint">Alternativ A-Record auf die Projekt-IP aus mStudio. TLS stellt Mittwald automatisch aus.</p>`:''}
<p class="hint" style="margin-top:8px">Redirect-URIs im Login ergeben sich aus Subdomain und eigener Domain und werden beim Deploy mit abgeglichen.</p></div>
<div class="card"><h3>OIDC-Client <span class="muted" style="font-weight:400">· Abgleich über die Login-Developer-API bei jedem Deploy</span></h3>
<div class="row" style="margin-bottom:8px">
<span class="badge ${o.api_key?'ok':'err'}">API-Key ${o.api_key?'vorhanden':'fehlt'}</span>
<span class="badge ${o.writes_allowed?'warn':''}">Schreiben ${o.writes_allowed?'erlaubt':'gesperrt'}</span>
<span class="badge ${o.client_id?'ok':'warn'} mono">${o.client_id?esc(o.client_id):'noch kein Client'}</span>
<span class="badge ${o.secret_set?'ok':'warn'}">Secret ${o.secret_set?'gesetzt':'fehlt'}</span></div>
<div class="fgrid">
<label class="f full">Verwaltung<select id="oc-mode">${['off','adopt','managed'].map(m=>`<option value="${m}" ${o.mode===m?'selected':''}>${m}</option>`).join('')}</select></label>
<div class="hint full" id="oc-modehelp" style="margin-top:-4px">${modeHelp[o.mode]}</div>
<label class="f">Zugriff (auth_mode)<select id="oc-auth">${['required','optional','disabled'].map(m=>`<option ${o.auth_mode===m?'selected':''}>${m}</option>`).join('')}</select></label>
<label class="f">2FA (totp_mode)<select id="oc-totp">${['disabled','optional','required'].map(m=>`<option ${o.totp_mode===m?'selected':''}>${m}</option>`).join('')}</select></label>
<label class="f full">Backchannel-Logout-URI (optional, öffentlich, https)<input type="text" id="oc-bc" value="${esc(o.backchannel_logout_uri||'')}"></label>
<label class="chk full"><input type="checkbox" id="oc-reg" ${o.allow_registration?'checked':''}><span><b>Selbstregistrierung</b> <span class="hint">Nutzer dürfen sich über diese App selbst registrieren.</span></span></label></div>
<div style="margin:4px 0 8px"><div class="hint">Redirect-URIs (Soll):</div>${o.redirect_uris.map(u=>`<div class="mono copy" onclick="copyText('${esc(u)}')">${esc(u)}</div>`).join('')}
<div class="hint">Logout-Ziel: <span class="mono">${esc(logout)}</span> (Core: OIDC_LOGOUT_REDIRECT_URI)</div></div>
<div class="row"><button class="btn sm" onclick="saveOidc(true)">Speichern</button>
<button class="btn sm" onclick="syncOidc(false)" ${o.api_key?'':'disabled'} title="Liest nur – zeigt, was beim Deploy passieren würde">Gegen Login prüfen</button>
<button class="btn sm cyan" onclick="syncOidc(true)" ${o.api_key&&o.writes_allowed&&o.mode!=='off'?'':'disabled'} title="${o.writes_allowed?'':'Gesperrt: OIDC_SYNC_LIVE/DEPLOY_LIVE nicht aktiv'}">Jetzt abgleichen</button>
<button class="btn sm danger" onclick="syncOidc(true,true)" ${o.api_key&&o.writes_allowed&&o.client_id?'':'disabled'} title="Neues Secret erzeugen; das alte wird sofort ungültig">Secret rotieren</button></div>
${o.last_sync?`<div class="hint" style="margin-top:6px">Letzter Abgleich: ${fmt(o.last_sync.ts)} · <span class="${o.last_sync.ok?'':'muted'}">${o.last_sync.ok?'✔ ok':'✘ '+esc(o.last_sync.error||'')}</span>${o.last_sync.created?' · Client neu angelegt':''}</div>`:''}
<div id="oc-out" style="margin-top:8px"></div></div></div>
<div class="grid2" style="margin-top:12px">
<div class="card"><div class="row" style="margin-bottom:6px"><h3 style="margin:0" class="grow">Freigaben</h3>
<button class="btn sm" onclick="importMembers()" ${o.api_key&&o.client_id?'':'disabled'} title="Aktuelle Freigaben aus dem Login lesen und hier übernehmen">Aus Login übernehmen</button></div>
<p class="sub">Wer darf die App nutzen (bei auth_mode „required“). Nutzer müssen im Login bereits existieren.</p>
<table class="t" id="oc-members"><tbody></tbody></table>
<div class="row" style="margin-top:6px"><button class="btn sm" onclick="addMember()">+ Nutzer</button>
<label class="f" style="display:flex;align-items:center;gap:6px;margin-left:auto">Abgleich<select id="oc-mm" style="width:auto;margin:0"><option value="add" ${o.members_mode==='add'?'selected':''}>nur ergänzen</option><option value="exact" ${o.members_mode==='exact'?'selected':''}>exakt (zusätzliche entziehen)</option></select></label></div></div>
<div class="card"><h3>Manuell eintragen</h3><p class="sub">Falls der Client nicht automatisch verwaltet wird (Verwaltung „off“) oder das Secret aus dem Altbestand übernommen werden soll.</p>
<div class="fgrid"><label class="f">Client-ID<input type="text" id="o-id" placeholder="${o.client_id?esc(o.client_id)+' – neu eintragen zum Ändern':'noch nicht gesetzt'}"></label>
<label class="f">Client-Secret<input type="password" id="o-sec" placeholder="${o.secret_set?'●●●● gesetzt':'noch nicht gesetzt'}"></label></div>
<div class="row" style="margin-top:8px"><button class="btn sm" onclick="saveManual()">Speichern</button></div></div></div>
<div class="card" style="margin-top:12px"><h3>Bundle-Abruf durch den Container</h3><p class="sub">So holt der Container beim Start seine Konfiguration (ohne Secrets); Token = <span class="mono">ADMIN_BUNDLE_TOKEN</span>. Der Core kann das noch nicht – Schnittstelle steht.</p>
<pre class="json">curl -H "Authorization: Bearer $ADMIN_BUNDLE_TOKEN" \\\n ${location.origin}/api/tenants/${TID}/bundle -o bundle.tar.gz</pre></div>`;
renderMembers(o.members);
$('#oc-mode').addEventListener('change', e => { $('#oc-modehelp').textContent = modeHelp[e.target.value]; });
}
function renderMembers(ms){
window._members = ms.map(m => ({...m}));
$('#oc-members tbody').innerHTML = window._members.length ? window._members.map((m,i)=>`<tr><td><input type="text" value="${esc(m.email)}" onchange="_members[${i}].email=this.value.trim().toLowerCase()" style="width:100%"></td>
<td style="width:100px"><select onchange="_members[${i}].role=this.value">${['viewer','editor','admin'].map(r=>`<option ${m.role===r?'selected':''}>${r}</option>`).join('')}</select></td>
<td style="width:30px"><button class="btn sm danger" onclick="_members.splice(${i},1);renderMembers(_members)">✕</button></td></tr>`).join('') : '<tr><td class="empty">Keine Freigaben gepflegt – bestehende bleiben unverändert (Abgleich „nur ergänzen“).</td></tr>';
}
function addMember(){ window._members.push({email:'', role:'viewer'}); renderMembers(window._members); }
async function saveOidc(toastOk){
try{
await api('PUT', A('/oidc'), {mode:$('#oc-mode').value, auth_mode:$('#oc-auth').value, totp_mode:$('#oc-totp').value, allow_registration:$('#oc-reg').checked,
backchannel_logout_uri:$('#oc-bc').value, members:window._members.filter(m=>m.email), members_mode:$('#oc-mm').value});
if(toastOk) { toast('OIDC-Konfiguration gespeichert'); sDomain(); } return true;
}catch(e){ toast(e.message,1); return false; }
}
async function syncOidc(apply, rotate){
if(!await saveOidc(false)) return;
if(rotate && !confirm('Secret rotieren? Das alte Secret ist sofort ungültig – der Container braucht danach einen Deploy.')) return;
if(apply && !rotate && !confirm('Jetzt im Login-System schreiben (Client anlegen/anpassen, Freigaben setzen)?')) return;
$('#oc-out').innerHTML = '<span class="muted">Frage das Login-System …</span>';
try{
const r = await api('POST', A('/oidc/sync'), {apply, rotate});
const acts = r.actions.map(a=>`<div>• ${esc(a.detail)}</div>`).join('') || (r.skipped?`<div>${esc(r.skipped)}</div>`:'<div>✔ Alles abgeglichen – keine Änderungen nötig.</div>');
$('#oc-out').innerHTML = `<div class="banner ${r.ok?(r.actions.length&&!apply?'info':'ok'):'err'}" style="margin:0"><b>${apply?'Abgleich':'Prüfung (nur lesen)'}:</b> ${r.ok?'':'✘ '+esc(r.error)}${acts}${r.warnings.map(w=>`<div>⚠ ${esc(w)}</div>`).join('')}</div>`;
if(apply){ const keep = $('#oc-out').innerHTML; await load(true); await sDomain(); $('#oc-out').innerHTML = keep; }
}catch(e){ $('#oc-out').innerHTML = `<div class="banner err" style="margin:0">${esc(e.message)}</div>`; }
}
async function importMembers(){ try{ const o = await api('POST', A('/oidc/import-members')); renderMembers(o.members); toast(o.members.length+' Freigaben übernommen – speichern nicht vergessen'); }catch(e){ toast(e.message,1) } }
async function saveManual(){ const id=$('#o-id').value, sc=$('#o-sec').value; if(id) await api('PUT',A('/secrets/OIDC_CLIENT_ID'),{value:id}); if(sc) await api('PUT',A('/secrets/OIDC_CLIENT_SECRET'),{value:sc}); toast('Gespeichert'); sDomain(); }
async function saveDomain(){ try{ await api('PUT', A('/domain'), {subdomain:$('#d-sub').value, custom_domain:$('#d-cust').value}); await load(true); sDomain(); toast('Domain gespeichert'); }catch(e){ toast(e.message,1) } }
async function checkDomain(){ $('#dns-msg').textContent='Prüfe …'; const r = await api('POST', A('/domain/check')); await load(true); await sDomain(); $('#dns-msg').textContent = r.message; }
async function sSecrets(){
const sec = await api('GET', A('/secrets')); renderNav({missing: sec.some(s=>s.required&&!s.set)});
$('#pane').innerHTML = `<div class="row" style="margin-bottom:8px"><h2 style="font-size:16px">Secrets</h2></div><div class="card"><p class="sub">Verschlüsselt in der Datenbank. Werte werden nie angezeigt und gelangen nie in Agent-Kontext oder Bundle – nur beim Deploy als Env an den Container.</p>
<table class="t"><thead><tr><th>Schlüssel</th><th>Status</th><th>Bedeutung</th><th></th></tr></thead><tbody>${sec.map(s=>`<tr><td class="mono nowrap">${esc(s.key)}${s.required?' <span class="badge">Pflicht</span>':''}</td>
<td>${s.set?'<span class="badge ok">gesetzt</span>':s.source==='sops-import'?'<span class="badge warn">noch in SOPS</span>':'<span class="badge err">fehlt</span>'}</td><td class="muted">${esc(s.help)}</td>
<td style="text-align:right" class="nowrap"><button class="btn sm" onclick="setSecret('${s.key}')">Wert setzen</button> ${['SESSION_SECRET','ADMIN_BUNDLE_TOKEN'].includes(s.key)?`<button class="btn sm" onclick="genSecret('${s.key}')">Neu erzeugen</button>`:''}</td></tr>`).join('')}</tbody></table>
<div class="row" style="margin-top:8px"><button class="btn sm" onclick="addSecret()">+ Weiteres Secret</button><span class="hint">Bestehende Tenants: <span class="mono">python scripts/import_tenants.py --with-secrets</span> liest SOPS und verschlüsselt neu.</span></div></div>`;
}
async function setSecret(k){ const v = prompt('Neuer Wert für '+k+':'); if(v===null) return; await api('PUT', A('/secrets/'+k), {value:v}); toast('Gespeichert'); sSecrets(); }
async function addSecret(){ const k = prompt('Name (z. B. MISTRAL_API_KEY):'); if(!k) return; await setSecret(k.trim().toUpperCase()); }
async function genSecret(k){ if(!confirm(k+' neu erzeugen? Der Container braucht danach einen Deploy.')) return; await api('PUT', A('/secrets/'+k), {generate:true}); toast('Neu erzeugt'); sSecrets(); }
async function followDeploy(id, con){
con.classList.remove('hide'); con.style.display = 'block';
const tick = async () => { const d = await api('GET','/api/deployments/'+id); con.innerHTML = esc(d.log).replace(/\[DRY-RUN\]/g,'<span class="sim">[DRY-RUN]</span>'); con.scrollTop = 1e9;
if(d.status==='running'||d.status==='queued') setTimeout(tick,500); else { await load(true); if(ui.sec==='deploy') sDeploy(true); } };
tick();
}
async function sDeploy(keepConsole){
const [plan, hist] = await Promise.all([api('GET', A('/plan')), api('GET', '/api/deployments')]);
const mine = hist.filter(d => d.tenant === TID); const m = plan.mode;
const old = keepConsole ? $('#con')?.innerHTML : null;
$('#pane').innerHTML = `<div class="row" style="margin-bottom:8px"><h2 style="font-size:16px">Deploy</h2></div>
${m.live?`<div class="banner ${m.tenant_allowed?'err':'warn'}">Live-Modus aktiv · freigegebene Tenants: <b>${esc(m.allowed)}</b> ${m.tenant_allowed?'– dieser Tenant darf live deployen.':'– dieser Tenant ist <b>nicht</b> freigegeben (nur Dry-Run).'}</div>`:'<div class="banner warn">Echte Deployments sind <b>gesperrt</b> (<span class="mono">DEPLOY_LIVE</span> nicht aktiv). Dry-Run läuft mit demselben Code und zeigt, was geschrieben würde.</div>'}
<div class="card"><div class="row" style="margin-bottom:6px"><h3 style="margin:0" class="grow">Vorflug-Check <span class="muted" style="font-weight:400">· ist alles bereit für den Live-Deploy? (liest nur)</span></h3><button class="btn sm" onclick="runPreflight()">Prüfen</button></div><div id="pf"><span class="hint">Noch nicht ausgeführt.</span></div></div>
<div class="card" style="margin-top:12px"><h3>Geplanter Rollout</h3>
<dl class="kv"><dt>Service</dt><dd class="mono">${esc(plan.service)}</dd><dt>Image</dt><dd class="mono">${esc(plan.patch.image)}</dd><dt>Version</dt><dd>v${S.current_version} ${S.has_draft?'<span class="badge warn">Entwurf wird nicht deployt</span>':''}</dd><dt>Ingress</dt><dd class="mono">${esc(plan.ingress.host)} → ${esc(plan.ingress.target)}</dd>
<dt>Voraussetzungen</dt><dd><span class="badge ${m.token?'ok':'warn'}">API-Token</span> <span class="badge ${m.project?'ok':'warn'}">Projekt-ID</span> ${S.source==='admin'?`<span class="badge ${m.core_bundle?'ok':'err'}">Core-Bundle</span>`:''}
<span class="badge ${plan.oidc.mode==='off'?'':plan.oidc.key?'ok':'warn'}" title="Wird bei jedem Deploy vorher mit der Login-API abgeglichen">OIDC: ${plan.oidc.mode}${plan.oidc.mode!=='off'&&!plan.oidc.key?' · API-Key fehlt':''}</span></dd></dl>
${plan.missing_secrets.length?`<div class="banner warn" style="margin-top:8px">Secrets ohne Wert: <span class="mono">${esc(plan.missing_secrets.join(', '))}</span></div>`:''}
<details class="fold"><summary>Service-Definition (PATCH-Body, Secrets maskiert)</summary><div class="in"><pre class="json">${esc(JSON.stringify(plan.patch,null,2))}</pre></div></details>
<div class="row" style="margin-top:10px"><button class="btn" onclick="startDeploy(true)">Dry-Run</button><button class="btn" onclick="recheckHealth()" title="Prüft https://<Host>/health erneut – z. B. wenn das Zertifikat bei Mittwald inzwischen fertig ist">Health erneut prüfen</button><button class="btn danger ${m.live?'':'disabled'}" onclick="rollbackTenant()" title="Auf den vorherigen erfolgreich deployten Core-Tag zurück">Zurückrollen</button><button class="btn cyan ${m.live&&m.tenant_allowed?'':'disabled'}" onclick="startDeploy(false)" title="${!m.live?'Gesperrt: DEPLOY_LIVE nicht aktiv':!m.tenant_allowed?'Gesperrt: Tenant nicht in DEPLOY_ALLOWED_TENANTS':''}">Deployen</button></div>
<div class="console hide" id="con" style="margin-top:10px"></div></div>
<div class="card" style="margin-top:12px"><h3>Verlauf</h3>${mine.length?`<table class="t"><thead><tr><th>Zeit</th><th>Art</th><th>Version</th><th>Status</th></tr></thead><tbody>${mine.map(d=>`<tr><td>${fmt(d.created_at)}</td><td>${kindLabel[d.kind]||d.kind} ${d.simulated?'<span class="badge">Dry-Run</span>':'<span class="badge err">live</span>'}</td><td>v${d.version}</td><td><span class="badge ${d.status==='success'?'ok':d.status==='failed'?'err':d.status==='warning'?'warn':''}">${d.status}</span></td></tr>`).join('')}</tbody></table>`:'<div class="empty">Noch keine Deployments für diesen Tenant.</div>'}</div>`;
if(old){ $('#con').classList.remove('hide'); $('#con').innerHTML = old; }
}
async function runPreflight(){
$('#pf').innerHTML = '<span class="muted">Prüfe (liest Mittwald-Stack, Login-API, Rendering) …</span>';
try{
const r = await api('GET', A('/preflight'));
const dot = {ok:'ok', fail:'no', warn:'no', info:''}; const ic = {ok:'✓', fail:'✕', warn:'!', info:'i'};
$('#pf').innerHTML = `<div class="banner ${r.ready?'ok':'err'}" style="margin-bottom:6px">${r.ready?'✔ Bereit für den Live-Deploy (keine harten Fehler)':'✘ Noch nicht bereit – bitte die roten Punkte beheben'} · Ziel <span class="mono">${esc(r.host)}</span> · Core <span class="mono">:${esc(String(r.tag).slice(0,12))}</span></div>`+
r.items.map(i=>`<div class="check"><span class="dot ${dot[i.level]}" ${i.level==='warn'?'style="background:#fbf0cf;color:#8a6200"':''}>${ic[i.level]}</span><span><b>${esc(i.label)}</b> <span class="muted">${esc(i.detail)}</span>${i.fix?`<div class="hint">→ ${esc(i.fix)}</div>`:''}</span></div>`).join('');
}catch(e){ $('#pf').innerHTML = `<div class="banner err">${esc(e.message)}</div>`; }
}
async function recheckHealth(){
const con = $('#con'); con.classList.remove('hide'); con.style.display='block'; con.textContent = 'Prüfe https://' + S.host + '/health … (bis ca. 40 s)';
try{ const r = await api('POST', A('/health-check')); con.innerHTML = esc(r.log.join('\n')) + (r.ok ? '\n✔ Health ok' : ''); if(r.ok){ await load(true); sDeploy(true); } }catch(e){ con.textContent = e.message; }
}
async function rollbackTenant(){ if(!confirm('Auf den vorherigen Core-Tag zurückrollen?')) return; try{ const r = await api('POST', A('/rollback')); toast('Rollback auf :'+String(r.tag).slice(0,10)+' gestartet'); followDeploy(r.id, $('#con')); }catch(e){ toast(e.message,1) } }
async function startDeploy(dry){
if(!dry && !confirm('Tenant jetzt auf Mittwald deployen?')) return;
try{ const r = await api('POST', A('/deploy'), {dry_run:dry}); followDeploy(r.id, $('#con')); }catch(e){ toast(e.message,1) }
}
/* ───────── Verlauf ───────── */
async function sVersions(){
const v = await api('GET', A('/versions'));
$('#pane').innerHTML = `<div class="row" style="margin-bottom:8px"><h2 style="font-size:16px">Verlauf</h2></div><div class="card"><p class="sub">Jede Veröffentlichung ist ein unveränderlicher Snapshot. Wiederherstellen legt die alte Version als <b>Entwurf</b> an – nichts wird überschrieben.</p>
<table class="t"><thead><tr><th>Version</th><th>Nachricht</th><th>Autor</th><th>Zeit</th><th>Dateien</th><th></th></tr></thead><tbody>${v.map(x=>`<tr><td class="nowrap"><b>v${x.number}</b> ${x.current?'<span class="badge dark">aktuell</span>':''} ${x.deployed?'<span class="badge ok">deployt</span>':''}</td><td>${esc(x.message)}</td><td>${esc(x.author)}</td><td class="nowrap">${fmt(x.created_at)}</td><td>${x.files}</td>
<td style="text-align:right" class="nowrap">${x.current?'':`<button class="btn sm" onclick="showDiff('v${x.number}','current','v${x.number} → aktuell')">Diff</button> <button class="btn sm" onclick="restoreV(${x.number})">Als Entwurf</button>`}</td></tr>`).join('')}</tbody></table></div>`;
}
async function showDiff(a,b,t){ const d = await api('GET', A(`/diff?a=${a}&b=${b}`)); $('#diff-title').textContent = 'Diff '+t; $('#diff-body').innerHTML = d.items.length ? d.items.map(diffHtml).join('') : '<div class="empty">Keine Unterschiede.</div>'; modal('m-diff', true); }
async function restoreV(n){ if(!confirm('v'+n+' als Entwurf wiederherstellen? Ein bestehender Entwurf wird ersetzt.')) return; await api('POST', A(`/versions/${n}/restore`)); await load(true); location.hash = 'brand'; renderSection(); toast('v'+n+' liegt jetzt im Entwurf'); }
(async () => { await loadSchema(); await load(); initAgent(); })();