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>
124 lines
9.2 KiB
HTML
124 lines
9.2 KiB
HTML
{% 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 %}
|