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:
13
app/templates/audit.html
Normal file
13
app/templates/audit.html
Normal file
@@ -0,0 +1,13 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Audit-Log · Onyscribe Admin{% endblock %}
|
||||
{% block body %}
|
||||
<div class="page">
|
||||
<div class="head"><div><div class="eyebrow">Nachvollziehbarkeit</div><h1 class="title">Audit-Log.</h1>
|
||||
<p class="lead">Wer hat wann was geändert, veröffentlicht oder deployt – inklusive Freigaben von Agent-Vorschlägen.</p></div></div>
|
||||
<div class="card">
|
||||
<table class="t"><thead><tr><th>Zeit</th><th>Wer</th><th>Aktion</th><th>Tenant</th><th>Detail</th></tr></thead><tbody>
|
||||
{% for r in rows %}<tr><td>{{ r.ts|dt }}</td><td>{{ r.actor }}</td><td><span class="badge">{{ r.action }}</span></td><td>{{ r.tenant_id or '–' }}</td><td class="muted">{{ r.detail }}</td></tr>
|
||||
{% else %}<tr><td colspan="5" class="empty">Noch keine Einträge.</td></tr>{% endfor %}
|
||||
</tbody></table></div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
44
app/templates/base.html
Normal file
44
app/templates/base.html
Normal file
@@ -0,0 +1,44 @@
|
||||
<!doctype html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<title>{% block title %}Onyscribe Admin{% endblock %}</title>
|
||||
<link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 64 64'%3E%3Crect width='64' height='64' rx='14' fill='%231a1a1a'/%3E%3Cpath d='M20 22l14 10-14 10' fill='none' stroke='%2300cccc' stroke-width='7' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E">
|
||||
<link rel="stylesheet" href="/static/admin.css">
|
||||
</head>
|
||||
<body>
|
||||
<aside class="side">
|
||||
<div class="brand"><b>onyva</b><span>Scribe Admin</span></div>
|
||||
<nav class="nav">
|
||||
<a href="/" class="{{ 'on' if nav=='dashboard' }}"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><rect x="3" y="3" width="7" height="7" rx="1.5"/><rect x="14" y="3" width="7" height="7" rx="1.5"/><rect x="3" y="14" width="7" height="7" rx="1.5"/><rect x="14" y="14" width="7" height="7" rx="1.5"/></svg><span>Tenants</span></a>
|
||||
<a href="/deployments" class="{{ 'on' if nav=='deployments' }}"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 3v12m0 0l-4-4m4 4l4-4M4 19h16"/></svg><span>Deployments</span></a>
|
||||
<a href="/core" class="{{ 'on' if nav=='core' }}"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 2l9 5v10l-9 5-9-5V7z"/><path d="M12 22V12M3 7l9 5 9-5"/></svg><span>Core</span></a>
|
||||
<a href="/settings" class="{{ 'on' if nav=='settings' }}"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><circle cx="12" cy="12" r="3"/><path d="M12 2v3m0 14v3M2 12h3m14 0h3M5 5l2 2m10 10l2 2M19 5l-2 2M7 17l-2 2"/></svg><span>Einstellungen</span></a>
|
||||
<a href="/audit" class="{{ 'on' if nav=='audit' }}"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M9 6h11M9 12h11M9 18h11M4 6h.01M4 12h.01M4 18h.01"/></svg><span>Audit-Log</span></a>
|
||||
</nav>
|
||||
<div class="side-foot">
|
||||
<span class="pill {{ 'live' if deploy_live() else 'lock' }}" title="Env DEPLOY_LIVE der Admin-Plattform">Deploy: {{ 'live' if deploy_live() else 'gesperrt · Dry-Run' }}</span>
|
||||
{% set u = request.state.user if request is defined else None %}
|
||||
{% if u and u.email and u.email != 'dev@local' %}<span>{{ u.email }}<br><a href="/auth/logout">Abmelden</a></span>{% else %}<span class="pill dev">Login: aus</span>{% endif %}
|
||||
</div>
|
||||
</aside>
|
||||
<div class="main">{% block body %}{% endblock %}</div>
|
||||
<div id="toast"></div>
|
||||
<script>
|
||||
function toast(msg, err){const t=document.getElementById('toast');t.textContent=msg;t.className=err?'err':'';t.style.display='block';clearTimeout(window._tt);window._tt=setTimeout(()=>t.style.display='none',3200)}
|
||||
async function api(method, url, body){
|
||||
const o={method,headers:{}};
|
||||
if(body!==undefined){o.headers['Content-Type']='application/json';o.body=JSON.stringify(body)}
|
||||
const r=await fetch(url,o);
|
||||
let d=null;try{d=await r.json()}catch(e){}
|
||||
if(!r.ok){throw new Error((d&&(d.detail||d.message))||('Fehler '+r.status))}
|
||||
return d;
|
||||
}
|
||||
const esc=s=>String(s??'').replace(/[&<>"]/g,c=>({'&':'&','<':'<','>':'>','"':'"'}[c]));
|
||||
function modal(id,on){document.getElementById(id).classList.toggle('on',on)}
|
||||
function fmt(d){return d?new Date(d).toLocaleString('de-DE',{day:'2-digit',month:'2-digit',year:'numeric',hour:'2-digit',minute:'2-digit'}):'–'}
|
||||
</script>
|
||||
{% block scripts %}{% endblock %}
|
||||
</body>
|
||||
</html>
|
||||
90
app/templates/core.html
Normal file
90
app/templates/core.html
Normal file
@@ -0,0 +1,90 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Core · Onyscribe Admin{% endblock %}
|
||||
{% block body %}
|
||||
<div class="page">
|
||||
<div class="head"><div><div class="eyebrow">Zusammenspiel mit dem Core</div><h1 class="title">Core-Releases.</h1>
|
||||
<p class="lead">Die Core-CI meldet neue Images hierher. Die Admin-Plattform prüft damit <b>alle</b> Tenants, gibt nach deiner Freigabe aus und rollt Canary-zuerst aus – mit Health-Check pro Tenant und Rollback.</p></div>
|
||||
<button class="btn" onclick="load()">Aktualisieren</button></div>
|
||||
<div id="warn"></div>
|
||||
<div class="grid2">
|
||||
<div class="card"><h3>Core der Admin-Plattform</h3><dl class="kv" id="admin-core"></dl>
|
||||
<p class="hint" style="margin-top:8px">Vorschau, Sandbox und Formulare nutzen genau diesen Core. Im Container ist das Admin-Image <span class="mono">FROM</span> das Core-Image gebaut – Schriften, Python-Pakete und WeasyPrint sind damit identisch zur Produktion.</p></div>
|
||||
<div class="card"><h3>Rollout-Regeln</h3><dl class="kv" id="rules"></dl>
|
||||
<div class="row" style="margin-top:8px"><label class="f" style="flex:1">Canary-Tenants (zuerst, kommagetrennt)<input type="text" id="canary" placeholder="demo"></label><button class="btn sm" onclick="saveCanary()" style="align-self:flex-end">Speichern</button></div>
|
||||
<p class="hint" style="margin-top:6px">Gepinnte Tenants (Tenant → Übersicht → Core-Version) nehmen nicht teil. Der Rollout bricht beim ersten Fehler ab; Echt-Rollouts brauchen <span class="mono">DEPLOY_LIVE=true</span>.</p></div>
|
||||
</div>
|
||||
<div class="card" style="margin-top:12px"><h3>Releases</h3><div id="rel"></div></div>
|
||||
</div>
|
||||
<div class="modal-bg" id="m-roll"><div class="modal"><h2 id="roll-title">Rollout</h2><p class="hint" id="roll-hint"></p><table class="t" id="roll-table"><tbody></tbody></table>
|
||||
<div class="acts"><button class="btn" onclick="modal('m-roll',false);load()">Schließen</button></div></div></div>
|
||||
{% endblock %}
|
||||
{% block scripts %}
|
||||
{% raw %}<script>
|
||||
let D = null, open_ = {}, timer = null;
|
||||
const stLabel = {new:'neu', checked:'geprüft', approved:'freigegeben', rolling:'wird ausgerollt', active:'aktiv', failed:'fehlgeschlagen', rejected:'abgelehnt', superseded:'abgelöst', rolled_back:'zurückgerollt'};
|
||||
const stCls = {active:'ok', approved:'cyan', checked:'', new:'warn', rolling:'cyan', failed:'err', rejected:'err', superseded:'', rolled_back:'warn'};
|
||||
async function load(){
|
||||
D = await api('GET','/api/core/releases');
|
||||
const a = D.admin_core;
|
||||
document.getElementById('admin-core').innerHTML = `<dt>Core-SHA</dt><dd class="mono">${esc(a.sha.slice(0,12))} <span class="muted">(${esc(a.source)})</span></dd><dt>Gebaut</dt><dd>${a.built_at?fmt(a.built_at):'–'}</dd>
|
||||
<dt>Vertrag</dt><dd>v${a.contract} ${a.supported?'<span class="badge ok">unterstützt</span>':'<span class="badge err">nicht unterstützt</span>'}</dd><dt>Schema</dt><dd>${a.fields??'–'} Felder · ${a.env??'–'} Env-Einträge <span class="muted mono">${esc((a.schema_sha||'').slice(0,8))}</span></dd>`;
|
||||
document.getElementById('rules').innerHTML = `<dt>Aktiver Tag</dt><dd class="mono">:${esc(String(D.active_tag).slice(0,12))}</dd><dt>Release-Eingang</dt><dd>${D.intake?'<span class="badge ok">Token gesetzt</span>':'<span class="badge warn">CORE_RELEASE_TOKEN fehlt</span>'} <span class="hint">CI: Job „notify-admin“</span></dd><dt>Rollout</dt><dd>${D.live?'<span class="badge err">live erlaubt</span>':'<span class="badge warn">gesperrt · nur Dry-Run</span>'}</dd>`;
|
||||
document.getElementById('canary').value = D.canary.join(', ');
|
||||
document.getElementById('warn').innerHTML = a.supported ? '' : '<div class="banner err">Dieser Core nutzt einen Vertrag, den diese Admin-Version nicht kennt – Admin aktualisieren, bevor etwas ausgerollt wird.</div>';
|
||||
renderRel();
|
||||
if(D.releases.some(r=>r.compat&&r.compat.running||r.status==='rolling')){ clearTimeout(timer); timer = setTimeout(load, 2500); }
|
||||
}
|
||||
function matrix(r){
|
||||
const c = r.compat; if(!c) return '<div class="hint">Noch nicht geprüft.</div>';
|
||||
const rows = Object.entries(c.tenants||{}).map(([t,v])=>`<tr><td><b>${esc(t)}</b></td><td><span class="badge ${v.status==='ok'?'ok':v.status==='error'?'err':v.status==='changed'?'warn':''}">${{ok:'unverändert',changed:'Rendering geändert',new:'neu (keine Baseline)',error:'Fehler'}[v.status]}</span></td>
|
||||
<td class="muted">${v.changed.length?esc(v.changed.join(', ')):''}${v.errors.map(e=>`<div class="mono" style="color:var(--err)">${esc(e).slice(0,300)}</div>`).join('')}${v.warnings.map(w=>`<div>⚠ ${esc(w)}</div>`).join('')}</td></tr>`).join('');
|
||||
return `<div class="hint" style="margin:4px 0">${c.running?'Prüfung läuft …':'Geprüft '+fmt(c.checked_at)+' mit Core '+esc((c.core_sha||'').slice(0,10))}${c.forced?' · <b>Abweichender Admin-Core (erzwungen)</b>':''} · ${Object.entries(c.summary||{}).filter(([k,v])=>v).map(([k,v])=>v+'× '+k).join(', ')}</div>
|
||||
<table class="t"><tbody>${rows}</tbody></table>`;
|
||||
}
|
||||
function renderRel(){
|
||||
document.getElementById('rel').innerHTML = D.releases.length ? `<table class="t"><thead><tr><th>Release</th><th>Nachricht</th><th>Gemeldet</th><th>Status</th><th>Admin</th><th></th></tr></thead><tbody>`+D.releases.map(r=>{
|
||||
const c = r.compat, sum = c&&c.summary ? c.summary : null;
|
||||
return `<tr><td class="nowrap"><span class="mono">${esc(r.sha.slice(0,10))}</span> ${r.active?'<span class="badge ok">aktiv</span>':''}<div class="hint">${esc(r.ref)}</div></td><td>${esc(r.message)}</td><td class="nowrap">${fmt(r.announced_at)}</td>
|
||||
<td><span class="badge ${stCls[r.status]||''}">${stLabel[r.status]||r.status}</span>${sum?`<div class="hint">${sum.ok||0} ok · ${sum.changed||0} geändert · ${sum.new||0} neu · ${sum.error||0} Fehler</div>`:''}</td>
|
||||
<td>${r.admin_matches?'<span class="badge ok">gleicher Core</span>':'<span class="badge warn" title="Admin muss FROM diesem Core neu gebaut werden">Admin älter/anders</span>'}</td>
|
||||
<td style="text-align:right" class="nowrap"><button class="btn sm" onclick="tg(${r.id})">Matrix</button>
|
||||
<button class="btn sm" onclick="act(${r.id},'check')">Prüfen</button>
|
||||
<button class="btn sm" ${['checked','approved','active','failed','rolled_back'].includes(r.status)?'':'disabled'} onclick="approve(${r.id})">Freigeben</button>
|
||||
<button class="btn sm" onclick="rollout(${r.id},true)">Dry-Run</button>
|
||||
<button class="btn sm cyan ${D.live&&['approved','active','failed','rolled_back'].includes(r.status)?'':'disabled'}" onclick="rollout(${r.id},false)">Ausrollen</button>
|
||||
${r.previous_tag?`<button class="btn sm danger ${D.live?'':'disabled'}" onclick="rollback(${r.id})">Zurückrollen</button>`:''}
|
||||
${['new','checked'].includes(r.status)?`<button class="btn sm danger" onclick="act(${r.id},'reject')">Ablehnen</button>`:''}</td></tr>
|
||||
<tr style="display:${open_[r.id]?'table-row':'none'}"><td colspan="6">${matrix(r)}</td></tr>`}).join('')+'</tbody></table>'
|
||||
: '<div class="empty">Noch kein Release gemeldet. Die Core-CI meldet Images per POST /api/core/releases (Job „notify-admin“).</div>';
|
||||
}
|
||||
function tg(id){ open_[id] = !open_[id]; renderRel(); }
|
||||
async function act(id, what){
|
||||
try{ await api('POST', `/api/core/releases/${id}/${what}`, what==='check' ? {force: !D.releases.find(r=>r.id===id).admin_matches && confirm('Die Admin-Plattform läuft nicht auf diesem Core. Trotzdem mit dem aktuellen Admin-Core prüfen (nur Entwicklung)?')} : {}); toast(what==='check'?'Prüfung gestartet':'ok'); open_[id]=true; load(); }
|
||||
catch(e){ toast(e.message,1) }
|
||||
}
|
||||
async function approve(id){
|
||||
const r = D.releases.find(x=>x.id===id); const changed = r.compat&&r.compat.summary&&r.compat.summary.changed;
|
||||
if(changed && !confirm(changed+' Tenant(s) rendern anders als zuvor (siehe Matrix). Bewusst freigeben?')) return;
|
||||
try{ await api('POST', `/api/core/releases/${id}/approve`, {acknowledge: !!changed}); toast('Freigegeben'); load(); }catch(e){ toast(e.message,1) }
|
||||
}
|
||||
async function rollout(id, dry){
|
||||
if(!dry && !confirm('Release jetzt Canary-zuerst auf alle (nicht gepinnten) Tenants ausrollen? Bei einem Fehler wird abgebrochen.')) return;
|
||||
let r; try{ r = await api('POST', `/api/core/releases/${id}/rollout`, {dry_run: dry}); }catch(e){ return toast(e.message,1) }
|
||||
followBatch(r.batch, dry ? 'Dry-Run des Rollouts' : 'Rollout läuft');
|
||||
}
|
||||
async function rollback(id){
|
||||
if(!confirm('Alle Tenants auf den Tag vor diesem Rollout zurückrollen?')) return;
|
||||
try{ const r = await api('POST', `/api/core/releases/${id}/rollback`); followBatch(r.batch, 'Rollback läuft'); }catch(e){ toast(e.message,1) }
|
||||
}
|
||||
function followBatch(batch, title){
|
||||
document.getElementById('roll-title').textContent = title; document.getElementById('roll-hint').textContent = 'Reihenfolge: Canary zuerst, dann der Rest; Abbruch beim ersten Fehler.'; modal('m-roll', true);
|
||||
const tick = async () => {
|
||||
const rows = await api('GET', '/api/deployments?batch=' + batch);
|
||||
document.querySelector('#roll-table tbody').innerHTML = rows.map(d=>{ const last=(d.log.trim().split('\n').filter(l=>!l.includes('[DRY-RUN]')&&!l.includes('body:')).pop()||'').slice(10); const cls=d.status==='success'?'ok':d.status==='failed'?'err':d.status==='running'?'cyan':''; return `<tr><td><b>${esc(d.tenant)}</b></td><td class="mono">:${esc(String(d.image).slice(0,10))}</td><td><span class="badge ${cls}">${d.status}</span></td><td class="muted mono">${esc(last)}</td></tr>`; }).join('');
|
||||
if(rows.some(d=>d.status==='running'||d.status==='queued')) setTimeout(tick, 800);
|
||||
}; tick();
|
||||
}
|
||||
async function saveCanary(){ try{ await api('PUT','/api/settings',{canary_tenants: document.getElementById('canary').value}); toast('Gespeichert'); }catch(e){ toast(e.message,1) } }
|
||||
load();
|
||||
</script>{% endraw %}
|
||||
{% endblock %}
|
||||
123
app/templates/dashboard.html
Normal file
123
app/templates/dashboard.html
Normal file
@@ -0,0 +1,123 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Tenants · Onyscribe Admin{% endblock %}
|
||||
{% block body %}
|
||||
<div class="page">
|
||||
<div class="head">
|
||||
<div><div class="eyebrow">Übersicht</div><h1 class="title">Tenants.</h1></div>
|
||||
<div class="row">
|
||||
<button class="btn" onclick="probe()" title="Fragt /health jedes Tenants ab: läuft der erwartete Core und das erwartete Bundle?">Zustand prüfen</button>
|
||||
<button class="btn" onclick="deployAll(true)" title="Plan für alle Tenants, ohne Schreibzugriff">Dry-Run aller</button>
|
||||
<button class="btn {{ '' if deploy_live() else 'disabled' }}" onclick="deployAll(false)" title="{{ '' if deploy_live() else 'Gesperrt: DEPLOY_LIVE ist nicht aktiv' }}">Alle neu deployen</button>
|
||||
<button class="btn cyan" onclick="modal('m-new',true)">+ Neuer Tenant</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="kpis">
|
||||
<div class="kpi click" data-f="all"><b>{{ stats.tenants }}</b><span>Tenants</span></div>
|
||||
<div class="kpi click" data-f="admin"><b>{{ stats.admin }}</b><span>Quelle Admin</span></div>
|
||||
<div class="kpi click" data-f="outdated"><b>{{ stats.outdated }}</b><span>Deploy veraltet</span></div>
|
||||
<div class="kpi click" data-f="draft"><b>{{ stats.drafts }}</b><span>Entwürfe</span></div>
|
||||
<div class="kpi click" data-f="secrets"><b>{{ stats.secrets }}</b><span>Secrets offen</span></div>
|
||||
<div class="kpi"><b class="mono" style="font-size:13px">:{{ tag }}</b><span>Core {{ core }}</span></div>
|
||||
</div>
|
||||
|
||||
<div class="card" style="padding:10px 12px">
|
||||
<div class="row" style="margin:2px 2px 8px">
|
||||
<input class="search" id="q" placeholder="Tenant suchen …" autofocus>
|
||||
<div class="chipbar" id="chips">
|
||||
{% for k,l in [('all','Alle'),('draft','Entwurf'),('outdated','Deploy veraltet'),('secrets','Secrets offen'),('git','Git'),('admin','Admin'),('code','mit Hook-Code'),('paused','pausiert / archiviert')] %}<span class="fchip {{ 'on' if k=='all' }}" data-f="{{ k }}">{{ l }}</span>{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
<table class="t hover" id="tt"><thead><tr><th>Tenant</th><th>Version</th><th>Quelle</th><th>Marke</th><th>Anpassungen</th><th>Ressourcen</th><th>Läuft</th><th>Hinweise</th></tr></thead><tbody>
|
||||
{% for r in rows %}
|
||||
<tr onclick="location.href='/tenants/{{ r.t.id }}'" data-q="{{ (r.t.id ~ ' ' ~ r.t.name ~ ' ' ~ r.host)|lower }}"
|
||||
data-flags="{{ 'draft ' if r.t.draft }}{{ 'outdated ' if r.outdated }}{{ 'secrets ' if r.missing_secrets }}{{ r.t.source }} {{ 'code ' if r.has_code }}{{ 'paused ' if r.t.status!='active' }}">
|
||||
<td><div class="row" style="flex-wrap:nowrap"><div class="logo-box">{% if r.logo %}<img src="{{ r.logo }}" alt="">{% endif %}</div>
|
||||
<div><b>{{ r.t.name }}</b>{% if r.t.status!='active' %} <span class="badge warn">{{ r.t.status }}</span>{% endif %}<br><span class="muted">{{ r.host }}</span></div></div></td>
|
||||
<td class="nowrap"><span class="badge dark">v{{ r.t.current_version }}</span>
|
||||
<span class="muted"><span class="dotc {{ 'warn' if r.outdated else 'ok' }}"></span>{{ 'deployt v'~(r.t.deployed_version or '–') if r.outdated else 'deployt' }}</span></td>
|
||||
<td><span class="badge {{ 'cyan' if r.t.source=='admin' else '' }}">{{ 'Admin' if r.t.source=='admin' else 'Git' }}</span></td>
|
||||
<td><span class="swatches">{% for k in ['accent','accent_dark','accent_light','bg','red'] %}<i style="background:{{ r.cfg.get('colors',{}).get(k,'#ddd') }}"></i>{% endfor %}</span></td>
|
||||
<td class="muted">STT {{ r.stt }}{% if r.custom_prompt %} · Prompt{% endif %}{% if r.custom_pdf %} · PDF{% endif %}{% if r.has_code %} · <span style="color:#8a6200;font-weight:700">Code</span>{% endif %}</td>
|
||||
<td class="muted nowrap">{{ r.t.cpu }} CPU · {{ r.t.memory }}</td>
|
||||
<td class="run nowrap" data-t="{{ r.t.id }}"><span class="muted">–</span></td>
|
||||
<td>{% if r.t.draft %}<span class="badge warn">Entwurf</span> {% endif %}{% if r.missing_secrets %}<span class="badge err">{{ r.missing_secrets }} Secret(s)</span>{% endif %}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody></table>
|
||||
<div class="empty hide" id="none">Keine Tenants für diesen Filter.</div>
|
||||
</div>
|
||||
|
||||
<div class="card" style="margin-top:12px"><h3>Letzte Deployments <a href="/deployments" class="muted" style="font-weight:400;font-size:12px">· alle ansehen</a></h3>
|
||||
{% if last %}<table class="t"><tbody>{% for d in last %}<tr><td class="nowrap">{{ d.created_at|dt }}</td><td><b>{{ d.tenant_id or 'alle' }}</b></td><td>{{ d.kind }}</td><td>v{{ d.version }}</td>
|
||||
<td><span class="badge {{ 'ok' if d.status=='success' else 'err' if d.status in ('failed',) else '' }}">{{ d.status }}</span></td></tr>{% endfor %}</tbody></table>
|
||||
{% else %}<div class="empty">Noch keine Deployments.</div>{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="modal-bg" id="m-new"><div class="modal" style="max-width:520px">
|
||||
<h2>Neuer Tenant</h2>
|
||||
<div class="fgrid">
|
||||
<label class="f">Name<input type="text" id="n-name" placeholder="z. B. Musterklinik GmbH"></label>
|
||||
<label class="f">ID (Slug)<input type="text" id="n-id" placeholder="musterklinik"></label>
|
||||
<label class="f full">Ausgangspunkt
|
||||
<select id="n-clone"><option value="">Leer – Core-Standard (Prompt, PDF-Layout, Farben)</option>
|
||||
{% for r in rows %}<option value="{{ r.t.id }}">Kopie von {{ r.t.name }}</option>{% endfor %}</select></label>
|
||||
</div>
|
||||
<p class="hint" style="margin-top:8px">SESSION_SECRET und Bundle-Token werden erzeugt. OIDC-Client und Cost-Key folgen unter „Domain & Login“ / „Secrets“.</p>
|
||||
<div class="acts"><button class="btn" onclick="modal('m-new',false)">Abbrechen</button><button class="btn cyan" onclick="createTenant()">Anlegen</button></div>
|
||||
</div></div>
|
||||
|
||||
<div class="modal-bg" id="m-all"><div class="modal">
|
||||
<h2 id="all-title">Rollout</h2><p class="hint" id="all-hint"></p>
|
||||
<table class="t" id="all-table"><tbody></tbody></table>
|
||||
<div class="acts"><button class="btn" onclick="modal('m-all',false);location.reload()">Schließen</button></div>
|
||||
</div></div>
|
||||
{% endblock %}
|
||||
{% block scripts %}
|
||||
<script>
|
||||
let flt='all';
|
||||
function apply(){
|
||||
const q=document.getElementById('q').value.toLowerCase().trim(); let n=0;
|
||||
document.querySelectorAll('#tt tbody tr').forEach(tr=>{
|
||||
const ok=(!q||tr.dataset.q.includes(q))&&(flt==='all'||tr.dataset.flags.split(' ').includes(flt)); tr.style.display=ok?'':'none'; n+=ok;});
|
||||
document.getElementById('none').classList.toggle('hide',n>0);
|
||||
document.querySelectorAll('#chips .fchip').forEach(c=>c.classList.toggle('on',c.dataset.f===flt));
|
||||
}
|
||||
document.getElementById('q').addEventListener('input',apply);
|
||||
document.querySelectorAll('[data-f]').forEach(e=>e.addEventListener('click',()=>{flt=e.dataset.f;apply()}));
|
||||
document.getElementById('n-name').addEventListener('input',e=>{
|
||||
const idEl=document.getElementById('n-id'); if(idEl.dataset.touched) return;
|
||||
idEl.value=e.target.value.toLowerCase().normalize('NFD').replace(/[̀-ͯ]/g,'').replace(/[^a-z0-9]+/g,'-').replace(/^-|-$/g,'').slice(0,38);
|
||||
});
|
||||
document.getElementById('n-id').addEventListener('input',e=>e.target.dataset.touched=1);
|
||||
async function probe(){
|
||||
document.querySelectorAll('td.run').forEach(td=>td.innerHTML='<span class="muted">…</span>');
|
||||
try{
|
||||
const r = await api('GET','/api/status?refresh=1');
|
||||
document.querySelectorAll('td.run').forEach(td=>{ const x=r[td.dataset.t]; if(!x){ td.innerHTML='<span class="muted">–</span>'; return; }
|
||||
td.innerHTML = x.reachable && !x.drift.length ? `<span class="badge ok" title="Core ${esc(x.core?.version||'')}">${esc((x.core?.version||'?').slice(0,7))}${x.bundle?.source==='admin'?' · b'+x.bundle.version:''}</span>` : `<span class="badge err" title="${esc(x.drift.join(' · '))}">${esc(x.drift[0]||'Abweichung')}</span>`; });
|
||||
toast('Zustand aktualisiert');
|
||||
}catch(e){ toast(e.message,1) }
|
||||
}
|
||||
async function createTenant(){
|
||||
try{const r=await api('POST','/api/tenants',{id:document.getElementById('n-id').value,name:document.getElementById('n-name').value,clone_from:document.getElementById('n-clone').value||null});location.href='/tenants/'+r.id+'#brand'}
|
||||
catch(e){toast(e.message,1)}
|
||||
}
|
||||
async function deployAll(dry){
|
||||
if(!dry && !confirm('Alle aktiven Tenants nacheinander neu deployen? Beim ersten Fehler wird abgebrochen.')) return;
|
||||
let r; try{r=await api('POST','/api/deploy-all',{dry_run:dry})}catch(e){return toast(e.message,1)}
|
||||
document.getElementById('all-title').textContent=dry?'Dry-Run aller Tenants':'Rollout aller Tenants';
|
||||
document.getElementById('all-hint').textContent=dry?'Es wird nichts geschrieben – nur der Plan geprüft.':'Sequenziell mit Health-Check; Abbruch beim ersten Fehler.';
|
||||
modal('m-all',true);
|
||||
const tick=async()=>{
|
||||
const rows=await api('GET','/api/deployments?batch='+r.batch);
|
||||
document.querySelector('#all-table tbody').innerHTML=rows.map(d=>{
|
||||
const last=(d.log.trim().split('\n').filter(l=>!l.includes('[DRY-RUN]')&&!l.includes('body:')).pop()||'').slice(10);
|
||||
const cls=d.status==='success'?'ok':d.status==='failed'?'err':d.status==='running'?'cyan':'';
|
||||
return `<tr><td><b>${esc(d.tenant)}</b></td><td><span class="badge ${cls}">${d.status}</span></td><td class="muted mono">${esc(last)}</td></tr>`}).join('');
|
||||
if(rows.some(d=>d.status==='running'||d.status==='queued')) setTimeout(tick,700);
|
||||
};tick();
|
||||
}
|
||||
</script>
|
||||
{% endblock %}
|
||||
31
app/templates/deployments.html
Normal file
31
app/templates/deployments.html
Normal file
@@ -0,0 +1,31 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Deployments · Onyscribe Admin{% endblock %}
|
||||
{% block body %}
|
||||
<div class="page">
|
||||
<div class="head">
|
||||
<div><div class="eyebrow">Rollout</div><h1 class="title">Deployments.</h1>
|
||||
<p class="lead">Differenzielles PATCH auf den Mittwald-Stack (nie PUT), Namensraum <span class="mono">app-</span>, Guard für fremde Container, Abbruch beim ersten Fehler.</p></div>
|
||||
<div class="row"><button class="btn" onclick="run(true)">Dry-Run aller</button><button class="btn cyan {{ '' if deploy_live() else 'disabled' }}" onclick="run(false)">Alle neu deployen</button></div>
|
||||
</div>
|
||||
{% if not deploy_live() %}<div class="banner warn">Echte Deployments sind <b>gesperrt</b> (Env <span class="mono">DEPLOY_LIVE</span> der Admin-Plattform ist nicht <span class="mono">true</span>). Dry-Runs laufen mit demselben Code und zeigen exakt, was geschrieben würde.</div>{% endif %}
|
||||
<div class="card" style="margin-bottom:12px"><div class="row"><b>Core-Image</b><span class="badge dark mono">:{{ tag }}</span>
|
||||
<span class="muted">Image-Tag und Registry unter Einstellungen. Nach dem Core-Umbau holen Tenants ihr Bundle zur Laufzeit – dann genügt ein Neustart statt Image-Build.</span></div></div>
|
||||
<div class="card"><h3>Verlauf</h3><div id="list"></div></div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
{% block scripts %}
|
||||
<script>
|
||||
let open_={};
|
||||
async function load(){
|
||||
const rows=await api('GET','/api/deployments');
|
||||
document.getElementById('list').innerHTML=rows.length?`<table class="t"><thead><tr><th>#</th><th>Zeit</th><th>Tenant</th><th>Art</th><th>Version</th><th>Image</th><th>Status</th><th></th></tr></thead><tbody>`+rows.map(d=>`
|
||||
<tr><td>${d.id}</td><td class="nowrap">${fmt(d.created_at)}</td><td><b>${esc(d.tenant||'alle')}</b></td><td>${d.kind} ${d.simulated?'<span class="badge">Dry-Run</span>':'<span class="badge err">live</span>'}</td><td>v${d.version??'–'}</td><td class="mono">:${esc(d.image)}</td>
|
||||
<td><span class="badge ${d.status==='success'?'ok':d.status==='failed'?'err':d.status==='running'?'cyan':''}">${d.status}</span></td><td><button class="btn sm" onclick="tg(${d.id})">Log</button></td></tr>
|
||||
<tr style="display:${open_[d.id]?'table-row':'none'}"><td colspan="8"><div class="console">${esc(d.log||'(leer)')}</div></td></tr>`).join('')+'</tbody></table>':'<div class="empty">Noch keine Deployments.</div>';
|
||||
if(rows.some(d=>d.status==='running'||d.status==='queued')) setTimeout(load,900);
|
||||
}
|
||||
function tg(id){open_[id]=!open_[id];load()}
|
||||
async function run(dry){ if(!dry&&!confirm('Alle aktiven Tenants nacheinander neu deployen?'))return; try{await api('POST','/api/deploy-all',{dry_run:dry});toast('Rollout gestartet');load()}catch(e){toast(e.message,1)} }
|
||||
load();
|
||||
</script>
|
||||
{% endblock %}
|
||||
133
app/templates/settings.html
Normal file
133
app/templates/settings.html
Normal file
@@ -0,0 +1,133 @@
|
||||
{% 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 %}
|
||||
51
app/templates/tenant.html
Normal file
51
app/templates/tenant.html
Normal file
@@ -0,0 +1,51 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}{{ t.name }} · Onyscribe Admin{% endblock %}
|
||||
{% block body %}
|
||||
<div class="thead">
|
||||
<div class="logo-box" id="h-logo">{% if logo %}<img src="{{ logo }}" alt="">{% endif %}</div>
|
||||
<div class="grow" style="min-width:160px">
|
||||
<div class="crumb"><a href="/">Tenants</a> / <span class="mono">{{ t.id }}</span></div>
|
||||
<h1 id="h-name">{{ t.name }}</h1>
|
||||
</div>
|
||||
<div class="row" id="h-badges"></div>
|
||||
<div class="row" id="h-actions"></div>
|
||||
<div class="seg" id="h-panel" title="Mitte: Formular oder KI-Agent – die Vorschau rechts bleibt sichtbar">
|
||||
<button data-p="edit">Bearbeiten</button><button data-p="agent">KI-Agent</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="tl open" id="tl">
|
||||
<nav class="snav" id="snav"></nav>
|
||||
<div class="mid">
|
||||
<main class="pane" id="pane"><div class="empty">Lade …</div></main>
|
||||
<section class="chat agpane hide" id="ag-wrap">
|
||||
<div class="pbar" style="margin-bottom:6px"><span class="badge" id="ag-mode">Agent</span><span class="hint grow" id="ag-usage"></span><button class="btn sm" title="Verlauf löschen" onclick="clearChat()">Verlauf leeren</button></div>
|
||||
<div class="msgs" id="msgs"></div>
|
||||
<div class="chips" id="ag-chips"></div>
|
||||
<div class="chat-in"><textarea id="chat-text" rows="2" placeholder="Was soll angepasst werden? (Farben, Prompt, PDF-Layout, Flags …) – die Vorschau rechts zeigt jede Änderung sofort."></textarea><button class="btn cyan" id="chat-send" onclick="sendChat()">Senden</button></div>
|
||||
</section>
|
||||
</div>
|
||||
<aside class="panel" id="panel">
|
||||
<div class="pbar">
|
||||
<div class="seg" id="pv-kind"><button data-k="web" class="on">Web-App</button><button data-k="pdf">PDF</button></div>
|
||||
<div class="seg" id="pv-dev"><button data-d="" class="on">Desktop</button><button data-d="tablet">Tablet</button><button data-d="mobile">Mobil</button></div>
|
||||
<div class="seg" id="pv-src"><button data-s="draft" class="on">Entwurf</button><button data-s="current">Veröffentlicht</button><button data-s="agent" id="pv-agent" class="hide">Agent-Stand</button></div>
|
||||
<span class="grow"></span><span class="hint" id="pv-info"></span><button class="btn sm" onclick="refreshPreview(true)" title="Neu rendern">↻</button>
|
||||
</div>
|
||||
<div class="pv-frame" id="pv-frame">
|
||||
<iframe id="f-web" sandbox="allow-scripts allow-popups" title="Web-Vorschau"></iframe>
|
||||
<iframe id="f-pdf" style="display:none" title="PDF-Vorschau"></iframe>
|
||||
<div class="pv-load" id="pv-load">Rendert mit dem echten Core …</div><div class="pv-err" id="pv-err"></div>
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
|
||||
<div class="modal-bg" id="m-pub"><div class="modal">
|
||||
<h2>Änderungen prüfen & veröffentlichen</h2><div id="pub-body"></div>
|
||||
<div class="acts"><button class="btn" onclick="modal('m-pub',false)">Abbrechen</button><button class="btn cyan" id="pub-go" onclick="publish()">Als neue Version veröffentlichen</button></div>
|
||||
</div></div>
|
||||
<div class="modal-bg" id="m-diff"><div class="modal"><h2 id="diff-title">Diff</h2><div id="diff-body"></div><div class="acts"><button class="btn" onclick="modal('m-diff',false)">Schließen</button></div></div></div>
|
||||
{% endblock %}
|
||||
{% block scripts %}
|
||||
<script>const TID={{ t.id|tojson }};</script>
|
||||
<script src="/static/tenant.js"></script>
|
||||
{% endblock %}
|
||||
Reference in New Issue
Block a user