mirror of
https://github.com/MarekWo/mc-webui.git
synced 2026-07-28 04:23:26 +02:00
feat(repeaters): Status tool (stage 3)
First management tool. The Status tile auto-fetches on open and shows a compact three-section table (not MO's oversized tiles): - System: battery %/V (linear 3.3-4.2 V estimate), uptime, repeater clock, queue length, debug/error events - Radio: last RSSI/SNR, noise floor, TX/RX airtime - Packets: sent + received (flood/direct split), duplicates, RX errors, channel utilization computed client-side as (tx_air+rx_air)/uptime (matches the firmware's own 10.09% reading) Refresh button + "updated Ns ago"; errors render inline with retry. Backend: GET /api/repeaters/<pk>/status and /clock, both gated on an existing login session (401 need_login) to fail fast instead of a 2-min timeout. Clock loads as a follow-up request so the table appears immediately, then the clock row fills in. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -5831,6 +5831,66 @@ def logout_my_repeater(public_key):
|
||||
return jsonify({'success': False, 'error': str(e)}), 500
|
||||
|
||||
|
||||
def _require_repeater_login(dm, pk):
|
||||
"""Return (None) if a session exists, else an (json, status) error tuple.
|
||||
|
||||
A remote request only succeeds if the repeater has us in its ACL, which
|
||||
requires a prior login. Failing fast here avoids a pointless ~2 min
|
||||
timeout when the panel is opened without a live session.
|
||||
"""
|
||||
if not dm.get_repeater_session(pk):
|
||||
return jsonify({'success': False, 'error': 'Not logged in', 'need_login': True}), 401
|
||||
return None
|
||||
|
||||
|
||||
@api_bp.route('/repeaters/<public_key>/status', methods=['GET'])
|
||||
def repeater_status(public_key):
|
||||
"""Binary status request to a repeater (battery, radio and packet stats)."""
|
||||
dm = _get_dm()
|
||||
if not dm:
|
||||
return jsonify({'success': False, 'error': 'Device not connected'}), 503
|
||||
pk = _normalize_repeater_key(public_key)
|
||||
if not pk:
|
||||
return jsonify({'success': False, 'error': 'Invalid public_key'}), 400
|
||||
login_error = _require_repeater_login(dm, pk)
|
||||
if login_error:
|
||||
return login_error
|
||||
try:
|
||||
result = dm.repeater_req_status(pk)
|
||||
if result.get('success'):
|
||||
return jsonify({'success': True, 'data': result['data']}), 200
|
||||
return jsonify({'success': False,
|
||||
'error': result.get('error', 'No status response')}), _repeater_result_status(result)
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting repeater status: {e}")
|
||||
return jsonify({'success': False, 'error': str(e)}), 500
|
||||
|
||||
|
||||
@api_bp.route('/repeaters/<public_key>/clock', methods=['GET'])
|
||||
def repeater_clock(public_key):
|
||||
"""Current clock of a repeater (unix seconds + formatted)."""
|
||||
dm = _get_dm()
|
||||
if not dm:
|
||||
return jsonify({'success': False, 'error': 'Device not connected'}), 503
|
||||
pk = _normalize_repeater_key(public_key)
|
||||
if not pk:
|
||||
return jsonify({'success': False, 'error': 'Invalid public_key'}), 400
|
||||
login_error = _require_repeater_login(dm, pk)
|
||||
if login_error:
|
||||
return login_error
|
||||
try:
|
||||
result = dm.repeater_req_clock(pk)
|
||||
if not result.get('success'):
|
||||
return jsonify({'success': False,
|
||||
'error': result.get('error', 'No clock response')}), _repeater_result_status(result)
|
||||
hex_data = (result['data'] or {}).get('data', '')
|
||||
timestamp = int.from_bytes(bytes.fromhex(hex_data[0:8]), byteorder='little', signed=False)
|
||||
return jsonify({'success': True, 'timestamp': timestamp}), 200
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting repeater clock: {e}")
|
||||
return jsonify({'success': False, 'error': str(e)}), 500
|
||||
|
||||
|
||||
@api_bp.route('/repeaters', methods=['POST'])
|
||||
def add_my_repeater():
|
||||
"""Add a device repeater contact to the My Repeaters list."""
|
||||
|
||||
@@ -229,6 +229,10 @@ function openToolPane(tool) {
|
||||
document.getElementById('paneTitle').textContent = tool.title;
|
||||
|
||||
const body = document.getElementById('paneBody');
|
||||
if (tool.key === 'status') {
|
||||
renderStatusPane(body);
|
||||
return;
|
||||
}
|
||||
body.innerHTML = `
|
||||
<div class="text-center text-muted py-4">
|
||||
<i class="bi ${tool.icon}" style="font-size: 2rem;"></i>
|
||||
@@ -237,6 +241,185 @@ function openToolPane(tool) {
|
||||
`;
|
||||
}
|
||||
|
||||
// ================================================================
|
||||
// Formatting helpers
|
||||
// ================================================================
|
||||
|
||||
function fmtDuration(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(' ');
|
||||
}
|
||||
|
||||
function fmtInt(n) {
|
||||
if (n == null || isNaN(n)) return '—';
|
||||
return Number(n).toLocaleString('en-US');
|
||||
}
|
||||
|
||||
function batteryPercent(mv) {
|
||||
if (mv == null || isNaN(mv)) return null;
|
||||
// Simple linear LiPo estimate over 3.3–4.2 V.
|
||||
const pct = ((mv / 1000) - 3.3) / (4.2 - 3.3) * 100;
|
||||
return Math.max(0, Math.min(100, Math.round(pct)));
|
||||
}
|
||||
|
||||
// ================================================================
|
||||
// Status tool
|
||||
// ================================================================
|
||||
|
||||
let _statusUpdatedAt = null;
|
||||
let _statusTimer = null;
|
||||
|
||||
function renderStatusPane(body) {
|
||||
body.innerHTML = `
|
||||
<div class="d-flex align-items-center mb-2">
|
||||
<span class="text-muted small flex-grow-1" id="statusUpdated"></span>
|
||||
<button type="button" class="btn btn-sm btn-outline-secondary" id="statusRefreshBtn" title="Refresh status">
|
||||
<i class="bi bi-arrow-clockwise"></i> Refresh
|
||||
</button>
|
||||
</div>
|
||||
<div id="statusContainer"></div>
|
||||
`;
|
||||
body.querySelector('#statusRefreshBtn').addEventListener('click', loadStatus);
|
||||
loadStatus();
|
||||
}
|
||||
|
||||
function setStatusUpdatedLabel() {
|
||||
const el = document.getElementById('statusUpdated');
|
||||
if (!el) return;
|
||||
if (_statusTimer) { clearInterval(_statusTimer); _statusTimer = null; }
|
||||
if (!_statusUpdatedAt) { el.textContent = ''; return; }
|
||||
const tick = () => {
|
||||
const label = document.getElementById('statusUpdated');
|
||||
if (!label) { clearInterval(_statusTimer); _statusTimer = null; return; }
|
||||
const secs = Math.floor((Date.now() - _statusUpdatedAt) / 1000);
|
||||
if (secs < 2) label.textContent = 'Updated just now';
|
||||
else if (secs < 60) label.textContent = `Updated ${secs}s ago`;
|
||||
else label.textContent = `Updated ${fmtDuration(secs)} ago`;
|
||||
};
|
||||
tick();
|
||||
_statusTimer = setInterval(tick, 5000);
|
||||
}
|
||||
|
||||
async function loadStatus() {
|
||||
const container = document.getElementById('statusContainer');
|
||||
const btn = document.getElementById('statusRefreshBtn');
|
||||
if (!container) return;
|
||||
if (btn) btn.disabled = true;
|
||||
container.innerHTML = `
|
||||
<div class="text-center text-muted py-4">
|
||||
<div class="spinner-border text-primary" role="status"></div>
|
||||
<p class="mt-2 mb-0">Requesting status from the repeater…</p>
|
||||
</div>
|
||||
`;
|
||||
|
||||
let data = null;
|
||||
try {
|
||||
const resp = await fetch(`/api/repeaters/${encodeURIComponent(_pubkey)}/status`);
|
||||
data = await resp.json();
|
||||
} catch (e) {
|
||||
data = { success: false, error: 'Request failed' };
|
||||
}
|
||||
if (btn) btn.disabled = false;
|
||||
|
||||
if (!data || !data.success) {
|
||||
container.innerHTML = `
|
||||
<div class="text-center py-4">
|
||||
<i class="bi bi-exclamation-triangle text-warning" style="font-size: 2rem;"></i>
|
||||
<p class="mt-2 mb-2">${esc((data && data.error) || 'Failed to get status')}</p>
|
||||
<button type="button" class="btn btn-sm btn-primary" id="statusRetryBtn">
|
||||
<i class="bi bi-arrow-clockwise"></i> Try again
|
||||
</button>
|
||||
</div>
|
||||
`;
|
||||
const retry = document.getElementById('statusRetryBtn');
|
||||
if (retry) retry.addEventListener('click', loadStatus);
|
||||
return;
|
||||
}
|
||||
|
||||
renderStatusTable(container, data.data);
|
||||
_statusUpdatedAt = Date.now();
|
||||
setStatusUpdatedLabel();
|
||||
loadClock();
|
||||
}
|
||||
|
||||
function statusSection(title, rows) {
|
||||
const body = rows.map(([label, value]) =>
|
||||
`<tr><td class="text-muted">${esc(label)}</td><td class="text-end fw-medium">${value}</td></tr>`
|
||||
).join('');
|
||||
return `
|
||||
<div class="text-muted text-uppercase small fw-bold mt-3 mb-1">${esc(title)}</div>
|
||||
<table class="table table-sm mb-0"><tbody>${body}</tbody></table>
|
||||
`;
|
||||
}
|
||||
|
||||
function renderStatusTable(container, s) {
|
||||
const bat = s.bat;
|
||||
const pct = batteryPercent(bat);
|
||||
const batStr = bat != null
|
||||
? `${pct != null ? pct + '% / ' : ''}${(bat / 1000).toFixed(2)} V`
|
||||
: '—';
|
||||
const util = (s.airtime != null && s.rx_airtime != null && s.uptime)
|
||||
? (((s.airtime + s.rx_airtime) / s.uptime) * 100).toFixed(2) + '%'
|
||||
: '—';
|
||||
|
||||
const system = statusSection('System Information', [
|
||||
['Battery', batStr],
|
||||
['Uptime', fmtDuration(s.uptime)],
|
||||
['Clock', '<span id="statusClock" class="text-muted">…</span>'],
|
||||
['Queue length', fmtInt(s.tx_queue_len)],
|
||||
['Debug / error events', fmtInt(s.full_evts)],
|
||||
]);
|
||||
const radio = statusSection('Radio Statistics', [
|
||||
['Last RSSI', s.last_rssi != null ? `${s.last_rssi} dBm` : '—'],
|
||||
['Last SNR', s.last_snr != null ? `${s.last_snr} dB` : '—'],
|
||||
['Noise floor', s.noise_floor != null ? `${s.noise_floor} dBm` : '—'],
|
||||
['TX airtime', fmtDuration(s.airtime)],
|
||||
['RX airtime', fmtDuration(s.rx_airtime)],
|
||||
]);
|
||||
const packets = statusSection('Packet Statistics', [
|
||||
['Sent', `${fmtInt(s.nb_sent)} <span class="text-muted small">(flood ${fmtInt(s.sent_flood)} · direct ${fmtInt(s.sent_direct)})</span>`],
|
||||
['Received', `${fmtInt(s.nb_recv)} <span class="text-muted small">(flood ${fmtInt(s.recv_flood)} · direct ${fmtInt(s.recv_direct)})</span>`],
|
||||
['Duplicates', `<span class="text-muted small">flood</span> ${fmtInt(s.flood_dups)} · <span class="text-muted small">direct</span> ${fmtInt(s.direct_dups)}`],
|
||||
...(s.recv_errors != null ? [['RX errors', fmtInt(s.recv_errors)]] : []),
|
||||
['Channel utilization', util],
|
||||
]);
|
||||
|
||||
container.innerHTML = system + radio + packets;
|
||||
}
|
||||
|
||||
async function loadClock() {
|
||||
const el = document.getElementById('statusClock');
|
||||
if (!el) return;
|
||||
el.innerHTML = '<span class="spinner-border spinner-border-sm"></span>';
|
||||
try {
|
||||
const resp = await fetch(`/api/repeaters/${encodeURIComponent(_pubkey)}/clock`);
|
||||
const data = await resp.json();
|
||||
if (data.success && data.timestamp) {
|
||||
const d = new Date(data.timestamp * 1000);
|
||||
el.classList.remove('text-muted');
|
||||
el.textContent = d.toLocaleString();
|
||||
} else {
|
||||
el.className = '';
|
||||
el.innerHTML = `<button type="button" class="btn btn-link btn-sm p-0 align-baseline" id="clockRetryBtn">fetch</button>`;
|
||||
const b = document.getElementById('clockRetryBtn');
|
||||
if (b) b.addEventListener('click', loadClock);
|
||||
}
|
||||
} catch (e) {
|
||||
el.className = '';
|
||||
el.innerHTML = `<button type="button" class="btn btn-link btn-sm p-0 align-baseline" id="clockRetryBtn">fetch</button>`;
|
||||
const b = document.getElementById('clockRetryBtn');
|
||||
if (b) b.addEventListener('click', loadClock);
|
||||
}
|
||||
}
|
||||
|
||||
// ================================================================
|
||||
// Login flow
|
||||
// ================================================================
|
||||
|
||||
Reference in New Issue
Block a user