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>
134 lines
13 KiB
HTML
134 lines
13 KiB
HTML
{% extends "base.html" %}
|
||
{% block title %}Einstellungen · Onyscribe Admin{% endblock %}
|
||
{% block body %}
|
||
<div class="page">
|
||
<div class="head"><div><div class="eyebrow">Plattform</div><h1 class="title">Einstellungen.</h1>
|
||
<p class="lead">Geteilte Werte für alle Tenants – bisher Gitea-„Variables“ und <span class="mono">secrets/shared.enc.yaml</span>.</p></div>
|
||
<button class="btn cyan" onclick="save()">Alles speichern</button></div>
|
||
|
||
<div class="grid2">
|
||
<div class="card"><h3>Hosting & Deploy</h3>
|
||
<div class="fgrid">
|
||
<label class="f">Basis-Domain<input type="text" data-k="base_domain" value="{{ v.base_domain }}"></label>
|
||
<label class="f">Core-Image-Tag<input type="text" data-k="core_image_tag" value="{{ v.core_image_tag }}"></label>
|
||
<label class="f">Mittwald PROJECT_ID<input type="text" data-k="mittwald_project_id" value="{{ v.mittwald_project_id }}" placeholder="p-xxxxxx"></label>
|
||
<label class="f">Mittwald STACK_ID<input type="text" data-k="mittwald_stack_id" value="{{ v.mittwald_stack_id }}" placeholder="s-xxxxxx (optional)"></label>
|
||
<label class="f">Registry<input type="text" data-k="registry" value="{{ v.registry }}" placeholder="git.example.de/owner"></label>
|
||
<label class="f">Image-Name<input type="text" data-k="image_name" value="{{ v.image_name }}"></label>
|
||
<label class="f full">Öffentliche URL der Admin-Plattform (für ADMIN_URL im Container)<input type="text" data-k="admin_public_url" value="{{ v.admin_public_url }}" placeholder="https://admin.example.de"></label>
|
||
</div>
|
||
<div style="margin-top:10px">
|
||
<div class="check"><span class="dot {{ 'ok' if mode.token else 'no' }}">{{ '✓' if mode.token else '!' }}</span><span><span class="mono">MITTWALD_API_TOKEN</span> {{ 'gesetzt (lesende Dry-Run-Aufrufe möglich)' if mode.token else 'nicht gesetzt (Dry-Run ohne Lesezugriff)' }}</span></div>
|
||
<div class="check"><span class="dot {{ 'ok' if mode.live else 'no' }}">{{ '✓' if mode.live else '–' }}</span><span><span class="mono">DEPLOY_LIVE</span> {{ 'true – echte Deployments erlaubt' if mode.live else 'nicht aktiv – Schreibzugriffe gesperrt' }}</span></div>
|
||
<div class="check"><span class="dot {{ 'ok' if mode.allowed != 'keiner' else 'no' }}">{{ '✓' if mode.allowed != 'keiner' else '–' }}</span><span><span class="mono">DEPLOY_ALLOWED_TENANTS</span> = <b>{{ mode.allowed }}</b> <span class="hint">– nur diese Tenants werden live angefasst (Mittwald, Login-API)</span></span></div>
|
||
<label class="chk"><input type="checkbox" data-kb="core_supports_bundle" {{ 'checked' if v.core_supports_bundle=='true' }}><span><b>Core kann Bundle laden</b> <span class="hint">(<span class="mono">TENANT_SOURCE=admin</span>) – erst nach dem Core-Umbau aktivieren. Solange aus, sind Deploys von Tenants mit Quelle „Admin“ gesperrt.</span></span></label>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="card"><h3>Login-API (OIDC-Clients automatisch verwalten)</h3>
|
||
<div class="check"><span class="dot {{ 'ok' if oidc.api_key else 'no' }}">{{ '✓' if oidc.api_key else '!' }}</span><span><span class="mono">ONYVA_LOGIN_API_KEY</span> {{ 'gesetzt' if oidc.api_key else 'nicht gesetzt – Abgleich beim Deploy nur als Hinweis (Dry-Run) bzw. Fehler (live)' }}</span></div>
|
||
<div class="check"><span class="dot {{ 'ok' if oidc.writes_allowed else 'no' }}">{{ '✓' if oidc.writes_allowed else '–' }}</span><span><span class="mono">OIDC_SYNC_LIVE</span> / <span class="mono">DEPLOY_LIVE</span> {{ 'aktiv – Schreiben im Login erlaubt' if oidc.writes_allowed else 'nicht aktiv – nur Lesen/Plan' }}</span></div>
|
||
<div class="fgrid" style="margin-top:6px"><label class="f full">API-Basis (leer = Issuer + /api/v1)<input type="text" data-k="onyva_login_api_base" value="{{ v.onyva_login_api_base }}" placeholder="{{ oidc.api_base }}"></label></div>
|
||
<div class="row" style="margin-top:8px"><button class="btn sm" onclick="testOidc()" {{ '' if oidc.api_key else 'disabled' }}>Verbindung testen</button><span class="hint" id="oidc-info">Liest GET /apps mit dem Key. Der Key liegt als Env der Admin-Plattform (wie ein Admin-Passwort), nie in der DB.</span></div>
|
||
</div>
|
||
|
||
<div class="card"><h3>Login (OIDC) & Limits</h3>
|
||
<div class="fgrid">
|
||
<label class="f">Issuer (Tenant-Container)<input type="text" data-k="oidc_issuer" value="{{ v.oidc_issuer }}"></label>
|
||
<label class="f">Auth in Tenant-Containern<select data-k="auth_enabled"><option {{ 'selected' if v.auth_enabled=='true' }}>true</option><option {{ 'selected' if v.auth_enabled=='false' }}>false</option></select></label>
|
||
<label class="f">Cookie secure<select data-k="app_cookie_secure"><option {{ 'selected' if v.app_cookie_secure=='true' }}>true</option><option {{ 'selected' if v.app_cookie_secure=='false' }}>false</option></select></label>
|
||
<label class="f">Max. Dateigröße (MB)<input type="number" data-k="max_file_size_mb" value="{{ v.max_file_size_mb }}"></label>
|
||
</div>
|
||
<div class="banner info" style="margin:10px 0 0">Der Login der Admin-Plattform selbst wird per Env konfiguriert (<span class="mono">AUTH_ENABLED, OIDC_CLIENT_ID/SECRET, OIDC_REDIRECT_URI, ADMIN_ALLOWED_EMAILS</span>) – siehe README.</div>
|
||
</div>
|
||
|
||
<div class="card"><h3>LLM & Transkription (Cortecs)</h3>
|
||
<div class="fgrid">
|
||
<label class="f full">Cortecs Base-URL<input type="text" data-k="cortecs_base_url" value="{{ v.cortecs_base_url }}"></label>
|
||
<label class="f">Protokoll-Modell (LLM_MODEL)<input type="text" data-k="llm_model" value="{{ v.llm_model }}"></label>
|
||
<label class="f">LLM_MAX_TOKENS / TEMPERATURE<div class="row" style="flex-wrap:nowrap"><input type="number" data-k="llm_max_tokens" value="{{ v.llm_max_tokens }}"><input type="text" data-k="llm_temperature" value="{{ v.llm_temperature }}" style="width:70px"></div></label>
|
||
<label class="f">Mistral-Modell<input type="text" data-k="mistral_transcribe_model" value="{{ v.mistral_transcribe_model }}"></label>
|
||
<label class="f">Mistral-Timeout (s)<input type="number" data-k="mistral_timeout" value="{{ v.mistral_timeout }}"></label>
|
||
</div>
|
||
<h3 style="margin-top:12px">Geteilte Secrets</h3>
|
||
{% for k in ['CORTECS_API_KEY','SMTP_PASSWORD'] %}
|
||
<div class="row" style="padding:3px 0"><span class="mono" style="width:150px">{{ k }}</span>
|
||
<span class="badge {{ 'ok' if shared.get(k) else 'warn' }}">{{ 'gesetzt' if shared.get(k) else 'extern (SOPS) – noch nicht übernommen' }}</span>
|
||
<button class="btn sm" onclick="setShared('{{ k }}')">Wert setzen</button></div>
|
||
{% endfor %}
|
||
</div>
|
||
|
||
<div class="card"><h3>KI-Agent (Admin-Chat)</h3>
|
||
<div class="row" style="margin-bottom:8px"><span class="badge {{ 'ok' if llm_ready else 'warn' }}">{{ 'Cortecs-Key vorhanden – echter Agent' if llm_ready else 'kein Key – Demo-Modus' }}</span></div>
|
||
<div class="fgrid">
|
||
<label class="f">Agent-Modell<input type="text" data-k="agent_model" id="agent_model" value="{{ v.agent_model }}" list="models"></label>
|
||
<label class="f">Protokoll<select data-k="agent_api"><option value="messages" {{ 'selected' if v.agent_api=='messages' }}>Anthropic Messages (/v1/messages)</option><option value="chat" {{ 'selected' if v.agent_api=='chat' }}>OpenAI Chat Completions</option></select></label>
|
||
</div>
|
||
<datalist id="models"></datalist>
|
||
<div class="row" style="margin-top:8px"><button class="btn sm" onclick="loadModels()">Modelle von Cortecs laden</button><span class="hint" id="models-info">Liest GET /v1/models mit dem geteilten Key und markiert Modelle mit Tool-Unterstützung.</span></div>
|
||
<div id="models-list" style="margin-top:8px"></div>
|
||
</div>
|
||
|
||
<div class="card"><h3>E-Mail (SMTP)</h3>
|
||
<div class="fgrid"><label class="f">SMTP_HOST<input type="text" data-k="smtp_host" value="{{ v.smtp_host }}"></label><label class="f">SMTP_PORT<input type="number" data-k="smtp_port" value="{{ v.smtp_port }}"></label>
|
||
<label class="f">SMTP_USER<input type="text" data-k="smtp_user" value="{{ v.smtp_user }}"></label><label class="f">SMTP_FROM<input type="text" data-k="smtp_from" value="{{ v.smtp_from }}"></label></div>
|
||
<h3 style="margin-top:12px">Kostenerfassung</h3>
|
||
<div class="fgrid"><label class="f">COST_API_BASE<input type="text" data-k="cost_api_base" value="{{ v.cost_api_base }}"></label><label class="f">COST_TRACKING_ENABLED<input type="text" data-k="cost_tracking_enabled" value="{{ v.cost_tracking_enabled }}" placeholder="true"></label></div>
|
||
</div>
|
||
|
||
<div class="card" style="grid-column:1/-1"><h3>Weitere Core-Einstellungen <span class="muted" style="font-weight:400">· aus dem Schema des Cores erzeugt, gelten für alle Tenants</span></h3>
|
||
<p class="sub">Alles, was der Core als geteilte Einstellung deklariert und oben noch keine eigene Maske hat. Neue Core-Variablen erscheinen hier automatisch und gehen beim Deploy ins Container-Env. Leer = Standard des Cores.</p>
|
||
<div id="dyn"><span class="muted">Lade …</span></div></div>
|
||
|
||
<div class="card"><h3>Rollout</h3>
|
||
<div class="fgrid"><label class="f">Canary-Tenants (kommagetrennt)<input type="text" data-k="canary_tenants" value="{{ v.canary_tenants }}" placeholder="demo"></label><label class="f">Aktiver Core-Tag<input type="text" data-k="core_image_tag" value="{{ v.core_image_tag }}" disabled></label></div>
|
||
<p class="hint" style="margin-top:6px">Der aktive Tag ändert sich durch Core-Releases (Menü „Core“).</p></div>
|
||
|
||
<div class="card"><h3>Core</h3>
|
||
<dl class="kv"><dt>Vendored Core</dt><dd class="mono">{{ core }}</dd><dt>Bedeutung</dt><dd>Live-Vorschau und Validierung führen genau diesen Stand aus (<span class="mono">vendor/platform</span>, aktualisieren mit <span class="mono">scripts/sync_core.sh</span>).</dd></dl>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
{% endblock %}
|
||
{% block scripts %}
|
||
<script>
|
||
async function save(){
|
||
const body={};document.querySelectorAll('[data-k]').forEach(e=>body[e.dataset.k]=e.value);
|
||
document.querySelectorAll('[data-kb]').forEach(e=>body[e.dataset.kb]=e.checked?'true':'false');
|
||
try{await api('PUT','/api/settings',body);toast('Gespeichert')}catch(e){toast(e.message,1)}
|
||
}
|
||
async function setShared(k){
|
||
const v=prompt('Neuer Wert für '+k+' (wird verschlüsselt gespeichert):'); if(v===null)return;
|
||
try{await api('PUT','/api/shared-secrets/'+k,{value:v});toast('Gespeichert');location.reload()}catch(e){toast(e.message,1)}
|
||
}
|
||
async function testOidc(){
|
||
const i=document.getElementById('oidc-info'); i.textContent='Teste …';
|
||
try{const r=await api('POST','/api/oidc/test'); i.textContent='✔ Verbindung ok – '+r.apps+' App(s) im Login'}catch(e){i.textContent='✘ '+e.message}
|
||
}
|
||
async function loadDyn(){
|
||
const box=document.getElementById('dyn');
|
||
try{
|
||
const [c, cur] = await Promise.all([api('GET','/api/contract'), api('GET','/api/settings')]);
|
||
if(!c.schema){ box.innerHTML='<div class="hint">Dieser Core liefert kein Schema – nur die festen Einstellungen oben.</div>'; return; }
|
||
const have=new Set([...document.querySelectorAll('[data-k]')].map(e=>e.dataset.k));
|
||
const rest=c.schema.env.filter(e=>e.kind==='setting'&&e.scope==='shared'&&!have.has(e.setting));
|
||
const groups={}; rest.forEach(e=>(groups[e.group]??=[]).push(e));
|
||
const field=e=>{const val=cur[e.setting]??''; const ph=e.default??''; const inp=e.type==='bool'?`<select data-k="${esc(e.setting)}"><option value="" ${val===''?'selected':''}>Standard (${esc(ph||'Core')})</option><option ${val==='true'?'selected':''}>true</option><option ${val==='false'?'selected':''}>false</option></select>`:`<input type="text" data-k="${esc(e.setting)}" value="${esc(val)}" placeholder="${esc(ph||'Standard')}">`;
|
||
return `<label class="f">${esc(e.label)} <span class="mono" style="text-transform:none;letter-spacing:0">${esc(e.name)}</span>${inp}${e.help?`<span class="hint" style="text-transform:none;letter-spacing:0;font-weight:400">${esc(e.help)}</span>`:''}</label>`;};
|
||
box.innerHTML=Object.entries(groups).map(([g,list])=>{const adv=list.filter(e=>e.advanced), basic=list.filter(e=>!e.advanced);
|
||
return `<h3 style="margin:10px 0 4px;font-size:12px;color:var(--gray);text-transform:uppercase;letter-spacing:1.2px">${esc(g)}</h3>${basic.length?`<div class="fgrid">${basic.map(field).join('')}</div>`:''}${adv.length?`<details class="fold"><summary>Erweitert (${adv.length})</summary><div class="in"><div class="fgrid">${adv.map(field).join('')}</div></div></details>`:''}`}).join('')||'<div class="hint">Alle Core-Einstellungen haben oben eine eigene Maske.</div>';
|
||
}catch(e){ box.innerHTML='<div class="hint">Schema nicht ladbar: '+esc(e.message)+'</div>'; }
|
||
}
|
||
loadDyn();
|
||
async function loadModels(){
|
||
const info=document.getElementById('models-info'); info.textContent='Lade …';
|
||
try{
|
||
const m=await api('GET','/api/llm/models');
|
||
document.getElementById('models').innerHTML=m.map(x=>`<option value="${esc(x.id)}">`).join('');
|
||
document.getElementById('models-list').innerHTML=`<div class="chipbar">${m.map(x=>`<span class="fchip" onclick="document.getElementById('agent_model').value='${esc(x.id)}'" title="${x.tools===true?'Tool-Calling: ja':x.tools===false?'Tool-Calling: nein':'Tool-Calling: unbekannt – testen'}">${esc(x.id)} ${x.tools===true?'🔧':x.tools===false?'✖':'?'}</span>`).join('')}</div>`;
|
||
info.textContent=m.length+' Modelle · 🔧 = Tools laut Cortecs, ? = unbekannt. Klick übernimmt die ID.';
|
||
}catch(e){info.textContent=e.message}
|
||
}
|
||
</script>
|
||
{% endblock %}
|