feat(pathanalyzer): Path Analyzer panel skeleton (stage 2)

Full-screen iframe panel wired per the add-fullscreen-panel checklist:
- /path-analyzer Flask view + standalone template (theme bootstrap before
  CSS, local vendor assets, Leaflet included for the stage-5 map view)
- path-analyzer.js: days selector (1/3/5/7), message table (time, channel,
  sender, preview, copyable packet hash, hops, echo count), spinner/empty
  states, local toast helpers fed from /api/ui/settings
- #pathAnalyzerModal in index.html, iframe src set on show.bs.modal for
  fresh data each open; FAB button + purple gradient class
- menu entry in #mainMenu (Tools) + placement radio row in Settings ->
  Appearance; registered in ITEM_PLACEMENT_DEFS/DEFAULTS (default: menu)
- style.css: #pathAnalyzerModal added to all three narrow-viewport
  fullscreen-override selector lists

Verified live via Playwright at 1280px and 390px, light + dark: modal
fills the viewport exactly, placement radios move the entry both ways,
iframe reloads on every open.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
MarekWo
2026-07-19 17:14:20 +02:00
parent 8286fb1e65
commit 9668651a61
7 changed files with 458 additions and 4 deletions
+9
View File
@@ -103,6 +103,15 @@ def console():
)
@views_bp.route('/path-analyzer')
def path_analyzer():
"""Path Analyzer - routing path analysis for channel messages."""
return render_template(
'path-analyzer.html',
device_name=runtime_config.get_device_name()
)
@views_bp.route('/repeaters')
def repeaters():
"""My Repeaters - repeater administration panel (list + login)."""
+13 -3
View File
@@ -1637,6 +1637,13 @@ main {
color: white;
}
/* Path Analyzer FAB button + modal header (purple) */
.fab-pathanalyzer,
.pathanalyzer-header {
background: linear-gradient(135deg, #6f42c1 0%, #59359a 100%);
color: white;
}
/* Filter bar overlay - slides down from top of chat area */
.filter-bar {
position: absolute;
@@ -1883,7 +1890,8 @@ emoji-picker {
#contactsModal .modal-dialog.modal-fullscreen,
#logsModal .modal-dialog.modal-fullscreen,
#consoleModal .modal-dialog.modal-fullscreen,
#repeatersModal .modal-dialog.modal-fullscreen {
#repeatersModal .modal-dialog.modal-fullscreen,
#pathAnalyzerModal .modal-dialog.modal-fullscreen {
margin: 0 !important;
width: 100vw !important;
max-width: 100vw !important;
@@ -1895,7 +1903,8 @@ emoji-picker {
#contactsModal .modal-content,
#logsModal .modal-content,
#consoleModal .modal-content,
#repeatersModal .modal-content {
#repeatersModal .modal-content,
#pathAnalyzerModal .modal-content {
border: none !important;
border-radius: 0 !important;
height: 100vh !important;
@@ -1905,7 +1914,8 @@ emoji-picker {
#contactsModal .modal-body,
#logsModal .modal-body,
#consoleModal .modal-body,
#repeatersModal .modal-body {
#repeatersModal .modal-body,
#pathAnalyzerModal .modal-body {
overflow: hidden !important;
}
+2 -1
View File
@@ -6030,6 +6030,7 @@ const ITEM_PLACEMENT_DEFS = {
map: { fab: '#fab-map', menu: '#mapBtn' },
console: { fab: '#fab-console', menu: '#consoleBtn' },
repeaters: { fab: '#fab-repeaters', menu: '#repeatersBtn' },
pathanalyzer: { fab: '#fab-pathanalyzer', menu: '#pathAnalyzerBtn' },
deviceinfo: { fab: '#fab-deviceinfo', menu: '#deviceInfoBtn' },
syslog: { fab: '#fab-syslog', menu: '#logsBtn' },
};
@@ -6037,7 +6038,7 @@ const ITEM_PLACEMENT_DEFS = {
const ITEM_PLACEMENT_DEFAULTS = {
filter: 'fab', search: 'fab', dm: 'fab', contacts: 'fab', settings: 'fab',
advert: 'menu', floodadvert: 'menu', map: 'menu',
console: 'menu', repeaters: 'menu', deviceinfo: 'menu', syslog: 'menu'
console: 'menu', repeaters: 'menu', pathanalyzer: 'menu', deviceinfo: 'menu', syslog: 'menu'
};
function readItemPlacements() {
+202
View File
@@ -0,0 +1,202 @@
// Path Analyzer panel
// - bulk channel messages across all channels (GET /api/path-analyzer/messages)
// - stage 2: days selector + flat message table
// - later stages: path detail rows, filters, per-repeater stats, map view
// ================================================================
// UI settings + toast (same behavior as repeaters.js)
// ================================================================
const PA_UI_SETTINGS_DEFAULTS = {
toast_timeout_sec: 2,
toast_no_autoclose: false,
toast_position: 'top-left'
};
const PA_TOAST_POSITION_CLASSES = {
'top-left': ['top-0', 'start-0'],
'top-right': ['top-0', 'end-0'],
'bottom-left': ['bottom-0', 'start-0'],
'bottom-right': ['bottom-0', 'end-0'],
'center': ['top-50', 'start-50', 'translate-middle']
};
const PA_ALL_POSITION_CLASSES = ['top-0', 'top-50', 'start-0', 'start-50', 'bottom-0', 'end-0', 'translate-middle'];
window.uiSettingsCache = window.uiSettingsCache || { ...PA_UI_SETTINGS_DEFAULTS };
function applyToastPosition(position) {
const classes = PA_TOAST_POSITION_CLASSES[position] || PA_TOAST_POSITION_CLASSES['top-left'];
document.querySelectorAll('[data-toast-container]').forEach(el => {
PA_ALL_POSITION_CLASSES.forEach(c => el.classList.remove(c));
classes.forEach(c => el.classList.add(c));
});
}
async function loadUiSettings() {
try {
const resp = await fetch('/api/ui/settings');
if (resp.ok) {
const data = await resp.json();
window.uiSettingsCache = { ...PA_UI_SETTINGS_DEFAULTS, ...data };
applyToastPosition(window.uiSettingsCache.toast_position);
}
} catch (e) {
console.error('Failed to load UI settings:', e);
}
}
function showNotification(message, type = 'info') {
const toastEl = document.getElementById('notificationToast');
if (!toastEl) return;
const toastBody = toastEl.querySelector('.toast-body');
if (toastBody) {
toastBody.textContent = message;
}
const toastHeader = toastEl.querySelector('.toast-header');
if (toastHeader) {
toastHeader.className = 'toast-header';
if (type === 'success') {
toastHeader.classList.add('bg-success', 'text-white');
} else if (type === 'danger') {
toastHeader.classList.add('bg-danger', 'text-white');
} else if (type === 'warning') {
toastHeader.classList.add('bg-warning');
}
}
const cfg = window.uiSettingsCache || {};
const noAutoclose = !!cfg.toast_no_autoclose;
const timeoutSec = parseFloat(cfg.toast_timeout_sec);
const delay = isFinite(timeoutSec) && timeoutSec > 0 ? Math.round(timeoutSec * 1000) : 2000;
const toast = new bootstrap.Toast(toastEl, {
autohide: !noAutoclose,
delay: delay
});
toast.show();
}
// ================================================================
// Data loading + table rendering
// ================================================================
let paMessages = [];
function paFormatTime(msg) {
if (!msg.timestamp) return '—';
const d = new Date(msg.timestamp * 1000);
const pad = n => String(n).padStart(2, '0');
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ` +
`${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`;
}
function paCopyText(text, label) {
navigator.clipboard.writeText(text).then(
() => showNotification(`${label} copied to clipboard`, 'success'),
() => showNotification(`Failed to copy ${label}`, 'danger')
);
}
function paRenderTable() {
const body = document.getElementById('paTableBody');
body.innerHTML = '';
for (const msg of paMessages) {
const tr = document.createElement('tr');
const tdTime = document.createElement('td');
tdTime.className = 'pa-time';
tdTime.textContent = paFormatTime(msg);
tr.appendChild(tdTime);
const tdChannel = document.createElement('td');
tdChannel.textContent = msg.channel_name || `#${msg.channel_idx}`;
tr.appendChild(tdChannel);
const tdSender = document.createElement('td');
tdSender.textContent = msg.is_own ? `${msg.sender || 'Me'} (own)` : (msg.sender || '—');
tr.appendChild(tdSender);
const tdContent = document.createElement('td');
const preview = document.createElement('div');
preview.className = 'pa-content-preview';
preview.textContent = msg.content || '';
preview.title = msg.content || '';
tdContent.appendChild(preview);
tr.appendChild(tdContent);
const tdHash = document.createElement('td');
if (msg.packet_hash) {
const span = document.createElement('span');
span.className = 'pa-hash';
span.textContent = msg.packet_hash;
span.title = 'Click to copy';
span.addEventListener('click', () => paCopyText(msg.packet_hash, 'Packet hash'));
tdHash.appendChild(span);
} else {
tdHash.innerHTML = '<span class="text-muted small">no path data</span>';
}
tr.appendChild(tdHash);
const tdHops = document.createElement('td');
tdHops.className = 'text-end';
tdHops.textContent = (msg.hop_count === null || msg.hop_count === undefined) ? '—' : msg.hop_count;
tr.appendChild(tdHops);
const tdEchoes = document.createElement('td');
tdEchoes.className = 'text-end';
tdEchoes.textContent = msg.echoes.length;
tr.appendChild(tdEchoes);
body.appendChild(tr);
}
document.getElementById('paCounter').textContent =
`${paMessages.length} message${paMessages.length === 1 ? '' : 's'}`;
}
function paSetView(state) {
document.getElementById('paLoading').classList.toggle('d-none', state !== 'loading');
document.getElementById('paEmpty').classList.toggle('d-none', state !== 'empty');
document.getElementById('paTableWrap').classList.toggle('d-none', state !== 'table');
}
async function paLoadMessages() {
const days = document.getElementById('paDaysSelect').value;
paSetView('loading');
document.getElementById('paCounter').textContent = '';
try {
const resp = await fetch(`/api/path-analyzer/messages?days=${encodeURIComponent(days)}`);
const data = await resp.json();
if (!resp.ok || !data.success) {
throw new Error(data.error || `HTTP ${resp.status}`);
}
// Newest first for the analysis table (API returns ascending)
paMessages = (data.messages || []).slice().reverse();
} catch (e) {
console.error('Failed to load messages:', e);
showNotification(`Failed to load messages: ${e.message}`, 'danger');
paMessages = [];
}
if (paMessages.length === 0) {
paSetView('empty');
} else {
paRenderTable();
paSetView('table');
}
}
// ================================================================
// Init
// ================================================================
document.addEventListener('DOMContentLoaded', () => {
loadUiSettings();
document.getElementById('paDaysSelect').addEventListener('change', paLoadMessages);
document.getElementById('paRefreshBtn').addEventListener('click', paLoadMessages);
paLoadMessages();
});
+18
View File
@@ -205,6 +205,13 @@
<small class="d-block text-muted">Repeater administration</small>
</div>
</button>
<button id="pathAnalyzerBtn" class="list-group-item list-group-item-action d-flex align-items-center gap-3" data-bs-toggle="modal" data-bs-target="#pathAnalyzerModal" data-bs-dismiss="offcanvas">
<i class="bi bi-signpost-split" style="font-size: 1.5rem;"></i>
<div>
<span>Path Analyzer</span>
<small class="d-block text-muted">Routing path analysis</small>
</div>
</button>
<!-- System -->
<div class="list-group-item py-2 mt-2">
@@ -894,6 +901,17 @@
</div>
</td>
</tr>
<tr data-placement-key="pathanalyzer">
<td class="ps-0">Path Analyzer</td>
<td class="pe-0 text-end">
<div class="btn-group btn-group-sm" role="group" aria-label="Path Analyzer placement">
<input type="radio" class="btn-check" name="place-pathanalyzer" id="place-pathanalyzer-fab" value="fab" autocomplete="off">
<label class="btn btn-outline-primary" for="place-pathanalyzer-fab">Quick Access</label>
<input type="radio" class="btn-check" name="place-pathanalyzer" id="place-pathanalyzer-menu" value="menu" autocomplete="off">
<label class="btn btn-outline-primary" for="place-pathanalyzer-menu">Main Menu</label>
</div>
</td>
</tr>
<tr data-placement-key="deviceinfo">
<td class="ps-0">Device Info</td>
<td class="pe-0 text-end">
+31
View File
@@ -148,6 +148,9 @@
<button class="fab fab-repeaters d-none" id="fab-repeaters" data-bs-toggle="modal" data-bs-target="#repeatersModal" title="My Repeaters">
<i class="bi bi-diagram-3"></i>
</button>
<button class="fab fab-pathanalyzer d-none" id="fab-pathanalyzer" data-bs-toggle="modal" data-bs-target="#pathAnalyzerModal" title="Path Analyzer">
<i class="bi bi-signpost-split"></i>
</button>
<button class="fab fab-deviceinfo d-none" id="fab-deviceinfo" data-bs-toggle="modal" data-bs-target="#deviceInfoModal" title="Device Info">
<i class="bi bi-cpu"></i>
</button>
@@ -224,6 +227,23 @@
</div>
</div>
<!-- Path Analyzer Modal (Full Screen) -->
<div class="modal fade" id="pathAnalyzerModal" tabindex="-1" aria-hidden="true">
<div class="modal-dialog modal-fullscreen">
<div class="modal-content">
<div class="modal-header pathanalyzer-header text-white">
<h5 class="modal-title"><i class="bi bi-signpost-split"></i> Path Analyzer</h5>
<button type="button" class="btn btn-outline-light" data-bs-dismiss="modal">
<i class="bi bi-x-lg"></i> Close
</button>
</div>
<div class="modal-body p-0">
<iframe id="pathAnalyzerFrame" style="width: 100%; height: 100%; border: none;"></iframe>
</div>
</div>
</div>
</div>
<!-- System Log Modal (Full Screen) -->
<div class="modal fade" id="logsModal" tabindex="-1" aria-hidden="true">
<div class="modal-dialog modal-fullscreen">
@@ -324,6 +344,17 @@
});
}
// Path Analyzer modal - (re)load iframe on open for fresh data
const pathAnalyzerModal = document.getElementById('pathAnalyzerModal');
if (pathAnalyzerModal) {
pathAnalyzerModal.addEventListener('show.bs.modal', function () {
const pathAnalyzerFrame = document.getElementById('pathAnalyzerFrame');
if (pathAnalyzerFrame) {
pathAnalyzerFrame.src = '/path-analyzer';
}
});
}
// System Log modal - load iframe on open, clear on close
const logsModal = document.getElementById('logsModal');
if (logsModal) {
+183
View File
@@ -0,0 +1,183 @@
<!DOCTYPE html>
<html lang="en" data-theme="light" data-bs-theme="light">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover">
<title>Path Analyzer - mc-webui</title>
<!-- Theme: apply saved preference before CSS loads to prevent flash -->
<script>
(function() {
var t = localStorage.getItem('mc-webui-theme') || 'light';
document.documentElement.setAttribute('data-theme', t);
document.documentElement.setAttribute('data-bs-theme', t);
})();
</script>
<!-- Favicon -->
<link rel="apple-touch-icon" sizes="180x180" href="{{ url_for('static', filename='images/apple-touch-icon.png') }}">
<link rel="icon" type="image/png" sizes="32x32" href="{{ url_for('static', filename='images/favicon-32x32.png') }}">
<link rel="icon" type="image/png" sizes="16x16" href="{{ url_for('static', filename='images/favicon-16x16.png') }}">
<link rel="shortcut icon" href="{{ url_for('static', filename='favicon.ico') }}">
<!-- Bootstrap 5 CSS (local) -->
<link href="{{ url_for('static', filename='vendor/bootstrap/css/bootstrap.min.css') }}" rel="stylesheet">
<!-- Bootstrap Icons (local) -->
<link rel="stylesheet" href="{{ url_for('static', filename='vendor/bootstrap-icons/bootstrap-icons.css') }}">
<!-- Leaflet CSS (for the map view, stage 5) -->
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css"
integrity="sha256-p4NxAoJBhIIN+hmNHrzRCf9tD/miZyoHS5obTRR9BMY="
crossorigin="" />
<!-- Custom CSS -->
<link rel="stylesheet" href="{{ url_for('static', filename='css/style.css') }}">
<!-- Theme CSS (light/dark mode) -->
<link rel="stylesheet" href="{{ url_for('static', filename='css/theme.css') }}">
<style>
/* Standalone page: allow normal scrolling (style.css sets overflow hidden) */
html, body {
overflow: auto !important;
height: 100%;
}
body {
display: flex;
flex-direction: column;
background-color: var(--bg-body);
color: var(--text-primary);
}
.pa-toolbar {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 0.5rem;
padding: 0.75rem 1rem;
border-bottom: 1px solid var(--border-color);
flex-shrink: 0;
}
.pa-content {
flex: 1 1 0;
min-height: 0;
overflow-y: auto;
-webkit-overflow-scrolling: touch;
padding: 0.75rem 1rem;
}
.pa-table-wrap {
overflow-x: auto;
}
.pa-table {
font-size: 0.85rem;
}
.pa-table th {
white-space: nowrap;
}
.pa-table td {
vertical-align: middle;
}
.pa-hash {
font-family: var(--bs-font-monospace, monospace);
font-size: 0.8rem;
cursor: pointer;
white-space: nowrap;
}
.pa-hash:hover {
text-decoration: underline;
}
.pa-content-preview {
max-width: 22rem;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.pa-time {
white-space: nowrap;
}
.empty-state {
text-align: center;
color: var(--text-secondary, #6c757d);
padding: 3rem 1rem;
}
.empty-state i {
font-size: 3rem;
display: block;
margin-bottom: 0.75rem;
}
</style>
</head>
<body>
<!-- Toolbar -->
<div class="pa-toolbar">
<label for="paDaysSelect" class="form-label mb-0 small text-muted">Time range:</label>
<select id="paDaysSelect" class="form-select form-select-sm" style="width: auto;">
<option value="1">Last 1 day</option>
<option value="3" selected>Last 3 days</option>
<option value="5">Last 5 days</option>
<option value="7">Last 7 days</option>
</select>
<button id="paRefreshBtn" class="btn btn-sm btn-outline-secondary" title="Reload">
<i class="bi bi-arrow-clockwise"></i>
</button>
<span id="paCounter" class="small text-muted ms-auto"></span>
</div>
<!-- Content -->
<div class="pa-content">
<div id="paLoading" class="empty-state">
<div class="spinner-border text-secondary" role="status"></div>
<div class="mt-2">Loading messages…</div>
</div>
<div id="paEmpty" class="empty-state d-none">
<i class="bi bi-signpost-split"></i>
No messages in the selected time range.
</div>
<div id="paTableWrap" class="pa-table-wrap d-none">
<table class="table table-hover pa-table mb-0">
<thead>
<tr>
<th>Time</th>
<th>Channel</th>
<th>Sender</th>
<th>Message</th>
<th>Hash</th>
<th class="text-end">Hops</th>
<th class="text-end">Echoes</th>
</tr>
</thead>
<tbody id="paTableBody"></tbody>
</table>
</div>
</div>
<!-- Toast container for notifications (position applied by JS from ui_settings) -->
<div class="toast-container position-fixed top-0 start-0 p-3" data-toast-container>
<div id="notificationToast" class="toast" role="alert">
<div class="toast-header">
<strong class="me-auto">Path Analyzer</strong>
<button type="button" class="btn-close" data-bs-dismiss="toast"></button>
</div>
<div class="toast-body"></div>
</div>
</div>
<!-- Bootstrap JS Bundle (local) -->
<script src="{{ url_for('static', filename='vendor/bootstrap/js/bootstrap.bundle.min.js') }}"></script>
<!-- Leaflet JS (for the map view, stage 5) -->
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"
integrity="sha256-20nQCchB9co0qIjJZRGuk2/Z9VM+kNiyxNV1lvTlZBo="
crossorigin=""></script>
<!-- Path Analyzer JS -->
<script src="{{ url_for('static', filename='js/path-analyzer.js') }}"></script>
</body>
</html>