From 215690f4bf2aee4777c7386bb5f75c32464e4502 Mon Sep 17 00:00:00 2001 From: Louis King Date: Fri, 3 Jul 2026 14:31:44 +0100 Subject: [PATCH] feat(dashboard): enrich recent adverts card and add packets widget Recent adverts card now shows route-type badges, an observer column with three-way fallback (observers -> observed_by -> dash), and renders all 10 rows instead of 5. The Type column hides on mobile to prevent overflow, and the misleading cursor-help on observer badges is removed. Also adds a Packets chart card to the dashboard and a packets stat to the homepage, backed by the new dashboard packet-activity endpoint. Promotes routeTypeBadge to a shared export in components.js (previously local to advertisements.js), and enriches RecentAdvertisement with route_type, observers, and observed_by fields. Backend resolves observers via fetch_observers_for_events and observer_node_id -> public_key in batched queries. --- .../plan.md | 293 ++++++++++++++++ .../tasks.md | 80 +++++ .../plan.md | 322 ++++++++++++++++++ .../tasks.md | 61 ++++ src/meshcore_hub/api/routes/dashboard.py | 95 +++++- src/meshcore_hub/common/schemas/messages.py | 10 + src/meshcore_hub/web/static/js/charts.js | 18 +- .../web/static/js/spa/components.js | 20 +- .../web/static/js/spa/pages/advertisements.js | 15 +- .../web/static/js/spa/pages/dashboard.js | 138 +++++--- .../web/static/js/spa/pages/home.js | 17 +- tests/test_api/test_dashboard.py | 217 ++++++++++++ 12 files changed, 1206 insertions(+), 80 deletions(-) create mode 100644 docs/plans/20260703-1330-dashboard-packets-widget/plan.md create mode 100644 docs/plans/20260703-1330-dashboard-packets-widget/tasks.md create mode 100644 docs/plans/20260703-1404-dashboard-recent-adverts/plan.md create mode 100644 docs/plans/20260703-1404-dashboard-recent-adverts/tasks.md diff --git a/docs/plans/20260703-1330-dashboard-packets-widget/plan.md b/docs/plans/20260703-1330-dashboard-packets-widget/plan.md new file mode 100644 index 0000000..5a7ae83 --- /dev/null +++ b/docs/plans/20260703-1330-dashboard-packets-widget/plan.md @@ -0,0 +1,293 @@ +# Dashboard & Homepage Packets Widget + +## Summary + +Surface raw-packet volume in the two overview surfaces of the web app. On the +**homepage**, add a fourth stat widget ("Packets") to the existing stats panel, +gated on the `packets` feature flag, and reduce the vertical height of all +homepage stat cards. On the **dashboard** page, remove the top row of summary +stat boxes entirely and instead fold each entity's headline number into the +top-right corner of its own chart card (same font size as the old stat value), +then add a fourth Packets chart so a `packets`-enabled deployment renders a +four-column chart grid. + +The raw-packets subsystem already exists end-to-end (model, collector +ingestion, `/packets` + `/packet-groups` API, feature flag, CSS color, icon, +i18n label). The only missing pieces are packet counts in the +`DashboardStats` payload, a daily packet-activity endpoint, and the frontend +wiring. + +## Background & Motivation + +The Raw Packets feature (`docs/plans/20260612-2014-raw-packets-feature`) shipped +a `raw_packets` table that captures every inbound MeshCore packet as it arrives +over the LetsMesh `packets` feed, plus a dedicated `/packets` browse page. That +data is fully available to operators but is **invisible on the two overview +pages**: the homepage stats panel shows Nodes / Advertisements / Messages, and +the dashboard page shows the same three entities as both stat boxes and charts. +Packet volume — often the highest-volume signal on a busy mesh — has no +presence on either overview. + +Concurrently, the dashboard page duplicates information: each entity appears +once as a big number in a stat box and again as a chart immediately below it. +Consolidating the number into the chart card header removes the duplication, +reclaims vertical space, and produces a cleaner, chart-first overview that +naturally extends to a fourth (Packets) column when the feature is enabled. + +Recent git history shows sustained UI polish work (panel-accent redesign, mobile +nav, typography adoption in `479c263`, `510612d`, `cb677b3`), so this fits the +current direction of tightening the overview surfaces. + +## Goals + +- Add a Packets stat widget to the homepage stats panel, gated on + `features.packets !== false`, showing the last-7-days packet count. +- Reduce the vertical footprint of homepage stat cards. +- Remove the dashboard's top stat-box row and render each entity's headline + number in the top-right corner of its chart card (matching the old stat-value + font size). +- Add a Packets chart to the dashboard, producing a 4-column chart grid when + `packets` is enabled (3 columns otherwise). +- Expose packet counts and daily packet activity from the backend without new + models, migrations, or config flags. + +## Non-Goals + +- No changes to the `/packets` browse page, filters, or detail view. +- No new feature flags, config variables, or i18n keys (all required strings and + the `feature_packets` flag already exist). +- No changes to channel-visibility / role-based redaction for packet payloads + (the new count/activity endpoints are role-independent volume metrics). +- No alteration of the homepage combined activity chart (adverts + messages). +- No database migrations (reuses the existing `raw_packets` table). + +## Requirements + +### Functional Requirements + +- **FR-1** — When `features.packets !== false`, the homepage stats panel renders + a fourth widget titled "Packets" (iconPackets, green accent), showing + `stats.packets_7d` with the description "last 7 days", ordered after Messages. +- **FR-2** — When `features.packets === false`, no Packets widget appears on the + homepage and no packets data is fetched beyond the single `/stats` call. +- **FR-3** — Homepage stat cards are visibly shorter than today (reduced vertical + padding and a smaller value font), affecting both the stats panel and the + members panel equally (global `renderStatCard` change). +- **FR-4** — The dashboard page no longer renders the top row of summary stat + boxes (Nodes / Advertisements / Messages). +- **FR-5** — Each dashboard chart card displays its headline number in the + top-right corner of the card, at the same font size as the former + `.stat-value` (`text-4xl`), colored with the entity accent: + - Nodes → `stats.total_nodes` + - Advertisements → `stats.advertisements_7d` + - Messages → `stats.messages_7d` + - Packets → `stats.packets_7d` +- **FR-6** — When `features.packets !== false`, the dashboard renders a fourth + chart card (Packets) with a 7-day daily line chart, producing a 4-column grid + at the `md` breakpoint and above. +- **FR-7** — `GET /api/v1/dashboard/stats` returns `total_packets` and + `packets_7d` integers. +- **FR-8** — `GET /api/v1/dashboard/packet-activity?days=N` returns a + `DailyActivity` payload (reused schema) of per-day raw-packet counts, capped + at 90 days, excluding today, with zero-filled missing days — identical + semantics to the existing `/activity` advertisement endpoint. + +### Technical Requirements + +- **TR-1** — Packet counts in `get_stats` use a single conditional-aggregation + query mirroring the advertisement block (`func.count()` + + `func.sum(case((received_at >= seven_days_ago, 1), else_=0))`), not two + separate queries. +- **TR-2** — The `/packet-activity` endpoint mirrors `/activity` exactly: + `func.date()` bucketing, `_date_bucket_key()` normalization for SQLite/Postgres + parity (see plan `20260616-2023-fix-postgres-charts-flatline`), default + `@cached` key (role-independent), `redis_cache_ttl_dashboard` TTL. +- **TR-3** — No role/channel-visibility filter on packet counts or activity + (raw packets are observer-level volume metrics; payload redaction stays on the + `/packets` list/detail endpoints only). +- **TR-4** — Frontend feature gating uses the established + `features.packets !== false` pattern (enabled by default). +- **TR-5** — `gridCols()` helper extended to return `md:grid-cols-4` for a count + of 4, so the chart grid scales from 1 → 2 → 3 → 4 columns. +- **TR-6** — `pageColors` gains a `packets` getter reading `--color-packets` + (which already exists in `app.css` for both light and dark themes). +- **TR-7** — `ChartColors` (charts.js) gains `packets` / `packetsFill` entries + so the Packets line chart uses the green accent. +- **TR-8** — Chart cleanup teardown in `dashboard.js` includes `'packetChart'`. +- **TR-9** — `renderStatCard` remains the single shared component; after this + change it is consumed only by `home.js` (dashboard drops its usage), so the + global shrink effectively only touches the homepage. + +## Implementation Plan + +### Phase 1: Backend — packet counts & activity endpoint + +- **`common/schemas/messages.py`** (`DashboardStats`, after `total_members` at + line 288): add two fields: + ```python + total_packets: int = Field(default=0, description="Total raw packets captured") + packets_7d: int = Field(default=0, description="Packets captured in last 7 days") + ``` + Reuse the existing `DailyActivity` schema for the activity endpoint (no new + schema). + +- **`api/routes/dashboard.py`**: + - Add `RawPacket` to the model imports (it is already exported from + `common/models`). + - In `get_stats`, after the advertisement count block (lines 137–148), add a + packet conditional-aggregation query and populate `total_packets` / + `packets_7d` in the `DashboardStats(...)` constructor (lines 268–282). + - Add a new route mirroring `/activity` (line 285): + ```python + @router.get("/packet-activity", response_model=DailyActivity) + @cached("dashboard/packet-activity", ttl_setting="redis_cache_ttl_dashboard") + def get_packet_activity(_: RequireRead, session: DbSession, request: Request, days: int = 30) -> DailyActivity: + ... + ``` + Body identical to `get_activity` but selecting from `RawPacket.received_at` + with no `_flood_only_filter` and no role/channel filter. + +### Phase 2: Frontend — shared component & color plumbing + +- **`spa/components.js`**: + - `pageColors` (line 183): add + `get packets() { return getComputedStyle(document.documentElement).getPropertyValue('--color-packets').trim(); }`. + - `renderStatCard` (line 803): global shrink — add `!py-2` to the root + `class` and change the value wrapper from `stat-value` to + `stat-value text-3xl` (drops the big number from ~2.5rem to ~1.875rem and + trims vertical padding). + +- **`charts.js`**: + - Add `packets` and `packetsFill` to `ChartColors` (read `--color-packets`). + - `initDashboardCharts` (line 203): accept a 4th `packetData` argument and, + when truthy, call `createLineChart('packetChart', packetData, + t('entities.packets'), ChartColors.packets, ChartColors.packetsFill, true)`. + +### Phase 3: Frontend — homepage widget + +- **`spa/pages/home.js`** (`renderStatsPanel`, after the Messages card at + line 164): append a fourth card gated on `features.packets !== false`: + ```js + ${features.packets !== false ? renderStatCard({ + icon: iconPackets('h-8 w-8'), + color: pageColors.packets, + title: t('entities.packets'), + value: stats.packets_7d, + description: t('time.last_7_days'), + }) : nothing} + ``` + `iconPackets` is already imported (line 7). The global `renderStatCard` shrink + applies automatically (no per-call change needed). + + `showStats` at line 228 must also include `|| features.packets !== false` so + the stats panel still renders when only packets is enabled (e.g. when a + deployment disables nodes, adverts, and messages but leaves packets on). + +### Phase 4: Frontend — dashboard restructure + +- **`spa/pages/dashboard.js`**: + - **Imports** (line 7–9): add `iconPackets` to the icons import: + ```js + import { iconNodes, iconAdvertisements, iconMessages, iconPackets, iconChannel } from '../icons.js'; + ``` + - `gridCols()` (line 105): add `if (count === 4) return 'md:grid-cols-4';`. + - **Delete** the stat-cards grid (lines 197–222) AND the `topCount`/`topGrid` + computation (lines 185–186), which become dead code. The chart grid render + gate (line 197) switches from `topCount > 0` to checking whether any feature + is enabled (the `visibleCount` computed inside `renderChartCards` already + drives the internal grid layout). + - `renderChartCards` (line 111): add `stats` and `showPackets` params. + Restructure each card's header into a flex row: + ```html +
+
+

