Compare commits

..

5 Commits

Author SHA1 Message Date
Kpa-clawbot
d1cb84b596 feat: Priority+ nav pattern for tablet viewports (768-1023px) (#345)
## Priority+ Navigation Pattern for Tablet Viewports

Phase 2 of responsive nav improvements for #322.

### What this does

On **tablet viewports (768-1023px)**, implements the [Priority+
navigation
pattern](https://css-tricks.com/the-priority-plus-navigation-pattern/):

- **5 high-priority tabs** shown inline: Home, Nodes, Packets, Map, Live
- **6 low-priority tabs** collapse into a "More ▾" dropdown: Channels,
Traces, Observers, Analytics, Perf, Lab
- The "More" button highlights when a low-priority page is active

**Desktop (>=1024px)** and **mobile (<768px)** behavior is unchanged.

### Changes

| File | Change |
|------|--------|
| `public/index.html` | Added `data-priority="high"` to 5 primary nav
links; added More button + dropdown menu |
| `public/style.css` | Split ≤1023px hamburger query into tablet
Priority+ (768-1023px) and mobile hamburger (<768px); added More
dropdown styles |
| `public/app.js` | Added `closeMoreMenu()`, More button toggle,
outside-click/Escape close, active state on More button |
| Cache busters | Bumped in same commit |

### Accessibility

- `aria-haspopup="true"` and `aria-expanded` on More button
- `role="menu"` / `role="menuitem"` on dropdown
- Focus moves to first item on open
- Escape key closes dropdown

### Testing

- All 308 existing tests pass (217 frontend-helpers + 62 packet-filter +
29 aging)
- No new dependencies added
- No build step changes

### Breakpoint summary

| Viewport | Behavior |
|----------|----------|
| >= 1024px | Full horizontal nav (unchanged) |
| 768-1023px | Priority+ pattern: 5 tabs + More dropdown **← NEW** |
| < 768px | Hamburger drawer with all items (unchanged) |

---------

Co-authored-by: Kpa-clawbot <259247574+Kpa-clawbot@users.noreply.github.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-03-31 23:38:54 -07:00
Kpa-clawbot
711889c823 chore: bump version to 3.2.0
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-03-31 23:36:25 -07:00
Kpa-clawbot
738d5fef39 fix: poller uses store max IDs to prevent replaying entire DB
When GetMaxTransmissionID() fails silently (e.g., corrupted DB returns 0
from COALESCE), the poller starts from ID 0 and replays the entire
database over WebSocket — broadcasting thousands of old packets per second.

Fix: after querying the DB, use the in-memory store's MaxTransmissionID
and MaxObservationID as a floor. Since Load() already read the full DB
successfully, the store has the correct max IDs.

Root cause discovered on staging: DB corruption caused MAX(id) query to
fail, returning 0. Poller log showed 'starting from transmission ID 0'
followed by 1000-2000 broadcasts per tick walking through 76K rows.

Also adds MaxObservationID() to PacketStore for observation cursor safety.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-03-31 23:28:56 -07:00
Kpa-clawbot
8e6fc9602f fix: stabilize Playwright packets test with explicit time window (#348)
## Summary

Fixes the Playwright CI regression on master where the "Packets page
loads with filter" test times out after 15 seconds waiting for able
tbody tr to appear.

## Root Cause

Three packets tests used an bout:blank round-trip pattern to force a
full page reload:

`
page.goto(BASE) → set localStorage → page.goto('about:blank') →
page.goto(BASE/#/packets)
`

This cross-origin round-trip through bout:blank causes the SPA's config
fetch and router to not fire reliably in CI's headless Chromium, leaving
the page uninitialized past the 15-second timeout.

## Fix

Replace the bout:blank pattern with page.reload() in all three affected
tests:

`
page.goto(BASE/#/packets)  →  set localStorage  →  page.reload()
`

This stays on the same origin throughout. Playwright handles same-origin
reloads predictably — the page fully re-initializes, the IIFE re-reads
localStorage, and loadPackets() uses the correct time window.

## Tests affected

| Test | Change |
|------|--------|
| Packets page loads with filter | bout:blank → page.reload() |
| Packets initial fetch honors persisted time window | bout:blank →
page.reload() |
| Packets groupByHash toggle works | bout:blank → page.reload() |

## Validation

- All 318 unit tests pass (packet-filter: 62, aging: 29, frontend: 227)
- No public/ files changed — no cache buster needed
- Single file changed: est-e2e-playwright.js (9 insertions, 15
deletions)

Co-authored-by: Kpa-clawbot <259247574+Kpa-clawbot@users.noreply.github.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-03-31 23:27:51 -07:00
Kpa-clawbot
e2556eaaff fix: filter WebSocket packets by time window on packets page
WS broadcast pushes all packets regardless of the selected time
window filter. This caused old packets to appear in the table even
when the API correctly returned zero results for the time range.

Add time window check to the WS packet filter — drops packets
with timestamps older than the selected window cutoff.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-03-31 23:09:42 -07:00
8 changed files with 162 additions and 54 deletions

View File

@@ -1344,6 +1344,20 @@ func (s *PacketStore) MaxTransmissionID() int {
return maxID
}
// MaxObservationID returns the highest observation ID in the store.
func (s *PacketStore) MaxObservationID() int {
s.mu.RLock()
defer s.mu.RUnlock()
maxID := 0
for id := range s.byObsID {
if id > maxID {
maxID = id
}
}
return maxID
}
// --- Internal filter/query helpers ---
// filterPackets applies PacketQuery filters to the in-memory packet list.

View File

@@ -166,6 +166,17 @@ func NewPoller(db *DB, hub *Hub, interval time.Duration) *Poller {
func (p *Poller) Start() {
lastID := p.db.GetMaxTransmissionID()
lastObsID := p.db.GetMaxObservationID()
// If the store already loaded data, use its max IDs as a floor.
// This prevents replaying the entire DB when the DB query fails
// (e.g., corrupted DB returns 0 from COALESCE).
if p.store != nil {
if storeMax := p.store.MaxTransmissionID(); storeMax > lastID {
lastID = storeMax
}
if storeMaxObs := p.store.MaxObservationID(); storeMaxObs > lastObsID {
lastObsID = storeMaxObs
}
}
log.Printf("[poller] starting from transmission ID %d, obs ID %d, interval %v", lastID, lastObsID, p.interval)
ticker := time.NewTicker(p.interval)

View File

@@ -1,6 +1,6 @@
{
"name": "meshcore-analyzer",
"version": "3.1.0",
"version": "3.2.0",
"description": "Community-run alternative to the closed-source `analyzer.letsmesh.net`. MQTT packet collection + open-source web analyzer for the Bay Area MeshCore mesh.",
"main": "index.js",
"scripts": {

View File

@@ -412,6 +412,14 @@ function closeNav() {
document.body.classList.remove('nav-open');
var btn = document.getElementById('hamburger');
if (btn) btn.setAttribute('aria-expanded', 'false');
closeMoreMenu();
}
function closeMoreMenu() {
var menu = document.getElementById('navMoreMenu');
var btn = document.getElementById('navMoreBtn');
if (menu) menu.classList.remove('open');
if (btn) btn.setAttribute('aria-expanded', 'false');
}
function navigate() {
@@ -448,6 +456,13 @@ function navigate() {
document.querySelectorAll('.nav-link[data-route]').forEach(el => {
el.classList.toggle('active', el.dataset.route === basePage);
});
// Update "More" button to show active state if a low-priority page is selected
var moreBtn = document.getElementById('navMoreBtn');
if (moreBtn) {
var moreMenu = document.getElementById('navMoreMenu');
var hasActiveMore = moreMenu && moreMenu.querySelector('.nav-link.active');
moreBtn.classList.toggle('active', !!hasActiveMore);
}
if (currentPage && pages[currentPage]?.destroy) {
pages[currentPage].destroy();
@@ -551,8 +566,36 @@ window.addEventListener('DOMContentLoaded', () => {
navLinks.querySelectorAll('.nav-link').forEach(link => {
link.addEventListener('click', closeNav);
});
// --- "More" dropdown (tablet Priority+ nav) ---
const navMoreBtn = document.getElementById('navMoreBtn');
const navMoreMenu = document.getElementById('navMoreMenu');
if (navMoreBtn && navMoreMenu) {
// Build More menu dynamically from non-priority nav links (DRY)
navMoreMenu.innerHTML = '';
document.querySelectorAll('.nav-links a:not([data-priority="high"])').forEach(function(link) {
var clone = link.cloneNode(true);
clone.setAttribute('role', 'menuitem');
clone.addEventListener('click', closeMoreMenu);
navMoreMenu.appendChild(clone);
});
navMoreBtn.addEventListener('click', (e) => {
e.stopPropagation();
const opening = !navMoreMenu.classList.contains('open');
navMoreMenu.classList.toggle('open');
navMoreBtn.setAttribute('aria-expanded', String(opening));
if (opening) {
var firstLink = navMoreMenu.querySelector('.nav-link');
if (firstLink) firstLink.focus();
}
});
}
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape' && navLinks.classList.contains('open')) closeNav();
if (e.key === 'Escape') {
if (navMoreMenu && navMoreMenu.classList.contains('open')) closeMoreMenu();
if (navLinks.classList.contains('open')) closeNav();
}
});
document.addEventListener('click', (e) => {
if (navLinks.classList.contains('open') &&
@@ -560,6 +603,11 @@ window.addEventListener('DOMContentLoaded', () => {
!hamburger.contains(e.target)) {
closeNav();
}
if (navMoreMenu && navMoreMenu.classList.contains('open') &&
!navMoreMenu.contains(e.target) &&
!navMoreBtn.contains(e.target)) {
closeMoreMenu();
}
});
// --- Favorites dropdown ---

View File

@@ -22,9 +22,9 @@
<meta name="twitter:title" content="CoreScope">
<meta name="twitter:description" content="Real-time MeshCore LoRa mesh network analyzer — live packet visualization, node tracking, channel decryption, and route analysis.">
<meta name="twitter:image" content="https://raw.githubusercontent.com/Kpa-clawbot/corescope/master/public/og-image.png">
<link rel="stylesheet" href="style.css?v=1775023114">
<link rel="stylesheet" href="home.css?v=1775023114">
<link rel="stylesheet" href="live.css?v=1775023114">
<link rel="stylesheet" href="style.css?v=1775022775">
<link rel="stylesheet" href="home.css?v=1775022775">
<link rel="stylesheet" href="live.css?v=1775022775">
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css"
integrity="sha256-p4NxAoJBhIIN+hmNHrzRCf9tD/miZyoHS5obTRR9BMY="
crossorigin="anonymous">
@@ -44,18 +44,22 @@
<span class="live-dot" id="liveDot" title="WebSocket connected" aria-label="WebSocket connected"></span>
</a>
<div class="nav-links">
<a href="#/home" class="nav-link" data-route="home">Home</a>
<a href="#/packets" class="nav-link" data-route="packets">Packets</a>
<a href="#/map" class="nav-link" data-route="map">Map</a>
<a href="#/live" class="nav-link" data-route="live">🔴 Live</a>
<a href="#/home" class="nav-link" data-route="home" data-priority="high">Home</a>
<a href="#/packets" class="nav-link" data-route="packets" data-priority="high">Packets</a>
<a href="#/map" class="nav-link" data-route="map" data-priority="high">Map</a>
<a href="#/live" class="nav-link" data-route="live" data-priority="high">🔴 Live</a>
<a href="#/channels" class="nav-link" data-route="channels">Channels</a>
<a href="#/nodes" class="nav-link" data-route="nodes">Nodes</a>
<a href="#/nodes" class="nav-link" data-route="nodes" data-priority="high">Nodes</a>
<a href="#/traces" class="nav-link" data-route="traces">Traces</a>
<a href="#/observers" class="nav-link" data-route="observers">Observers</a>
<a href="#/analytics" class="nav-link" data-route="analytics">Analytics</a>
<a href="#/perf" class="nav-link" data-route="perf">⚡ Perf</a>
<a href="#/audio-lab" class="nav-link" data-route="audio-lab">🎵 Lab</a>
</div>
<div class="nav-more-wrap">
<button class="nav-btn nav-more-btn" id="navMoreBtn" aria-haspopup="true" aria-expanded="false" aria-controls="navMoreMenu" title="More pages">More ▾</button>
<div class="nav-more-menu" id="navMoreMenu" role="menu"></div>
</div>
</div>
<div class="nav-right">
<div class="nav-stats" id="navStats" title="Live stats"></div>
@@ -81,30 +85,30 @@
<main id="app" role="main"></main>
<script src="vendor/qrcode.js"></script>
<script src="roles.js?v=1775023114"></script>
<script src="customize.js?v=1775023114" onerror="console.error('Failed to load:', this.src)"></script>
<script src="region-filter.js?v=1775023114"></script>
<script src="hop-resolver.js?v=1775023114"></script>
<script src="hop-display.js?v=1775023114"></script>
<script src="app.js?v=1775023114"></script>
<script src="home.js?v=1775023114"></script>
<script src="packet-filter.js?v=1775023114"></script>
<script src="packets.js?v=1775023114"></script>
<script src="geo-filter-overlay.js?v=1775023114"></script>
<script src="map.js?v=1775023114" onerror="console.error('Failed to load:', this.src)"></script>
<script src="channels.js?v=1775023114" onerror="console.error('Failed to load:', this.src)"></script>
<script src="nodes.js?v=1775023114" onerror="console.error('Failed to load:', this.src)"></script>
<script src="traces.js?v=1775023114" onerror="console.error('Failed to load:', this.src)"></script>
<script src="analytics.js?v=1775023114" onerror="console.error('Failed to load:', this.src)"></script>
<script src="audio.js?v=1775023114" onerror="console.error('Failed to load:', this.src)"></script>
<script src="audio-v1-constellation.js?v=1775023114" onerror="console.error('Failed to load:', this.src)"></script>
<script src="audio-v2-constellation.js?v=1775023114" onerror="console.error('Failed to load:', this.src)"></script>
<script src="audio-lab.js?v=1775023114" onerror="console.error('Failed to load:', this.src)"></script>
<script src="live.js?v=1775023114" onerror="console.error('Failed to load:', this.src)"></script>
<script src="observers.js?v=1775023114" onerror="console.error('Failed to load:', this.src)"></script>
<script src="observer-detail.js?v=1775023114" onerror="console.error('Failed to load:', this.src)"></script>
<script src="compare.js?v=1775023114" onerror="console.error('Failed to load:', this.src)"></script>
<script src="node-analytics.js?v=1775023114" onerror="console.error('Failed to load:', this.src)"></script>
<script src="perf.js?v=1775023114" onerror="console.error('Failed to load:', this.src)"></script>
<script src="roles.js?v=1775022775"></script>
<script src="customize.js?v=1775022775" onerror="console.error('Failed to load:', this.src)"></script>
<script src="region-filter.js?v=1775022775"></script>
<script src="hop-resolver.js?v=1775022775"></script>
<script src="hop-display.js?v=1775022775"></script>
<script src="app.js?v=1775022775"></script>
<script src="home.js?v=1775022775"></script>
<script src="packet-filter.js?v=1775022775"></script>
<script src="packets.js?v=1775022775"></script>
<script src="geo-filter-overlay.js?v=1775022775"></script>
<script src="map.js?v=1775022775" onerror="console.error('Failed to load:', this.src)"></script>
<script src="channels.js?v=1775022775" onerror="console.error('Failed to load:', this.src)"></script>
<script src="nodes.js?v=1775022775" onerror="console.error('Failed to load:', this.src)"></script>
<script src="traces.js?v=1775022775" onerror="console.error('Failed to load:', this.src)"></script>
<script src="analytics.js?v=1775022775" onerror="console.error('Failed to load:', this.src)"></script>
<script src="audio.js?v=1775022775" onerror="console.error('Failed to load:', this.src)"></script>
<script src="audio-v1-constellation.js?v=1775022775" onerror="console.error('Failed to load:', this.src)"></script>
<script src="audio-v2-constellation.js?v=1775022775" onerror="console.error('Failed to load:', this.src)"></script>
<script src="audio-lab.js?v=1775022775" onerror="console.error('Failed to load:', this.src)"></script>
<script src="live.js?v=1775022775" onerror="console.error('Failed to load:', this.src)"></script>
<script src="observers.js?v=1775022775" onerror="console.error('Failed to load:', this.src)"></script>
<script src="observer-detail.js?v=1775022775" onerror="console.error('Failed to load:', this.src)"></script>
<script src="compare.js?v=1775022775" onerror="console.error('Failed to load:', this.src)"></script>
<script src="node-analytics.js?v=1775022775" onerror="console.error('Failed to load:', this.src)"></script>
<script src="perf.js?v=1775022775" onerror="console.error('Failed to load:', this.src)"></script>
</body>
</html>

View File

@@ -324,6 +324,13 @@
// Check if new packets pass current filters
const filtered = newPkts.filter(p => {
// Respect time window filter — drop packets outside the selected window
const windowMin = savedTimeWindowMin;
if (windowMin > 0) {
const cutoff = new Date(Date.now() - windowMin * 60000).toISOString();
const pktTime = p.latest || p.timestamp || p.first_seen;
if (pktTime && pktTime < cutoff) return false;
}
if (filters.type) { const types = filters.type.split(',').map(Number); if (!types.includes(p.payload_type)) return false; }
if (filters.observer) { const obsSet = new Set(filters.observer.split(',')); if (!obsSet.has(p.observer_id)) return false; }
if (filters.hash && p.hash !== filters.hash) return false;

View File

@@ -5,6 +5,7 @@
--nav-bg2: #1a1a2e;
--nav-text: #ffffff;
--nav-text-muted: #cbd5e1;
--nav-active-bg: rgba(74, 158, 255, 0.15);
--accent: #4a9eff;
--geo-filter-color: #3b82f6;
--status-green: #22c55e;
@@ -128,7 +129,7 @@ a:focus-visible, button:focus-visible, input:focus-visible, select:focus-visible
.nav-link.active {
color: var(--nav-text);
border-bottom-color: transparent;
background: rgba(74, 158, 255, 0.15);
background: var(--nav-active-bg);
border-radius: 6px;
margin: 4px 0;
padding: 10px 12px;
@@ -837,6 +838,22 @@ button.ch-item.selected { background: var(--selected-bg); }
/* === Hamburger (hidden on desktop) === */
.hamburger { display: none; }
/* "More" button (hidden on desktop) */
.nav-more-wrap { display: none; position: relative; }
.nav-more-btn { display: inline-flex; }
.nav-more-menu {
display: none; position: absolute; top: calc(var(--top-nav-h, 52px) - 4px); right: 0;
background: var(--nav-bg); border: 1px solid var(--border); border-radius: 8px;
box-shadow: 0 4px 12px rgba(0,0,0,0.15); flex-direction: column;
min-width: 160px; padding: 4px 0; z-index: 1200;
}
.nav-more-menu.open { display: flex; }
.nav-more-menu .nav-link {
padding: 10px 16px; border-bottom: none; border-radius: 0; margin: 0;
white-space: nowrap;
}
.nav-more-menu .nav-link:hover { background: var(--nav-bg2); color: var(--nav-text); }
.nav-more-menu .nav-link.active { background: var(--nav-active-bg); }
/* Ensure nav stays above Leaflet map */
.nav-links.open { z-index: 1100; }
#map-wrap .leaflet-container { z-index: 1; }
@@ -851,18 +868,31 @@ button.ch-item.selected { background: var(--selected-bg); }
.map-controls { width: 180px; font-size: 12px; }
}
/* === Responsive — Hamburger nav (1023px) === */
@media (max-width: 1023px) {
/* === Responsive — Tablet Priority+ nav (7681023px) === */
@media (min-width: 768px) and (max-width: 1023px) {
.nav-links { display: flex !important; flex-direction: row; gap: 2px; }
.nav-links a:not([data-priority="high"]) { display: none; }
.nav-more-wrap { display: flex; align-items: center; }
.hamburger { display: none; }
.nav-link { padding: 14px 8px; font-size: 13px; }
.nav-links a[data-priority="high"] { order: -1; }
.nav-link.active { background: var(--nav-active-bg); border-radius: 6px; margin: 4px 0; padding: 10px 8px; }
}
/* === Responsive — Hamburger nav (<768px) === */
@media (max-width: 767px) {
.hamburger { display: inline-flex; }
.nav-more-wrap { display: none !important; }
.nav-links {
display: none; position: absolute; top: 52px; left: 0; right: 0;
background: var(--nav-bg); flex-direction: column; padding: 8px 0;
box-shadow: 0 8px 24px rgba(0,0,0,.4); z-index: 1100;
max-height: calc(100dvh - 52px); overflow-y: auto;
}
.nav-links a:not([data-priority="high"]) { display: flex; }
.nav-links.open { display: flex; }
.nav-link { padding: 12px 20px; border-bottom: none; }
.nav-link.active { background: rgba(74,158,255,0.15); border-radius: 0; margin: 0; padding: 12px 20px; }
.nav-link.active { background: var(--nav-active-bg); border-radius: 0; margin: 0; padding: 12px 20px; }
.nav-left { gap: 12px; }
body.nav-open { overflow: hidden; }
}

View File

@@ -365,13 +365,12 @@ async function run() {
await test('Packets page loads with filter', async () => {
// Ensure desktop viewport and broad time window so fixture timestamps are included.
await page.setViewportSize({ width: 1280, height: 720 });
await page.goto(BASE, { waitUntil: 'domcontentloaded' });
// Set time window BEFORE packets.js IIFE re-executes (525600 min ≈ 1 year)
// Set time window BEFORE packets.js IIFE re-executes (525600 min ≈ 1 year).
// Navigate to the packets URL then reload — avoids about:blank cross-origin issues
// that can prevent the SPA from fully initializing within the timeout.
await page.goto(`${BASE}/#/packets`, { waitUntil: 'domcontentloaded' });
await page.evaluate(() => localStorage.setItem('meshcore-time-window', '525600'));
// Navigate away so next goto is a full page load (not a same-document hash change).
// This guarantees scripts re-execute and packets.js IIFE reads the new localStorage.
await page.goto('about:blank');
await page.goto(`${BASE}/#/packets`, { waitUntil: 'load' });
await page.reload({ waitUntil: 'load' });
await page.waitForSelector('table tbody tr', { timeout: 15000 });
const rowsBefore = await page.$$('table tbody tr');
assert(rowsBefore.length > 0, 'No packets visible');
@@ -387,11 +386,8 @@ async function run() {
});
await test('Packets initial fetch honors persisted time window', async () => {
// Navigate to base first to get same-origin context for localStorage
await page.goto(BASE, { waitUntil: 'domcontentloaded' });
// Set persisted time window to 60 min and reload so the IIFE reads it
await page.evaluate(() => localStorage.setItem('meshcore-time-window', '60'));
// Navigate away so next goto is a full page load
await page.goto('about:blank');
const packetsRequestPromise = page.waitForRequest((req) => {
try {
@@ -402,8 +398,8 @@ async function run() {
}
}, { timeout: 10000 });
// Full navigation from about:blank — scripts re-execute, IIFE reads localStorage
await page.goto(`${BASE}/#/packets`, { waitUntil: 'load' });
// Full reload on the packets page — scripts re-execute, IIFE reads localStorage
await page.reload({ waitUntil: 'load' });
await page.waitForSelector('#fTimeWindow', { timeout: 10000 });
const timeWindowValue = await page.$eval('#fTimeWindow', (el) => el.value);
assert(timeWindowValue === '60', `Expected time window dropdown to restore 60, got ${timeWindowValue}`);
@@ -428,10 +424,8 @@ async function run() {
// Test: Packets groupByHash toggle changes view
await test('Packets groupByHash toggle works', async () => {
// Restore wide time window — previous test set it to 60 min which excludes fixture data
await page.goto(BASE, { waitUntil: 'domcontentloaded' });
await page.evaluate(() => localStorage.setItem('meshcore-time-window', '525600'));
await page.goto('about:blank');
await page.goto(`${BASE}/#/packets`, { waitUntil: 'load' });
await page.reload({ waitUntil: 'load' });
await page.waitForSelector('table tbody tr', { timeout: 15000 });
const groupBtn = await page.$('#fGroup');
assert(groupBtn, 'Group by hash button (#fGroup) not found');