mirror of
https://github.com/MarekWo/mc-webui.git
synced 2026-08-10 02:32:56 +02:00
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:
@@ -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)."""
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
@@ -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">
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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>
|
||||
Reference in New Issue
Block a user