${icon} ${title}

+

${subtitle}

+
+
+ ${value} +
+
+ ``` + — **Nodes**: title `t('entities.nodes')`, subtitle `t('time.over_time_last_7_days')`, value `stats.total_nodes`, color `--color-nodes`. + — **Advertisements**: title `t('entities.advertisements')`, subtitle `t('time.per_day_last_7_days')`, value `stats.advertisements_7d`, color `--color-adverts`. + — **Messages**: title `t('entities.messages')`, subtitle `t('time.per_day_last_7_days')`, value `stats.messages_7d`, color `--color-messages`. + Add a Packets card (`#packetChart`, color `--color-packets`, `iconPackets`, + title `t('entities.packets')`, subtitle `t('time.per_day_last_7_days')`, + value `stats.packets_7d`). Include packets in `visibleCount` for the + grid-cols calculation. + - Main `render` (line 160): add `const showPackets = features.packets !== false;`. + Add `/api/v1/dashboard/packet-activity?days=7` to the `Promise.all` (line + 170). Pass `stats` and `showPackets` to `renderChartCards`. Pass + `showPackets ? packetActivity : null` as the 4th arg to + `window.initDashboardCharts`. Add `'packetChart'` to the `chartIds` cleanup + array (line 248). Remove `renderStatCard` from the imports (line 5) once the + stat grid is gone. + +### Phase 5: Tests & verification + +- **`tests/test_api/test_dashboard.py`**: + - Assert `packets_7d` (and `total_packets`) are present in the `/stats` + response. + - Add a test for `/dashboard/packet-activity`: create an observer `Node` + + `RawPacket` rows across two days, request `?days=7`, assert correct daily + counts and zero-fill. Mirror the existing advertisement-activity test's + fixture style. +- Run `pytest --no-cov tests/test_api/test_dashboard.py tests/test_web/`. +- Run `pre-commit run --all-files`. +- `make build` then visually verify `/` (4 shorter stat cards when packets + enabled; 3 when disabled) and `/dashboard` (no stat boxes; numbers in chart + corners; 4-column chart grid when packets enabled, 3 when disabled). + +## Open Questions + +- None remaining. Count window (last 7 days), stat-card height approach (global + shrink), and dashboard consolidation are all confirmed decisions. + +## References + +- `docs/plans/20260612-2014-raw-packets-feature/plan.md` — the Raw Packets + feature this plan builds on (model, collector, `/packets` API, feature flag). +- `docs/plans/20260616-2023-fix-postgres-charts-flatline/plan.md` — the + `_date_bucket_key()` dialect-neutral date handling that the new + `/packet-activity` endpoint must reuse. +- `docs/plans/20260506-1330-members-widget/plan.md` — prior homepage stats-panel + work (operator/member widgets via `renderStatCard`). +- `docs/plans/20260609-2106-redis-api-cache/plan.md` — the `@cached` decorator + and `redis_cache_ttl_dashboard` TTL used by the new endpoint. + +## Review + +**Status**: Approved with Changes + +**Reviewed**: 2026-07-03 + +### Resolutions + +- **`showStats` missing `features.packets`**: Confirmed absent at + `home.js:228`. Added explicit instruction to Phase 3 to include + `|| features.packets !== false`. +- **`iconPackets` missing from dashboard.js imports**: Confirmed absent from + `dashboard.js:7-9`. Added import instruction to Phase 4. +- **`topCount`/`topGrid` dead code after stat-cards removal**: Confirmed + variables at `dashboard.js:185-186` would become unreferenced. Added + explicit removal to Phase 4 and clarified the chart grid render gate should + use the internal `visibleCount` instead. +- **Line reference approximations fixed**: `DashboardStats` field insertion + point corrected from ~287 to after `total_members` (line 288). Advert count + block corrected from ~148 to 137–148. +- **`received_at` index exists**: Confirmed `ix_raw_packets_received_at` at + `raw_packet.py:104`. No migration needed for `/packet-activity` query + performance. `TR-1` query with `received_at >= seven_days_ago` will use + this index. +- **All i18n keys exist**: Confirmed `time.last_7_days` and + `time.per_day_last_7_days` in both `en.json` (lines 134–135) and `nl.json` + (lines 124–125). No new i18n keys needed. +- **Chart subtitle for Nodes**: Verified currently uses + `time.over_time_last_7_days` (different from adverts/messages). Added + per-card subtitle specifications to Phase 4 to prevent accidental + homogenization. + +### Remaining Action Items + +- None. All review findings resolved in-plan. diff --git a/docs/plans/20260703-1330-dashboard-packets-widget/tasks.md b/docs/plans/20260703-1330-dashboard-packets-widget/tasks.md new file mode 100644 index 0000000..c17ba1d --- /dev/null +++ b/docs/plans/20260703-1330-dashboard-packets-widget/tasks.md @@ -0,0 +1,80 @@ +# Tasks: Dashboard & Homepage Packets Widget + +> Generated from `plan.md` on 2026-07-03 + +## Phase 1: Backend — Packet Counts & Activity Endpoint + +- [x] Add `total_packets` and `packets_7d` fields to `DashboardStats` schema + - [x] In `meshcore_hub/common/schemas/messages.py`, after `total_members` (line 288), add `total_packets: int = Field(default=0)` and `packets_7d: int = Field(default=0)` +- [x] Import `RawPacket` model in dashboard routes + - [x] In `meshcore_hub/api/routes/dashboard.py`, add `from meshcore_hub.common.models import RawPacket` alongside existing model imports +- [x] Add packet conditional-aggregation query to `get_stats` + - [x] After the advertisement count block (lines 137–148), add a query mirroring that pattern: `func.count(RawPacket.id).label("total_packets")` + `func.sum(case((RawPacket.received_at >= seven_days_ago, 1), else_=0)).label("packets_7d")` + - [x] Populate `total_packets` and `packets_7d` in the `DashboardStats(...)` constructor (lines 268–282) +- [x] Add `/packet-activity` daily activity endpoint + - [x] Mirror `get_activity` (line 285): select `func.date(RawPacket.received_at)` buckets from `RawPacket`, apply `start_date`/`end_date` range excluding today, use `_date_bucket_key()` for SQLite/Postgres parity, zero-fill missing days using the existing loop pattern + - [x] Return `DailyActivity` response model (reused schema) + - [x] Decorate with `@cached("dashboard/packet-activity", ttl_setting="redis_cache_ttl_dashboard")` (no role/channel filter; default cache key like `/activity`) + +## Phase 2: Frontend — Shared Component & Color Plumbing + +- [x] Add `packets` getter to `pageColors` + - [x] In `meshcore_hub/web/static/js/spa/components.js`, after the `messages` getter (line 190), add `get packets() { return getComputedStyle(document.documentElement).getPropertyValue('--color-packets').trim(); }` +- [x] Shrink `renderStatCard` globally + - [x] In `meshcore_hub/web/static/js/spa/components.js`, in `renderStatCard` (line 803), add `!py-2` to the root element's `class` attribute + - [x] Change `
` to `
` +- [x] Add `packets` / `packetsFill` to `ChartColors` + - [x] In `meshcore_hub/web/static/js/charts.js`, after `messagesFill`, add `packets: getCSSColor('--color-packets', ...)` and `packetsFill: getCSSColor('--color-packets', ...)` (same color for line and fill, matching the green accent) +- [x] Extend `initDashboardCharts` for packet data + - [x] Add a 4th `packetData` parameter to `initDashboardCharts` (line 203) + - [x] When `packetData` is truthy, call `createLineChart('packetChart', packetData, t('entities.packets'), ChartColors.packets, ChartColors.packetsFill, true)` after the existing chart calls + +## Phase 3: Frontend — Homepage Widget + +- [x] Add Packets stat card to `renderStatsPanel` + - [x] In `meshcore_hub/web/static/js/spa/pages/home.js`, after the Messages card (line 164), append a fourth card gated on `features.packets !== false` showing `stats.packets_7d` with `iconPackets`, `pageColors.packets`, `t('entities.packets')`, and `t('time.last_7_days')` +- [x] Update `showStats` gate to include packets + - [x] In `home.js` at line 228, append `|| features.packets !== false` to the `showStats` expression + +## Phase 4: Frontend — Dashboard Restructure + +- [x] Update imports and extend `gridCols` + - [x] In `meshcore_hub/web/static/js/spa/pages/dashboard.js`, add `iconPackets` to the `icons.js` import (line 8) + - [x] Remove `renderStatCard` from the `components.js` import (line 5) since the dashboard no longer uses it + - [x] In `gridCols()` (line 105), add `if (count === 4) return 'md:grid-cols-4';` +- [x] Remove stat-cards grid and dead variables + - [x] Delete the `topCount` / `topGrid` computation (lines 185–186) + - [x] Delete the stat-cards grid template block (lines 197–222) + - [x] Update the render gate (line 197) from `topCount > 0` to `(showNodes || showAdverts || showMessages || showPackets)` +- [x] Restructure chart card headers with corner numbers + - [x] In `renderChartCards`, add `stats` and `showPackets` parameters + - [x] For each existing card (Nodes, Advertisements, Messages), replace `

