/* 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' ? `${S.status}` : ''; $('#h-badges').innerHTML = `${S.has_draft?'Entwurf':''}${st}v${S.current_version} deployt v${S.deployed_version??'–'} Quelle: ${S.source==='admin'?'Admin':'Git'}`; $('#h-actions').innerHTML = S.has_draft ? `` : ''; 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 ? `${nfiles}` : '', versions: `${S.current_version}`, secrets: extra?.missing ? `` : '', code: S.files['overrides.py'] ? '' : '', deploy: S.deployed_version !== S.current_version ? '' : ''}; for(const [k, l, grp] of SECTIONS){ if(grp !== g){ g = grp; if(grp) out += `
${grp}
`; } out += ``; } $('#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?``:''; } 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 = () => `${S.has_draft?'Entwurf vorhanden':'Entspricht der veröffentlichten Version'}`; const title = (t, sub) => `

${t}${sub?` ${sub}`:''}

${saveInd()}
`; /* ───────── 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 = `

Status

URL
${esc(S.host)} ${S.domain_status==='verified'?'Domain ok':S.domain_status==='pending'?'DNS offen':'nicht eingerichtet'}
Version
v${S.current_version} veröffentlicht · v${S.deployed_version??'–'} deployt ${S.deployed_version!==S.current_version?'veraltet':''}
Quelle
${S.source==='admin'?'Admin (Bundle zur Laufzeit)':'Git – ins Image gebacken'}
Core
:${esc(String(S.core.tag).slice(0,12))} ${S.core.pin?'gepinnt':'folgt Rollouts'} · Admin-Vorschau: ${esc(S.core.sha)}
STT
${esc((c.stt||{}).provider||'mistral')} ${esc((c.stt||{}).model||'')}
Dateien
${Object.keys(S.files).map(f=>`${esc(f)}`).join(' ')||'–'}
Marke
${Object.entries(col).map(([k,v])=>``).join('')}

Cut-over auf die Admin-Plattform

Bis alles grün ist, bleibt der Tenant auf Git. Umschalten = TENANT_SOURCE=admin beim nächsten Deploy.

Prüfe …

Betrieb

Ressourcen wirken beim nächsten Deploy. „paused/archived“ nimmt den Tenant aus „Alle neu deployen“.

Notizen

Core-Version

Neue Core-Releases rollt die Admin-Plattform zentral aus (Menü „Core“). Ein Pin hält diesen Tenant auf einem festen Tag – er nimmt dann an Rollouts nicht teil.

Gefahrenbereich

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.

`; 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=>`
${x.ok?'✓':'!'}${esc(x.label)}
`).join('') + `
${cut.source==='admin' ? `Quelle: Admin` : `${ok?'':'noch nicht alle Bedingungen erfüllt'}`}
`; } 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 = 'prüfe …'; try{ const r = (await api('GET', '/api/status?refresh=1'))[TID]; $('#run-state').innerHTML = !r ? '' : r.reachable && !r.drift.length ? `läuft: Core ${esc((r.core?.version||'?').slice(0,10))}${r.bundle?.source==='admin'?' · Bundle v'+r.bundle.version:''}` : `${esc(r.drift.join(' · ')||'nicht erreichbar')}`; } catch(e){ $('#run-state').innerHTML = `${esc(e.message)}`; } } 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='Validiere mit dem echten Core …'; $('#val').innerHTML = valHtml(await api('POST', A('/validate?source=current'))); } const valHtml = r => ``; 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={}) => ``; /* ───────── 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') + `

Name & Titel

${fld('Name','name')}${fld('Seitentitel (Browser-Tab)','title')}${fld('Beschreibung','description',{full:1})}

Logo & Favicon

${logo?``:'kein Logo'}
Word-Export braucht zusätzlich logo.png.
${S.files['favicon.svg']?``:'–'}

Farben

${Object.keys(S.color_labels).map(k=>`
${esc(S.color_labels[k])}
`).join('')}
`; 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') + `

Absender / Kopf- & Fußzeile

Erscheint im PDF und im Word-Export.

${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})}

PDF-Beschriftungen

${fld('Bezeichnung „Projekt“ (z. B. „Thema“)','pdf_projekt_label',{ph:'Projekt'})}${fld('Fußzeilen-Hinweis','pdf_footer_note',{ph:'Standard des Cores'})}
`; 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 = `

${esc(o.title)}

${has ? `eigene Datei` : `Core-Standard`}
${o.sub?`

${o.sub}

`:''} `; 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') + `
Beispielprotokoll für die Vorschau
Format wie vom LLM geliefert; gilt nur für die Vorschau.
`; $('#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') + `

Selbstbeschreibung

Ersetzt @@ORG@@ · @@ORG_SHORT@@ · @@ORG_PROFILE@@ im Standard-Prompt.

${fld('Organisation (voll)','ai_detect_org')}${fld('Kurzname im Protokolltext','org_short')}${fld('Ein Satz: was die Organisation macht','org_profile',{full:1})}
Prompt testen (echter LLM-Lauf) kostet Cortecs-Guthaben
${S.llm_ready ? '' : ''}
`; 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 = `
${esc(r.text)}
`; }catch(e){ $('#pt-info').textContent = ''; $('#pt-out').innerHTML = ``; } $('#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 = `
${r.chars} Zeichen – so geht der Prompt (nach Platzhaltern und Hooks) an das LLM:
${esc(r.prompt)}
`; } catch(e){ $('#pt-out').innerHTML = ``; } } function sFiles(){ const rows = Object.entries(S.files).map(([p,m]) => `${esc(p)} ${m.personal?'personenbezogen':''}${(m.size/1024).toFixed(1)} KB${m.text?'Text':'Binär'}${m.text&&!['prompt.txt','protocol.html','protocol.css','overrides.py'].includes(p)?` `:''}`).join(''); $('#pane').innerHTML = title('Dateien') + `
${rows||''}
PfadGrößeTyp
Keine Dateien
z. B. namensliste.txt – lesbar für Hook-Code im Container. Namenslisten sind für den KI-Agenten automatisch gesperrt.
`; } 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 ? ` ${esc(f.help)}` : ''; if(f.type === 'bool') return ``; if(f.type === 'enum') return ``; if(f.type === 'color') return ``; if(f.type === 'text') return ``; return ``; } 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 => `
${list.map(schemaField).join('')}
`; return `

${esc(g)}${known.includes(g)?'':' neu im Core'}

${basic.length?grid(basic):''}${adv.length?`
Erweitert
${grid(adv)}
`:''} ${g==='Spracherkennung'?'

Bei „cortecs“ wird der geteilte CORTECS_API_KEY genutzt – ein eigener Mistral-Key ist nicht nötig.

':''}
`; }; $('#pane').innerHTML = title('Funktion & STT') + `

Aus dem Schema des Cores (Version ${esc(S.core?.sha||'')}) erzeugt – neue Optionen erscheinen hier automatisch.

` + 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 ``; }; $('#pane').innerHTML = title('Funktion & STT') + `

Funktionen (Flags)

${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)}

Spracherkennung (STT)

${fld('Modell','stt.model',{ph:'voxtral-mini-transcribe-2602'})}
`; bindCfg($('#pane')); } async function sCode(){ const has = !!S.files['overrides.py']; $('#pane').innerHTML = title('Hook-Code') + `

Verfügbare Hooks

build_prompt
Prompt vor dem LLM-Aufruf anpassen
transform_transcript
Transkript nachbearbeiten
pdf_context
Daten fürs PDF-Template ändern
extra_routes
zusätzliche FastAPI-Routen
`; if(has) $('#ed').appendChild(await fileEditor('overrides.py', {title:'overrides.py', sub:'Syntax wird beim Speichern geprüft.', h:460})); else $('#ed').innerHTML = `
Dieser Tenant nutzt keinen Hook-Code.

`; } 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') + `

Alle Felder, auch solche ohne Formular. Gültiges JSON wird automatisch übernommen. Der deploy-Block ist nicht Teil des Bundles (Domain/Ressourcen verwaltet die Plattform).

`; $('#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 = '
Validiere (echter Core) und berechne Änderungen …
'; $('#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) + `

${diff.items.length} geänderte Datei(en)

` + diff.items.map(diffHtml).join('') + (code ? `` : '') + ``; const upd = () => { $('#pub-go').disabled = !val.ok || (code && !$('#codeok').checked); }; $('#codeok')?.addEventListener('change', upd); upd(); } const diffHtml = i => `
${esc(i.path)}${i.kind}${i.added!=null?`+${i.added} −${i.removed}`:''}
${i.diff?`
${i.diff.map(l=>`
${esc(l)||' '}
`).join('')}
`:'
Binärdatei
'}
`; 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=>`${esc(s)}`).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 = '
Der Agent arbeitet auf einem Entwurf und schlägt Änderungen nur vor – du gibst sie frei.
'; else h.forEach(m => box.appendChild(msgEl(m))); box.scrollTop = 1e9; } function stepsHtml(steps){ return (steps||[]).map(s=>`
${esc(s.tool)}${esc(s.arg)} → ${esc(s.result)}
`).join(''); } function propHtml(m){ if(!m.proposal) return ''; const items = (m.proposal.items||[]).map(i=>`${esc(i.path)}`).join(' '); return `
Vorschlag: ${esc(m.proposal.summary)}
${items}
${m.proposal_status==='open' ? `` : `${m.proposal_status==='applied'?'übernommen – prüfen & veröffentlichen':'verworfen'}`}
`; } 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 ? `
${esc(m.usage.model||'')} · ${m.usage.in}→${m.usage.out} Tokens
` : ''; d.innerHTML = (m.steps&&m.steps.length?`
${stepsHtml(m.steps)}
`:'') + 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 = '
Agent arbeitet …
'; box.appendChild(live); box.scrollTop = 1e9; $('#chat-send').disabled = true; try { const r = await fetch(A('/chat'), {method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify({message:text})}); if(!r.ok) throw new Error((await r.json()).detail || 'Fehler '+r.status); const rd = r.body.getReader(), dec = new TextDecoder(); let buf = ''; while(true){ const {value, done} = await rd.read(); if(done) break; buf += dec.decode(value, {stream:true}); let i; while((i = buf.indexOf('\n\n')) >= 0){ const ev = JSON.parse(buf.slice(6, i)); buf = buf.slice(i+2); if(ev.type==='mode'){ ui.agentSrc = 'ws:' + ev.run; $('#pv-agent').textContent = 'Agent-Stand (live)'; setPvSrc('agent'); } else if(ev.type==='changed'){ showPdfIfRelevant(ev.paths); refreshPreviewLive(); } else if(ev.type==='step') live.querySelector('.steps').insertAdjacentHTML('beforeend', stepsHtml([ev])); else if(ev.type==='thought') live.querySelector('.steps').insertAdjacentHTML('beforeend', `
${esc(ev.text)}
`); else if(ev.type==='error') live.querySelector('.typing').textContent = '⚠ ' + ev.text; else if(ev.type==='done'){ live.replaceWith(msgEl(ev.message)); if(ev.message.proposal){ showPdfIfRelevant((ev.message.proposal.items||[]).map(i=>i.path)); ui.agentSrc = 'proposal:' + ev.message.id; $('#pv-agent').textContent = 'Agent-Vorschlag'; setPvSrc('agent'); } else { ui.agentSrc = null; setPvSrc('draft'); } refreshPreview(true); } box.scrollTop = 1e9; } } } catch(e){ live.remove(); toast(e.message, 1); } $('#chat-send').disabled = false; } const PDF_PATH = /^(protocol\.|pdf_|office\.|preview_sample)/; function showPdfIfRelevant(paths){ if(!(paths||[]).some(p => PDF_PATH.test(p))) return; if(ui.pvKind !== 'pdf'){ ui.pvKind = 'pdf'; document.querySelectorAll('#pv-kind button').forEach(b => b.classList.toggle('on', b.dataset.k === 'pdf')); } } async function decide(id, d){ const m = await api('POST', A(`/chat/${id}/${d}`)); $('#m'+id).replaceWith(msgEl(m)); ui.agentSrc = null; setPvSrc('draft'); if(d==='apply'){ await load(true); renderNav(); headLogo(); 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('') : '
Keine Unterschiede zum Entwurf.
'; modal('m-diff', true); } async function clearChat(){ if(!confirm('Chatverlauf dieses Tenants löschen?')) return; await api('DELETE', A('/chat')); $('#msgs').innerHTML = '
Verlauf geleert.
'; } /* ───────── 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 = `

Domain & Login

Domain

${S.domain_status==='verified'?'verifiziert':S.domain_status==='pending'?'DNS ausstehend':'nicht eingerichtet'}
${S.custom_domain?`
TypNameWert
CNAME${esc(S.custom_domain)}${esc(dnsTarget)}

Alternativ A-Record auf die Projekt-IP aus mStudio. TLS stellt Mittwald automatisch aus.

`:''}

Redirect-URIs im Login ergeben sich aus Subdomain und eigener Domain und werden beim Deploy mit abgeglichen.

OIDC-Client · Abgleich über die Login-Developer-API bei jedem Deploy

API-Key ${o.api_key?'vorhanden':'fehlt'} Schreiben ${o.writes_allowed?'erlaubt':'gesperrt'} ${o.client_id?esc(o.client_id):'noch kein Client'} Secret ${o.secret_set?'gesetzt':'fehlt'}
${modeHelp[o.mode]}
Redirect-URIs (Soll):
${o.redirect_uris.map(u=>`
${esc(u)}
`).join('')}
Logout-Ziel: ${esc(logout)} (Core: OIDC_LOGOUT_REDIRECT_URI)
${o.last_sync?`
Letzter Abgleich: ${fmt(o.last_sync.ts)} · ${o.last_sync.ok?'✔ ok':'✘ '+esc(o.last_sync.error||'')}${o.last_sync.created?' · Client neu angelegt':''}
`:''}

Freigaben

Wer darf die App nutzen (bei auth_mode „required“). Nutzer müssen im Login bereits existieren.

Manuell eintragen

Falls der Client nicht automatisch verwaltet wird (Verwaltung „off“) oder das Secret aus dem Altbestand übernommen werden soll.

Bundle-Abruf durch den Container

So holt der Container beim Start seine Konfiguration (ohne Secrets); Token = ADMIN_BUNDLE_TOKEN. Der Core kann das noch nicht – Schnittstelle steht.

curl -H "Authorization: Bearer $ADMIN_BUNDLE_TOKEN" \\\n  ${location.origin}/api/tenants/${TID}/bundle -o bundle.tar.gz
`; 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)=>` `).join('') : 'Keine Freigaben gepflegt – bestehende bleiben unverändert (Abgleich „nur ergänzen“).'; } 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 = 'Frage das Login-System …'; try{ const r = await api('POST', A('/oidc/sync'), {apply, rotate}); const acts = r.actions.map(a=>`
• ${esc(a.detail)}
`).join('') || (r.skipped?`
${esc(r.skipped)}
`:'
✔ Alles abgeglichen – keine Änderungen nötig.
'); $('#oc-out').innerHTML = ``; if(apply){ const keep = $('#oc-out').innerHTML; await load(true); await sDomain(); $('#oc-out').innerHTML = keep; } }catch(e){ $('#oc-out').innerHTML = ``; } } 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 = `

Secrets

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.

${sec.map(s=>``).join('')}
SchlüsselStatusBedeutung
${esc(s.key)}${s.required?' Pflicht':''} ${s.set?'gesetzt':s.source==='sops-import'?'noch in SOPS':'fehlt'}${esc(s.help)} ${['SESSION_SECRET','ADMIN_BUNDLE_TOKEN'].includes(s.key)?``:''}
Bestehende Tenants: python scripts/import_tenants.py --with-secrets liest SOPS und verschlüsselt neu.
`; } 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,'[DRY-RUN]'); 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 = `

Deploy

${m.live?``:''}

Vorflug-Check · ist alles bereit für den Live-Deploy? (liest nur)

Noch nicht ausgeführt.

Geplanter Rollout

Service
${esc(plan.service)}
Image
${esc(plan.patch.image)}
Version
v${S.current_version} ${S.has_draft?'Entwurf wird nicht deployt':''}
Ingress
${esc(plan.ingress.host)} → ${esc(plan.ingress.target)}
Voraussetzungen
API-Token Projekt-ID ${S.source==='admin'?`Core-Bundle`:''} OIDC: ${plan.oidc.mode}${plan.oidc.mode!=='off'&&!plan.oidc.key?' · API-Key fehlt':''}
${plan.missing_secrets.length?``:''}
Service-Definition (PATCH-Body, Secrets maskiert)
${esc(JSON.stringify(plan.patch,null,2))}

Verlauf

${mine.length?`${mine.map(d=>``).join('')}
ZeitArtVersionStatus
${fmt(d.created_at)}${kindLabel[d.kind]||d.kind} ${d.simulated?'Dry-Run':'live'}v${d.version}${d.status}
`:'
Noch keine Deployments für diesen Tenant.
'}
`; if(old){ $('#con').classList.remove('hide'); $('#con').innerHTML = old; } } async function runPreflight(){ $('#pf').innerHTML = 'Prüfe (liest Mittwald-Stack, Login-API, Rendering) …'; 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 = ``+ r.items.map(i=>`
${ic[i.level]}${esc(i.label)} ${esc(i.detail)}${i.fix?`
→ ${esc(i.fix)}
`:''}
`).join(''); }catch(e){ $('#pf').innerHTML = ``; } } async function recheckHealth(){ const con = $('#con'); con.classList.remove('hide'); con.style.display='block'; con.textContent = 'Prüfe https://' + S.host + '/health … (bis ca. 40 s)'; try{ const r = await api('POST', A('/health-check')); con.innerHTML = esc(r.log.join('\n')) + (r.ok ? '\n✔ Health ok' : ''); if(r.ok){ await load(true); sDeploy(true); } }catch(e){ con.textContent = e.message; } } async function rollbackTenant(){ if(!confirm('Auf den vorherigen Core-Tag zurückrollen?')) return; try{ const r = await api('POST', A('/rollback')); toast('Rollback auf :'+String(r.tag).slice(0,10)+' gestartet'); followDeploy(r.id, $('#con')); }catch(e){ toast(e.message,1) } } async function startDeploy(dry){ if(!dry && !confirm('Tenant jetzt auf Mittwald deployen?')) return; try{ const r = await api('POST', A('/deploy'), {dry_run:dry}); followDeploy(r.id, $('#con')); }catch(e){ toast(e.message,1) } } /* ───────── Verlauf ───────── */ async function sVersions(){ const v = await api('GET', A('/versions')); $('#pane').innerHTML = `

Verlauf

Jede Veröffentlichung ist ein unveränderlicher Snapshot. Wiederherstellen legt die alte Version als Entwurf an – nichts wird überschrieben.

${v.map(x=>``).join('')}
VersionNachrichtAutorZeitDateien
v${x.number} ${x.current?'aktuell':''} ${x.deployed?'deployt':''}${esc(x.message)}${esc(x.author)}${fmt(x.created_at)}${x.files} ${x.current?'':` `}
`; } 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('') : '
Keine Unterschiede.
'; 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(); })();