gwoe-antragspruefer/app/templates/v2/screens/admin_stand.html
Dotty Dotter ad73c824d3 perf(browser-mem): Polling-Frequenz + Page-Hide-Cleanup (#183)
Drei Mitigations:

1) Admin-Queue-Polling 5s → 15s. Die Queue ändert sich pro Sekunde
   ohnehin nicht spürbar; senkt CPU + Network ohne UX-Verlust.

2) ``pagehide``-Listener in admin_queue.html, admin_stand.html und
   auswertungen.html. Zerstört Chart.js-Instanzen + cleart setInterval-
   Handles, sobald die Page in den Back/Forward-Cache geht oder
   geschlossen wird. Bisher hingen sie bis Browser-GC.

3) /auswertungen: zentrales Cleanup für ``_histChart``, alle ``_svCharts.*``
   und ``window._zeitreiheModalChart`` beim pagehide. Bisher zerstört
   nur die einzelnen Render-Funktionen ihre Vorgänger; beim Page-Verlassen
   blieben sie alle stehen.

Was nicht abgedeckt ist (für eventuelle Folge-Iteration mit konkretem
Heap-Snapshot):
- Lazy-Render lange News-/Drucksachen-Listen via IntersectionObserver
- Detaillierte Detached-DOM-Untersuchung pro Seite

Bestehende Maßnahmen (bereits da, hier nicht angefasst): chart.destroy()
vor jedem neuen Chart, sim.stop() in cluster.html, visibilitychange-
Pause für Polling.
2026-05-09 02:17:23 +02:00