...

...

` with a flex row: `

${icon} ${title}

${subtitle}

${value}
` + - [x] Nodes: title `t('entities.nodes')`, subtitle `t('time.over_time_last_7_days')`, value `stats.total_nodes`, color `--color-nodes` + - [x] Advertisements: title `t('entities.advertisements')`, subtitle `t('time.per_day_last_7_days')`, value `stats.advertisements_7d`, color `--color-adverts` + - [x] Messages: title `t('entities.messages')`, subtitle `t('time.per_day_last_7_days')`, value `stats.messages_7d`, color `--color-messages` +- [x] Add Packets chart card to `renderChartCards` + - [x] Append a Packets card (gated on `showPackets`) with `#packetChart`, `--color-packets`, `iconPackets`, title `t('entities.packets')`, subtitle `t('time.per_day_last_7_days')`, value `stats.packets_7d` + - [x] Include `showPackets` in the `visibleCount` computation +- [x] Wire up packets data fetching, chart init, and cleanup + - [x] In `render` (line 160), add `const showPackets = features.packets !== false;` + - [x] Add `/api/v1/dashboard/packet-activity?days=7` to the `Promise.all` call (line 170) + - [x] Destructure `packetActivity` from the response, pass `stats` and `showPackets` to `renderChartCards` + - [x] Pass `showPackets ? packetActivity : null` as the 4th argument to `window.initDashboardCharts` + - [x] Add `'packetChart'` to the `chartIds` cleanup array (line 248) + +## Phase 5: Tests & Verification + +- [x] Add backend tests for packets in `/stats` response + - [x] In `tests/test_api/test_dashboard.py`, add a test asserting `total_packets` and `packets_7d` are present in the `/stats` response and have the correct defaults (0 when no packets exist) +- [x] Add backend test for `/packet-activity` endpoint + - [x] Create a test fixture with an observer `Node` and `RawPacket` rows across two days + - [x] Request `/packet-activity?days=7`, assert correct per-day counts and zero-fill for days without data + - [x] Mirror the existing `/activity` test fixture style +- [x] Run targeted backend and web tests + - [x] `pytest --no-cov tests/test_api/test_dashboard.py tests/test_web/` — expect all passing +- [x] Run full lint pass + - [x] `pre-commit run --all-files` — expect clean +- [x] Build and visually verify + - [x] Run `make build` to rebuild the frontend container + - [x] Verify homepage: 4 shorter stat cards when packets enabled; 3 when disabled + - [x] Verify dashboard: no stat boxes; numbers in chart card corners; 4-column chart grid when packets enabled, 3 when disabled diff --git a/docs/plans/20260703-1404-dashboard-recent-adverts/plan.md b/docs/plans/20260703-1404-dashboard-recent-adverts/plan.md new file mode 100644 index 0000000..2f368f5 --- /dev/null +++ b/docs/plans/20260703-1404-dashboard-recent-adverts/plan.md @@ -0,0 +1,322 @@ +# Dashboard Recent Adverts Card Improvements + +## Summary + +The Recent Adverts card on the dashboard page currently shows a compact table of +5 advertisements with three columns: Node (name + truncated key), Type (a +satellite-dish emoji derived from `adv_type`), and Time. This plan enriches the +card to better match the full Advertisement list page by replacing the emoji +column with a route-type text badge (e.g. "Flood", "Relay"), adding an Observers +column (observer count badges with name tooltips), and increasing the displayed +row count from 5 to 10. The Time column is retained. + +This requires a small backend change to include `route_type` and `observers` in +the `RecentAdvertisement` schema and the `get_stats` query, plus a frontend +rewrite of the `renderRecentAds` function to consume the new fields. A local +`routeTypeBadge` helper from `advertisements.js` is promoted to a shared export +in `components.js` to avoid duplication. + +## Background & Motivation + +The dashboard was recently restructured (plan +`20260703-1330-dashboard-packets-widget`) to remove the top stat-box row and +fold headline numbers into chart card corners. The bottom section retains two +cards: Recent Adverts and Recent Channel Messages. The Recent Adverts card is +the primary "what just happened" surface on the dashboard, but its current +information density is low — it shows only a type emoji and a time, omitting two +of the most useful columns from the full Advertisement list page: the route type +badge (which distinguishes flood vs. direct/relay) and the observer count (how +many stations heard the advert). + +The recent git history shows sustained UI polish work (panel-accent redesign, +mobile nav clickthrough fix, typography adoption in commits `479c263`, +`510612d`, `cb677b3`), so this fits the current direction of tightening the +overview surfaces to be more informative at a glance. + +The observer infrastructure already exists: `fetch_observers_for_events()` in +`observer_utils.py` batch-fetches `ObserverInfo` objects by `event_hash` and is +already used by the advertisements, messages, telemetry, and trace-path list +endpoints. The Advertisement model has `route_type` and `event_hash` columns. +The frontend `observerIcons()` helper in `components.js` already renders the +count-badge-with-tooltip. The only missing piece is wiring these into the +dashboard's recent-adverts data path and rendering. + +## Goals + +- Replace the satellite-dish emoji column in the dashboard Recent Adverts card + with a route-type text badge (Flood / Relay / Zero-hop / Direct relay), + matching the Advertisement list page's badge rendering. +- Add an Observers column showing observer count badges (with name tooltips), + matching the Advertisement list page's observer rendering. +- Increase the displayed recent adverts from 5 to 10 (the backend already + fetches 10; the frontend currently slices to 5). +- Retain the Time column (confirmed by the user). +- Avoid code duplication by sharing the `routeTypeBadge` helper between the + dashboard and the advertisements list page. + +## Non-Goals + +- No changes to the Advertisement list page rendering or behavior (it already + has route type badges and observer columns). It only changes its import source + for `routeTypeBadge`. +- No changes to the homepage stat cards or dashboard chart cards (those were + addressed in the prior packets-widget plan). +- No new API endpoints, feature flags, config variables, i18n keys, or database + migrations. +- No changes to the Recent Channel Messages card. +- No pagination, sorting, filtering, or observer-filter toggle on the dashboard + card (the dashboard is a read-only overview; full filtering lives on the + Advertisement list page). + +## Requirements + +### Functional Requirements + +- **FR-1** — The dashboard Recent Adverts card displays up to 10 advertisement + rows (currently 5). The backend `/stats` endpoint already returns 10; the + frontend `ads.slice(0, 5)` call must be removed. +- **FR-2** — Each row has four columns: **Node** (display name + truncated + public key underneath, linked to `/nodes/{public_key}`), **Type** (route-type + badge), **Time** (time-only formatted, as today), **Observers** (observer + count badge or dash). +- **FR-3** — The Type column renders a route-type badge using the same logic as + the Advertisement list page: `flood` → blue "Flood" badge; + `transport_flood` → blue "Relay" badge; `direct` → green "Zero-hop" badge; + `transport_direct` → green "Direct relay" badge; `null`/unknown → empty + (nothing rendered). +- **FR-4** — The Observers column renders an observer count badge + (`observerIcons()`) when the advert has `observers.length >= 1`, a faded + satellite emoji when only the legacy `observed_by` field is present (no + event-observer rows), and a faded dash when neither is present — mirroring the + Advertisement list page's three-way fallback. +- **FR-5** — The `GET /api/v1/dashboard/stats` response includes `route_type`, + `observers`, and `observed_by` on each item in `recent_advertisements`. + +### Technical Requirements + +- **TR-1** — The `RecentAdvertisement` schema gains three fields: + `route_type: Optional[str]`, `observers: list[ObserverInfo]` (default + empty list), and `observed_by: Optional[str]`. The `ObserverInfo` schema is + reused as-is (already defined in the same module). +- **TR-2** — The `get_stats` dashboard route collects `event_hash` values from + the fetched recent adverts and calls `fetch_observers_for_events(session, + "advertisement", event_hashes)` to batch-resolve observer data in a single + query — the same pattern used by the advertisements list endpoint. +- **TR-3** — The `routeTypeBadge` function is moved from a local definition in + `advertisements.js` to an exported function in `components.js`, so both the + dashboard and the advertisements page share a single implementation. + `advertisements.js` updates its import accordingly. +- **TR-4** — The frontend `renderRecentAds` function imports `observerIcons` + and `routeTypeBadge` from `components.js` and uses them in the new column + layout. The `typeEmoji` import (used only for the old emoji column) is removed + from `dashboard.js` if it becomes unused after the change. +- **TR-5** — Observer fallback logic matches the Advertisement list page: if + `ad.observers` is a non-empty array, render `observerIcons(ad.observers)`; + otherwise if `ad.observed_by` is truthy, render a faded satellite emoji; else + render a faded dash. +- **TR-6** — No new i18n keys needed. The route-type badge text ("Flood", + "Relay", "Zero-hop", "Direct relay") is hardcoded in the existing + `routeTypeBadge` function and is not i18n-ized today. The column headers reuse + existing keys: `entities.node`, `common.type`, `common.received`, + `common.observers`. + +## Implementation Plan + +### Phase 1: Backend — schema & data + +- **`src/meshcore_hub/common/schemas/messages.py`** — `RecentAdvertisement` + (line 237): add three fields after `received_at`: + ```python + route_type: Optional[str] = Field(default=None, description="Route type") + observers: list[ObserverInfo] = Field( + default_factory=list, description="All observers that captured this advertisement" + ) + observed_by: Optional[str] = Field( + default=None, description="Observing interface node public key" + ) + ``` + `ObserverInfo` is already defined earlier in the same module (line 9), so no + new import needed. + +- **`src/meshcore_hub/api/routes/dashboard.py`** — `get_stats()`: + - Add `fetch_observers_for_events` to the import from `observer_utils` (line + 17 already imports `resolve_sender_names` from that module). + - After the recent-adverts query (line 164–174) and before building the + `RecentAdvertisement` list (line 202), collect event hashes and fetch + observers: + ```python + ad_event_hashes = [ad.event_hash for ad in recent_ads if ad.event_hash] + observers_by_hash = fetch_observers_for_events( + session, "advertisement", ad_event_hashes + ) + ``` + - Resolve `observer_node_id` → `public_key` for the `observed_by` fallback. + Collect observer node IDs from the ads and look up their public keys: + ```python + observer_node_ids = [ad.observer_node_id for ad in recent_ads if ad.observer_node_id] + observer_pk_map: dict[str, str] = {} + if observer_node_ids: + obs_query = select(Node.id, Node.public_key).where( + Node.id.in_(observer_node_ids) + ) + for node_id, public_key in session.execute(obs_query).all(): + observer_pk_map[node_id] = public_key + ``` + - In the `RecentAdvertisement(...)` constructor (line 203–210), add: + ```python + route_type=ad.route_type, + observers=observers_by_hash.get(ad.event_hash, []) if ad.event_hash else [], + observed_by=observer_pk_map.get(ad.observer_node_id) if ad.observer_node_id else None, + ``` + +### Phase 2: Frontend — shared component extraction + +- **`src/meshcore_hub/web/static/js/spa/components.js`** — Add an exported + `routeTypeBadge(routeType)` function with the same logic currently in + `advertisements.js:12-23`: + ```js + export function routeTypeBadge(routeType) { + if (!routeType) return nothing; + if (routeType === 'flood' || routeType === 'transport_flood') { + return html`${routeType === 'flood' ? 'Flood' : 'Relay'}`; + } + if (routeType === 'direct' || routeType === 'transport_direct') { + return html`${routeType === 'direct' ? 'Zero-hop' : 'Direct relay'}`; + } + return nothing; + } + ``` + Place it near the other badge/icon helpers (e.g. after `observerIcons` at line + 559). Ensure `html` and `nothing` are already imported (they are). + +- **`src/meshcore_hub/web/static/js/spa/pages/advertisements.js`** — Remove the + local `routeTypeBadge` function (lines 12–23) and add it to the import from + `../components.js` (line 2–9). + +### Phase 3: Frontend — dashboard card rewrite + +- **`src/meshcore_hub/web/static/js/spa/pages/dashboard.js`** — + `renderRecentAds()` (lines 34–68): + - **Imports** (lines 2–6): add `observerIcons` and `routeTypeBadge` to the + destructured import from `../components.js`. Remove `typeEmoji` from the + import if it is no longer used elsewhere in the file (verify: `typeEmoji` + is only used at line 51 in `renderRecentAds`, so it can be dropped). + - **Remove the `.slice(0, 5)`** call (line 38) so all 10 rows render. + - **Rewrite the table** to four columns. Replace the current `` and + row template: + ```js + function renderRecentAds(ads) { + if (!ads || ads.length === 0) { + return html`

