mirror of
https://github.com/MarekWo/mc-webui.git
synced 2026-11-03 09:12:19 +01:00
On a phone the map view gives the map a fixed ~45vh strip under the message list, which is too small to read a multi-hop route on. A control in the map's top-right corner now expands #paMap over the frame and asks the parent to hide the modal header, so the map really does get the whole screen; the same button becomes a labelled Close, and Escape works too. CSS overlay rather than the Fullscreen API: the panel runs in an iframe and inside the Android WebView, where requestFullscreen on a plain element is unreliable. The position rule needs !important — Leaflet writes position:relative on the container as an inline style. Verified live at 412x915 and 1440x900, both themes, in the modal, as the standalone page and through a chat deep link: the map fills the frame, the drawn route and the panned viewport survive both transitions, and no console errors. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
769 lines
27 KiB
HTML
769 lines
27 KiB
HTML
<!DOCTYPE html>
|
|
<html lang="{{ lang }}" 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>{{ t('pa.title') }} - mc-webui</title>
|
|
|
|
<!-- Theme: apply saved preference before CSS loads to prevent flash -->
|
|
<script>
|
|
(function() {
|
|
var theme = localStorage.getItem('mc-webui-theme') || 'light';
|
|
document.documentElement.setAttribute('data-theme', theme);
|
|
document.documentElement.setAttribute('data-bs-theme', theme);
|
|
})();
|
|
</script>
|
|
|
|
{% include "_head_i18n.html" %}
|
|
|
|
<!-- 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;
|
|
}
|
|
|
|
/* Collapsible filter group. On >=768px it renders as if its children
|
|
sat directly in the toolbar (display: contents), so the desktop
|
|
layout is unchanged and the collapse state is ignored. Below 768px
|
|
it is a Bootstrap collapse spanning the full toolbar width,
|
|
toggled by the Filters button. Keep these rules on the class (not
|
|
the #id) so Bootstrap's .collapse:not(.show) still wins. */
|
|
.pa-filters {
|
|
display: flex;
|
|
flex-wrap: wrap;
|
|
align-items: center;
|
|
gap: 0.5rem;
|
|
}
|
|
|
|
@media (min-width: 768px) {
|
|
.pa-filters {
|
|
display: contents !important;
|
|
}
|
|
}
|
|
|
|
@media (max-width: 767.98px) {
|
|
.pa-filters {
|
|
width: 100%;
|
|
order: 10; /* keep the counter on the first toolbar line */
|
|
}
|
|
|
|
.pa-filters input.form-control {
|
|
flex: 1 1 42%;
|
|
width: auto !important;
|
|
min-width: 0;
|
|
}
|
|
}
|
|
|
|
.pa-content {
|
|
flex: 1 1 0;
|
|
min-height: 0;
|
|
display: flex;
|
|
flex-direction: column;
|
|
overflow-y: auto;
|
|
-webkit-overflow-scrolling: touch;
|
|
padding: 0.75rem 1rem;
|
|
}
|
|
|
|
#paMapWrap {
|
|
flex: 1 1 0;
|
|
min-height: 0;
|
|
display: flex;
|
|
gap: 0.75rem;
|
|
}
|
|
|
|
#paMapSidebar {
|
|
width: 340px;
|
|
min-width: 280px;
|
|
overflow-y: auto;
|
|
flex-shrink: 0;
|
|
}
|
|
|
|
#paMap {
|
|
flex: 1 1 0;
|
|
min-height: 300px;
|
|
border-radius: 0.5rem;
|
|
border: 1px solid var(--border-color);
|
|
}
|
|
|
|
@media (max-width: 768px) {
|
|
#paMapWrap {
|
|
flex-direction: column;
|
|
}
|
|
|
|
/* Message/path list gets the remaining space, the map keeps a
|
|
fixed ~45% of the viewport height */
|
|
#paMapSidebar {
|
|
width: auto;
|
|
min-width: 0;
|
|
max-height: none;
|
|
flex: 1 1 0;
|
|
min-height: 0;
|
|
}
|
|
|
|
#paMap {
|
|
flex: 0 0 45vh;
|
|
min-height: 0;
|
|
}
|
|
}
|
|
|
|
/* Maximized map. A CSS overlay rather than the Fullscreen API: the
|
|
panel runs inside an iframe (and inside the Android WebView),
|
|
where requestFullscreen on a plain element is unreliable. The
|
|
parent hides the modal header on the same toggle, so the map
|
|
really does get the whole screen. Specificity beats the
|
|
media-query rules above regardless of order. */
|
|
body.pa-map-full {
|
|
overflow: hidden !important;
|
|
}
|
|
|
|
body.pa-map-full #paMap {
|
|
/* !important: Leaflet writes position:relative on the container
|
|
as an inline style, which no stylesheet rule can outrank */
|
|
position: fixed !important;
|
|
top: 0;
|
|
right: 0;
|
|
bottom: 0;
|
|
left: 0;
|
|
z-index: 1080;
|
|
flex: none;
|
|
min-height: 0;
|
|
border: none;
|
|
border-radius: 0;
|
|
}
|
|
|
|
/* Maximize / close control, top-right above the overlay toggles */
|
|
.pa-map-fs-btn {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 0.3rem;
|
|
background-color: var(--bg-surface);
|
|
color: var(--text-primary);
|
|
border: 1px solid var(--border-color);
|
|
border-radius: 0.35rem;
|
|
padding: 0.4rem 0.55rem;
|
|
font-size: 0.85rem;
|
|
line-height: 1;
|
|
cursor: pointer;
|
|
box-shadow: 0 1px 5px rgba(0, 0, 0, 0.3);
|
|
}
|
|
|
|
.pa-map-msg {
|
|
border: 1px solid var(--border-color);
|
|
border-radius: 0.4rem;
|
|
padding: 0.4rem 0.6rem;
|
|
margin-bottom: 0.4rem;
|
|
cursor: pointer;
|
|
font-size: 0.82rem;
|
|
}
|
|
|
|
.pa-map-msg:hover {
|
|
background-color: var(--hover-bg, rgba(0, 0, 0, 0.05));
|
|
}
|
|
|
|
.pa-map-msg.pa-selected {
|
|
border-color: #6f42c1;
|
|
}
|
|
|
|
.pa-map-echo {
|
|
border-top: 1px dashed var(--border-color);
|
|
margin-top: 0.35rem;
|
|
padding: 0.25rem 0.15rem 0;
|
|
cursor: pointer;
|
|
font-size: 0.78rem;
|
|
}
|
|
|
|
.pa-map-echo:hover {
|
|
background-color: var(--hover-bg, rgba(0, 0, 0, 0.05));
|
|
}
|
|
|
|
.pa-map-echo.pa-selected {
|
|
color: #6f42c1;
|
|
font-weight: 600;
|
|
}
|
|
|
|
/* Ties an echo row to the colour of its line on the map */
|
|
.pa-echo-swatch {
|
|
display: inline-block;
|
|
width: 0.6rem;
|
|
height: 0.6rem;
|
|
border-radius: 50%;
|
|
margin-right: 0.3rem;
|
|
vertical-align: baseline;
|
|
}
|
|
|
|
.pa-legend {
|
|
margin-top: 0.4rem;
|
|
padding: 0.4rem 0.5rem;
|
|
background-color: var(--bg-surface, rgba(0, 0, 0, 0.03));
|
|
border-radius: 0.4rem;
|
|
font-size: 0.78rem;
|
|
}
|
|
|
|
.pa-legend-hop {
|
|
display: flex;
|
|
align-items: center;
|
|
flex-wrap: wrap;
|
|
gap: 0.3rem;
|
|
padding: 0.15rem 0;
|
|
}
|
|
|
|
.pa-pick-chip {
|
|
border: 1px solid #d39e00;
|
|
color: #b58900;
|
|
border-radius: 0.25rem;
|
|
padding: 0 0.3rem;
|
|
cursor: pointer;
|
|
font-size: 0.72rem;
|
|
}
|
|
|
|
.pa-pick-chip:hover,
|
|
.pa-pick-chip.pa-picked {
|
|
background-color: #ffc107;
|
|
color: #212529;
|
|
}
|
|
|
|
.pa-unpick {
|
|
cursor: pointer;
|
|
color: var(--text-secondary, #6c757d);
|
|
}
|
|
|
|
.pa-unpick:hover {
|
|
color: #dc3545;
|
|
}
|
|
|
|
.pa-reset-picks {
|
|
font-size: 0.72rem;
|
|
}
|
|
|
|
/* Numbered hop markers + name labels on the drawn path */
|
|
.pa-hop-badge {
|
|
width: 22px;
|
|
height: 22px;
|
|
border-radius: 50%;
|
|
background-color: #dc3545;
|
|
color: #fff;
|
|
border: 2px solid #fff;
|
|
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.4);
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
font-size: 0.7rem;
|
|
font-weight: 700;
|
|
}
|
|
|
|
.pa-hop-label {
|
|
font-size: 0.7rem;
|
|
padding: 0 4px;
|
|
background: rgba(255, 255, 255, 0.85);
|
|
border: 1px solid rgba(0, 0, 0, 0.2);
|
|
box-shadow: none;
|
|
}
|
|
|
|
/* Map overlay toggles (Leaflet control, top-right corner) - these
|
|
only apply to the Map view, hence not in the shared filter bar */
|
|
.pa-map-toggles {
|
|
background-color: var(--bg-surface);
|
|
color: var(--text-primary);
|
|
border: 1px solid var(--border-color);
|
|
border-radius: 0.35rem;
|
|
padding: 0.35rem 0.55rem;
|
|
font-size: 0.78rem;
|
|
box-shadow: 0 1px 5px rgba(0, 0, 0, 0.3);
|
|
}
|
|
|
|
.pa-map-toggles label {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 0.35rem;
|
|
cursor: pointer;
|
|
white-space: nowrap;
|
|
margin: 0;
|
|
}
|
|
|
|
.pa-map-toggles label + label {
|
|
margin-top: 0.25rem;
|
|
}
|
|
|
|
.pa-map-toggles input {
|
|
cursor: pointer;
|
|
}
|
|
|
|
/* Packet hash inside the expanded detail row - narrow screens only,
|
|
where the Hash column is hidden */
|
|
.pa-detail-hash {
|
|
display: none;
|
|
font-size: 0.75rem;
|
|
padding-bottom: 0.2rem;
|
|
}
|
|
|
|
.pa-time-short {
|
|
display: none;
|
|
}
|
|
|
|
.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;
|
|
}
|
|
|
|
.pa-caret {
|
|
display: inline-block;
|
|
transition: transform 0.15s;
|
|
font-size: 0.7rem;
|
|
width: 1rem;
|
|
}
|
|
|
|
tr.pa-open .pa-caret {
|
|
transform: rotate(90deg);
|
|
}
|
|
|
|
.pa-msg-row {
|
|
cursor: pointer;
|
|
}
|
|
|
|
.pa-msg-row.pa-no-echoes {
|
|
cursor: default;
|
|
}
|
|
|
|
.pa-msg-row.pa-no-echoes .pa-caret {
|
|
visibility: hidden;
|
|
}
|
|
|
|
.pa-echo-cell {
|
|
background-color: var(--bg-surface, rgba(0, 0, 0, 0.03));
|
|
padding: 0.35rem 0.75rem 0.35rem 2rem;
|
|
}
|
|
|
|
.pa-echo-line {
|
|
display: flex;
|
|
align-items: center;
|
|
flex-wrap: wrap;
|
|
gap: 0.35rem;
|
|
padding: 0.2rem 0;
|
|
cursor: pointer;
|
|
border-radius: 0.25rem;
|
|
}
|
|
|
|
.pa-echo-line:hover {
|
|
background-color: var(--hover-bg, rgba(0, 0, 0, 0.05));
|
|
}
|
|
|
|
.pa-echo-meta {
|
|
font-size: 0.75rem;
|
|
color: var(--text-secondary, #6c757d);
|
|
white-space: nowrap;
|
|
}
|
|
|
|
.pa-chip {
|
|
font-family: var(--bs-font-monospace, monospace);
|
|
font-size: 0.75rem;
|
|
border: 1px solid var(--border-color);
|
|
border-radius: 0.25rem;
|
|
padding: 0.05rem 0.35rem;
|
|
cursor: pointer;
|
|
background-color: var(--card-bg, transparent);
|
|
}
|
|
|
|
.pa-chip:hover {
|
|
background-color: #6f42c1;
|
|
border-color: #6f42c1;
|
|
color: #fff;
|
|
}
|
|
|
|
.pa-chip-arrow {
|
|
font-size: 0.7rem;
|
|
color: var(--text-secondary, #6c757d);
|
|
}
|
|
|
|
.pa-echo-mapbtn {
|
|
cursor: pointer;
|
|
padding: 0 0.3rem;
|
|
color: var(--text-secondary, #6c757d);
|
|
}
|
|
|
|
.pa-echo-mapbtn:hover {
|
|
color: #6f42c1;
|
|
}
|
|
|
|
.pa-map-msg-chan {
|
|
font-size: 0.72rem;
|
|
color: var(--text-secondary, #6c757d);
|
|
white-space: nowrap;
|
|
}
|
|
|
|
.pa-map-msg-preview {
|
|
font-size: 0.75rem;
|
|
color: var(--text-secondary, #6c757d);
|
|
margin-top: 0.1rem;
|
|
}
|
|
|
|
.pa-direct {
|
|
font-size: 0.75rem;
|
|
font-style: italic;
|
|
color: var(--text-secondary, #6c757d);
|
|
}
|
|
|
|
.pa-sortable {
|
|
cursor: pointer;
|
|
user-select: none;
|
|
}
|
|
|
|
.pa-sortable:hover {
|
|
text-decoration: underline;
|
|
}
|
|
|
|
.pa-stats-row {
|
|
cursor: pointer;
|
|
}
|
|
|
|
.pa-ambiguous {
|
|
color: var(--text-secondary, #6c757d);
|
|
font-style: italic;
|
|
}
|
|
|
|
.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;
|
|
}
|
|
|
|
/* Narrow screens: drop secondary columns so rows fit the viewport
|
|
and echo paths can wrap (keeps the jump-to-map button reachable).
|
|
Hash and HB move into the expanded detail row. Must stay LAST in
|
|
this stylesheet so it overrides the base rules above. */
|
|
@media (max-width: 576px) {
|
|
.pa-col-hash,
|
|
.pa-col-hb,
|
|
.pa-col-msgs,
|
|
.pa-col-lasthop {
|
|
display: none;
|
|
}
|
|
|
|
.pa-content-preview {
|
|
max-width: 9rem;
|
|
}
|
|
|
|
.pa-time-full {
|
|
display: none;
|
|
}
|
|
|
|
.pa-time-short {
|
|
display: inline;
|
|
}
|
|
|
|
.pa-detail-hash {
|
|
display: block;
|
|
}
|
|
|
|
.pa-echo-cell {
|
|
padding-left: 0.75rem;
|
|
}
|
|
|
|
/* Long contact names must not push the SNR column off-screen */
|
|
#paStatsBody td:nth-child(2) {
|
|
max-width: 8rem;
|
|
overflow: hidden;
|
|
text-overflow: ellipsis;
|
|
white-space: nowrap;
|
|
}
|
|
|
|
.pa-col-echoes {
|
|
display: none;
|
|
}
|
|
|
|
.pa-table td,
|
|
.pa-table th {
|
|
padding: 0.3rem 0.2rem;
|
|
}
|
|
|
|
/* Truncate long channel names */
|
|
#paTableBody td:nth-child(3) {
|
|
max-width: 3.8rem;
|
|
overflow: hidden;
|
|
text-overflow: ellipsis;
|
|
white-space: nowrap;
|
|
}
|
|
|
|
/* Let long headers (Avg SNR...) wrap instead of widening columns */
|
|
.pa-table th {
|
|
white-space: normal;
|
|
}
|
|
|
|
/* Truncate long sender names */
|
|
#paTableBody td:nth-child(4) {
|
|
max-width: 5.5rem;
|
|
overflow: hidden;
|
|
text-overflow: ellipsis;
|
|
white-space: nowrap;
|
|
}
|
|
|
|
.pa-content-preview {
|
|
max-width: 5.2rem;
|
|
}
|
|
|
|
/* Date and time stack into two lines */
|
|
.pa-time {
|
|
white-space: normal;
|
|
}
|
|
|
|
.pa-time-short {
|
|
display: inline-block;
|
|
width: 3.2rem;
|
|
}
|
|
}
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<!-- Toolbar -->
|
|
<div class="pa-toolbar">
|
|
<div class="btn-group btn-group-sm" role="group" aria-label="{{ t('pa.view_aria') }}">
|
|
<button id="paViewMessagesBtn" class="btn btn-primary" title="{{ t('pa.view.messages_title') }}">
|
|
<i class="bi bi-list-ul"></i><span class="d-none d-sm-inline"> {{ t('pa.view.messages') }}</span>
|
|
</button>
|
|
<button id="paViewStatsBtn" class="btn btn-outline-primary" title="{{ t('pa.view.repeaters_title') }}">
|
|
<i class="bi bi-bar-chart"></i><span class="d-none d-sm-inline"> {{ t('pa.view.repeaters') }}</span>
|
|
</button>
|
|
<button id="paViewRoutesBtn" class="btn btn-outline-primary" title="{{ t('pa.view.routes_title') }}">
|
|
<i class="bi bi-signpost-2"></i><span class="d-none d-sm-inline"> {{ t('pa.view.routes') }}</span>
|
|
</button>
|
|
<button id="paViewMapBtn" class="btn btn-outline-primary" title="{{ t('pa.view.map_title') }}">
|
|
<i class="bi bi-map"></i><span class="d-none d-sm-inline"> {{ t('pa.view.map') }}</span>
|
|
</button>
|
|
</div>
|
|
<button id="paFiltersToggleBtn" class="btn btn-sm btn-outline-secondary d-md-none" type="button"
|
|
data-bs-toggle="collapse" data-bs-target="#paFiltersPanel"
|
|
aria-expanded="false" aria-controls="paFiltersPanel" title="{{ t('pa.filters_toggle_title') }}">
|
|
<i class="bi bi-funnel"></i> {{ t('pa.filters') }}
|
|
<span id="paFiltersBadge" class="badge text-bg-primary d-none"></span>
|
|
</button>
|
|
<div id="paFiltersPanel" class="pa-filters collapse">
|
|
<div class="vr d-none d-sm-block"></div>
|
|
<label for="paDaysSelect" class="form-label mb-0 small text-muted">{{ t('pa.time_range') }}</label>
|
|
<select id="paDaysSelect" class="form-select form-select-sm" style="width: auto;">
|
|
<option value="1">{{ tn('pa.last_days', 1) }}</option>
|
|
<option value="3" selected>{{ tn('pa.last_days', 3) }}</option>
|
|
<option value="5">{{ tn('pa.last_days', 5) }}</option>
|
|
<option value="7">{{ tn('pa.last_days', 7) }}</option>
|
|
</select>
|
|
<button id="paRefreshBtn" class="btn btn-sm btn-outline-secondary" title="{{ t('pa.reload_title') }}">
|
|
<i class="bi bi-arrow-clockwise"></i>
|
|
</button>
|
|
<div class="vr d-none d-sm-block"></div>
|
|
<select id="paHopsFilter" class="form-select form-select-sm" style="width: auto;" title="{{ t('pa.hops_filter_title') }}">
|
|
<option value="any" selected>{{ t('pa.any_hops') }}</option>
|
|
<option value="0">{{ tn('pa.n_hops', 0) }}</option>
|
|
<option value="1">{{ tn('pa.n_hops', 1) }}</option>
|
|
<option value="2">{{ tn('pa.n_hops', 2) }}</option>
|
|
<option value="3">{{ tn('pa.n_hops', 3) }}</option>
|
|
<option value="4+">{{ t('pa.hops_4plus') }}</option>
|
|
</select>
|
|
<select id="paHashSizeFilter" class="form-select form-select-sm" style="width: auto;"
|
|
title="{{ t('pa.hb_filter_title') }}">
|
|
<option value="any" selected>{{ t('pa.any_hb') }}</option>
|
|
<option value="1">{{ tn('pa.n_byte', 1) }}</option>
|
|
<option value="2">{{ tn('pa.n_byte', 2) }}</option>
|
|
<option value="3">{{ tn('pa.n_byte', 3) }}</option>
|
|
<option value="2,3">{{ t('pa.byte_2_3') }}</option>
|
|
</select>
|
|
<input id="paTokenFilter" type="text" class="form-control form-control-sm" style="width: 10.5rem;"
|
|
placeholder="{{ t('pa.token_ph') }}"
|
|
title="{{ t('pa.token_title') }}">
|
|
<input id="paSenderFilter" type="text" class="form-control form-control-sm" style="width: 9.5rem;"
|
|
placeholder="{{ t('pa.sender_ph') }}" title="{{ t('pa.sender_title') }}">
|
|
<input id="paContentFilter" type="text" class="form-control form-control-sm" style="width: 11rem;"
|
|
placeholder="{{ t('pa.content_ph') }}" title="{{ t('pa.content_title') }}">
|
|
<button id="paClearFiltersBtn" class="btn btn-sm btn-outline-secondary d-none" title="{{ t('pa.clear_filters_title') }}">
|
|
<i class="bi bi-x-lg"></i> {{ t('pa.clear') }}
|
|
</button>
|
|
</div>
|
|
<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">{{ t('pa.loading') }}</div>
|
|
</div>
|
|
<div id="paEmpty" class="empty-state d-none">
|
|
<i class="bi bi-signpost-split"></i>
|
|
<span id="paEmptyText">{{ t('pa.empty_range') }}</span>
|
|
</div>
|
|
<div id="paTableWrap" class="pa-table-wrap d-none">
|
|
<table class="table table-hover pa-table mb-0">
|
|
<thead>
|
|
<tr>
|
|
<th style="width: 1.5rem;"></th>
|
|
<th>{{ t('pa.col.time') }}</th>
|
|
<th>{{ t('pa.col.channel') }}</th>
|
|
<th>{{ t('pa.col.sender') }}</th>
|
|
<th>{{ t('pa.col.message') }}</th>
|
|
<th class="pa-col-hash">{{ t('pa.col.hash') }}</th>
|
|
<th class="text-end">{{ t('pa.col.hops') }}</th>
|
|
<th class="text-end pa-col-hb" title="{{ t('pa.col.hb_title') }}">{{ t('pa.col.hb') }}</th>
|
|
<th class="text-end pa-col-echoes">{{ t('pa.col.echoes') }}</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody id="paTableBody"></tbody>
|
|
</table>
|
|
</div>
|
|
<div id="paStatsWrap" class="pa-table-wrap d-none">
|
|
<table class="table table-hover pa-table mb-0">
|
|
<thead>
|
|
<tr>
|
|
<th>{{ t('pa.col.repeater') }}</th>
|
|
<th>{{ t('pa.col.contact') }}</th>
|
|
<th class="text-end pa-sortable" data-sort="relayed"
|
|
title="{{ t('pa.col.relayed_title') }}">{{ t('pa.col.relayed') }}</th>
|
|
<th class="text-end pa-sortable pa-col-msgs" data-sort="messages"
|
|
title="{{ t('pa.col.msgs_hash_title') }}">{{ t('pa.col.message_plural') }}</th>
|
|
<th class="text-end pa-sortable pa-col-lasthop" data-sort="lastHop"
|
|
title="{{ t('pa.col.lasthop_title') }}">{{ t('pa.col.lasthop') }}</th>
|
|
<th class="text-end pa-sortable" data-sort="avgSnr"
|
|
title="{{ t('pa.col.avgsnr_title') }}">{{ t('pa.col.avgsnr') }}</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody id="paStatsBody"></tbody>
|
|
</table>
|
|
</div>
|
|
<div id="paRoutesWrap" class="d-none">
|
|
<div class="d-flex align-items-center gap-2 mb-2">
|
|
<label for="paSegLenSelect" class="form-label small text-muted mb-0">{{ t('pa.segment_length') }}</label>
|
|
<select id="paSegLenSelect" class="form-select form-select-sm" style="width: auto;"
|
|
title="{{ t('pa.seglen_title') }}">
|
|
<option value="2" selected>{{ tn('pa.n_hops', 2) }}</option>
|
|
<option value="3">{{ tn('pa.n_hops', 3) }}</option>
|
|
<option value="4">{{ tn('pa.n_hops', 4) }}</option>
|
|
</select>
|
|
</div>
|
|
<div class="pa-table-wrap">
|
|
<table class="table table-hover pa-table mb-0">
|
|
<thead>
|
|
<tr>
|
|
<th>{{ t('pa.col.route') }}</th>
|
|
<th class="text-end pa-sortable" data-sort="echoes"
|
|
title="{{ t('pa.col.echoes_seg_title') }}">{{ t('pa.col.echoes') }}</th>
|
|
<th class="text-end pa-sortable pa-col-msgs" data-sort="messages"
|
|
title="{{ t('pa.col.msgs_seg_title') }}">{{ t('pa.col.message_plural') }}</th>
|
|
<th class="text-end pa-sortable" data-sort="pathEnd"
|
|
title="{{ t('pa.col.pathend_title') }}">{{ t('pa.col.pathend') }}</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody id="paRoutesBody"></tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
<div id="paMapWrap" class="d-none">
|
|
<div id="paMapSidebar">
|
|
<div class="d-flex align-items-center justify-content-between mb-1">
|
|
<span class="small text-muted">{{ t('pa.map.hint') }}</span>
|
|
<button id="paMapClearBtn" class="btn btn-sm btn-outline-secondary d-none" title="{{ t('pa.map.clear_title') }}">
|
|
<i class="bi bi-eraser"></i>
|
|
</button>
|
|
</div>
|
|
<div id="paMapMsgList"></div>
|
|
</div>
|
|
<div id="paMap"></div>
|
|
</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">{{ t('pa.title') }}</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>
|
|
|
|
<!-- Coordinate validation shared by every map (must load after Leaflet) -->
|
|
<script src="{{ url_for('static', filename='js/geo-utils.js') }}"></script>
|
|
<!-- Path Analyzer JS -->
|
|
<script src="{{ url_for('static', filename='js/path-analyzer.js') }}"></script>
|
|
</body>
|
|
</html>
|