diff --git a/app/static/js/app.js b/app/static/js/app.js index 1bca216..bb61c31 100644 --- a/app/static/js/app.js +++ b/app/static/js/app.js @@ -2108,7 +2108,43 @@ async function loadDeviceInfo() { } /** - * Load device statistics (Stats tab in Device modal) + * Duration in seconds -> "6d 14h 46m". A copy of fmtDuration() in repeater-manage.js, + * which runs in the My Repeaters panel and shares no scope with this file. + */ +function formatStatsDuration(seconds) { + if (seconds == null || isNaN(seconds)) return '—'; + seconds = Math.floor(seconds); + const d = Math.floor(seconds / 86400); + const h = Math.floor((seconds % 86400) / 3600); + const m = Math.floor((seconds % 3600) / 60); + const parts = []; + if (d) parts.push(`${d}d`); + if (h || d) parts.push(`${h}h`); + parts.push(`${m}m`); + return parts.join(' '); +} + +/** + * One titled block of the Stats tab, laid out like the repeater status view. + * Labels are escaped here, so callers pass t(); values are markup built by the + * caller and go in as-is. + */ +function deviceStatsSection(title, rows) { + const body = rows.map(([label, value]) => + `${escapeHtml(label)}${value}` + ).join(''); + return ` +
${escapeHtml(title)}
+ ${body}
+ `; +} + +/** + * Load device statistics (Stats tab in Device modal). + * + * Field names come straight from the firmware payload - the same ones the `stats` + * console command prints (see app/main.py). They are not the names the repeater + * status payload uses, even where the value means the same thing. */ async function loadDeviceStats() { const container = document.getElementById('deviceStatsContent'); @@ -2126,67 +2162,70 @@ async function loadDeviceStats() { } const stats = data.stats || {}; + const core = stats.core || {}; + const radio = stats.radio || {}; + const pkt = stats.packets || {}; + const db = data.db_stats || {}; const bat = data.battery || {}; - let html = ''; - // Battery (from dedicated get_bat or from core stats) - if (bat && typeof bat === 'object' && bat.voltage) { - html += ``; - } else if (stats.core && stats.core.battery_mv) { - html += ``; + // Battery comes from the dedicated get_bat call, or from core stats when + // that returned nothing. + const volts = (bat && bat.voltage) ? Number(bat.voltage).toFixed(2) + : (core.battery_mv != null ? (core.battery_mv / 1000).toFixed(2) : null); + + // Share of uptime the radio spent transmitting or receiving. + const util = (radio.tx_air_secs != null && radio.rx_air_secs != null && core.uptime_secs) + ? (((radio.tx_air_secs + radio.rx_air_secs) / core.uptime_secs) * 100).toFixed(2) + '%' + : '—'; + + // "flood" and "direct" are protocol terms and stay English in every + // language (see docs/translations.md). The split drops to its own line + // below sm, where keeping it inline wraps the row label as well. + const split = (flood, direct) => + `(flood ${formatInt(flood)} · direct ${formatInt(direct)})`; + + let html = ''; + + if (Object.keys(core).length || volts !== null) { + html += deviceStatsSection(t('rptmgmt.status.system'), [ + [t('device.stats.battery'), volts !== null ? `${volts}V` : '—'], + [t('device.stats.uptime'), formatStatsDuration(core.uptime_secs)], + [t('device.stats.queue'), formatInt(core.queue_len)], + [t('device.stats.errors'), formatInt(core.errors)], + ]); } - // Core stats - if (stats.core) { - const c = stats.core; - if (c.uptime !== undefined) { - const d = Math.floor(c.uptime / 86400); - const h = Math.floor((c.uptime % 86400) / 3600); - const m = Math.floor((c.uptime % 3600) / 60); - html += ``; - } - if (c.queue_length !== undefined) - html += ``; - if (c.errors !== undefined) - html += ``; + if (Object.keys(radio).length) { + html += deviceStatsSection(t('rptmgmt.status.radio'), [ + [t('rptmgmt.status.last_rssi'), radio.last_rssi != null ? `${radio.last_rssi} dBm` : '—'], + [t('rptmgmt.status.last_snr'), radio.last_snr != null ? `${radio.last_snr} dB` : '—'], + [t('rptmgmt.status.noise'), radio.noise_floor != null ? `${radio.noise_floor} dBm` : '—'], + [t('device.stats.tx_air'), formatStatsDuration(radio.tx_air_secs)], + [t('device.stats.rx_air'), formatStatsDuration(radio.rx_air_secs)], + [t('rptmgmt.status.utilization'), util], + ]); } - // Radio stats - if (stats.radio) { - const r = stats.radio; - if (r.tx_air_time !== undefined) - html += ``; - if (r.rx_air_time !== undefined) - html += ``; + if (Object.keys(pkt).length) { + const rows = [ + [t('device.stats.packets_tx'), `${formatInt(pkt.sent)} ${split(pkt.flood_tx, pkt.direct_tx)}`], + [t('device.stats.packets_rx'), `${formatInt(pkt.recv)} ${split(pkt.flood_rx, pkt.direct_rx)}`], + ]; + if (pkt.recv_errors != null) rows.push([t('rptmgmt.status.rx_errors'), formatInt(pkt.recv_errors)]); + html += deviceStatsSection(t('rptmgmt.status.packets'), rows); } - // Packet stats - if (stats.packets) { - const p = stats.packets; - if (p.sent !== undefined) - html += ``; - if (p.received !== undefined) - html += ``; + if (Object.keys(db).length) { + html += deviceStatsSection(t('device.stats.database'), [ + [t('device.stats.contacts_db'), formatInt(db.contacts)], + [t('device.stats.channel_msgs'), formatInt(db.channel_messages)], + [t('device.stats.direct_msgs'), formatInt(db.direct_messages)], + [t('device.stats.db_size'), db.db_size_bytes != null + ? `${(db.db_size_bytes / (1024 * 1024)).toFixed(1)} MB` : '—'], + ]); } - // DB stats (included in same response) - if (data.db_stats) { - const db = data.db_stats; - if (db.contacts !== undefined) - html += ``; - if (db.channel_messages !== undefined) - html += ``; - if (db.direct_messages !== undefined) - html += ``; - if (db.db_size_bytes !== undefined) { - const sizeMB = (db.db_size_bytes / (1024 * 1024)).toFixed(1); - html += ``; - } - } - - html += '
${tHtml('device.stats.battery')}${bat.voltage}V
${tHtml('device.stats.battery')}${(stats.core.battery_mv / 1000).toFixed(2)}V
${tHtml('device.stats.uptime')}${d}d ${h}h ${m}m
${tHtml('device.stats.queue')}${c.queue_length}
${tHtml('device.stats.errors')}${c.errors}
${tHtml('device.stats.tx_air')}${r.tx_air_time.toFixed(1)} min
${tHtml('device.stats.rx_air')}${r.rx_air_time.toFixed(1)} min
${tHtml('device.stats.packets_tx')}${p.sent.toLocaleString()}
${tHtml('device.stats.packets_rx')}${p.received.toLocaleString()}
${tHtml('device.stats.contacts_db')}${db.contacts}
${tHtml('device.stats.channel_msgs')}${db.channel_messages.toLocaleString()}
${tHtml('device.stats.direct_msgs')}${db.direct_messages.toLocaleString()}
${tHtml('device.stats.db_size')}${sizeMB} MB
'; - - if (html === '
') { + if (!html) { container.innerHTML = `
${tHtml('device.stats.none')}
`; } else { container.innerHTML = html; diff --git a/app/translations/en.json b/app/translations/en.json index d8a20dd..47f66f5 100644 --- a/app/translations/en.json +++ b/app/translations/en.json @@ -434,6 +434,7 @@ "device.stats.battery": "Battery", "device.stats.channel_msgs": "Channel Msgs", "device.stats.contacts_db": "Contacts (DB)", + "device.stats.database": "Database", "device.stats.db_size": "DB Size", "device.stats.direct_msgs": "Direct Msgs", "device.stats.errors": "Errors", diff --git a/app/translations/pl.json b/app/translations/pl.json index a91b9ff..1059f59 100644 --- a/app/translations/pl.json +++ b/app/translations/pl.json @@ -454,6 +454,7 @@ "device.stats.battery": "Bateria", "device.stats.channel_msgs": "Wiadomości kanałowe", "device.stats.contacts_db": "Kontakty (DB)", + "device.stats.database": "Baza danych", "device.stats.db_size": "Rozmiar bazy", "device.stats.direct_msgs": "Wiadomości prywatne", "device.stats.errors": "Błędy", diff --git a/docs/user-guide.md b/docs/user-guide.md index 5c9913c..2aa5eff 100644 --- a/docs/user-guide.md +++ b/docs/user-guide.md @@ -735,10 +735,18 @@ Displays device parameters in a readable table: ### Stats Tab -Shows live device statistics: -- Uptime, free memory, battery voltage -- Message counters (sent, received, forwarded) -- Current airtime usage +Shows everything the `stats` console command reports, laid out like the repeater +[Status](#status) view: + +- **System Information** - Battery, uptime, TX queue length, error counter +- **Radio Statistics** - Last RSSI / SNR, noise floor, TX and RX airtime, and channel + utilization (TX+RX airtime as a percentage of uptime) +- **Packet Statistics** - Sent and received counts with their flood/direct split, plus + RX errors +- **Database** - What mc-webui itself has stored: contacts, channel and direct message + counts, and the database file size + +The device numbers are counters since the last reboot, not lifetime totals. ### Share Tab