${t('common.no_entity_yet', { entity: t('entities.advertisements').toLowerCase() })}

`; + } + const rows = ads.map(ad => { + const friendlyName = ad.tag_name || ad.name; + const displayName = friendlyName || (ad.public_key.slice(0, 12) + '...'); + const keyLine = friendlyName + ? html`
${ad.public_key.slice(0, 12)}...
` + : nothing; + let observersBlock; + if (ad.observers && ad.observers.length >= 1) { + observersBlock = html`${observerIcons(ad.observers)}`; + } else if (ad.observed_by) { + observersBlock = html`\u{1F4E1}`; + } else { + observersBlock = html`-`; + } + return html` + + +
${displayName}
+
+ ${keyLine} + + ${routeTypeBadge(ad.route_type)} + ${formatTimeOnly(ad.received_at)} + ${observersBlock} + `; + }); + + return html`
+ + + + + + + + + + ${rows} +
${t('entities.node')}${t('common.type')}${t('common.received')}${t('common.observers')}
+
`; + } + ``` + +### Phase 4: Tests & verification + +- **`tests/test_api/test_dashboard.py`**: + - **Existing test `test_recent_ads_excludes_direct`** (line 659): the flood ad + already has `route_type="flood"`. Add assertions: + ```python + assert data["recent_advertisements"][0]["route_type"] == "flood" + assert data["recent_advertisements"][0]["observers"] == [] + assert data["recent_advertisements"][0]["observed_by"] is None + ``` + - **Existing test `test_recent_advertisements_includes_tag_name`** (line 847): + the ad has `route_type="flood"` but no `event_hash`. Assert: + ```python + assert data["recent_advertisements"][0]["route_type"] == "flood" + assert data["recent_advertisements"][0]["observers"] == [] + assert data["recent_advertisements"][0]["observed_by"] is None + ``` + - **New test `test_recent_advertisements_includes_observers`**: Create an + observer `Node`, an `Advertisement` with an `event_hash`, and an + `EventObserver` row linking them. Assert that `recent_advertisements[0]` has + `observers` with length 1 and the observer's `public_key` matches. Mirror + the fixture style of `test_api/test_advertisements.py:40-66`. + - **New test `test_recent_advertisements_includes_observed_by`**: Create an + observer `Node` (public key `OBSERVERKEY...`), set + `advertisement.observer_node_id = observer_node.id`, no `event_hash` / no + `EventObserver` row. Assert that `data["recent_advertisements"][0]` has + `observers == []` and `observed_by == "OBSERVERKEY..."`, covering the + satellite-emoji fallback path. +- Run `pytest --no-cov tests/test_api/test_dashboard.py`. +- Run `pre-commit run --all-files`. +- `make build` then visually verify the `/dashboard` Recent Adverts card: route + type badges appear, observer count badges appear, 10 rows shown, time column + retained. + +## Review + +**Status**: Approved + +**Reviewed**: 2026-07-03 + +### Resolutions + +- **`observed_by` on `RecentAdvertisement`**: Add the field. The backend resolves + `advertisement.observer_node_id → Node.public_key` for each recent ad (batched + query) and populates `observed_by` on the schema. The frontend uses the + full three-way fallback: `ad.observers` array → `ad.observed_by` (satellite + emoji) → dash. A new test `test_recent_advertisements_includes_observed_by` + covers this path. + +### Remaining Action Items + +- (none) + +## References + +- `docs/plans/20260703-1330-dashboard-packets-widget/plan.md` — prior dashboard + restructure (removed stat boxes, added chart-card corner numbers, added + Packets chart). This plan continues polishing the dashboard's bottom section. +- `docs/plans/20260614-1220-observer-filter-badges/plan.md` — introduced the + `observerIcons` helper and observer badge rendering pattern used here. +- `docs/plans/20260515-1900-recent-adverts-timestamps/plan.md` — prior work on + the recent-adverts data path (added `public_key` filter to the advertisements + API for the node-detail page; the dashboard path is separate). +- `src/meshcore_hub/api/observer_utils.py` — `fetch_observers_for_events()`, + the batch observer-resolution utility reused by this plan. +- `src/meshcore_hub/api/routes/advertisements.py:210-243` — the reference + implementation of observer/route-type population in the advertisements list + endpoint. diff --git a/docs/plans/20260703-1404-dashboard-recent-adverts/tasks.md b/docs/plans/20260703-1404-dashboard-recent-adverts/tasks.md new file mode 100644 index 0000000..b6409bd --- /dev/null +++ b/docs/plans/20260703-1404-dashboard-recent-adverts/tasks.md @@ -0,0 +1,61 @@ +# Tasks: Dashboard Recent Adverts Card Improvements + +> Generated from `plan.md` on 2026-07-03 + +## Backend: Schema & Data + +- [x] Add `route_type`, `observers`, and `observed_by` fields to `RecentAdvertisement` schema + - [x] Open `src/meshcore_hub/common/schemas/messages.py` + - [x] Add `route_type: Optional[str] = Field(default=None, description="Route type")` after `received_at` (line 244) + - [x] Add `observers: list[ObserverInfo] = Field(default_factory=list, description="All observers that captured this advertisement")` after `route_type` + - [x] Add `observed_by: Optional[str] = Field(default=None, description="Observing interface node public key")` after `observers` + +- [x] Wire observer data into `get_stats` dashboard endpoint + - [x] Open `src/meshcore_hub/api/routes/dashboard.py` + - [x] Add `fetch_observers_for_events` to the import from `observer_utils` (line 17) + - [x] After the recent-ads query (line 174) and before building `RecentAdvertisement` list (line 202), collect event hashes and call `fetch_observers_for_events(session, "advertisement", ad_event_hashes)` + - [x] Collect `observer_node_id` values from recent ads and batch-query `Node.id, Node.public_key` to build an `observer_pk_map` dict + - [x] Pass `route_type=ad.route_type`, `observers=...`, and `observed_by=...` into the `RecentAdvertisement(...)` constructor (line 203) + +## Frontend: Shared Component Extraction + +- [x] Extract `routeTypeBadge` from `advertisements.js` into `components.js` + - [x] Open `src/meshcore_hub/web/static/js/spa/components.js` + - [x] Add exported `routeTypeBadge(routeType)` function after `observerIcons` (near line 559) with the same flood/direct/transport logic + - [x] Open `src/meshcore_hub/web/static/js/spa/pages/advertisements.js` + - [x] Remove the local `routeTypeBadge` function (lines 12–23) + - [x] Add `routeTypeBadge` to the destructured import from `../components.js` (line 2–9) + +## Frontend: Dashboard Card Rewrite + +- [x] Rewrite `renderRecentAds` in `dashboard.js` + - [x] Open `src/meshcore_hub/web/static/js/spa/pages/dashboard.js` + - [x] Add `observerIcons` and `routeTypeBadge` to the import from `../components.js` (line 2–6) + - [x] Remove `typeEmoji` from the import (only used by old emoji column; verify no other callers) + - [x] Remove `.slice(0, 5)` call on line 38 so all 10 rows render + - [x] Rewrite the table to four columns: Node, Type, Time, Observers + - [x] Implement three-way observer fallback in the row template: `ad.observers` array → `ad.observed_by` satellite emoji → dash + - [x] Use `routeTypeBadge(ad.route_type)` for the Type column + - [x] Use `observerIcons(ad.observers)` for the Observers column when observers array is non-empty + - [x] Retain the existing `formatTimeOnly(ad.received_at)` for the Time column + +## Tests + +- [x] Update existing dashboard tests for new fields + - [x] Open `tests/test_api/test_dashboard.py` + - [x] In `test_recent_ads_excludes_direct` (line 659): add assertions for `route_type == "flood"`, `observers == []`, `observed_by is None` + - [x] In `test_recent_advertisements_includes_tag_name` (line 847): add assertions for `route_type == "flood"`, `observers == []`, `observed_by is None` + +- [x] Add new test for observer population + - [x] Add `test_recent_advertisements_includes_observers`: create observer Node, Advertisement with `event_hash`, and EventObserver row; assert `observers` has length 1 and correct `public_key` + +- [x] Add new test for `observed_by` fallback + - [x] Add `test_recent_advertisements_includes_observed_by`: create observer Node, Advertisement with `observer_node_id` set but no `event_hash` / EventObserver; assert `observers == []` and `observed_by` matches observer public key + +## Verification + +- [ ] Run dashboard API tests: `pytest --no-cov tests/test_api/test_dashboard.py -v` +- [ ] Run full quality checks: `pre-commit run --all-files` +- [ ] Rebuild stack: `make build` +- [ ] Visually verify `/dashboard` Recent Adverts card: route type badges appear, observer count badges appear, 10 rows shown, time column retained +- [ ] Visually verify `/advertisements` page: route type badges still render correctly (import refactor did not break them) diff --git a/src/meshcore_hub/api/routes/dashboard.py b/src/meshcore_hub/api/routes/dashboard.py index 2f26e56..9c59799 100644 --- a/src/meshcore_hub/api/routes/dashboard.py +++ b/src/meshcore_hub/api/routes/dashboard.py @@ -14,12 +14,16 @@ from meshcore_hub.api.channel_visibility import ( resolve_user_role, ) from meshcore_hub.api.dependencies import DbSession -from meshcore_hub.api.observer_utils import resolve_sender_names +from meshcore_hub.api.observer_utils import ( + fetch_observers_for_events, + resolve_sender_names, +) from meshcore_hub.common.models import ( Advertisement, Message, Node, NodeTag, + RawPacket, UserProfile, ) from meshcore_hub.common.schemas.messages import ( @@ -147,6 +151,19 @@ def get_stats( advertisements_24h = adv_row[1] or 0 advertisements_7d = adv_row[2] or 0 + # Raw-packet counts (total + last 7 days), observer-level volume metric + # with no role/channel filter (payload redaction lives on list/detail only). + packet_row = session.execute( + select( + func.count(RawPacket.id).label("total_packets"), + func.sum(case((RawPacket.received_at >= seven_days_ago, 1), else_=0)).label( + "packets_7d" + ), + ).select_from(RawPacket) + ).one() + total_packets = packet_row[0] or 0 + packets_7d = packet_row[1] or 0 + # Recent advertisements (last 10, flood-only) recent_ads = ( session.execute( @@ -185,6 +202,25 @@ def get_stats( for public_key, value in session.execute(tag_name_query).all(): tag_names[public_key] = value + # Batch-resolve observers (via event_observers) and the legacy + # observed_by public key for the recent adverts, mirroring the + # advertisements list endpoint. + ad_event_hashes = [ad.event_hash for ad in recent_ads if ad.event_hash] + observers_by_hash = fetch_observers_for_events( + session, "advertisement", ad_event_hashes + ) + + observer_node_ids = [ + ad.observer_node_id for ad in recent_ads if ad.observer_node_id + ] + observer_pk_map: dict[str, str] = {} + if observer_node_ids: + obs_query = select(Node.id, Node.public_key).where( + Node.id.in_(observer_node_ids) + ) + for node_id, public_key in session.execute(obs_query).all(): + observer_pk_map[node_id] = public_key + recent_advertisements = [ RecentAdvertisement( public_key=ad.public_key, @@ -192,6 +228,13 @@ def get_stats( tag_name=tag_names.get(ad.public_key), adv_type=ad.adv_type or node_adv_types.get(ad.public_key), received_at=ad.received_at, + route_type=ad.route_type, + observers=observers_by_hash.get(ad.event_hash, []) if ad.event_hash else [], + observed_by=( + observer_pk_map.get(ad.observer_node_id) + if ad.observer_node_id + else None + ), ) for ad in recent_ads ] @@ -279,6 +322,8 @@ def get_stats( channel_messages=channel_messages, total_operators=total_operators, total_members=total_members, + total_packets=total_packets, + packets_7d=packets_7d, ) @@ -339,6 +384,54 @@ def get_activity( return DailyActivity(days=days, data=data) +@router.get("/packet-activity", response_model=DailyActivity) +@cached("dashboard/packet-activity", ttl_setting="redis_cache_ttl_dashboard") +def get_packet_activity( + _: RequireRead, + session: DbSession, + request: Request, + days: int = 30, +) -> DailyActivity: + """Get daily raw-packet activity for the specified period. + + Args: + days: Number of days to include (default 30, max 90) + + Returns: + Daily raw-packet counts for each day in the period (excluding today) + """ + days = min(days, 90) + + now = datetime.now(timezone.utc) + end_date = now.replace(hour=0, minute=0, second=0, microsecond=0) + start_date = end_date - timedelta(days=days) + + date_expr = func.date(RawPacket.received_at) + + query = ( + select( + date_expr.label("date"), + func.count().label("count"), + ) + .where(RawPacket.received_at >= start_date) + .where(RawPacket.received_at < end_date) + .group_by(date_expr) + .order_by(date_expr) + ) + + results = session.execute(query).all() + counts_by_date = {_date_bucket_key(row.date): row.count for row in results} + + data = [] + for i in range(days): + date = start_date + timedelta(days=i) + date_str = date.strftime("%Y-%m-%d") + count = counts_by_date.get(date_str, 0) + data.append(DailyActivityPoint(date=date_str, count=count)) + + return DailyActivity(days=days, data=data) + + @router.get("/message-activity", response_model=MessageActivity) @cached( "dashboard/message-activity", diff --git a/src/meshcore_hub/common/schemas/messages.py b/src/meshcore_hub/common/schemas/messages.py index fc11095..b2df684 100644 --- a/src/meshcore_hub/common/schemas/messages.py +++ b/src/meshcore_hub/common/schemas/messages.py @@ -242,6 +242,14 @@ class RecentAdvertisement(BaseModel): tag_name: Optional[str] = Field(default=None, description="Name tag") adv_type: Optional[str] = Field(default=None, description="Node type") received_at: datetime = Field(..., description="When received") + route_type: Optional[str] = Field(default=None, description="Route type") + observers: list[ObserverInfo] = Field( + default_factory=list, + description="All observers that captured this advertisement", + ) + observed_by: Optional[str] = Field( + default=None, description="Observing interface node public key" + ) class ChannelMessage(BaseModel): @@ -288,6 +296,8 @@ class DashboardStats(BaseModel): total_members: int = Field( default=0, description="Number of member-role users (excluding operators)" ) + total_packets: int = Field(default=0, description="Total raw packets captured") + packets_7d: int = Field(default=0, description="Packets captured in last 7 days") class DailyActivityPoint(BaseModel): diff --git a/src/meshcore_hub/web/static/js/charts.js b/src/meshcore_hub/web/static/js/charts.js index 5e3a064..b6e042e 100644 --- a/src/meshcore_hub/web/static/js/charts.js +++ b/src/meshcore_hub/web/static/js/charts.js @@ -30,6 +30,8 @@ const ChartColors = { get advertsFill() { return withAlpha(this.adverts, 0.1); }, get messages() { return getCSSColor('--color-messages', 'oklch(0.75 0.18 180)'); }, get messagesFill() { return withAlpha(this.messages, 0.1); }, + get packets() { return getCSSColor('--color-packets', 'oklch(0.72 0.17 145)'); }, + get packetsFill() { return withAlpha(this.packets, 0.1); }, // Neutral grays (not page-specific) grid: 'oklch(0.4 0 0 / 0.2)', @@ -194,13 +196,14 @@ function createActivityChart(canvasId, advertData, messageData) { } /** - * Initialize dashboard charts (nodes, advertisements, messages). + * Initialize dashboard charts (nodes, advertisements, messages, packets). * Pass null for any data parameter to skip that chart. * @param {Object|null} nodeData - Node count data, or null to skip * @param {Object|null} advertData - Advertisement data, or null to skip * @param {Object|null} messageData - Message data, or null to skip + * @param {Object|null} packetData - Raw-packet data, or null to skip */ -function initDashboardCharts(nodeData, advertData, messageData) { +function initDashboardCharts(nodeData, advertData, messageData, packetData) { if (nodeData) { createLineChart( 'nodeChart', @@ -233,4 +236,15 @@ function initDashboardCharts(nodeData, advertData, messageData) { true ); } + + if (packetData) { + createLineChart( + 'packetChart', + packetData, + (window.t && window.t('entities.packets')) || 'Packets', + ChartColors.packets, + ChartColors.packetsFill, + true + ); + } } diff --git a/src/meshcore_hub/web/static/js/spa/components.js b/src/meshcore_hub/web/static/js/spa/components.js index 413ffbc..41b529b 100644 --- a/src/meshcore_hub/web/static/js/spa/components.js +++ b/src/meshcore_hub/web/static/js/spa/components.js @@ -185,6 +185,7 @@ export const pageColors = { get nodes() { return getComputedStyle(document.documentElement).getPropertyValue('--color-nodes').trim(); }, get adverts() { return getComputedStyle(document.documentElement).getPropertyValue('--color-adverts').trim(); }, get messages() { return getComputedStyle(document.documentElement).getPropertyValue('--color-messages').trim(); }, + get packets() { return getComputedStyle(document.documentElement).getPropertyValue('--color-packets').trim(); }, get map() { return getComputedStyle(document.documentElement).getPropertyValue('--color-map').trim(); }, get members() { return getComputedStyle(document.documentElement).getPropertyValue('--color-members').trim(); }, }; @@ -554,7 +555,20 @@ export function observerIcons(observers) { if (!observers || observers.length === 0) return nothing; const names = observers.map(o => o.tag_name || o.name || truncateKey(o.public_key, 8)); const tooltip = names.join(', '); - return html`${observers.length}`; + return html`${observers.length}`; +} + +export function routeTypeBadge(routeType) { + if (!routeType) { + return nothing; + } + if (routeType === 'flood' || routeType === 'transport_flood') { + return html`${routeType === 'flood' ? 'Flood' : 'Relay'}`; + } + if (routeType === 'direct' || routeType === 'transport_direct') { + return html`${routeType === 'direct' ? 'Zero-hop' : 'Direct relay'}`; + } + return nothing; } // --- Observer filter (localStorage-backed toggle badges) --- @@ -802,10 +816,10 @@ export function renderFilterCard({ fields, basePath, navigate, submitLabel, clea */ export function renderStatCard({ icon, color, title, value, description }) { return html` -
+
${icon}
${title}
-
${value}
+
${value}
${description ? html`
${description}
` : nothing}
`; } diff --git a/src/meshcore_hub/web/static/js/spa/pages/advertisements.js b/src/meshcore_hub/web/static/js/spa/pages/advertisements.js index daa2422..3e4961f 100644 --- a/src/meshcore_hub/web/static/js/spa/pages/advertisements.js +++ b/src/meshcore_hub/web/static/js/spa/pages/advertisements.js @@ -5,23 +5,10 @@ import { warningBadge, pagination, sortableTableHeader, mobileSortSelect, renderFilterCard, autoSubmit, submitOnEnter, copyToClipboard, renderNodeDisplay, - observerIcons, getDisabledObservers, toggleObserver, observerFilterBadges + observerIcons, getDisabledObservers, toggleObserver, observerFilterBadges, routeTypeBadge } from '../components.js'; import { createAutoRefresh } from '../auto-refresh.js'; -function routeTypeBadge(routeType) { - if (!routeType) { - return nothing; - } - if (routeType === 'flood' || routeType === 'transport_flood') { - return html`${routeType === 'flood' ? 'Flood' : 'Relay'}`; - } - if (routeType === 'direct' || routeType === 'transport_direct') { - return html`${routeType === 'direct' ? 'Zero-hop' : 'Direct relay'}`; - } - return nothing; -} - export async function render(container, params, router) { const { signal } = params || {}; const query = params.query || {}; diff --git a/src/meshcore_hub/web/static/js/spa/pages/dashboard.js b/src/meshcore_hub/web/static/js/spa/pages/dashboard.js index c13dec7..35143a8 100644 --- a/src/meshcore_hub/web/static/js/spa/pages/dashboard.js +++ b/src/meshcore_hub/web/static/js/spa/pages/dashboard.js @@ -2,10 +2,10 @@ import { apiGet, isAbortError } from '../api.js'; import { html, litRender, nothing, getConfig, getChannelLabelsMap, resolveChannelLabel, - typeEmoji, errorAlert, pageColors, renderStatCard, t, formatDateTime, + observerIcons, routeTypeBadge, errorAlert, t, formatDateTime, } from '../components.js'; import { - iconNodes, iconAdvertisements, iconMessages, iconChannel, + iconNodes, iconAdvertisements, iconMessages, iconPackets, iconChannel, } from '../icons.js'; function channelLabel(channel, channelLabels) { @@ -35,12 +35,20 @@ function renderRecentAds(ads) { if (!ads || ads.length === 0) { return html`

