diff --git a/web/zebra-spaces.html b/web/zebra-spaces.html index d317255..9f75c10 100644 --- a/web/zebra-spaces.html +++ b/web/zebra-spaces.html @@ -116,17 +116,19 @@ * bad ≥ 150ms — colour-tagged so the user can scan at a glance. */ .latency-rows { display: grid; gap: 0.18rem; font-size: 0.78rem; margin-bottom: 0.4rem; } .lat-row { - display: grid; grid-template-columns: 1fr 4.5rem 3rem; gap: 0.5rem; - align-items: baseline; padding: 0.1rem 0; + display: grid; grid-template-columns: 1fr 3.6rem 2.6rem 2.6rem 2.6rem; gap: 0.35rem; + align-items: baseline; padding: 0.12rem 0; border-bottom: 1px dotted #eee; font-family: monospace; } .lat-row .lat-name { color: #333; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } - .lat-row .lat-rtt { text-align: right; font-weight: bold; } + .lat-row .lat-rtt, .lat-row .lat-loss, .lat-row .lat-jit { text-align: right; font-weight: bold; } + .lat-row .lat-loss::before { content: 'loss '; color: #888; font-weight: normal; font-size: 0.65rem; } + .lat-row .lat-jit::before { content: 'jit '; color: #888; font-weight: normal; font-size: 0.65rem; } .lat-row .lat-path { text-align: right; color: #888; font-size: 0.7rem; text-transform: uppercase; } - .lat-row.lat-good .lat-rtt { color: #060; } - .lat-row.lat-mid .lat-rtt { color: #b80; } - .lat-row.lat-bad .lat-rtt { color: #b00; } - .lat-row.lat-na .lat-rtt { color: #999; } + .lat-row.lat-good .lat-rtt, .lat-row.lat-good .lat-loss, .lat-row.lat-good .lat-jit { color: #060; } + .lat-row.lat-mid .lat-rtt, .lat-row.lat-mid .lat-loss, .lat-row.lat-mid .lat-jit { color: #b80; } + .lat-row.lat-bad .lat-rtt, .lat-row.lat-bad .lat-loss, .lat-row.lat-bad .lat-jit { color: #b00; } + .lat-row.lat-na .lat-rtt, .lat-row.lat-na .lat-loss, .lat-row.lat-na .lat-jit { color: #999; } h1 { font-family: 'chunkfiveregular', serif; @@ -1770,15 +1772,15 @@ refreshCameraList(); * have one PC per kind (sfuPubPC, sfuScreenPC, sfuCameraPC), so each * gets its own row. Mesh peers get one row each. * ================================================================== */ -async function statsForPC(pc){ - if (!pc) return { rtt: null, path: '' }; +/* per-PC stats keyed by an arbitrary ID so we can compute deltas + * (packetsLost, packetsReceived) across polls — % loss is a delta over + * a delta, not a cumulative. The pcStatsPrev object holds the last poll. */ +const pcStatsPrev = new Map(); +async function statsForPC(pc, key){ + if (!pc) return { rtt: null, path: '', lossPct: null, jitterMs: null }; try { const stats = await pc.getStats(); - let pair = null; - /* WebRTC reports many candidate-pairs; we want the 'nominated' / - * succeeded one that's actually carrying media. Some browsers tag - * it via 'selected' on transport; fall back to scanning. */ - let selectedPairId = ''; + let pair = null, selectedPairId = ''; stats.forEach(s => { if (s.type === 'transport' && s.selectedCandidatePairId) selectedPairId = s.selectedCandidatePairId; }); stats.forEach(s => { if (s.type !== 'candidate-pair') return; @@ -1788,12 +1790,38 @@ async function statsForPC(pc){ if (!pair){ stats.forEach(s => { if (!pair && s.type === 'candidate-pair' && s.state === 'succeeded') pair = s; }); } - if (!pair) return { rtt: null, path: '' }; - let path = ''; - stats.forEach(s => { if (s.type === 'local-candidate' && s.id === pair.localCandidateId) path = s.candidateType || ''; }); - const rtt = (typeof pair.currentRoundTripTime === 'number') ? Math.round(pair.currentRoundTripTime * 1000) : null; - return { rtt, path }; - } catch(_){ return { rtt: null, path: '' }; } + let path = '', rtt = null; + if (pair){ + stats.forEach(s => { if (s.type === 'local-candidate' && s.id === pair.localCandidateId) path = s.candidateType || ''; }); + if (typeof pair.currentRoundTripTime === 'number') rtt = Math.round(pair.currentRoundTripTime * 1000); + } + /* sum packetsLost + packetsReceived across inbound-rtp (subscriber + * side: many incoming streams). For publishers we use the remote + * report (remote-inbound-rtp tells the sender what its peer lost + + * the jitter at the receiver). Both directions are interesting. */ + let pktsLost = 0, pktsBase = 0, jitter = 0, jitterSamples = 0; + stats.forEach(s => { + if (s.type === 'inbound-rtp' && typeof s.packetsLost === 'number'){ + pktsLost += s.packetsLost; + pktsBase += (s.packetsReceived || 0) + s.packetsLost; + if (typeof s.jitter === 'number'){ jitter += s.jitter; jitterSamples++; } + } else if (s.type === 'remote-inbound-rtp' && typeof s.packetsLost === 'number'){ + pktsLost += s.packetsLost; + /* remote report doesn't include packetsReceived; we use it only + * for loss when no inbound-rtp is present (publish-side PC) */ + if (typeof s.jitter === 'number'){ jitter += s.jitter; jitterSamples++; } + } + }); + /* convert cumulative loss/received into a delta vs last poll so the + * percentage reflects what's happening NOW, not the session lifetime */ + const prev = pcStatsPrev.get(key) || { lost: 0, base: 0 }; + const dLost = Math.max(0, pktsLost - prev.lost); + const dBase = Math.max(0, pktsBase - prev.base); + pcStatsPrev.set(key, { lost: pktsLost, base: pktsBase }); + const lossPct = dBase > 0 ? (100 * dLost / dBase) : (dLost > 0 ? 100 : 0); + const jitterMs = jitterSamples > 0 ? Math.round(1000 * jitter / jitterSamples) : null; + return { rtt, path, lossPct, jitterMs }; + } catch(_){ return { rtt: null, path: '', lossPct: null, jitterMs: null }; } } function rttClass(rtt){ if (rtt == null) return 'lat-na'; @@ -1810,19 +1838,42 @@ function fmtPath(p){ if (p === 'relay') return 'TURN'; return p; } +function fmtLoss(v){ return (v == null) ? '—' : (v < 0.05 ? '0%' : v.toFixed(1)+'%'); } +function fmtJitter(v){ return (v == null) ? '—' : v + 'ms'; } +function rowClass(rtt, lossPct, jitterMs){ + /* worst of three signals drives the colour, so a green RTT with + * 5% loss still flags red */ + let worst = 'lat-good'; + const bump = c => { const order = ['lat-na','lat-good','lat-mid','lat-bad']; if (order.indexOf(c) > order.indexOf(worst)) worst = c; }; + if (rtt == null) bump('lat-na'); + else if (rtt < 50) bump('lat-good'); + else if (rtt < 150) bump('lat-mid'); + else bump('lat-bad'); + if (lossPct != null){ + if (lossPct < 0.5) bump('lat-good'); + else if (lossPct < 2) bump('lat-mid'); + else bump('lat-bad'); + } + if (jitterMs != null){ + if (jitterMs < 20) bump('lat-good'); + else if (jitterMs < 50) bump('lat-mid'); + else bump('lat-bad'); + } + return worst; +} async function refreshLatency(){ const rows = []; /* SFU publishers — only present if you're a speaker */ - if (sfuPubPC){ rows.push({ name: 'sfu mic out', pc: sfuPubPC }); } - if (sfuScreenPC){ rows.push({ name: 'sfu screen out', pc: sfuScreenPC }); } - if (sfuCameraPC){ rows.push({ name: 'sfu camera out', pc: sfuCameraPC }); } + if (sfuPubPC){ rows.push({ name: 'sfu mic out', pc: sfuPubPC, key: 'pub' }); } + if (sfuScreenPC){ rows.push({ name: 'sfu screen out', pc: sfuScreenPC, key: 'screen' }); } + if (sfuCameraPC){ rows.push({ name: 'sfu camera out', pc: sfuCameraPC, key: 'camera' }); } /* SFU subscriber — carries every incoming stream from other speakers */ - if (sfuSubPC){ rows.push({ name: 'sfu in (host + screen + cams)', pc: sfuSubPC }); } + if (sfuSubPC){ rows.push({ name: 'sfu in (host + screen + cams)', pc: sfuSubPC, key: 'sub' }); } /* mesh peers — one row each */ for (const [uuid, pc] of peers){ const mm = members.get(uuid); const handle = (mm && mm.handle) || shortHex(uuid); - rows.push({ name: 'peer '+handle, pc }); + rows.push({ name: 'peer '+handle, pc, key: 'mesh-'+uuid }); } const container = $('latency-rows'); if (!container) return; @@ -1833,16 +1884,18 @@ async function refreshLatency(){ } $('sec-latency').classList.remove('hidden'); /* gather all stats in parallel */ - const data = await Promise.all(rows.map(r => statsForPC(r.pc))); + const data = await Promise.all(rows.map(r => statsForPC(r.pc, r.key))); container.innerHTML = ''; for (let i = 0; i < rows.length; i++){ - const { rtt, path } = data[i]; + const { rtt, path, lossPct, jitterMs } = data[i]; const div = document.createElement('div'); - div.className = 'lat-row ' + rttClass(rtt); - const n = document.createElement('span'); n.className = 'lat-name'; n.textContent = rows[i].name; - const r = document.createElement('span'); r.className = 'lat-rtt'; r.textContent = fmtRtt(rtt); - const p = document.createElement('span'); p.className = 'lat-path'; p.textContent = fmtPath(path); - div.appendChild(n); div.appendChild(r); div.appendChild(p); + div.className = 'lat-row ' + rowClass(rtt, lossPct, jitterMs); + const n = document.createElement('span'); n.className = 'lat-name'; n.textContent = rows[i].name; + const r = document.createElement('span'); r.className = 'lat-rtt'; r.textContent = fmtRtt(rtt); + const lo = document.createElement('span'); lo.className = 'lat-loss'; lo.textContent = fmtLoss(lossPct); + const j = document.createElement('span'); j.className = 'lat-jit'; j.textContent = fmtJitter(jitterMs); + const p = document.createElement('span'); p.className = 'lat-path'; p.textContent = fmtPath(path); + div.appendChild(n); div.appendChild(r); div.appendChild(lo); div.appendChild(j); div.appendChild(p); container.appendChild(div); } } @@ -2004,8 +2057,8 @@ logLine('', 'ready — pick a handle, type a rendezvous code, enter the space');