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:
agessaman
2026-07-29 23:07:38 -07:00
parent d5170606c1
commit 196f42789c
9 changed files with 81 additions and 188 deletions
+4
View File
@@ -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
+4
View File
@@ -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.**
+8 -4
View File
@@ -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)]
+23 -17
View File
@@ -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;
-84
View File
@@ -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();
});
+4 -62
View File
@@ -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">
+22
View File
@@ -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
View File
@@ -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")
+2 -2
View File
@@ -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))",