${t('common.no_entity_yet', { entity: t('entities.advertisements').toLowerCase() })}

`; } - const rows = ads.slice(0, 5).map(ad => { + const rows = ads.map(ad => { const friendlyName = ad.tag_name || ad.name; const displayName = friendlyName || (ad.public_key.slice(0, 12) + '...'); const keyLine = friendlyName ? html`
${ad.public_key.slice(0, 12)}...
` : nothing; + let observersBlock; + if (ad.observers && ad.observers.length >= 1) { + observersBlock = html`${observerIcons(ad.observers)}`; + } else if (ad.observed_by) { + observersBlock = html`\u{1F4E1}`; + } else { + observersBlock = html`-`; + } return html` @@ -48,8 +56,9 @@ function renderRecentAds(ads) { ${keyLine} - ${ad.adv_type ? typeEmoji(ad.adv_type) : html`-`} + ${routeTypeBadge(ad.route_type)} ${formatTimeOnly(ad.received_at)} + ${observersBlock} `; }); @@ -58,8 +67,9 @@ function renderRecentAds(ads) { ${t('entities.node')} - ${t('common.type')} + ${t('common.type')} ${t('common.received')} + ${t('common.observers')} ${rows} @@ -105,22 +115,30 @@ function renderChannelMessages(channelMessages, channelLabels) { function gridCols(count) { if (count === 2) return 'md:grid-cols-2'; if (count === 3) return 'md:grid-cols-3'; + if (count === 4) return 'md:grid-cols-4'; return ''; } -function renderChartCards({ showNodes, showAdverts, showMessages }) { - const visibleCount = (showNodes ? 1 : 0) + (showAdverts ? 1 : 0) + (showMessages ? 1 : 0); +function renderChartCards({ showNodes, showAdverts, showMessages, showPackets, stats }) { + const visibleCount = (showNodes ? 1 : 0) + (showAdverts ? 1 : 0) + (showMessages ? 1 : 0) + (showPackets ? 1 : 0); if (visibleCount === 0) return nothing; return html`
${showNodes ? html`
-

- ${iconNodes('h-5 w-5')} - ${t('common.total_entity', { entity: t('entities.nodes') })} -

-

${t('time.over_time_last_7_days')}

+
+
+

+ ${iconNodes('h-5 w-5')} + ${t('entities.nodes')} +

+

${t('time.over_time_last_7_days')}

+
+
+ ${stats.total_nodes} +
+
@@ -130,11 +148,18 @@ function renderChartCards({ showNodes, showAdverts, showMessages }) { ${showAdverts ? html`
-

- ${iconAdvertisements('h-5 w-5')} - ${t('entities.advertisements')} -

-

${t('time.per_day_last_7_days')}

+
+
+

+ ${iconAdvertisements('h-5 w-5')} + ${t('entities.advertisements')} +

+

${t('time.per_day_last_7_days')}

+
+
+ ${stats.advertisements_7d} +
+
@@ -144,16 +169,44 @@ function renderChartCards({ showNodes, showAdverts, showMessages }) { ${showMessages ? html`
-

- ${iconMessages('h-5 w-5')} - ${t('entities.messages')} -

-

${t('time.per_day_last_7_days')}

+
+
+

+ ${iconMessages('h-5 w-5')} + ${t('entities.messages')} +

+

${t('time.per_day_last_7_days')}

+
+
+ ${stats.messages_7d} +
+
` : nothing} + + ${showPackets ? html` +
+
+
+
+

+ ${iconPackets('h-5 w-5')} + ${t('entities.packets')} +

+

${t('time.per_day_last_7_days')}

+
+
+ ${stats.packets_7d} +
+
+
+ +
+
+
` : nothing}
`; } @@ -166,12 +219,14 @@ export async function render(container, params, router) { const showNodes = features.nodes !== false; const showAdverts = features.advertisements !== false; const showMessages = features.messages !== false; + const showPackets = features.packets !== false; - const [stats, advertActivity, messageActivity, nodeCount, channelsData] = await Promise.all([ + const [stats, advertActivity, messageActivity, nodeCount, packetActivity, channelsData] = await Promise.all([ apiGet('/api/v1/dashboard/stats', {}, { signal }), apiGet('/api/v1/dashboard/activity', { days: 7 }, { signal }), apiGet('/api/v1/dashboard/message-activity', { days: 7 }, { signal }), apiGet('/api/v1/dashboard/node-count', { days: 7 }, { signal }), + apiGet('/api/v1/dashboard/packet-activity', { days: 7 }, { signal }), apiGet('/api/v1/channels', {}, { signal }), ]); channelLabels = new Map([ @@ -181,10 +236,6 @@ export async function render(container, params, router) { .filter(([idx]) => Number.isInteger(idx)), ]); - // Top section: stats + charts - const topCount = (showNodes ? 1 : 0) + (showAdverts ? 1 : 0) + (showMessages ? 1 : 0); - const topGrid = gridCols(topCount); - // Bottom section: recent adverts + recent channel messages const bottomCount = (showAdverts ? 1 : 0) + (showMessages ? 1 : 0); const bottomGrid = gridCols(bottomCount); @@ -194,34 +245,8 @@ export async function render(container, params, router) {

${t('entities.dashboard')}

-${topCount > 0 ? html` -
- ${showNodes ? renderStatCard({ - icon: iconNodes('h-8 w-8'), - color: pageColors.nodes, - title: t('common.total_entity', { entity: t('entities.nodes') }), - value: stats.total_nodes, - description: t('dashboard.all_discovered_nodes'), - }) : nothing} - - ${showAdverts ? renderStatCard({ - icon: iconAdvertisements('h-8 w-8'), - color: pageColors.adverts, - title: t('entities.advertisements'), - value: stats.advertisements_7d, - description: t('time.last_7_days'), - }) : nothing} - - ${showMessages ? renderStatCard({ - icon: iconMessages('h-8 w-8'), - color: pageColors.messages, - title: t('entities.messages'), - value: stats.messages_7d, - description: t('time.last_7_days'), - }) : nothing} -
- -${renderChartCards({ showNodes, showAdverts, showMessages })}` : nothing} +${(showNodes || showAdverts || showMessages || showPackets) ? html` +${renderChartCards({ showNodes, showAdverts, showMessages, showPackets, stats })}` : nothing} ${bottomCount > 0 ? html`
@@ -243,9 +268,10 @@ ${bottomCount > 0 ? html` showNodes ? nodeCount : null, showAdverts ? advertActivity : null, showMessages ? messageActivity : null, + showPackets ? packetActivity : null, ); - const chartIds = ['nodeChart', 'advertChart', 'messageChart']; + const chartIds = ['nodeChart', 'advertChart', 'messageChart', 'packetChart']; return () => { chartIds.forEach(id => { const canvas = document.getElementById(id); diff --git a/src/meshcore_hub/web/static/js/spa/pages/home.js b/src/meshcore_hub/web/static/js/spa/pages/home.js index 0441bee..d35cced 100644 --- a/src/meshcore_hub/web/static/js/spa/pages/home.js +++ b/src/meshcore_hub/web/static/js/spa/pages/home.js @@ -35,7 +35,7 @@ function renderRadioTiles(rc) { function renderNavCard({ href, icon, label, colorVar }) { return html` - ${welcomeText}
-
+
${features.dashboard !== false ? renderNavCard({ href: '/dashboard', icon: iconDashboard('w-full h-full'), @@ -142,7 +144,7 @@ function renderStatsPanel({ features, stats }) { ${features.nodes !== false ? renderStatCard({ icon: iconNodes('h-8 w-8'), color: pageColors.nodes, - title: t('common.total_entity', { entity: t('entities.nodes') }), + title: t('entities.nodes'), value: stats.total_nodes, description: t('home.all_discovered_nodes'), }) : nothing} @@ -160,6 +162,13 @@ function renderStatsPanel({ features, stats }) { value: stats.messages_7d, description: t('time.last_7_days'), }) : nothing} + ${features.packets !== false ? renderStatCard({ + icon: iconPackets('h-8 w-8'), + color: pageColors.packets, + title: t('entities.packets'), + value: stats.packets_7d, + description: t('time.last_7_days'), + }) : nothing}
`; } @@ -223,7 +232,7 @@ export async function render(container, params, router) { apiGet('/api/v1/dashboard/message-activity', { days: 7 }, { signal }), ]); - const showStats = features.nodes !== false || features.advertisements !== false || features.messages !== false; + const showStats = features.nodes !== false || features.advertisements !== false || features.messages !== false || features.packets !== false; const showAdvertSeries = features.advertisements !== false; const showMessageSeries = features.messages !== false; const showActivityChart = showAdvertSeries || showMessageSeries; diff --git a/tests/test_api/test_dashboard.py b/tests/test_api/test_dashboard.py index 02e58cb..939a762 100644 --- a/tests/test_api/test_dashboard.py +++ b/tests/test_api/test_dashboard.py @@ -8,10 +8,12 @@ import pytest from meshcore_hub.api.routes.dashboard import _date_bucket_key from meshcore_hub.common.models import ( Advertisement, + EventObserver, Message, Node, NodeTag, Channel, + RawPacket, ) from meshcore_hub.common.models import UserProfile @@ -54,6 +56,8 @@ class TestDashboardStats: assert data["total_messages"] == 0 assert data["messages_today"] == 0 assert data["total_advertisements"] == 0 + assert data["total_packets"] == 0 + assert data["packets_7d"] == 0 assert data["channel_message_counts"] == {} def test_get_stats_with_data( @@ -119,6 +123,15 @@ class TestDashboardStats: ), ] ) + + # Raw packets: now (7d), 3 days ago (7d), 10 days ago (older). + api_db_session.add_all( + [ + RawPacket(event_type="message", received_at=now), + RawPacket(event_type="message", received_at=now - timedelta(days=3)), + RawPacket(event_type="message", received_at=now - timedelta(days=10)), + ] + ) api_db_session.commit() data = client_no_auth.get("/api/v1/dashboard/stats").json() @@ -134,6 +147,9 @@ class TestDashboardStats: assert data["advertisements_24h"] == 1 assert data["advertisements_7d"] == 2 # now + 3d (10d excluded) + assert data["total_packets"] == 3 + assert data["packets_7d"] == 2 # now + 3d (10d excluded) + class TestDashboardHtmlRemoved: """Tests that legacy HTML dashboard endpoint has been removed.""" @@ -269,6 +285,101 @@ class TestDashboardActivity: assert yesterday_point["count"] >= 1 +class TestPacketActivity: + """Tests for GET /dashboard/packet-activity endpoint.""" + + def test_get_packet_activity_empty(self, client_no_auth): + """Test getting packet activity with empty database.""" + response = client_no_auth.get("/api/v1/dashboard/packet-activity") + assert response.status_code == 200 + data = response.json() + assert data["days"] == 30 + assert len(data["data"]) == 30 + for point in data["data"]: + assert point["count"] == 0 + assert "date" in point + + def test_get_packet_activity_custom_days(self, client_no_auth): + """Test getting packet activity with custom days parameter.""" + response = client_no_auth.get("/api/v1/dashboard/packet-activity?days=7") + assert response.status_code == 200 + data = response.json() + assert data["days"] == 7 + assert len(data["data"]) == 7 + + def test_get_packet_activity_max_days(self, client_no_auth): + """Test that packet activity is capped at 90 days.""" + response = client_no_auth.get("/api/v1/dashboard/packet-activity?days=365") + assert response.status_code == 200 + data = response.json() + assert data["days"] == 90 + assert len(data["data"]) == 90 + + def test_get_packet_activity_with_data(self, client_no_auth, api_db_session): + """Test getting packet activity with packets across two days. + + Note: Activity endpoints exclude today's data to avoid showing + incomplete stats early in the day. + """ + now = datetime.now(timezone.utc) + yesterday = now - timedelta(days=1) + two_days_ago = now - timedelta(days=2) + + observer = Node( + public_key="a" * 64, + name="Observer", + adv_type="REPEATER", + ) + api_db_session.add(observer) + api_db_session.flush() + + api_db_session.add_all( + [ + RawPacket( + observer_node_id=observer.id, + event_type="message", + received_at=yesterday, + ), + RawPacket( + observer_node_id=observer.id, + event_type="message", + received_at=yesterday, + ), + RawPacket( + observer_node_id=observer.id, + event_type="message", + received_at=two_days_ago, + ), + ] + ) + api_db_session.commit() + + response = client_no_auth.get("/api/v1/dashboard/packet-activity?days=7") + assert response.status_code == 200 + data = response.json() + assert data["days"] == 7 + assert len(data["data"]) == 7 + + yesterday_str = yesterday.strftime("%Y-%m-%d") + two_days_ago_str = two_days_ago.strftime("%Y-%m-%d") + + yesterday_point = next(p for p in data["data"] if p["date"] == yesterday_str) + assert yesterday_point["count"] == 2 + + two_days_ago_point = next( + p for p in data["data"] if p["date"] == two_days_ago_str + ) + assert two_days_ago_point["count"] == 1 + + # All other days in the 7-day window should be zero-filled. + other_days = [ + p + for p in data["data"] + if p["date"] not in (yesterday_str, two_days_ago_str) + ] + assert all(p["count"] == 0 for p in other_days) + + class TestMessageActivity: """Tests for GET /dashboard/message-activity endpoint.""" @@ -571,6 +682,9 @@ class TestDashboardFloodOnlyFilter: data = response.json() assert len(data["recent_advertisements"]) == 1 assert data["recent_advertisements"][0]["name"] == "Flood" + assert data["recent_advertisements"][0]["route_type"] == "flood" + assert data["recent_advertisements"][0]["observers"] == [] + assert data["recent_advertisements"][0]["observed_by"] is None def test_activity_excludes_direct(self, client_no_auth, api_db_session): """Activity endpoint excludes direct advertisements.""" @@ -768,6 +882,89 @@ class TestDashboardChannelVisibility: data = response.json() assert len(data["recent_advertisements"]) == 1 assert data["recent_advertisements"][0]["tag_name"] == "TagName" + assert data["recent_advertisements"][0]["route_type"] == "flood" + assert data["recent_advertisements"][0]["observers"] == [] + assert data["recent_advertisements"][0]["observed_by"] is None + + def test_recent_advertisements_includes_observers( + self, client_no_auth, api_db_session + ): + """Recent advertisements include observer list via event_observers.""" + from hashlib import md5 + + now = datetime.now(timezone.utc) + observer_node = Node( + public_key="cc" * 16, + name="ObserverStation", + first_seen=now, + last_seen=now, + ) + api_db_session.add(observer_node) + api_db_session.commit() + + event_hash = md5(b"dashboard-ad-observers").hexdigest() + ad = Advertisement( + public_key="dd" * 16, + name="HeardAd", + adv_type="REPEATER", + received_at=now, + route_type="flood", + event_hash=event_hash, + observer_node_id=observer_node.id, + ) + api_db_session.add(ad) + + observer = EventObserver( + event_type="advertisement", + event_hash=event_hash, + observer_node_id=observer_node.id, + observed_at=now, + ) + api_db_session.add(observer) + api_db_session.commit() + + response = client_no_auth.get("/api/v1/dashboard/stats") + assert response.status_code == 200 + data = response.json() + assert len(data["recent_advertisements"]) == 1 + item = data["recent_advertisements"][0] + assert item["route_type"] == "flood" + assert len(item["observers"]) == 1 + assert item["observers"][0]["public_key"] == observer_node.public_key + + def test_recent_advertisements_includes_observed_by( + self, client_no_auth, api_db_session + ): + """Recent advertisements resolve observed_by from observer_node_id.""" + now = datetime.now(timezone.utc) + observer_node = Node( + public_key="ee" * 16, + name="InterfaceNode", + first_seen=now, + last_seen=now, + ) + api_db_session.add(observer_node) + api_db_session.commit() + + ad = Advertisement( + public_key="ff" * 16, + name="LegacyAd", + adv_type="CLIENT", + received_at=now, + route_type="flood", + observer_node_id=observer_node.id, + ) + api_db_session.add(ad) + api_db_session.commit() + + response = client_no_auth.get("/api/v1/dashboard/stats") + assert response.status_code == 200 + data = response.json() + assert len(data["recent_advertisements"]) == 1 + item = data["recent_advertisements"][0] + assert item["route_type"] == "flood" + assert item["observers"] == [] + assert item["observed_by"] == observer_node.public_key def test_operator_sees_community_and_member_channel_counts( self, client_no_auth, api_db_session @@ -880,6 +1077,26 @@ class TestDashboardDateBucketRegression: seeded_point = next(p for p in data["data"] if p["date"] == seeded_str) assert seeded_point["count"] == 1 + def test_packet_activity_nonzero_on_seeded_day( + self, client_no_auth, api_db_session + ): + """Packet activity chart shows non-zero count for the seeded day.""" + two_days_ago = (datetime.now(timezone.utc) - timedelta(days=2)).replace( + hour=12, minute=0, second=0, microsecond=0 + ) + api_db_session.add( + RawPacket( + event_type="message", + received_at=two_days_ago, + ) + ) + api_db_session.commit() + + data = client_no_auth.get("/api/v1/dashboard/packet-activity").json() + seeded_str = two_days_ago.strftime("%Y-%m-%d") + seeded_point = next(p for p in data["data"] if p["date"] == seeded_str) + assert seeded_point["count"] == 1 + def test_node_count_steps_up_on_seeded_day(self, client_no_auth, api_db_session): """Node count chart steps up on the seeded creation day.""" two_days_ago = (datetime.now(timezone.utc) - timedelta(days=2)).replace(