347 lines
12 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 "v2/base.html" %}
{% block title %}System-Stand — GWÖ-Antragsprüfer{% endblock %}
{% set v2_active_nav = "admin_stand" %}
{% block head_extra %}
<script src="/static/chart.umd.min.js"></script>
<style>
.stand-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
gap: 14px;
margin-bottom: 1.5rem;
}
.stand-kpi {
background: var(--ecg-card-bg);
border: 1px solid var(--ecg-border);
border-radius: 6px;
padding: 16px;
text-align: center;
}
.stand-kpi-value {
font-family: var(--font-mono);
font-size: 30px;
font-weight: 700;
color: var(--ecg-teal);
line-height: 1.1;
}
.stand-kpi-label {
font-family: var(--font-mono);
font-size: 11px;
text-transform: uppercase;
letter-spacing: 0.06em;
opacity: 0.6;
margin-top: 4px;
}
.stand-kpi-sub {
font-family: var(--font-mono);
font-size: 11px;
opacity: 0.55;
margin-top: 6px;
}
.stand-section {
margin-top: 2rem;
}
.stand-section h2 {
font-family: var(--font-display);
font-size: 16px;
color: var(--ecg-teal);
margin: 0 0 10px;
}
.stand-table {
width: 100%;
border-collapse: collapse;
font-family: var(--font-mono);
font-size: 12px;
}
.stand-table td, .stand-table th {
border-bottom: 1px solid var(--ecg-border);
padding: 4px 8px;
text-align: left;
}
.stand-table th {
font-size: 10px;
text-transform: uppercase;
letter-spacing: 0.05em;
opacity: 0.6;
font-weight: 700;
}
.stand-table td:nth-child(2) {
text-align: right;
}
</style>
{% endblock %}
{% block main %}
<div style="padding:0 0 1.5rem;">
<h1 style="font-family:var(--font-display);font-size:22px;color:var(--ecg-teal);margin:0 0 4px;">System-Stand</h1>
<p style="font-size:12px;font-family:var(--font-mono);color:var(--ecg-dark);opacity:0.6;">
Datenüberblick · automatische Aktualisierung alle 30 s
</p>
</div>
<div id="stand-loading" style="font-family:var(--font-mono);font-size:12px;opacity:0.5;padding:16px 0;">Lade Stand …</div>
<div id="stand-content" style="display:none;">
<!-- KPI-Kacheln -->
<div class="stand-grid">
<div class="stand-kpi">
<div class="stand-kpi-value" id="kpi-ass"></div>
<div class="stand-kpi-label">Bewertungen</div>
<div class="stand-kpi-sub" id="kpi-ass-7d">— in 7 Tagen</div>
</div>
<div class="stand-kpi">
<div class="stand-kpi-value" id="kpi-votes"></div>
<div class="stand-kpi-label">Plenum-Votes</div>
<div class="stand-kpi-sub">aus Plenarprotokollen</div>
</div>
<div class="stand-kpi">
<div class="stand-kpi-value" id="kpi-match"></div>
<div class="stand-kpi-label">Bewertung ∩ Vote</div>
<div class="stand-kpi-sub" id="kpi-orphans">— Vote-Orphans</div>
</div>
<div class="stand-kpi">
<div class="stand-kpi-value" id="kpi-news"></div>
<div class="stand-kpi-label">News</div>
<div class="stand-kpi-sub" id="kpi-news-7d">— in 7 Tagen</div>
</div>
<div class="stand-kpi">
<div class="stand-kpi-value" id="kpi-drafts"></div>
<div class="stand-kpi-label">PM-Entwürfe</div>
<div class="stand-kpi-sub" id="kpi-drafts-7d">— in 7 Tagen</div>
</div>
<div class="stand-kpi">
<div class="stand-kpi-value" id="kpi-bookmarks"></div>
<div class="stand-kpi-label">Merklisten-Einträge</div>
<div class="stand-kpi-sub">user-übergreifend</div>
</div>
</div>
<!-- Score-Histogram -->
<div class="stand-section">
<h2>GWÖ-Score-Verteilung</h2>
<div style="background:var(--ecg-card-bg);border:1px solid var(--ecg-border);border-radius:6px;padding:14px;">
<canvas id="stand-score-chart" style="max-height:240px;"></canvas>
</div>
</div>
<!-- Per-Bundesland -->
<div class="stand-section">
<h2>Bewertungen + Votes pro Bundesland</h2>
<table class="stand-table">
<thead><tr><th>BL</th><th>Bewertungen</th><th>Plenum-Votes</th></tr></thead>
<tbody id="stand-bl-rows"></tbody>
</table>
</div>
<!-- News-Quellen -->
<div class="stand-section">
<h2>News pro Quelle</h2>
<table class="stand-table">
<thead><tr><th>Quelle</th><th>Anzahl</th></tr></thead>
<tbody id="stand-news-rows"></tbody>
</table>
</div>
<!-- Vote-Orphans-Auto-Bewertung (#173) -->
<div class="stand-section">
<h2>Vote-Orphans-Auto-Bewertung</h2>
<p style="font-size:12px;opacity:0.65;margin:-4px 0 8px;">
Heute: <strong id="auto-rate-today"></strong>.
Cron läuft alle 6h und enqueued bis zu 30 Orphans pro Lauf, max. 200 Anträge/Tag.
</p>
<table class="stand-table">
<thead>
<tr>
<th>Zeitpunkt</th><th>Quelle</th><th>BL</th>
<th style="text-align:right;">Versucht</th>
<th style="text-align:right;">Enqueued</th>
<th style="text-align:right;">Skipped</th>
<th>Notiz</th>
</tr>
</thead>
<tbody id="stand-autorate-rows"></tbody>
</table>
</div>
<div id="stand-meta" style="font-family:var(--font-mono);font-size:11px;opacity:0.5;margin-top:1.5rem;"></div>
{# Alternative Detail-Ansichten — nur im Admin sichtbar.
Default ist v3 (`/antrag/{drs}`), der frühere Profi-Modus läuft
unter /v2/antrag/. Hier ein Sample-Link, damit Admins ihn finden. #}
<h3 class="v2-h3" style="margin-top:32px;">Alternative Ansichten</h3>
<p style="font-family:var(--font-mono);font-size:12px;opacity:0.75;line-height:1.6;">
Standard-Detailansicht ist v3 (Bürger:innen-Modus, single column).
Die frühere v2-Profi-Ansicht (zwei Spalten, alle Felder gleichzeitig)
bleibt unter <code>/v2/antrag/&lt;Drucksache&gt;</code> erreichbar.
</p>
<p id="alt-views-sample" style="font-family:var(--font-mono);font-size:12px;line-height:1.8;">
Lade aktuelles Beispiel …
</p>
<h3 class="v2-h3" style="margin-top:24px;">Design-Werkstätten</h3>
<p style="font-family:var(--font-mono);font-size:12px;opacity:0.75;line-height:1.6;">
Live-Editoren für Layout-Iteration ohne Server-Redeploy:
</p>
<ul style="font-family:var(--font-mono);font-size:12px;line-height:1.8;">
<li><a href="/v2/scorecard-werkstatt">Scorecard-Werkstatt</a>
Live-Vorschau aller Card-Formate (Portrait / Square / OG) mit
CSS-Editor und Embed-Link-Generator.</li>
</ul>
</div>
{% endblock %}
{% block body_scripts %}
<script>
let _scoreChart = null;
function fmtN(n) {
return (n == null) ? '—' : n.toLocaleString('de-DE');
}
async function loadStand() {
try {
const r = await fetch('/api/admin/stand');
if (!r.ok) {
document.getElementById('stand-loading').textContent = 'Fehler ' + r.status;
return;
}
const d = await r.json();
document.getElementById('stand-loading').style.display = 'none';
document.getElementById('stand-content').style.display = '';
// KPIs
document.getElementById('kpi-ass').textContent = fmtN(d.assessments.total);
document.getElementById('kpi-ass-7d').textContent = '+' + fmtN(d.assessments.last_7_days) + ' in 7 Tagen';
document.getElementById('kpi-votes').textContent = fmtN(d.plenum_votes.total);
document.getElementById('kpi-match').textContent = fmtN(d.match.with_assessment_and_vote);
document.getElementById('kpi-orphans').textContent = fmtN(d.match.vote_orphans) + ' Vote-Orphans';
document.getElementById('kpi-news').textContent = fmtN(d.news.total);
document.getElementById('kpi-news-7d').textContent =
'+' + fmtN(d.news.last_7_days) + ' in 7 Tagen, ' + fmtN(d.news.embedded) + ' embedded';
document.getElementById('kpi-drafts').textContent = fmtN(d.presse_drafts.total);
document.getElementById('kpi-drafts-7d').textContent = '+' + fmtN(d.presse_drafts.last_7_days) + ' in 7 Tagen';
document.getElementById('kpi-bookmarks').textContent = fmtN(d.bookmarks);
// Score-Histogram
const dist = d.assessments.score_distribution || {};
const buckets = [0,1,2,3,4,5,6,7,8,9,10];
const values = buckets.map(b => dist[String(b)] || 0);
const colors = buckets.map(b => {
if (b <= 2) return 'rgba(200,30,30,0.6)';
if (b <= 4) return 'rgba(247,148,29,0.6)';
if (b <= 6) return 'rgba(150,150,150,0.5)';
if (b <= 8) return 'rgba(136,158,51,0.6)';
return 'rgba(0,157,165,0.7)';
});
if (_scoreChart) _scoreChart.destroy();
_scoreChart = new Chart(document.getElementById('stand-score-chart'), {
type: 'bar',
data: { labels: buckets.map(b => b + '' + (b+1)), datasets: [{
label: 'Bewertungen',
data: values,
backgroundColor: colors,
}]},
options: {
responsive: true,
plugins: { legend: { display: false } },
scales: {
y: { beginAtZero: true, title: { display: true, text: 'Anzahl' } },
x: { title: { display: true, text: 'GWÖ-Score-Bucket' } },
},
},
});
// Per-BL-Tabelle
const ass_bl = d.assessments.by_bundesland || {};
const vote_bl = d.plenum_votes.by_bundesland || {};
const bl_set = new Set([...Object.keys(ass_bl), ...Object.keys(vote_bl)]);
const bl_rows = [...bl_set].sort();
document.getElementById('stand-bl-rows').innerHTML = bl_rows.map(bl => `
<tr>
<td>${bl}</td>
<td>${fmtN(ass_bl[bl] || 0)}</td>
<td>${fmtN(vote_bl[bl] || 0)}</td>
</tr>`).join('');
// News-Source-Tabelle
const ns = d.news.by_source || {};
document.getElementById('stand-news-rows').innerHTML =
Object.entries(ns).sort((a, b) => b[1] - a[1]).map(([s, n]) => `
<tr><td>${s}</td><td>${fmtN(n)}</td></tr>`).join('');
// Auto-Rate-Run-Tabelle (#173)
const ar = d.auto_rate || {};
const elToday = document.getElementById('auto-rate-today');
if (elToday) {
elToday.textContent =
`${fmtN(ar.today_runs || 0)} Runs · ${fmtN(ar.today_attempted || 0)} Anträge versucht · ${fmtN(ar.today_succeeded || 0)} enqueued`;
}
const arRecent = ar.recent || [];
const arRowsEl = document.getElementById('stand-autorate-rows');
if (arRowsEl) {
arRowsEl.innerHTML = arRecent.length
? arRecent.map(r => `
<tr>
<td style="font-family:var(--font-mono);font-size:11px;">${(r.started_at || '').slice(0, 16)}</td>
<td style="font-family:var(--font-mono);font-size:11px;">${r.source || '—'}</td>
<td>${r.bundesland || 'ALL'}</td>
<td style="text-align:right;">${fmtN(r.n_attempted)}</td>
<td style="text-align:right;">${fmtN(r.n_succeeded)}</td>
<td style="text-align:right;">${fmtN(r.n_skipped)}</td>
<td style="font-family:var(--font-mono);font-size:11px;opacity:0.65;">${r.error_summary || ''}</td>
</tr>`).join('')
: '<tr><td colspan="7" style="opacity:0.5;font-style:italic;">Noch kein Run heute oder in den letzten 5 Läufen.</td></tr>';
}
document.getElementById('stand-meta').textContent =
'Aktualisiert: ' + new Date().toLocaleTimeString('de-DE');
} catch (e) {
document.getElementById('stand-loading').textContent = 'Fehler: ' + e;
}
}
/* Sample-Link für Alternative Ansichten: aktuellster Antrag */
(async function () {
var el = document.getElementById('alt-views-sample');
if (!el) return;
try {
var r = await fetch('/api/assessments?limit=1');
var data = await r.json();
var a = (data && data[0]) || (data.results && data.results[0]) || null;
if (!a || !a.drucksache) {
el.textContent = 'Kein Beispiel-Antrag verfügbar.';
return;
}
var drs = encodeURIComponent(a.drucksache);
var labelFor = function (s) { return s.length > 70 ? s.slice(0,68) + '…' : s; };
el.innerHTML =
'<strong>Beispiel:</strong> ' + (a.bundesland || '?') + ' · Drs. ' + a.drucksache +
' — „' + labelFor((a.title || '')) + '"<br>' +
'· <a href="/antrag/' + drs + '">Standard (v3, Bürger:innen-Modus)</a><br>' +
'· <a href="/v2/antrag/' + drs + '">v2 — Profi-Modus, zwei Spalten</a>';
} catch (e) {
el.textContent = 'Konnte Beispiel-Link nicht laden.';
}
})();
loadStand();
let _standInterval = setInterval(loadStand, 30000);
// Pause-Polling wenn Tab versteckt + beim Verlassen der Page (#183).
document.addEventListener('visibilitychange', function () {
if (document.hidden) {
if (_standInterval) { clearInterval(_standInterval); _standInterval = null; }
} else if (!_standInterval) {
loadStand();
_standInterval = setInterval(loadStand, 30000);
}
});
window.addEventListener('pagehide', function () {
if (_standInterval) { clearInterval(_standInterval); _standInterval = null; }
});
</script>
{% endblock %}