Onyscribe Admin: Initialer Stand

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>
This commit is contained in:
RubenRWU
2026-10-15 15:30:26 +02:00
commit 7d8c226da2
89 changed files with 13975 additions and 0 deletions

170
app/static/admin.css Normal file
View File

@@ -0,0 +1,170 @@
:root{
--creme:#f4efe8; --dark:#1a1a1a; --cyan:#00cccc; --cyan-d:#009e9e; --white:#fff; --stone:#eceae4; --mist:#f7f4f0;
--border:#ddd8d0; --gray:#888; --mid:#555; --ok:#2a8a2a; --warn:#e0a000; --err:#c0392b;
--font:"Liberation Sans",Arial,Helvetica,sans-serif; --mono:ui-monospace,"JetBrains Mono","Liberation Mono",Menlo,monospace;
}
*{box-sizing:border-box}
html,body{margin:0;height:100%}
body{font-family:var(--font);background:var(--creme);color:#2c2c2c;font-size:13px;line-height:1.45;display:flex}
a{color:inherit}
h1,h2,h3{margin:0;color:var(--dark)}
button,input,select,textarea{font:inherit}
.muted{color:var(--gray)} .mono{font-family:var(--mono);font-size:12px} .grow{flex:1;min-width:0}
.row{display:flex;gap:8px;align-items:center;flex-wrap:wrap}
.nowrap{white-space:nowrap}
/* ── Sidebar ─────────────────────────────────────────────── */
.side{width:196px;flex:none;background:var(--dark);color:#d8d4cc;display:flex;flex-direction:column;height:100vh;position:sticky;top:0;border-right:2px solid var(--cyan)}
.brand{padding:18px 18px 12px}
.brand b{color:#fff;font-size:19px;letter-spacing:-.3px}
.brand span{display:block;color:var(--cyan);font-size:9.5px;font-weight:700;letter-spacing:2px;text-transform:uppercase}
.nav{padding:6px 8px;display:flex;flex-direction:column;gap:1px}
.nav a{display:flex;gap:9px;align-items:center;padding:7px 10px;border-radius:7px;text-decoration:none;color:#bdb8ae;font-weight:600}
.nav a:hover{background:#262626;color:#fff} .nav a.on{background:#262626;color:#fff;box-shadow:inset 3px 0 0 var(--cyan)}
.nav svg{width:15px;height:15px;flex:none}
.side-foot{margin-top:auto;padding:12px 16px;font-size:11px;color:#8a867e;border-top:1px solid #2c2c2c;display:flex;flex-direction:column;gap:6px}
.pill{display:inline-block;border-radius:4px;padding:1px 7px;font-weight:700;letter-spacing:.8px;font-size:9.5px;text-transform:uppercase;width:fit-content}
.pill.lock{background:#3a2f12;color:#f0c55a} .pill.live{background:#4a1d17;color:#ff9a8a} .pill.dev{background:#2c2c2c;color:var(--cyan)}
.side-foot a{color:#bdb8ae}
/* ── Seiten-Layout ───────────────────────────────────────── */
.main{flex:1;min-width:0;display:flex;flex-direction:column;height:100vh;overflow:auto}
.page{padding:22px 28px 50px;max-width:1500px;width:100%}
.eyebrow{font-size:10.5px;font-weight:700;letter-spacing:1.8px;color:var(--gray);text-transform:uppercase}
.eyebrow::before{content:"— ";color:var(--cyan)}
.title{font-size:24px;font-weight:700;letter-spacing:-.4px;margin:2px 0 0}
.lead{color:var(--mid);margin:4px 0 0;max-width:760px}
.head{display:flex;gap:16px;align-items:flex-end;justify-content:space-between;margin-bottom:16px;flex-wrap:wrap}
/* ── Cards ───────────────────────────────────────────────── */
.card{background:var(--white);border:1px solid var(--border);border-radius:10px;padding:14px 16px}
.card + .card,.card + .grid2,.grid2 + .card{margin-top:12px}
.card h3{font-size:13.5px;margin-bottom:8px}
.card .sub{color:var(--gray);font-size:12px;margin:-4px 0 10px}
.grid2{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:12px}
.grid2 > .card{margin:0!important}
.kpis{display:flex;gap:8px;flex-wrap:wrap;margin-bottom:12px}
.kpi{background:var(--white);border:1px solid var(--border);border-radius:9px;padding:7px 13px;display:flex;gap:9px;align-items:baseline;cursor:default}
.kpi b{font-size:18px;color:var(--dark)} .kpi span{color:var(--gray);font-size:10.5px;font-weight:700;letter-spacing:1.2px;text-transform:uppercase}
.kpi.click{cursor:pointer} .kpi.click:hover{border-color:var(--cyan)}
/* ── Badges / Buttons ────────────────────────────────────── */
.badge{display:inline-flex;align-items:center;gap:4px;font-size:10.5px;font-weight:700;border-radius:999px;padding:1px 8px;background:var(--stone);color:var(--mid);white-space:nowrap;line-height:1.6}
.badge.ok{background:#e3f3e3;color:var(--ok)} .badge.warn{background:#fbf0cf;color:#8a6200}
.badge.err{background:#f8dcd8;color:var(--err)} .badge.cyan{background:#d6f5f5;color:#007878} .badge.dark{background:var(--dark);color:#fff}
.btn{display:inline-flex;align-items:center;gap:6px;border:1px solid var(--border);background:var(--white);color:var(--dark);border-radius:8px;padding:6px 12px;font-weight:700;cursor:pointer;text-decoration:none;font-size:12.5px;white-space:nowrap}
.btn:hover{border-color:var(--dark)} .btn.primary{background:var(--dark);color:#fff;border-color:var(--dark)}
.btn.cyan{background:var(--cyan);border-color:var(--cyan);color:#002b2b} .btn.cyan:hover{background:var(--cyan-d);border-color:var(--cyan-d);color:#fff}
.btn.danger{color:var(--err)} .btn.danger:hover{border-color:var(--err)} .btn.sm{padding:3px 9px;font-size:11.5px;border-radius:6px}
.btn[disabled],.btn.disabled{opacity:.45;pointer-events:none}
.seg{display:inline-flex;background:var(--stone);border-radius:8px;padding:2px;gap:1px}
.seg button{border:0;background:none;padding:4px 10px;border-radius:6px;font-weight:700;color:var(--mid);cursor:pointer;font-size:12px}
.seg button.on{background:var(--white);color:var(--dark);box-shadow:0 1px 2px rgba(0,0,0,.14)}
.chipbar{display:flex;gap:5px;flex-wrap:wrap}
.fchip{border:1px solid var(--border);background:var(--white);border-radius:999px;padding:3px 11px;font-size:12px;font-weight:700;color:var(--mid);cursor:pointer}
.fchip.on{background:var(--dark);color:#fff;border-color:var(--dark)}
input.search{border:1px solid var(--border);border-radius:8px;padding:6px 10px;min-width:220px;background:#fff}
/* ── Tabellen ────────────────────────────────────────────── */
table.t{width:100%;border-collapse:collapse}
table.t th{text-align:left;font-size:10.5px;font-weight:700;letter-spacing:1.2px;text-transform:uppercase;color:var(--gray);padding:6px 9px;border-bottom:1px solid var(--border);white-space:nowrap}
table.t td{padding:7px 9px;border-bottom:1px solid var(--stone);vertical-align:middle}
table.t tr:last-child td{border-bottom:0}
table.t.hover tbody tr{cursor:pointer} table.t.hover tbody tr:hover td{background:var(--mist)}
.logo-box{width:34px;height:34px;border:1px solid var(--border);border-radius:7px;background:var(--mist);display:grid;place-items:center;flex:none;overflow:hidden}
.logo-box img{max-width:28px;max-height:28px}
.logo-box.lg{width:120px;height:56px} .logo-box.lg img{max-width:104px;max-height:44px}
.swatches{display:inline-flex;gap:2px;vertical-align:middle}
.swatches i{width:12px;height:12px;border-radius:3px;border:1px solid rgba(0,0,0,.14);display:block}
.dotc{width:8px;height:8px;border-radius:50%;display:inline-block;margin-right:5px;background:var(--stone)}
.dotc.ok{background:var(--ok)} .dotc.warn{background:var(--warn)} .dotc.err{background:var(--err)} .dotc.cyan{background:var(--cyan)}
/* ── Tenant-Seite ────────────────────────────────────────── */
.thead{padding:12px 20px 10px;display:flex;gap:12px;align-items:center;flex-wrap:wrap;border-bottom:1px solid var(--border);background:var(--creme);position:sticky;top:0;z-index:6}
.thead h1{font-size:17px;letter-spacing:-.2px;line-height:1.2}
.crumb{font-size:11px;color:var(--gray)} .crumb a{text-decoration:none} .crumb a:hover{color:var(--dark)}
.dirty{display:inline-flex;gap:6px;align-items:center;font-size:11.5px;font-weight:700;color:#8a6200;background:#fbf0cf;border-radius:999px;padding:2px 10px}
.dirty::before{content:"";width:7px;height:7px;border-radius:50%;background:var(--warn)}
.mid{min-width:0}
.tl{display:grid;grid-template-columns:176px minmax(0,1fr);flex:1;min-height:0;align-items:start}
.tl.open{grid-template-columns:176px minmax(340px,5fr) minmax(360px,6fr)}
.snav{position:sticky;top:53px;padding:12px 8px 20px 14px;display:flex;flex-direction:column;gap:1px;max-height:calc(100vh - 53px);overflow:auto}
.snav h6{margin:12px 8px 3px;font-size:9.5px;letter-spacing:1.6px;text-transform:uppercase;color:var(--gray)}
.snav h6:first-child{margin-top:0}
.snav button{display:flex;align-items:center;gap:7px;border:0;background:none;text-align:left;padding:6px 9px;border-radius:7px;font-weight:600;color:var(--mid);cursor:pointer;width:100%}
.snav button:hover{background:var(--stone);color:var(--dark)} .snav button.on{background:var(--white);color:var(--dark);box-shadow:0 1px 3px rgba(0,0,0,.1),inset 3px 0 0 var(--cyan)}
.snav .n{margin-left:auto;font-size:10.5px}
.pane{padding:14px 18px 60px;min-width:0;container-type:inline-size}
@container (max-width:780px){.grid2{grid-template-columns:1fr}.fgrid.c3{grid-template-columns:1fr 1fr}}
.panel{position:sticky;top:53px;height:calc(100vh - 53px);padding:10px 20px 14px 4px;display:none;flex-direction:column;gap:8px;min-width:0}
.tl.open .panel{display:flex}
.pbar{display:flex;gap:6px;align-items:center;flex-wrap:wrap}
.pv-frame{flex:1;background:var(--white);border:1px solid var(--border);border-radius:10px;position:relative;overflow:hidden;display:flex;justify-content:center;background-image:radial-gradient(#e6e1d8 1px,transparent 1px);background-size:14px 14px}
.pv-frame iframe{border:0;background:#fff;height:100%;width:100%;transition:width .2s;box-shadow:0 0 0 1px var(--border)}
.pv-frame.tablet iframe{width:768px;max-width:100%} .pv-frame.mobile iframe{width:390px;max-width:100%}
.pv-load{position:absolute;inset:0;background:rgba(244,239,232,.72);display:none;place-items:center;font-weight:700;color:var(--mid)} .pv-load.on{display:grid}
.pv-err{position:absolute;inset:0;background:#fff;padding:16px;overflow:auto;display:none;color:var(--err);font-family:var(--mono);font-size:12px;white-space:pre-wrap} .pv-err.on{display:block}
.hide{display:none!important}
/* ── Formulare ───────────────────────────────────────────── */
.fgrid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:8px 12px} .fgrid.c3{grid-template-columns:repeat(3,minmax(0,1fr))}
.fgrid .full{grid-column:1/-1}
label.f{display:block;font-size:10.5px;font-weight:700;letter-spacing:.9px;text-transform:uppercase;color:var(--gray)}
label.f > input,label.f > select,label.f > textarea{display:block;width:100%;margin-top:3px;text-transform:none;letter-spacing:0;font-weight:400;color:#222}
input[type=text],input[type=email],input[type=url],input[type=password],input[type=number],select,textarea{border:1px solid var(--border);border-radius:7px;padding:5px 9px;background:#fff;outline:none;font-size:12.5px}
input:focus,select:focus,textarea:focus{border-color:var(--cyan);box-shadow:0 0 0 3px rgba(0,204,204,.16)}
textarea{width:100%;resize:vertical;line-height:1.5}
textarea.code{font-family:var(--mono);font-size:12px;tab-size:4;min-height:300px;background:#fbfaf8;white-space:pre}
.colors{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:6px 14px}
.colorf{display:flex;gap:7px;align-items:center;min-width:0}
.colorf input[type=color]{width:24px;height:24px;border:1px solid var(--border);border-radius:5px;padding:0;background:none;cursor:pointer;flex:none}
.colorf span{font-size:11px;color:var(--mid);flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.colorf input[type=text]{width:74px;padding:2px 5px;font-family:var(--mono);font-size:11.5px}
.chk{display:flex;gap:8px;align-items:flex-start;padding:5px 0}.chk input{margin-top:2px;accent-color:var(--dark)}
.hint{font-size:11.5px;color:var(--gray)}
.banner{border-radius:8px;padding:7px 12px;font-size:12.5px;margin-bottom:10px;border:1px solid}
.banner.warn{background:#fdf6df;border-color:#ecd48a;color:#6f5200} .banner.info{background:#e6f8f8;border-color:#a8e6e6;color:#005e5e}
.banner.err{background:#fbe6e3;border-color:#efb5ad;color:#8e2418} .banner.ok{background:#e8f5e8;border-color:#b3dcb3;color:#1e661e}
details.fold{border:1px solid var(--border);border-radius:9px;background:var(--white);margin-top:10px}
details.fold > summary{cursor:pointer;padding:8px 13px;font-weight:700;list-style:none;display:flex;gap:8px;align-items:center}
details.fold > summary::before{content:"▸";color:var(--gray);transition:.15s} details.fold[open] > summary::before{transform:rotate(90deg)}
details.fold > .in{padding:2px 14px 13px}
.kv{display:grid;grid-template-columns:104px 1fr;gap:5px 10px;font-size:12.5px} .kv dt{color:var(--gray);font-weight:700;font-size:10.5px;letter-spacing:.9px;text-transform:uppercase;padding-top:2px} .kv dd{margin:0}
.check{display:flex;gap:8px;padding:4px 0;align-items:center;font-size:12.5px}
.dot{width:17px;height:17px;border-radius:50%;display:grid;place-items:center;font-size:10px;font-weight:700;flex:none;background:var(--stone);color:var(--gray)}
.dot.ok{background:var(--ok);color:#fff} .dot.no{background:#f2d9d5;color:var(--err)}
.console{background:#141414;color:#d6f5e6;font-family:var(--mono);font-size:12px;border-radius:8px;padding:11px 13px;white-space:pre-wrap;min-height:90px;max-height:340px;overflow:auto}
.console .sim{color:#7fd8d8}
pre.json{background:#141414;color:#e6e6e6;border-radius:8px;padding:11px;font-size:11.5px;overflow:auto;max-height:300px;font-family:var(--mono);margin:6px 0 0}
.diff{font-family:var(--mono);font-size:11.5px;background:#fbfaf8;border:1px solid var(--border);border-radius:7px;overflow:auto;max-height:260px;margin:5px 0 10px}
.diff div{padding:0 9px;white-space:pre} .diff .a{background:#e3f6e3;color:#1c5a1c} .diff .r{background:#fbe3df;color:#8e2418} .diff .h{color:var(--gray);background:var(--stone)}
.empty{padding:22px;text-align:center;color:var(--gray)}
.copy{cursor:pointer;border-bottom:1px dashed var(--gray)}
/* ── Agent-Chat ──────────────────────────────────────────── */
.chat{display:flex;flex-direction:column;flex:1;min-height:0}
.agpane{position:sticky;top:53px;height:calc(100vh - 53px);padding:12px 12px 14px 18px}
.agpane.hide{display:none!important}
.msgs{flex:1;overflow:auto;display:flex;flex-direction:column;gap:9px;padding:2px 2px 10px}
.bub{max-width:94%;padding:8px 12px;border-radius:12px;white-space:pre-wrap;font-size:12.5px}
.bub.user{align-self:flex-end;background:var(--dark);color:#fff;border-bottom-right-radius:3px}
.bub.assistant{align-self:flex-start;background:var(--white);border:1px solid var(--border);border-bottom-left-radius:3px}
.steps{display:flex;flex-direction:column;gap:3px;margin:0 0 6px}
.step{display:flex;gap:6px;align-items:baseline;font-size:11.5px;color:var(--mid);background:var(--mist);border:1px solid var(--stone);border-radius:6px;padding:2px 8px;white-space:normal;overflow-wrap:anywhere}
.step b{font-family:var(--mono);color:#007878;flex:none} .step.err{background:#fbe6e3;border-color:#efb5ad} .step.err b{color:var(--err)}
.thought{color:var(--gray);font-style:italic;font-size:12px;margin:0 0 5px}
.prop{margin-top:8px;border:1px dashed var(--cyan);border-radius:8px;padding:8px 10px;background:#f0fbfb}
.chat-in{display:flex;gap:6px;border-top:1px solid var(--border);padding-top:8px}
.chat-in textarea{min-height:38px;max-height:120px}
.chips{display:flex;gap:5px;flex-wrap:wrap;margin-bottom:7px}
.chip{border:1px solid var(--border);background:var(--white);border-radius:999px;padding:2px 10px;font-size:11.5px;cursor:pointer}.chip:hover{border-color:var(--cyan)}
.typing{align-self:flex-start;color:var(--gray);font-size:11.5px}
/* ── Modal / Toast ───────────────────────────────────────── */
.modal-bg{position:fixed;inset:0;background:rgba(20,20,20,.5);display:none;place-items:center;z-index:50;padding:20px}.modal-bg.on{display:grid}
.modal{background:var(--creme);border-radius:12px;width:min(780px,100%);max-height:90vh;overflow:auto;padding:20px 22px;border:1px solid var(--border);box-shadow:0 20px 60px rgba(0,0,0,.35)}
.modal h2{font-size:17px;margin-bottom:10px}.modal .acts{display:flex;gap:8px;justify-content:flex-end;margin-top:14px}
#toast{position:fixed;bottom:18px;right:18px;background:var(--dark);color:#fff;padding:9px 15px;border-radius:9px;font-weight:700;z-index:99;display:none;border-left:4px solid var(--cyan);max-width:420px}#toast.err{border-color:var(--err)}
@media (max-width:1250px){.grid2{grid-template-columns:1fr}.tl.open{grid-template-columns:150px minmax(300px,1fr) minmax(320px,1fr)}}
@media (max-width:800px){.side{width:56px}.side .brand span,.nav a span,.side-foot{display:none}.tl,.tl.open{grid-template-columns:1fr}.snav{position:static;flex-direction:row;flex-wrap:wrap;max-height:none}.snav h6{display:none}}

598
app/static/tenant.js Normal file
View File

@@ -0,0 +1,598 @@
/* 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; 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 &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'){ 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(); })();