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>
91 lines
9.6 KiB
HTML
91 lines
9.6 KiB
HTML
{% 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 %}
|