PHP Classes

File: templates/admin/sse_test.twig

Recommend this page to a friend!
  Packages of Matthew Asham   Binkterm PHP   templates/admin/sse_test.twig   Download  
File: templates/admin/sse_test.twig
Role: Auxiliary data
Content type: text/plain
Description: Auxiliary data
Class: Binkterm PHP
Bulletin board system based on the Web
Author: By
Last change:
Date: 5 days ago
Size: 18,916 bytes
 

Contents

Class file image Download
{% extends "base.twig" %} {% block title %}{{ t('ui.admin.sse_test.page_title') }} ? {{ app_name }}{% endblock %} {% block content %} <div class="container-fluid py-3"> <div class="d-flex align-items-center mb-3 gap-2 flex-wrap"> <h4 class="mb-0"><i class="fas fa-satellite-dish me-2"></i>{{ t('ui.admin.sse_test.heading') }}</h4> <span id="connBadge" class="badge bg-secondary ms-2">{{ t('ui.admin.sse_test.status_idle') }}</span> </div> {# Controls #} <div class="card mb-3"> <div class="card-body"> <div class="row g-3 align-items-end"> <div class="col-sm-auto"> <label class="form-label mb-1">Mode</label> <select class="form-select form-select-sm" id="ctrlMode"> <option value="direct">Direct stream</option> <option value="realpath">Real path (via sse_events)</option> </select> </div> <div class="col-sm-auto"> <label class="form-label mb-1">{{ t('ui.admin.sse_test.interval') }}</label> <select class="form-select form-select-sm" id="ctrlInterval"> <option value="100">100 ms</option> <option value="250">250 ms</option> <option value="500" selected>500 ms</option> <option value="1000">1 s</option> <option value="2000">2 s</option> </select> </div> <div class="col-sm-auto"> <label class="form-label mb-1">{{ t('ui.admin.sse_test.duration') }}</label> <select class="form-select form-select-sm" id="ctrlDuration"> <option value="10">10 s</option> <option value="30" selected>30 s</option> <option value="60">60 s</option> <option value="120">120 s</option> </select> </div> <div class="col-sm-auto" id="payloadWrap"> <label class="form-label mb-1">{{ t('ui.admin.sse_test.payload') }}</label> <select class="form-select form-select-sm" id="ctrlPayload"> <option value="0" selected>{{ t('ui.admin.sse_test.payload_none') }}</option> <option value="256">256 B</option> <option value="1024">1 KB</option> <option value="8192">8 KB</option> <option value="65536">64 KB</option> </select> </div> <div class="col-sm-auto"> <button class="btn btn-primary btn-sm" id="btnStart"><i class="fas fa-play me-1"></i>{{ t('ui.admin.sse_test.start') }}</button> <button class="btn btn-danger btn-sm d-none ms-1" id="btnStop"><i class="fas fa-stop me-1"></i>{{ t('ui.admin.sse_test.stop') }}</button> </div> <div class="col-sm-auto ms-auto"> <button class="btn btn-outline-secondary btn-sm" id="btnClear"><i class="fas fa-trash me-1"></i>{{ t('ui.admin.sse_test.clear') }}</button> </div> </div> </div> </div> {# Summary cards #} <div class="row g-3 mb-3"> <div class="col-6 col-md-3"> <div class="card text-center"> <div class="card-body py-2"> <div class="h4 mb-0" id="statEvents">?</div> <div class="small text-muted">{{ t('ui.admin.sse_test.stat_events') }}</div> </div> </div> </div> <div class="col-6 col-md-3"> <div class="card text-center"> <div class="card-body py-2"> <div class="h4 mb-0" id="statMissed"><span class="text-muted">?</span></div> <div class="small text-muted">{{ t('ui.admin.sse_test.stat_missed') }}</div> </div> </div> </div> <div class="col-6 col-md-3"> <div class="card text-center"> <div class="card-body py-2"> <div class="h4 mb-0" id="statLatency">?</div> <div class="small text-muted">{{ t('ui.admin.sse_test.stat_latency') }}</div> </div> </div> </div> <div class="col-6 col-md-3"> <div class="card text-center"> <div class="card-body py-2"> <div class="h4 mb-0" id="statGapDev">?</div> <div class="small text-muted">{{ t('ui.admin.sse_test.stat_gap_dev') }}</div> </div> </div> </div> </div> <div class="row g-3"> {# Latency sparkline #} <div class="col-12"> <div class="card mb-0"> <div class="card-header py-1 d-flex align-items-center justify-content-between"> <span class="small fw-semibold">{{ t('ui.admin.sse_test.latency_chart') }}</span> <span class="small text-muted" id="chartNote">{{ t('ui.admin.sse_test.chart_note') }}</span> </div> <div class="card-body p-2"> <canvas id="latencyCanvas" height="80" style="width:100%;display:block;"></canvas> </div> </div> </div> {# Event log #} <div class="col-12"> <div class="card"> <div class="card-header py-1 d-flex align-items-center justify-content-between"> <span class="small fw-semibold">{{ t('ui.admin.sse_test.event_log') }}</span> <span class="small text-muted" id="logNote">{{ t('ui.admin.sse_test.log_note') }}</span> </div> <div class="card-body p-0"> <div class="table-responsive" style="max-height:280px;overflow-y:auto;" id="logScroll"> <table class="table table-sm table-hover mb-0 font-monospace small"> <thead class="table-light sticky-top"> <tr> <th>{{ t('ui.admin.sse_test.col_seq') }}</th> <th>{{ t('ui.admin.sse_test.col_server_ts') }}</th> <th>{{ t('ui.admin.sse_test.col_client_ts') }}</th> <th>{{ t('ui.admin.sse_test.col_latency') }}</th> <th>{{ t('ui.admin.sse_test.col_gap') }}</th> <th>{{ t('ui.admin.sse_test.col_gap_dev') }}</th> <th>{{ t('ui.admin.sse_test.col_note') }}</th> </tr> </thead> <tbody id="logBody"></tbody> </table> </div> </div> </div> </div> </div> </div> <script> (function () { let es = null; let injectTimer = null; let durationTimer = null; let rows = []; let latencies = []; let gapDeviations = []; let lastClientTs = null; let lastSeq = -1; let totalMissed = 0; let intervalMs = 500; // Real-path mode: track injected events awaiting SSE delivery {seq -> server_ts} let pendingInjections = new Map(); let injectSeq = 0; const MAX_ROWS = 200; const MAX_CHART_PTS = 120; const btnStart = document.getElementById('btnStart'); const btnStop = document.getElementById('btnStop'); const btnClear = document.getElementById('btnClear'); const badge = document.getElementById('connBadge'); const logBody = document.getElementById('logBody'); const canvas = document.getElementById('latencyCanvas'); const ctx = canvas.getContext('2d'); const modeSelect = document.getElementById('ctrlMode'); // Hide payload picker in real-path mode (not relevant) modeSelect.addEventListener('change', function() { document.getElementById('payloadWrap').style.display = this.value === 'realpath' ? 'none' : ''; }); function setStatus(text, cls) { badge.textContent = text; badge.className = 'badge ms-2 ' + cls; } function fmtMs(v) { return v === null ? '?' : v.toFixed(1) + ' ms'; } function mean(arr) { if (!arr.length) return null; return arr.reduce((a, b) => a + b, 0) / arr.length; } function p95(arr) { if (!arr.length) return null; const s = [...arr].sort((a, b) => a - b); return s[Math.floor(s.length * 0.95)]; } function updateStats() { document.getElementById('statEvents').textContent = rows.length; const missedEl = document.getElementById('statMissed'); missedEl.innerHTML = totalMissed > 0 ? `<span class="text-danger">${totalMissed}</span>` : `<span class="text-success">0</span>`; const avgLat = mean(latencies); const p95Lat = p95(latencies); document.getElementById('statLatency').innerHTML = avgLat === null ? '?' : `${fmtMs(avgLat)} <span class="small text-muted">p95 ${fmtMs(p95Lat)}</span>`; const avgDev = mean(gapDeviations.map(Math.abs)); const maxDev = gapDeviations.length ? Math.max(...gapDeviations.map(Math.abs)) : null; document.getElementById('statGapDev').innerHTML = avgDev === null ? '?' : `${fmtMs(avgDev)} <span class="small text-muted">max ${fmtMs(maxDev)}</span>`; } function drawChart() { const dpr = window.devicePixelRatio || 1; const w = canvas.parentElement.clientWidth; const h = 80; canvas.width = w * dpr; canvas.height = h * dpr; canvas.style.width = w + 'px'; canvas.style.height = h + 'px'; ctx.scale(dpr, dpr); ctx.clearRect(0, 0, w, h); const pts = latencies.slice(-MAX_CHART_PTS); if (pts.length < 2) return; const maxVal = Math.max(...pts, 1); const minVal = Math.min(...pts, 0); const range = maxVal - minVal || 1; const pad = 4; ctx.beginPath(); ctx.strokeStyle = '#0d6efd'; ctx.lineWidth = 1.5; pts.forEach((v, i) => { const x = pad + (i / (pts.length - 1)) * (w - pad * 2); const y = h - pad - ((v - minVal) / range) * (h - pad * 2); i === 0 ? ctx.moveTo(x, y) : ctx.lineTo(x, y); }); ctx.stroke(); if (intervalMs >= minVal && intervalMs <= maxVal) { const y = h - pad - ((intervalMs - minVal) / range) * (h - pad * 2); ctx.beginPath(); ctx.strokeStyle = 'rgba(25,135,84,0.4)'; ctx.lineWidth = 1; ctx.setLineDash([4, 4]); ctx.moveTo(pad, y); ctx.lineTo(w - pad, y); ctx.stroke(); ctx.setLineDash([]); } ctx.fillStyle = '#6c757d'; ctx.font = '10px monospace'; ctx.fillText(fmtMs(maxVal), pad + 2, pad + 10); ctx.fillText(fmtMs(minVal), pad + 2, h - pad - 2); } function addRow(data, clientTs, note) { const latency = clientTs - data.server_ts; const gap = lastClientTs !== null ? clientTs - lastClientTs : null; const gapDev = gap !== null ? gap - intervalMs : null; latencies.push(latency); if (gapDev !== null) gapDeviations.push(gapDev); if (latencies.length > MAX_CHART_PTS * 2) latencies.shift(); if (gapDeviations.length > MAX_CHART_PTS * 2) gapDeviations.shift(); lastClientTs = clientTs; const tr = document.createElement('tr'); const latClass = latency > 500 ? 'text-danger' : latency > 200 ? 'text-warning' : ''; const gapDevClass = gapDev !== null && Math.abs(gapDev) > intervalMs * 0.5 ? 'text-danger' : gapDev !== null && Math.abs(gapDev) > intervalMs * 0.2 ? 'text-warning' : ''; tr.innerHTML = ` <td>${data.seq}</td> <td>${data.server_ts}</td> <td>${clientTs}</td> <td class="${latClass}">${fmtMs(latency)}</td> <td>${gap !== null ? fmtMs(gap) : '?'}</td> <td class="${gapDevClass}">${gapDev !== null ? fmtMs(gapDev) : '?'}</td> <td class="text-muted">${note || ''}</td> `; logBody.prepend(tr); rows.push(tr); if (rows.length > MAX_ROWS) rows.shift().remove(); updateStats(); drawChart(); } // ?? Direct stream mode ???????????????????????????????????????????????????? function startDirect(interval, duration, payload) { const url = `/admin/sse-test/stream?interval=${interval}&duration=${duration}&payload=${payload}`; es = new EventSource(url); setStatus('{{ t('ui.admin.sse_test.status_connecting') }}', 'bg-warning text-dark'); es.addEventListener('open', function(e) { // The native EventSource connection event also fires here with e.data === undefined. // Guard against it ? only process the server-sent 'open' event that carries JSON. if (!e.data) return; const data = JSON.parse(e.data); setStatus('{{ t('ui.admin.sse_test.status_connected') }}', 'bg-success'); addRow({ seq: -1, server_ts: data.server_ts }, Date.now(), 'open'); lastSeq = -1; }); es.onmessage = function(e) { const clientTs = Date.now(); const data = JSON.parse(e.data); const expectedSeq = lastSeq + 1; let note = ''; if (lastSeq >= 0 && data.seq !== expectedSeq) { totalMissed += data.seq - expectedSeq; note = `? gap (missed ${data.seq - expectedSeq})`; } lastSeq = data.seq; addRow(data, clientTs, note); }; es.addEventListener('done', function() { setStatus('{{ t('ui.admin.sse_test.status_done') }}', 'bg-secondary'); stop(false); }); es.onerror = function() { if (es && es.readyState === EventSource.CLOSED) { setStatus('{{ t('ui.admin.sse_test.status_error') }}', 'bg-danger'); stop(false); } }; } // ?? Real path mode ???????????????????????????????????????????????????????? // Injects events into sse_events via POST and receives them through the // real /api/stream polling path via the SharedWorker (window.BinkStream). // Latency = client receipt time ? server insert time. function startRealPath(interval, duration) { setStatus('{{ t('ui.admin.sse_test.status_connecting') }}', 'bg-warning text-dark'); window.BinkStream.on('sse_test', handleRealPathEvent); setStatus('{{ t('ui.admin.sse_test.status_connected') }}', 'bg-success'); lastSeq = -1; injectSeq = 0; pendingInjections.clear(); injectTimer = setInterval(function() { const seq = injectSeq++; fetch('/admin/sse-test/inject', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ seq }) }) .then(r => r.json()) .then(function(data) { if (data.ok) { // Store server_ts from the inject response as the reference time pendingInjections.set(seq, data.server_ts); } }) .catch(function() { // Inject failed ? count as missed when we notice the gap }); }, interval); durationTimer = setTimeout(function() { setStatus('{{ t('ui.admin.sse_test.status_done') }}', 'bg-secondary'); stop(false); }, duration * 1000); } function handleRealPathEvent(payload) { if (!payload || payload.seq === undefined) return; const clientTs = Date.now(); const seq = payload.seq; const serverTs = pendingInjections.has(seq) ? pendingInjections.get(seq) : payload.server_ts; pendingInjections.delete(seq); const expectedSeq = lastSeq + 1; let note = ''; if (lastSeq >= 0 && seq !== expectedSeq) { totalMissed += seq - expectedSeq; note = `? gap (missed ${seq - expectedSeq})`; } lastSeq = seq; addRow({ seq, server_ts: serverTs }, clientTs, note); } // ?? Common ???????????????????????????????????????????????????????????????? function start() { const mode = modeSelect.value; const interval = parseInt(document.getElementById('ctrlInterval').value, 10); const duration = parseInt(document.getElementById('ctrlDuration').value, 10); const payload = parseInt(document.getElementById('ctrlPayload').value, 10); intervalMs = interval; btnStart.classList.add('d-none'); btnStop.classList.remove('d-none'); if (mode === 'realpath') { startRealPath(interval, duration); } else { startDirect(interval, duration, payload); } } function stop(closeEs = true) { if (closeEs && es) { es.close(); } es = null; if (injectTimer) { clearInterval(injectTimer); injectTimer = null; } if (durationTimer) { clearTimeout(durationTimer); durationTimer = null; } window.BinkStream.off('sse_test', handleRealPathEvent); pendingInjections.clear(); btnStart.classList.remove('d-none'); btnStop.classList.add('d-none'); } function clear() { rows.forEach(r => r.remove()); rows = []; latencies = []; gapDeviations = []; lastClientTs = null; lastSeq = -1; totalMissed = 0; ['statEvents','statMissed','statLatency','statGapDev'].forEach(id => { document.getElementById(id).innerHTML = '?'; }); ctx.clearRect(0, 0, canvas.width, canvas.height); } btnStart.addEventListener('click', start); btnStop.addEventListener('click', () => stop(true)); btnClear.addEventListener('click', clear); window.addEventListener('resize', drawChart); })(); </script> {% endblock %}