Files
Onyscribe-Admin/app/templates/dashboard.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

124 lines
9.2 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 %}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 &amp; 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 %}