diff --git a/public/analytics.js b/public/analytics.js
index 1130fb5c..7650d380 100644
--- a/public/analytics.js
+++ b/public/analytics.js
@@ -120,6 +120,7 @@
+
@@ -138,7 +139,7 @@
`;
// Tabs where the area filter is meaningful (transmitter GPS attribution)
- const AREA_FILTER_TABS = new Set(['overview', 'rf', 'topology', 'hashsizes', 'collisions', 'nodes', 'clock-health']);
+ const AREA_FILTER_TABS = new Set(['overview', 'rf', 'topology', 'hashsizes', 'collisions', 'nodes', 'my-repeaters', 'clock-health']);
function setAreaFilterVisibility(tab) {
const el = document.getElementById('analyticsAreaFilter');
@@ -284,6 +285,7 @@
case 'collisions': await renderCollisionTab(el, d.hashData, d.collisionData); break;
case 'subpaths': await renderSubpaths(el); break;
case 'nodes': await renderNodesTab(el); break;
+ case 'my-repeaters': await renderMyRepeatersTab(el); break;
case 'distance': await renderDistanceTab(el); break;
case 'neighbor-graph': await renderNeighborGraphTab(el); break;
case 'rf-health': await renderRFHealthTab(el); break;
@@ -2376,6 +2378,222 @@
}
}
+ // ===================== MY REPEATERS (favorites monitoring dashboard) =====================
+ // An at-a-glance monitor over the repeaters the operator has starred
+ // (meshcore-favorites). Pure frontend aggregation over existing endpoints:
+ // /api/nodes relay activity + traffic/bridge scores
+ // /api/nodes/clock-skew per-node "is the clock OK" severity
+ // /api/analytics/neighbor-graph affinity edges, filtered to favorites
+ // ("throughput between my repeaters")
+ function _mrSummaryCard(value, label, color) {
+ return `
+
${esc(String(value))}
+
${esc(label)}
+
`;
+ }
+
+ // Status is carried by colour AND text — never colour alone — so it is
+ // legible to color-blind users and screen readers.
+ const MR_STATUS_META = {
+ active: { color: 'var(--status-green)', label: 'Active' },
+ degraded: { color: 'var(--status-yellow)', label: 'Degraded' },
+ silent: { color: 'var(--status-red)', label: 'Silent' },
+ unknown: { color: 'var(--text-muted)', label: 'Unknown' },
+ };
+ function _mrStatusCell(status) {
+ // Fall back to an explicit "Unknown" rather than silently mislabelling an
+ // unexpected status as "Silent".
+ const m = MR_STATUS_META[status] || MR_STATUS_META.unknown;
+ return `${m.label}`;
+ }
+
+ function _mrEmptyState() {
+ return `
+
+
+
No favorite repeaters yet
+
Star a repeater (the on the Nodes page or a node's detail) to add it to your watch-list. Starred repeaters show up here for at-a-glance monitoring.
';
+ try {
+ const rq = RegionFilter.regionQueryString() + AreaFilter.areaQueryString();
+ // When a region/area filter is active the node fetch is scoped, so a
+ // starred repeater outside that scope won't come back. Surface that count
+ // instead of silently dropping it from the watch-list (#1761 MAJOR).
+ const filterActive = rq.length > 0;
+ // Fetch the three sources ONCE; star toggles re-paint from this cache
+ // (paint() below) instead of re-hitting the network. Trade-off: this
+ // pages the full node list (up to ~10k) to keep only the starred few —
+ // fine for a handful of favorites and amortized by CLIENT_TTL.nodeList
+ // across tabs. If favorite counts ever grow large, switch to per-node
+ // Promise.all(favs.map(pk => api('/nodes/' + pk))).
+ const [nodesResp, clockData, graphData] = await Promise.all([
+ fetchAllNodes('&sortBy=lastSeen' + rq, { ttl: CLIENT_TTL.nodeList }),
+ api('/nodes/clock-skew', { ttl: CLIENT_TTL.analyticsRF }).catch(() => []),
+ api('/analytics/neighbor-graph?min_count=1&min_score=0', { ttl: CLIENT_TTL.analyticsRF }).catch(() => ({ nodes: [], edges: [] })),
+ ]);
+ const allNodes = nodesResp.nodes || nodesResp;
+ const clockByPk = {};
+ (Array.isArray(clockData) ? clockData : []).forEach(c => { if (c && c.pubkey) clockByPk[c.pubkey] = c; });
+
+ const now = Date.now();
+ function ageOf(n) {
+ const ms = n.last_heard ? new Date(n.last_heard).getTime() : n.last_seen ? new Date(n.last_seen).getTime() : 0;
+ return ms ? now - ms : Infinity;
+ }
+ function statusOf(n) {
+ const th = (typeof getHealthThresholds === 'function') ? getHealthThresholds(n.role) : { degradedMs: 3600000, silentMs: 86400000 };
+ const age = ageOf(n);
+ return age < th.degradedMs ? 'active' : age < th.silentMs ? 'degraded' : 'silent';
+ }
+ const pct = v => (v != null ? (v * 100).toFixed(1) + '%' : '—');
+ // #1456: prefer traffic_share_score, fall back to usefulness_score.
+ const trafficOf = n => (n.traffic_share_score != null ? n.traffic_share_score : (n.usefulness_score != null ? n.usefulness_score : null));
+ function roleBadge(role) {
+ // Route the unknown-role fallback through ROLE_COLORS.unknown (backed by
+ // --mc-role-unknown / the shared Wong palette) instead of a hard-coded
+ // literal, so every swatch color flows through the CSS-variable system
+ // (#1761 nit). ROLE_COLORS always resolves .unknown.
+ const rc = window.ROLE_COLORS || {};
+ const c = rc[role] || rc.unknown || 'var(--role-unknown, #6b7280)';
+ const style = (window.aaBadgeStyle && window.aaBadgeStyle(c)) || ('background:' + c + ';color:#fff');
+ return `${esc(role)}`;
+ }
+
+ // Re-renders from the cached fetch using the LIVE favorites set, so an
+ // un-star drops the row with no refetch. Re-binds its stars after each
+ // paint (the innerHTML reset clears listeners).
+ function paint() {
+ const favs = new Set(haveFavs());
+ if (!favs.size) { el.innerHTML = _mrEmptyState(); return; }
+ const myNodes = allNodes.filter(n => favs.has(n.public_key) && (n.role === 'repeater' || n.role === 'room'));
+ const myKeys = new Set(myNodes.map(n => n.public_key));
+
+ // Favorites the scoped fetch didn't return — with a region/area filter
+ // active these are (most likely) outside the current scope. Surface the
+ // count so a hidden favorite isn't silently missing (#1761 MAJOR).
+ const allByPk = new Set(allNodes.map(n => n.public_key));
+ // Favorites absent from the scoped fetch: usually outside the active
+ // region/area filter, but the set also covers deleted/expired stars
+ // and role-filtered clients — so the notice stays causally neutral
+ // ("not shown with the active filter") rather than claiming a cause.
+ const hiddenByFilter = [...favs].filter(pk => !allByPk.has(pk)).length;
+ const filterNotice = (filterActive && hiddenByFilter > 0)
+ ? `
${hiddenByFilter} favorite${hiddenByFilter === 1 ? '' : 's'} not shown with the active region/area filter.
No affinity links detected between your repeaters yet — they may be too far apart, or not enough shared traffic has been observed.
`;
+ } else {
+ affinityHtml = `
+
Link
Affinity
Packets
Avg SNR
+ ${myEdges.map(e => {
+ // The affinity graph is undirected — the server marks every edge
+ // bidirectional — so the link glyph is always a double arrow.
+ const arrow = '↔';
+ const snr = (e.avg_snr != null) ? e.avg_snr.toFixed(1) + ' dB' : '—';
+ return `