Files
Onyscribe-Admin/app/templates/settings.html
RubenRWU 7d8c226da2 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>
2026-10-15 15:30:26 +02:00

134 lines
13 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
{% 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 &amp; 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) &amp; 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 &amp; 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 %}