import { api } from '../api.js';
import { on, off, requestScreenshot, startRemote, stopRemote, sendTouch, sendSwipe, sendKey, sendCommand } from '../socket.js';
import { showToast } from '../components/toast.js';
import { esc, livenessBadge, hydrateAuthImages } from '../utils.js';
import { t, tn } from '../i18n.js';
import { showDeviceOwnerQRModal } from '../components/device-owner-qr-modal.js';
import { frameDeviceOutput, displayAspectRatio } from '../lib/device-frame.js';
// The player distinguishes three cases for the Wi-Fi name, because "--" was hiding a real
// answer: Android 8.1+ refuses to reveal the SSID to an app without location permission, and a
// customer reasonably read the blank as a bug in the player. "permission" means we are not
// allowed to know; empty means there is genuinely no Wi-Fi (an Ethernet panel).
function ssidLabel(ssid) {
if (ssid === 'permission') return esc(t('device.info.wifi_needs_location'));
if (!ssid) return '--';
return esc(ssid);
}
// #238: turn the Now Playing screenshot the way the wall mount turns the panel. The placeholder
// ("no screenshot yet") is deliberately left alone — it is dashboard chrome, not device output.
function frameNowPlaying() {
const stage = document.getElementById('screenshotStage');
const img = document.getElementById('currentScreenshot');
if (stage && img && img.tagName === 'IMG') frameDeviceOutput(stage, img, currentDevice?.orientation);
}
let currentDevice = null;
let statusHandler = null;
let screenshotHandler = null;
let playbackHandler = null;
let logHandler = null;
let shellHandler = null;
let diagPollTimer = null; // polls a diag-smoothness widget's reported frame stats while the page is open
let screenshotInterval = null;
let remoteActive = false;
// Mirrors the Debug-logging checkbox so cleanup() can switch the device's stream back off.
// Without this, leaving the screen left the panel streaming into nothing: the device kept
// emitting, the dashboard kept relaying, and nobody was listening. The player carries its own
// auto-off as the backstop for the case this can't cover -- a tab that is killed, not closed.
let debugStreamOn = false;
let debugFrozen = false;
let debugHeld = []; // lines that arrived while frozen, replayed on resume
const DEBUG_PANEL_MAX = 500; // panel rows AND the held-while-frozen cap
// Every player sends a level and the panel used to render all four identically, so the one line
// that explains the fault sat in a wall of grey. Errors and warnings are why the operator opened it.
const DEBUG_LEVEL_COLOR = { e: '#f87171', w: '#fbbf24', d: '#64748b' };
function debugLineText(d) {
return `${new Date(d.ts || Date.now()).toLocaleTimeString()} [${d.tag || ''}] ${d.message || ''}`;
}
function appendDebugLine(d) {
const panel = document.getElementById('debugLogPanel');
if (!panel) return;
const line = document.createElement('div');
line.textContent = debugLineText(d); // textContent — no HTML injection
const tone = DEBUG_LEVEL_COLOR[(d.level || '').toLowerCase()];
if (tone) line.style.color = tone;
panel.appendChild(line);
while (panel.childElementCount > DEBUG_PANEL_MAX) panel.removeChild(panel.firstChild);
panel.scrollTop = panel.scrollHeight;
}
function updateDebugTools() {
const btn = document.getElementById('debugFreezeBtn');
const status = document.getElementById('debugLogStatus');
if (btn) btn.textContent = debugFrozen ? t('device.debug.resume') : t('device.debug.freeze');
if (status) {
// Say how many are waiting, so freezing never feels like the device went quiet.
status.textContent = debugFrozen
? (debugHeld.length >= DEBUG_PANEL_MAX
? t('device.debug.held_max', { n: debugHeld.length })
: t('device.debug.held', { n: debugHeld.length }))
: '';
}
}
function setDebugFrozen(frozen) {
debugFrozen = frozen;
if (!frozen) {
const held = debugHeld;
debugHeld = [];
for (const d of held) appendDebugLine(d); // resume shows what you missed, in order
}
updateDebugTools();
}
/*
* Clipboard with a fallback, because a self-hosted dashboard on plain http is NOT a secure context
* and `navigator.clipboard` is simply absent there — the copy buttons elsewhere in this app quietly
* do nothing in that case. A debug log is precisely what a self-hoster wants to paste into an issue.
*/
async function copyToClipboard(text) {
try {
if (navigator.clipboard && window.isSecureContext) { await navigator.clipboard.writeText(text); return true; }
} catch (e) { /* fall through to the legacy path */ }
try {
const ta = document.createElement('textarea');
ta.value = text;
ta.setAttribute('readonly', '');
ta.style.cssText = 'position:fixed;top:-1000px;left:-1000px;opacity:0';
document.body.appendChild(ta);
ta.select();
ta.setSelectionRange(0, ta.value.length);
const ok = document.execCommand('copy');
document.body.removeChild(ta);
return ok;
} catch (e) { return false; }
}
// Belt for the orphaned-stream fix: if the tab is hidden/closed/backgrounded while a Remote session
// is live, stop it (the server also auto-stops on socket drop, but bfcache keeps the socket alive).
if (typeof window !== 'undefined') {
window.addEventListener('pagehide', () => {
if (remoteActive && currentDevice) { remoteActive = false; try { stopRemote(currentDevice.id); } catch (e) {} }
});
}
// #161 device-owner Terminal presets. Commands chosen to work at the APP UID (not root) — getprop,
// /proc + /sys reads, df, pm list, ip. dumpsys/settings are deliberately avoided (OS-denied to apps).
const TERMINAL_PRESETS = [
{ label: 'Device info', cmd: 'getprop ro.product.manufacturer; getprop ro.product.model; echo "Android $(getprop ro.build.version.release) (sdk $(getprop ro.build.version.sdk))"' },
{ label: 'Build', cmd: 'getprop ro.build.fingerprint; echo "serial=$(getprop ro.serialno)"' },
{ label: 'Memory', cmd: 'head -3 /proc/meminfo' },
{ label: 'CPU', cmd: 'grep -iE "hardware|processor" /proc/cpuinfo | head; echo "cores=$(cat /proc/cpuinfo | grep -c ^processor)"' },
{ label: 'Storage', cmd: 'df -h /data 2>/dev/null; df -h /storage/emulated/0 2>/dev/null' },
{ label: 'Uptime', cmd: 'echo "up $(cut -d. -f1 /proc/uptime)s"' },
{ label: 'Date / TZ', cmd: 'date; echo "tz=$(getprop persist.sys.timezone)"' },
{ label: 'Display', cmd: 'getprop | grep -iE "lcd_density|ro.sf.lcd|ro.hwui|ro.surface_flinger" | head' },
{ label: '3rd-party apps', cmd: 'pm list packages -3 2>/dev/null | sed s/package:// | head -40 || echo "pm list denied at app uid"' },
{ label: 'Props', cmd: 'getprop | grep -iE "model|version.release|serialno|wifi.interface|timezone"' },
{ label: 'Whoami', cmd: 'id' },
];
function formatBytes(mb) {
if (mb === null || mb === undefined) return '--';
if (mb >= 1024) return `${(mb / 1024).toFixed(1)} GB`;
return `${mb} MB`;
}
function formatUptime(seconds) {
if (!seconds) return '--';
const d = Math.floor(seconds / 86400);
const h = Math.floor((seconds % 86400) / 3600);
const m = Math.floor((seconds % 3600) / 60);
if (d > 0) return `${d}d ${h}h ${m}m`;
if (h > 0) return `${h}h ${m}m`;
return `${m}m`;
}
// #74/#75: device clock + skew indicator. Compares the device's reported UTC to the
// server's receipt time; a gap > 2 min means the device clock is wrong, so per-item
// schedules will fire at the wrong local time — surface it instead of a support mystery.
function renderDeviceClock(device) {
const tz = device.reported_timezone || device.timezone || '--';
if (!device.reported_utc || !device.reported_at) return tz;
const skewSec = Math.abs(Math.round(device.reported_utc / 1000) - device.reported_at);
let local = '';
try {
local = new Date(device.reported_utc).toLocaleString(undefined,
{ timeZone: device.reported_timezone || undefined, hour: '2-digit', minute: '2-digit', month: 'short', day: 'numeric' });
} catch (e) { /* bad tz id -> skip local render */ }
const warn = skewSec > 120
? `
` : ''}${warn}`;
}
// A BrightSign runs the same web player, so client_type is 'player' and it would otherwise read as
// "Web Player" — indistinguishable from a browser tab on someone's desk. The player reports
// platform 'brightsign' (autorun.brs puts ?platform=brightsign on the URL); the user-agent check
// covers panels paired before that existed, which registered as "Chrome 120" with a BrightSign UA.
function isBrightSignDevice(device) {
if (!device) return false;
// platform only: `devices` has no user_agent column, so a fallback on it could never fire.
return String(device.platform || '').toLowerCase().includes('brightsign');
}
// Mirrors platformFamily() in server/lib/player-capabilities.js — SAME FOUR SIGNALS, SAME ORDER,
// so the UI and the server never disagree about what a device is.
//
// The precedence is the whole point and is easy to get wrong. An earlier version of this helper
// kept only the last test, and a Tizen TV registers `android_version: 'Tizen 6.5'` (see
// tizen/js/app.js) — non-empty, not "Web/..." — so every Samsung panel in the fleet classified as
// Android. It was invisible only because Tizen happens to declare remote.screenshot today; the
// moment that changes, a MediaProjection button appears on a TV that has no such API.
//
// Gates the MediaProjection capture bootstrap below, and that gate is deliberately Android-and-
// nothing-else — NOT "Android that cannot already capture".
//
// The tempting extra condition is to hide it once a panel declares remote.screenshot. Two reasons
// not to. First, the dashboard cannot tell "this device declared it" from "the server filled in a
// baseline": /api/devices/:id ships capabilitiesFor(), which resolves both into one array (see
// server/routes/devices.js), and the android baseline CONTAINS remote.screenshot — so that
// condition hides the button from every one of the ~440 undeclared panels in the field, which is
// exactly backwards. Second, even where capture already works it is the accessibility path;
// MediaProjection is the better one (WebSocketService tries it FIRST), so offering the upgrade to
// a panel that has the weaker path is a feature, not redundancy.
function isAndroidDevice(device) {
if (!device) return false;
const platform = String(device.platform || '').toLowerCase();
if (platform.includes('brightsign')) return false;
if (platform.includes('tizen')) return false;
// Second, independent signal for a Tizen TV: the .wgt player sends client_type 'wgt'. `platform`
// is the primary key, but it lives in a column an older client's register could overwrite.
if (device.client_type === 'wgt') return false;
if (device.client_type === 'apk') return true;
const av = String(device.android_version || '');
return av !== '' && !av.startsWith('Web/');
}
export function render(container, deviceId) {
container.innerHTML = `
`;
loadDevice(deviceId);
// Real-time updates
statusHandler = (data) => {
if (data.device_id !== deviceId) return;
const badge = document.querySelector('.device-status-badge');
if (badge) {
const b = livenessBadge(data); // v4: 3-state liveness when present, else binary status
badge.className = `device-status-badge ${b.state}`;
badge.textContent = b.label;
badge.title = b.title || ''; // exit-reason hover (empty for non-offline / no-reason)
}
if (data.telemetry) updateTelemetryDisplay(data.telemetry);
};
screenshotHandler = (data) => {
if (data.device_id !== deviceId) return;
// Use inline base64 data if available, otherwise fall back to URL
const imgSrc = data.image_data || (() => {
const token = localStorage.getItem('token');
return data.url + (data.url.includes('?') ? '&' : '?') + 'token=' + token;
})();
// Update screenshot in Now Playing tab
const screenshotEl = document.getElementById('currentScreenshot');
if (screenshotEl) {
if (screenshotEl.tagName === 'IMG') {
screenshotEl.src = imgSrc;
} else {
// Replace placeholder div with actual image
const img = document.createElement('img');
img.id = 'currentScreenshot';
img.src = imgSrc;
img.alt = 'Current screen';
img.style.cssText = 'width:100%;height:100%;object-fit:contain';
screenshotEl.replaceWith(img);
}
// #238: a screenshot is the RAW framebuffer, so a portrait panel's arrives sideways — the
// player rotated the content into it and only the wall mount turns it back. Re-frame on every
// arrival, not just at render: the branch above swaps the element out from under us.
frameNowPlaying();
}
// Update remote canvas
const canvas = document.getElementById('remoteCanvas');
if (canvas && remoteActive) {
const ctx = canvas.getContext('2d');
const img = new Image();
img.onload = () => {
canvas.width = img.width;
canvas.height = img.height;
ctx.drawImage(img, 0, 0);
};
img.src = imgSrc;
}
};
playbackHandler = (data) => {
if (data.device_id !== deviceId) return;
const el = document.getElementById('nowPlayingInfo');
if (el && data.current_content_id) {
el.textContent = t('device.now_playing_id', { id: data.current_content_id });
}
};
// Live debug log lines streamed from the device (when the Debug logging
// checkbox is on). Appended via textContent — no HTML injection.
logHandler = (data) => {
if (data.device_id !== deviceId) return;
// Frozen: HOLD the line rather than drop it. A log you froze to read something is the exact
// moment the lines that explain it are still arriving — pausing the stream would throw away
// the part you were about to want.
if (debugFrozen) {
debugHeld.push(data);
if (debugHeld.length > DEBUG_PANEL_MAX) debugHeld.shift();
updateDebugTools();
return;
}
appendDebugLine(data);
};
on('device-status', statusHandler);
on('screenshot-ready', screenshotHandler);
on('playback-state', playbackHandler);
on('device-log', logHandler);
}
async function loadDevice(deviceId, activeTab = null) {
const contentEl = document.getElementById('deviceContent');
try {
const device = await api.getDevice(deviceId);
currentDevice = device;
/*
* Does this display support `cap`? Drives which controls render at all.
*
* Every control used to be offered to every display: a browser tab was shown "Reboot device",
* a Tizen TV was shown screen power. They did nothing, silently, and read as bugs. Hidden
* rather than disabled — a greyed-out button on a panel that will NEVER gain the capability is
* a permanent question ("what do I have to do to enable this?") with no answer. The capability
* list is shown in the Info tab so a missing control is explainable.
*
* The server resolves the baseline for the ~440 displays that declare nothing, so this sees a
* populated list either way and never has to know the difference.
*/
const caps = Array.isArray(device.capabilities) ? device.capabilities : null;
const can = (cap) => (caps ? caps.includes(cap) : true); // no list at all => pre-capability server, show everything
const latestTelemetry = device.telemetry?.[0] || {};
const diagWidget = (device.assignments || []).find(a => a && a.widget_type === 'diag-smoothness');
contentEl.innerHTML = `
${/* tier===2 is kept alongside the capability: it is already an accurate RUNTIME signal from
the panel, and a device-owner display that has not yet shipped a capability declaration
would otherwise lose these buttons the day this deploys. */
(device.tier === 2 || can('system.device_owner')) ? `
` : ''}
`;
// If this device is assigned the smoothness-diagnostic widget, poll THIS device's reported stats.
if (diagWidget) startDiagPoll(diagWidget.widget_id, deviceId);
// Hydrate authenticated thumbnail images in the playlist tab
const pc = document.getElementById('playlistContainer');
if (pc) hydrateAuthImages(pc);
// Global key/command handlers for remote
window._sendKey = (keycode) => {
if (currentDevice) sendKey(currentDevice.id, keycode);
};
window._sendCmd = (type) => {
if (currentDevice) sendCommand(currentDevice.id, type, {});
};
window._enableSystemView = () => {
if (!currentDevice) return;
sendCommand(currentDevice.id, 'enable_system_capture', {});
// Unlock the system controls after a short delay (user needs to tap "Start now" on device)
const btn = document.getElementById('enableSystemCaptureBtn');
const hint = document.getElementById('systemViewHint');
if (btn) { btn.textContent = t('device.remote.waiting_for_approval'); btn.disabled = true; }
// Check periodically if the device granted it (we'll know because screenshots keep coming even after Home)
setTimeout(() => {
const controls = document.getElementById('systemViewControls');
if (controls) { controls.style.opacity = '1'; controls.style.pointerEvents = 'auto'; }
if (btn) { btn.textContent = t('device.remote.system_view_enabled'); btn.style.background = 'var(--success)'; }
if (hint) hint.textContent = t('device.remote.unlocked_hint');
}, 5000);
};
// #161 device-owner Terminal tab (tier 2 only): a real scrollback shell + preset commands + push-APK.
if (device.tier === 2) {
const termOut = document.getElementById('termOut');
const append = (text) => { if (!termOut) return; termOut.textContent += text; termOut.scrollTop = termOut.scrollHeight; };
const runCmd = (cmd) => { if (!cmd) return; append('\n$ ' + cmd + '\n'); sendCommand(device.id, 'shell', { cmd }); };
const termCmd = document.getElementById('termCmd');
document.getElementById('termRun')?.addEventListener('click', () => { const c = termCmd?.value?.trim(); if (c) { runCmd(c); termCmd.value = ''; } });
termCmd?.addEventListener('keydown', (e) => { if (e.key === 'Enter') { const c = e.target.value.trim(); if (c) { runCmd(c); e.target.value = ''; } } });
document.querySelectorAll('.term-preset').forEach(b => b.addEventListener('click', () => runCmd(b.dataset.cmd)));
document.getElementById('termClear')?.addEventListener('click', () => { if (termOut) termOut.textContent = ''; });
document.getElementById('apkInstall')?.addEventListener('click', () => {
const url = document.getElementById('apkUrl')?.value?.trim();
if (!url) return;
if (!/^https?:\/\//.test(url)) { showToast(t('device.owner_tools.bad_url'), 'error'); return; }
sendCommand(device.id, 'install_apk', { url });
append('\n# push apk → ' + url + ' (installs silently on a device owner)\n');
showToast(t('device.owner_tools.apk_sent'), 'success');
});
if (shellHandler) off('shell-result', shellHandler);
shellHandler = (data) => {
if (data.device_id !== device.id) return;
append((data.output || '') + (data.exit != null && data.exit !== 0 ? '\n[exit ' + data.exit + ']\n' : '\n'));
};
on('shell-result', shellHandler);
}
// Render uptime timeline
renderUptimeTimeline(device.uptimeData || [], device.statusLog || []);
// Render the Recent incidents panel (merges typed device_events with
// offline→online transitions derived from the status log).
renderIncidents(device.deviceEvents || [], device.statusLog || []);
frameNowPlaying();
setupTabs();
setupActions(device);
setupRemote(device);
setupPlaylistActions(device);
// Restore active tab if specified (e.g. after layout change)
if (activeTab) {
document.querySelectorAll('.tab').forEach(t => t.classList.remove('active'));
document.querySelectorAll('.tab-content').forEach(t => t.classList.remove('active'));
// Both loops above just cleared every tab, so a requested tab that no longer renders (its
// capability went away, or the page was reloaded against a player that has since declared a
// smaller set) would leave NO tab selected and the page blank. Fall back to Info, which is
// never gated.
const wanted = document.getElementById(`tab-${activeTab}`) ? activeTab : 'info';
const tab = document.querySelector(`.tab[data-tab="${wanted}"]`);
if (tab) tab.classList.add('active');
const content = document.getElementById(`tab-${wanted}`);
if (content) content.classList.add('active');
}
// Request a fresh screenshot on page load + poll periodically. #159: the preview used to go stale
// because nothing re-requested it — the device only sends a frame on an explicit request or during
// a live Remote session. Poll every 5s while this page is open so the Now Playing preview stays
// current (cleared on view teardown). A Remote session streams at its own faster rate on top.
if (device.status === 'online') {
requestScreenshot(deviceId);
if (screenshotInterval) clearInterval(screenshotInterval);
screenshotInterval = setInterval(() => {
if (!document.hidden) requestScreenshot(deviceId);
}, 5000);
}
} catch (err) {
contentEl.innerHTML = `
${t('device.failed_load')}
${esc(err.message)}
`;
}
}
function renderPlaylist(assignments) {
if (!assignments.length) {
return `
`).join('');
}
function setupTabs() {
document.querySelectorAll('.tab').forEach(tab => {
tab.addEventListener('click', () => {
document.querySelectorAll('.tab').forEach(t => t.classList.remove('active'));
document.querySelectorAll('.tab-content').forEach(t => t.classList.remove('active'));
tab.classList.add('active');
document.getElementById(`tab-${tab.dataset.tab}`).classList.add('active');
});
});
}
// #104: device preview — reuse the player in device-free preview mode, iframed
// same-origin (dashboard CSP frame-src 'self' allows it). Shows the device's CURRENT
// playlist in the device's OWN layout/orientation (server payload). wall members
// preview full-frame (server forces wall_config:null in v1).
//
// #238: the iframe is the panel's FRAMEBUFFER, not its face. It used to be given the as-displayed
// 9/16 shape directly, so on a portrait device the player rotated content a second time inside a
// box that was already the finished picture and the preview came out sideways — while the panel
// itself was right, which is the worst possible split for someone trying to verify their work.
// The stage is the face; the frame is landscape underneath it and the mount turns it back.
function showDevicePreview(device) {
const overlay = document.createElement('div');
overlay.style.cssText = 'position:fixed;inset:0;background:rgba(0,0,0,0.85);display:flex;align-items:center;justify-content:center;z-index:10000;padding:16px';
overlay.innerHTML = `
${t('device.preview_btn')} — ${esc(device.name)}
`;
document.body.appendChild(overlay);
frameDeviceOutput(overlay.querySelector('#dpvStage'), overlay.querySelector('#dpvStage iframe'), device.orientation);
const close = () => overlay.remove();
overlay.querySelector('#dpvClose').onclick = close;
overlay.onclick = (e) => { if (e.target === overlay) close(); };
document.addEventListener('keydown', function esc2(ev) {
if (ev.key === 'Escape') { close(); document.removeEventListener('keydown', esc2); }
});
}
// #150 re-adopt fallback: browse the workspace's previously-removed device snapshots and
// apply one onto THIS (usually blank, just-re-paired) device. Primary restore is the silent
// fingerprint-match on re-pair; this is for factory-reset / new-hardware / changed-fingerprint.
const ORIENT_LABELS = {
'landscape': 'device.form.orientation.landscape',
'portrait': 'device.form.orientation.portrait',
'landscape-flipped': 'device.form.orientation.landscape_flipped',
'portrait-flipped': 'device.form.orientation.portrait_flipped',
};
const orientLabel = (o) => t(ORIENT_LABELS[o] || ORIENT_LABELS.landscape);
const fmtTs = (ts) => (ts ? new Date(ts * 1000).toLocaleString() : '—');
// #161: device-owner provisioning helper — QR (scan after factory-reset, tap welcome 6×) + the ADB
// one-liner. Device owner is optional; it unlocks silent updates, reboot, kiosk, time control.
async function showReAdoptModal(device) {
let snapshots, playlists;
try {
[snapshots, playlists] = await Promise.all([
api.getRemovedDevices(),
api.getPlaylists().catch(() => []), // best-effort: only used to label the restored playlist
]);
} catch (err) { showToast(err.message || t('device.readopt.error'), 'error'); return; }
const plById = new Map((playlists || []).map(p => [p.id, p.name]));
const playlistLabel = (s) => !s.playlist_id
? t('device.readopt.playlist_none')
: (plById.get(s.playlist_id) || t('device.readopt.playlist_removed'));
const rowsHtml = (snapshots || []).map((s, i) => {
const blockedBadge = s.blocked
? `${t('device.readopt.blocked')}`
: '';
// Fingerprint is the key but not an operator-facing identifier — truncated + on-hover only.
const fpShort = (s.fingerprint || '').slice(0, 8);
return `
`;
}).join('');
}
function updateTelemetryDisplay(telemetry) {
const update = (id, val) => {
const el = document.getElementById(id);
if (el) el.textContent = val;
};
if (telemetry.battery_level != null) update('telBattery', telemetry.battery_level + '%');
if (telemetry.storage_free_mb) update('telStorage', t('device.info.size_free', { size: formatBytes(telemetry.storage_free_mb) }));
if (telemetry.wifi_ssid !== undefined) update('telWifi', ssidLabel(telemetry.wifi_ssid));
if (telemetry.local_ip) update('telLocalIp', telemetry.local_ip);
// update() no-ops when the card is absent, which is the case for a v4-only panel — a screen that
// acquires a v6 address mid-session picks the card up on the next full render, not this path.
if (telemetry.local_ip6) update('telLocalIp6', telemetry.local_ip6);
if (telemetry.wifi_rssi) update('telRssi', telemetry.wifi_rssi + ' dBm');
if (telemetry.uptime_seconds) update('telUptime', formatUptime(telemetry.uptime_seconds));
if (telemetry.ram_free_mb) update('telRam', t('device.info.size_free', { size: formatBytes(telemetry.ram_free_mb) }));
if (telemetry.cpu_usage != null) update('telCpu', telemetry.cpu_usage.toFixed(1) + '%');
}
// ----- diag-smoothness widget: show the frame stats it reports from the panel -----
function renderDiagPanel(w) {
return `