Admin-Plattform für die Tenants von Onyscribe: Editor mit Live-Vorschau (echter Core in Sandbox), Versionen, KI-Agent (Cortecs), OIDC-Abgleich über die Login-Developer-API, Deploy-Engine (Mittwald, gesperrt ohne DEPLOY_LIVE, Freigabe pro Tenant), Core-Vertrag (Schema/Manifest), Core-Releases mit Kompatibilitätsprüfung, Canary-Rollout und Rollback, Zustandsabgleich. Runbook: docs/RUNBOOK_PARALLELTEST.md Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
599 lines
63 KiB
JavaScript
599 lines
63 KiB
JavaScript
/* 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 & 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; renderNav(); layout(); if(ui.mode === 'agent') $('#chat-text').focus(); });
|
||
function renderSection(){
|
||
renderNav(); layout();
|
||
({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]();
|
||
}
|
||
|
||
/* ───────── 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 & 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 & 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- & 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 & 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 & 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'){ 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){ 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;
|
||
}
|
||
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(); if(ui.sec==='overview') sOverview(); 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 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}</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 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(); })();
|