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.
16 KiB
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
routeTypeBadgehelper 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
/statsendpoint already returns 10; the frontendads.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 hasobservers.length >= 1, a faded satellite emoji when only the legacyobserved_byfield 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/statsresponse includesroute_type,observers, andobserved_byon each item inrecent_advertisements.
Technical Requirements
- TR-1 — The
RecentAdvertisementschema gains three fields:route_type: Optional[str],observers: list[ObserverInfo](default empty list), andobserved_by: Optional[str]. TheObserverInfoschema is reused as-is (already defined in the same module). - TR-2 — The
get_statsdashboard route collectsevent_hashvalues from the fetched recent adverts and callsfetch_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
routeTypeBadgefunction is moved from a local definition inadvertisements.jsto an exported function incomponents.js, so both the dashboard and the advertisements page share a single implementation.advertisements.jsupdates its import accordingly. - TR-4 — The frontend
renderRecentAdsfunction importsobserverIconsandrouteTypeBadgefromcomponents.jsand uses them in the new column layout. ThetypeEmojiimport (used only for the old emoji column) is removed fromdashboard.jsif it becomes unused after the change. - TR-5 — Observer fallback logic matches the Advertisement list page: if
ad.observersis a non-empty array, renderobserverIcons(ad.observers); otherwise ifad.observed_byis 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
routeTypeBadgefunction 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 afterreceived_at: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" )ObserverInfois 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_eventsto the import fromobserver_utils(line 17 already importsresolve_sender_namesfrom that module). - After the recent-adverts query (line 164–174) and before building the
RecentAdvertisementlist (line 202), collect event hashes and fetch observers: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_keyfor theobserved_byfallback. Collect observer node IDs from the ads and look up their public keys: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: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,
- Add
Phase 2: Frontend — shared component extraction
-
src/meshcore_hub/web/static/js/spa/components.js— Add an exportedrouteTypeBadge(routeType)function with the same logic currently inadvertisements.js:12-23:export function routeTypeBadge(routeType) { if (!routeType) return nothing; if (routeType === 'flood' || routeType === 'transport_flood') { return html`<span class="badge badge-sm badge-info">${routeType === 'flood' ? 'Flood' : 'Relay'}</span>`; } if (routeType === 'direct' || routeType === 'transport_direct') { return html`<span class="badge badge-sm badge-success">${routeType === 'direct' ? 'Zero-hop' : 'Direct relay'}</span>`; } return nothing; }Place it near the other badge/icon helpers (e.g. after
observerIconsat line 559). Ensurehtmlandnothingare already imported (they are). -
src/meshcore_hub/web/static/js/spa/pages/advertisements.js— Remove the localrouteTypeBadgefunction (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
observerIconsandrouteTypeBadgeto the destructured import from../components.js. RemovetypeEmojifrom the import if it is no longer used elsewhere in the file (verify:typeEmojiis only used at line 51 inrenderRecentAds, 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
<thead>and row template:function renderRecentAds(ads) { if (!ads || ads.length === 0) { return html`<p class="text-sm opacity-70">${t('common.no_entity_yet', { entity: t('entities.advertisements').toLowerCase() })}</p>`; } 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`<div class="text-xs opacity-50 font-mono">${ad.public_key.slice(0, 12)}...</div>` : nothing; let observersBlock; if (ad.observers && ad.observers.length >= 1) { observersBlock = html`${observerIcons(ad.observers)}`; } else if (ad.observed_by) { observersBlock = html`<span class="opacity-50">\u{1F4E1}</span>`; } else { observersBlock = html`<span class="opacity-50">-</span>`; } return html`<tr> <td> <a href="/nodes/${ad.public_key}" class="link link-hover"> <div class="font-medium">${displayName}</div> </a> ${keyLine} </td> <td>${routeTypeBadge(ad.route_type)}</td> <td class="text-right text-sm opacity-70">${formatTimeOnly(ad.received_at)}</td> <td>${observersBlock}</td> </tr>`; }); return html`<div class="overflow-x-auto"> <table class="table table-sm w-full"> <thead> <tr> <th>${t('entities.node')}</th> <th>${t('common.type')}</th> <th class="text-right">${t('common.received')}</th> <th>${t('common.observers')}</th> </tr> </thead> <tbody>${rows}</tbody> </table> </div>`; }
- Imports (lines 2–6): add
Phase 4: Tests & verification
tests/test_api/test_dashboard.py:- Existing test
test_recent_ads_excludes_direct(line 659): the flood ad already hasroute_type="flood". Add assertions: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 hasroute_type="flood"but noevent_hash. Assert: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 observerNode, anAdvertisementwith anevent_hash, and anEventObserverrow linking them. Assert thatrecent_advertisements[0]hasobserverswith length 1 and the observer'spublic_keymatches. Mirror the fixture style oftest_api/test_advertisements.py:40-66. - New test
test_recent_advertisements_includes_observed_by: Create an observerNode(public keyOBSERVERKEY...), setadvertisement.observer_node_id = observer_node.id, noevent_hash/ noEventObserverrow. Assert thatdata["recent_advertisements"][0]hasobservers == []andobserved_by == "OBSERVERKEY...", covering the satellite-emoji fallback path.
- Existing test
- Run
pytest --no-cov tests/test_api/test_dashboard.py. - Run
pre-commit run --all-files. make buildthen visually verify the/dashboardRecent 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_byonRecentAdvertisement: Add the field. The backend resolvesadvertisement.observer_node_id → Node.public_keyfor each recent ad (batched query) and populatesobserved_byon the schema. The frontend uses the full three-way fallback:ad.observersarray →ad.observed_by(satellite emoji) → dash. A new testtest_recent_advertisements_includes_observed_bycovers 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 theobserverIconshelper and observer badge rendering pattern used here.docs/plans/20260515-1900-recent-adverts-timestamps/plan.md— prior work on the recent-adverts data path (addedpublic_keyfilter 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.