mirror of
https://github.com/agessaman/meshcore-bot.git
synced 2026-09-01 16:48:26 +00:00
refactor(web-viewer): fill card height, trim hop axis, drop live feed
Three layout and cost changes. Hops away and Roles now grow into their cards instead of leaving dead space under a fixed-height canvas. Chart.js needs a positioned parent with a real height, so the body becomes a flex column and the chart takes the slack via flex-basis 0 — height: 100% would resolve against an auto-height parent and collapse. The hop axis now ends at the last hop that carries an observation rather than at the last non-empty bucket of the padded union. On a quiet window that collapses 64 buckets to 13; on a full one it changes nothing, because the flood series really does have packets at every hop out to 63 — 14 of them at hop 63, and 14.1% of all flood traffic beyond hop 20. That tail is real data, so it is drawn rather than truncated. Dropped the Live Activity card. It opened three SocketIO subscriptions and re-rendered on every packet to duplicate /realtime, which is a page that already does it better. The dashboard now costs one snapshot read per poll and holds no streaming subscriptions. A test asserts it stays that way rather than merely that the markup is gone.
This commit is contained in:
@@ -94,6 +94,10 @@ semantic versioning.
|
||||
|
||||
- The orphaned `/stats` page, which was unreachable from the navigation and
|
||||
rendered stub charts that never populated.
|
||||
- The dashboard's Live Activity feed. It opened three SocketIO subscriptions
|
||||
and re-rendered on every packet to duplicate a page that already exists at
|
||||
`/realtime`, so the dashboard now costs one snapshot read per poll and
|
||||
nothing else.
|
||||
|
||||
### Deprecated
|
||||
|
||||
|
||||
@@ -146,6 +146,10 @@ proxy_set_header X-Forwarded-Proto $scheme;
|
||||
plus a 30-day multibyte adoption trend
|
||||
- Busiest repeaters, and **one-hop neighbours** (24-hour or 7-day window)
|
||||
|
||||
The live packet feed lives on the **Real-time** page rather than here; the
|
||||
dashboard reads a single snapshot per poll and holds no streaming
|
||||
subscriptions.
|
||||
|
||||
Two measurement notes for the mesh charts:
|
||||
|
||||
**Hops are derived, and the two path tables disagree on units.**
|
||||
|
||||
@@ -856,10 +856,14 @@ class DashboardStatsService:
|
||||
)
|
||||
)
|
||||
|
||||
# Pad both onto one contiguous axis so the bars line up.
|
||||
edges = [hop for series in distribution.values() for hop, _ in series]
|
||||
if edges:
|
||||
low, high = min(edges), max(edges)
|
||||
# Pad both onto one contiguous axis so the bars line up, bounded by the
|
||||
# hops that actually carry something: an axis that runs on past the last
|
||||
# observation spends its width on nothing.
|
||||
populated = [
|
||||
hop for series in distribution.values() for hop, count in series if count
|
||||
]
|
||||
if populated:
|
||||
low, high = min(populated), max(populated)
|
||||
for key, series in distribution.items():
|
||||
counts = dict(series)
|
||||
distribution[key] = [[hop, counts.get(hop, 0)] for hop in range(low, high + 1)]
|
||||
|
||||
@@ -342,6 +342,29 @@
|
||||
height: 240px;
|
||||
}
|
||||
|
||||
/* Cards in an equal-height row: let the chart or list take the slack instead
|
||||
* of leaving it as dead space under a fixed-height canvas. Chart.js needs a
|
||||
* positioned parent with a real height, which flex-basis: 0 + flex-grow gives
|
||||
* it — plain height: 100% resolves against an auto-height parent and collapses.
|
||||
*/
|
||||
.card-body--fill {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.chart-box--fill {
|
||||
flex: 1 1 0;
|
||||
min-height: 200px;
|
||||
height: auto;
|
||||
}
|
||||
|
||||
.mix-fill {
|
||||
flex: 1 1 auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: space-evenly;
|
||||
}
|
||||
|
||||
.doughnut-box {
|
||||
position: relative;
|
||||
max-width: 190px;
|
||||
@@ -385,23 +408,6 @@
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
/* ── Live activity feed ────────────────────────────────────────────────── */
|
||||
|
||||
.live-feed {
|
||||
height: 260px;
|
||||
overflow-y: auto;
|
||||
background-color: var(--bg-secondary);
|
||||
padding: 0.5rem;
|
||||
}
|
||||
|
||||
.live-feed__entry {
|
||||
padding: 3px 8px;
|
||||
margin-bottom: 3px;
|
||||
font-size: 0.82rem;
|
||||
border-radius: 2px;
|
||||
border-left: 3px solid var(--border-color);
|
||||
}
|
||||
|
||||
@media (max-width: 575.98px) {
|
||||
.stat-tile__value {
|
||||
font-size: 1.35rem;
|
||||
|
||||
@@ -1001,89 +1001,6 @@
|
||||
},
|
||||
};
|
||||
|
||||
// ── live activity feed ───────────────────────────────────────────────
|
||||
|
||||
function initLiveFeed() {
|
||||
const feed = el('live-feed');
|
||||
const dot = el('live-dot');
|
||||
const countBadge = el('live-count');
|
||||
const placeholder = el('live-placeholder');
|
||||
if (!feed || !window.connectionManager) return;
|
||||
|
||||
const TYPE_COLORS = { packet: '#fd7e14', command: '#198754', message: '#0dcaf0' };
|
||||
const activeFilters = { packet: true, command: true, message: true };
|
||||
const MAX_ENTRIES = 100;
|
||||
let paused = false;
|
||||
let total = 0;
|
||||
|
||||
function applyFilters() {
|
||||
Array.from(feed.children).forEach((node) => {
|
||||
const type = node.dataset.type;
|
||||
if (type) node.style.display = activeFilters[type] ? '' : 'none';
|
||||
});
|
||||
}
|
||||
|
||||
document.querySelectorAll('.live-filter-cb').forEach((checkbox) => {
|
||||
checkbox.addEventListener('change', () => {
|
||||
activeFilters[checkbox.dataset.type] = checkbox.checked;
|
||||
applyFilters();
|
||||
});
|
||||
});
|
||||
|
||||
function addEntry(label, text, type) {
|
||||
if (paused) return;
|
||||
if (placeholder && placeholder.parentNode) placeholder.remove();
|
||||
const node = document.createElement('div');
|
||||
node.dataset.type = type;
|
||||
node.className = 'live-feed__entry';
|
||||
node.style.borderLeftColor = TYPE_COLORS[type] || '#6c757d';
|
||||
node.append(
|
||||
makeTextElement('span', new Date().toLocaleTimeString(), 'text-muted me-2'),
|
||||
makeTextElement('strong', label),
|
||||
makeTextElement('span', ' ' + String(text ?? ''), 'text-muted')
|
||||
);
|
||||
if (!activeFilters[type]) node.style.display = 'none';
|
||||
feed.insertBefore(node, feed.firstChild);
|
||||
total += 1;
|
||||
if (countBadge) countBadge.textContent = total;
|
||||
while (feed.children.length > MAX_ENTRIES) feed.removeChild(feed.lastChild);
|
||||
}
|
||||
|
||||
el('live-pause-btn')?.addEventListener('click', () => {
|
||||
paused = !paused;
|
||||
const icon = el('live-pause-icon');
|
||||
if (icon) icon.className = paused ? 'fas fa-play' : 'fas fa-pause';
|
||||
});
|
||||
el('live-clear-btn')?.addEventListener('click', () => {
|
||||
feed.replaceChildren();
|
||||
total = 0;
|
||||
if (countBadge) countBadge.textContent = '0';
|
||||
});
|
||||
el('live-scroll-top')?.addEventListener('click', () => { feed.scrollTop = 0; });
|
||||
el('live-scroll-bottom')?.addEventListener('click', () => { feed.scrollTop = feed.scrollHeight; });
|
||||
|
||||
const socket = window.connectionManager.socket;
|
||||
socket.on('connect', () => {
|
||||
if (dot) dot.className = 'status-indicator status-connected ms-2';
|
||||
socket.emit('subscribe_packets');
|
||||
socket.emit('subscribe_commands');
|
||||
socket.emit('subscribe_messages');
|
||||
});
|
||||
socket.on('disconnect', () => {
|
||||
if (dot) dot.className = 'status-indicator status-disconnected ms-2';
|
||||
});
|
||||
socket.on('packet_data', (d) => {
|
||||
addEntry(d.payload_type_name || d.type_name || 'Packet', d.from_name || d.pubkey_prefix || '', 'packet');
|
||||
});
|
||||
socket.on('command_data', (d) => {
|
||||
addEntry('Cmd: ' + (d.command || '?'), (d.user || '') + ' → ' + (d.channel || ''), 'command');
|
||||
});
|
||||
socket.on('message_data', (d) => {
|
||||
const channel = d.channel ? '[' + d.channel + '] ' : (d.is_dm ? '[DM] ' : '');
|
||||
addEntry(d.sender || '?', channel + (d.content || ''), 'message');
|
||||
});
|
||||
}
|
||||
|
||||
// ── connected clients modal ──────────────────────────────────────────
|
||||
|
||||
async function loadConnectedClients() {
|
||||
@@ -1178,7 +1095,6 @@
|
||||
event.preventDefault();
|
||||
loadConnectedClients();
|
||||
});
|
||||
initLiveFeed();
|
||||
initTooltips();
|
||||
});
|
||||
|
||||
|
||||
@@ -137,12 +137,12 @@
|
||||
<i class="fas fa-info-circle" aria-hidden="true"></i>
|
||||
</button>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="card-body card-body--fill">
|
||||
<p class="dashboard-note mb-2">
|
||||
Nodes by their closest advert path, and how far arriving
|
||||
flood packets had travelled
|
||||
</p>
|
||||
<div class="chart-box chart-box--tall">
|
||||
<div class="chart-box chart-box--fill">
|
||||
<canvas id="hopsHistogramChart"
|
||||
aria-label="Nodes and flood packets by hop distance"></canvas>
|
||||
</div>
|
||||
@@ -152,8 +152,8 @@
|
||||
<div class="col-12 col-lg-3">
|
||||
<div class="card h-100">
|
||||
<div class="card-header"><i class="fas fa-microchip"></i> Roles</div>
|
||||
<div class="card-body">
|
||||
<div id="role-mix"><div class="dashboard-empty">Loading…</div></div>
|
||||
<div class="card-body card-body--fill">
|
||||
<div id="role-mix" class="mix-fill"><div class="dashboard-empty">Loading…</div></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -371,64 +371,6 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Row 7 — live activity -->
|
||||
<div class="row">
|
||||
<div class="col-12">
|
||||
<div class="card">
|
||||
<div class="card-header d-flex justify-content-between align-items-center flex-wrap gap-2">
|
||||
<span><i class="fas fa-satellite-dish me-2"></i>Live Activity
|
||||
<span id="live-dot" class="status-indicator status-disconnected ms-2"
|
||||
title="SocketIO connection"></span>
|
||||
</span>
|
||||
<div class="d-flex gap-2 align-items-center flex-wrap">
|
||||
<div class="d-flex gap-2 align-items-center" id="live-filters">
|
||||
<small class="text-muted">Filter:</small>
|
||||
<div class="form-check form-check-inline mb-0">
|
||||
<input class="form-check-input live-filter-cb" type="checkbox"
|
||||
id="filter-packet" data-type="packet" checked>
|
||||
<label class="form-check-label" for="filter-packet"
|
||||
style="font-size:0.8rem;color:#fd7e14">Packets</label>
|
||||
</div>
|
||||
<div class="form-check form-check-inline mb-0">
|
||||
<input class="form-check-input live-filter-cb" type="checkbox"
|
||||
id="filter-command" data-type="command" checked>
|
||||
<label class="form-check-label" for="filter-command"
|
||||
style="font-size:0.8rem;color:#198754">Commands</label>
|
||||
</div>
|
||||
<div class="form-check form-check-inline mb-0">
|
||||
<input class="form-check-input live-filter-cb" type="checkbox"
|
||||
id="filter-message" data-type="message" checked>
|
||||
<label class="form-check-label" for="filter-message"
|
||||
style="font-size:0.8rem;color:#0dcaf0">Messages</label>
|
||||
</div>
|
||||
</div>
|
||||
<button class="btn btn-sm btn-outline-secondary" id="live-scroll-top"
|
||||
title="Scroll to newest"><i class="fas fa-arrow-up"></i></button>
|
||||
<button class="btn btn-sm btn-outline-secondary" id="live-scroll-bottom"
|
||||
title="Scroll to oldest"><i class="fas fa-arrow-down"></i></button>
|
||||
<span id="live-count" class="badge bg-secondary">0</span>
|
||||
<button class="btn btn-sm btn-outline-secondary" id="live-pause-btn">
|
||||
<i class="fas fa-pause" id="live-pause-icon"></i>
|
||||
</button>
|
||||
<button class="btn btn-sm btn-outline-secondary" id="live-clear-btn">
|
||||
<i class="fas fa-trash-alt"></i>
|
||||
</button>
|
||||
<a href="/realtime" class="btn btn-sm btn-outline-primary">
|
||||
Full Monitor <i class="fas fa-external-link-alt ms-1"></i>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-body p-0">
|
||||
<div id="live-feed" class="live-feed">
|
||||
<div class="text-muted text-center py-3" id="live-placeholder">
|
||||
<i class="fas fa-hourglass-half"></i> Connecting…
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Connected Clients Modal -->
|
||||
<div class="modal fade" id="connectedClientsModal" tabindex="-1"
|
||||
aria-labelledby="connectedClientsModalLabel" aria-hidden="true">
|
||||
|
||||
@@ -824,6 +824,28 @@ class TestHopConventions:
|
||||
)
|
||||
assert dict(self._hops(viewer)["nodes"]) == {48: 1}
|
||||
|
||||
def test_axis_stops_at_the_last_populated_hop(self, viewer):
|
||||
"""An axis running past the last observation spends its width on nothing."""
|
||||
with sqlite3.connect(viewer.db_path) as conn:
|
||||
conn.execute(
|
||||
"""
|
||||
INSERT INTO observed_paths (public_key, from_prefix, to_prefix, path_hex,
|
||||
path_length, bytes_per_hop, packet_type, last_seen)
|
||||
VALUES (?, 'aa', 'bb', 'ab', 2, 1, 'advert', datetime('now','localtime'))
|
||||
""",
|
||||
(_pk(1),),
|
||||
)
|
||||
conn.execute(
|
||||
"INSERT INTO packet_stream (timestamp, data, type, route_type_name, path_len, "
|
||||
"bytes_per_hop) VALUES (?, '{}', 'packet', 'FLOOD', 5, 1)",
|
||||
(time.time(),),
|
||||
)
|
||||
hops = self._hops(viewer)
|
||||
assert [h for h, _ in hops["nodes"]] == [2, 3, 4, 5]
|
||||
# Both ends of the axis carry an observation in at least one series.
|
||||
assert hops["nodes"][0][1] or hops["flood_packets"][0][1]
|
||||
assert hops["nodes"][-1][1] or hops["flood_packets"][-1][1]
|
||||
|
||||
def test_impossible_hop_counts_are_dropped(self, viewer):
|
||||
"""Beyond 64 the path field cannot hold it, so the value is corrupt."""
|
||||
with sqlite3.connect(viewer.db_path) as conn:
|
||||
|
||||
+14
-19
@@ -165,32 +165,27 @@ class TestPageRoutes:
|
||||
resp = client.get("/")
|
||||
assert resp.status_code == 200
|
||||
|
||||
def test_index_live_activity_controls(self, client):
|
||||
"""Dashboard index page contains scroll buttons and type-filter checkboxes."""
|
||||
def test_index_loads_the_external_dashboard_script(self, client):
|
||||
resp = client.get("/")
|
||||
assert resp.status_code == 200
|
||||
html = resp.data.decode()
|
||||
# Scroll buttons
|
||||
assert 'id="live-scroll-top"' in html
|
||||
assert 'id="live-scroll-bottom"' in html
|
||||
# Filter checkboxes with data-type attributes
|
||||
assert 'data-type="packet"' in html
|
||||
assert 'data-type="command"' in html
|
||||
assert 'data-type="message"' in html
|
||||
assert 'live-filter-cb' in html
|
||||
# Behaviour lives in the external, nonce-free dashboard script
|
||||
assert 'js/dashboard.js' in html
|
||||
assert 'js/dashboard.js' in resp.data.decode()
|
||||
|
||||
def test_dashboard_script_wires_live_feed_controls(self):
|
||||
"""The extracted dashboard script still implements the feed behaviour."""
|
||||
def test_dashboard_does_not_subscribe_to_live_streams(self, client):
|
||||
"""The live feed moved to /realtime; the dashboard must not re-add it.
|
||||
|
||||
It opened three SocketIO subscriptions and re-rendered on every packet,
|
||||
duplicating a page that already exists, so the dashboard now costs one
|
||||
snapshot read per poll and nothing else.
|
||||
"""
|
||||
html = client.get("/").data.decode()
|
||||
script = (
|
||||
Path(__file__).resolve().parents[1]
|
||||
/ "modules" / "web_viewer" / "static" / "js" / "dashboard.js"
|
||||
).read_text(encoding="utf-8")
|
||||
assert "applyFilters" in script
|
||||
assert "live-scroll-top" in script
|
||||
assert "live-scroll-bottom" in script
|
||||
assert "initLiveFeed" in script
|
||||
for marker in ("live-feed", "live-filter-cb", "live-scroll-top"):
|
||||
assert marker not in html, marker
|
||||
for marker in ("subscribe_packets", "subscribe_commands", "subscribe_messages"):
|
||||
assert marker not in script, marker
|
||||
|
||||
def test_realtime(self, client):
|
||||
resp = client.get("/realtime")
|
||||
|
||||
@@ -72,8 +72,8 @@ def test_dashboard_has_no_html_parser_sink_for_api_or_mesh_values() -> None:
|
||||
# and role values all terminate in textContent-backed DOM construction.
|
||||
for safe_sink in (
|
||||
"node.textContent = String(value ?? '');",
|
||||
"makeTextElement('strong', label)",
|
||||
"makeTextElement('span', ' ' + String(text ?? ''), 'text-muted')",
|
||||
"makeTextElement('div', item.name, 'neighbor-row__name')",
|
||||
"makeTextElement('span', name, 'fw-semibold top-list-row__name')",
|
||||
"label.title = String(name ?? '');",
|
||||
"makeTextElement('code', item.path_string, 'small text-break')",
|
||||
"idCell.appendChild(makeTextElement('code', client.client_id))",
|
||||
|
||||
Reference in New Issue
Block a user