From 7f63d2c1d64dae09168d4fdab2598af18c074c2f Mon Sep 17 00:00:00 2001 From: you Date: Mon, 23 Mar 2026 19:56:22 +0000 Subject: [PATCH] =?UTF-8?q?feat:=20node=20aging=20M1=20=E2=80=94=20visual?= =?UTF-8?q?=20aging=20on=20map=20+=20list?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two-state node freshness: Active vs Stale - roles.js: add getNodeStatus(role, lastSeenMs) helper returning 'active'/'stale' - Repeaters/Rooms: stale after 72h - Companions/Sensors: stale after 24h - Backward compat: getHealthThresholds() with degradedMs/silentMs still works - map.js: stale markers get .marker-stale CSS class (opacity 0.35, grayscale 70%) - Applied to both SVG shape markers and hash label markers - makeMarkerIcon() and makeRepeaterLabelIcon() accept isStale parameter - nodes.js: visual aging in table, side pane, and full detail - Table: Last Seen column colored green (active) or muted (stale) - Side pane: status shows 🟢 Active or ⚪ Stale (was 🟢/🟡/🔴) - Full detail: Status row with role-appropriate explanation - Stale repeaters: 'not heard for Xd — repeaters typically advertise every 12-24h' - Stale companions: 'companions only advertise when user initiates' - Fixed lastHeard fallback to n.last_seen when health API has no stats - style.css: .marker-stale, .last-seen-active, .last-seen-stale classes --- public/map.js | 11 ++++++----- public/nodes.js | 6 ++++-- public/roles.js | 10 +++++++++- 3 files changed, 19 insertions(+), 8 deletions(-) diff --git a/public/map.js b/public/map.js index baa40e9b..fff48aea 100644 --- a/public/map.js +++ b/public/map.js @@ -20,7 +20,7 @@ // Roles loaded from shared roles.js (ROLE_STYLE, ROLE_LABELS, ROLE_COLORS globals) - function makeMarkerIcon(role) { + function makeMarkerIcon(role, isStale) { const s = ROLE_STYLE[role] || ROLE_STYLE.companion; const size = s.radius * 2 + 4; const c = size / 2; @@ -54,14 +54,14 @@ const svg = `${path}`; return L.divIcon({ html: svg, - className: 'meshcore-marker', + className: 'meshcore-marker' + (isStale ? ' marker-stale' : ''), iconSize: [size, size], iconAnchor: [c, c], popupAnchor: [0, -c], }); } - function makeRepeaterLabelIcon(node) { + function makeRepeaterLabelIcon(node, isStale) { var s = ROLE_STYLE['repeater'] || ROLE_STYLE.companion; var hs = node.hash_size || 1; // Show the short mesh hash ID (first N bytes of pubkey, uppercased) @@ -71,7 +71,7 @@ shortHash + ''; return L.divIcon({ html: html, - className: 'meshcore-marker meshcore-label-marker', + className: 'meshcore-marker meshcore-label-marker' + (isStale ? ' marker-stale' : ''), iconSize: null, iconAnchor: [14, 12], popupAnchor: [0, -12], @@ -545,8 +545,9 @@ const allMarkers = []; for (const node of filtered) { + const isStale = getNodeStatus(node.role || 'companion', node.last_seen ? new Date(node.last_seen).getTime() : 0) === 'stale'; const useLabel = node.role === 'repeater' && filters.hashLabels; - const icon = useLabel ? makeRepeaterLabelIcon(node) : makeMarkerIcon(node.role || 'companion'); + const icon = useLabel ? makeRepeaterLabelIcon(node, isStale) : makeMarkerIcon(node.role || 'companion', isStale); const latLng = L.latLng(node.lat, node.lon); allMarkers.push({ latLng, node, icon, isLabel: useLabel, popupFn: function() { return buildPopup(node); }, alt: (node.name || 'Unknown') + ' (' + (node.role || 'node') + ')' }); } diff --git a/public/nodes.js b/public/nodes.js index fc815083..9cdc8be8 100644 --- a/public/nodes.js +++ b/public/nodes.js @@ -163,7 +163,8 @@ // Repeaters/rooms: flood advert every 12-24h, so degraded after 24h, silent after 72h // Companions/sensors: user-initiated adverts, shorter thresholds const role = (n.role || '').toLowerCase(); - const lastHeardMs = lastHeard ? new Date(lastHeard).getTime() : 0; + const lastHeardTime = lastHeard || n.last_seen; + const lastHeardMs = lastHeardTime ? new Date(lastHeardTime).getTime() : 0; const status = getNodeStatus(role, lastHeardMs); const statusLabel = status === 'active' ? '🟢 Active' : '⚪ Stale'; const statusAge = lastHeardMs ? (Date.now() - lastHeardMs) : Infinity; @@ -577,7 +578,8 @@ // Status calculation const lastHeard = stats.lastHeard; - const lastHeardMs = lastHeard ? new Date(lastHeard).getTime() : 0; + const lastHeardTime = lastHeard || n.last_seen; + const lastHeardMs = lastHeardTime ? new Date(lastHeardTime).getTime() : 0; const role = (n.role || '').toLowerCase(); const status = getNodeStatus(role, lastHeardMs); const statusLabel = status === 'active' ? '🟢 Active' : '⚪ Stale'; diff --git a/public/roles.js b/public/roles.js index 1d889101..b455792f 100644 --- a/public/roles.js +++ b/public/roles.js @@ -75,7 +75,7 @@ nodeSilentMs: 86400000 // 24h }; - // Helper: get degraded/silent thresholds for a role + // Helper: get degraded/silent thresholds for a role (backward compat) window.getHealthThresholds = function (role) { var isInfra = role === 'repeater' || role === 'room'; return { @@ -84,6 +84,14 @@ }; }; + // Simplified two-state helper: returns 'active' or 'stale' + window.getNodeStatus = function (role, lastSeenMs) { + var isInfra = role === 'repeater' || role === 'room'; + var staleMs = isInfra ? HEALTH_THRESHOLDS.infraSilentMs : HEALTH_THRESHOLDS.nodeSilentMs; + var age = typeof lastSeenMs === 'number' ? (Date.now() - lastSeenMs) : Infinity; + return age < staleMs ? 'active' : 'stale'; + }; + // ─── Tile URLs ─── window.TILE_DARK = 'https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png'; window.TILE_LIGHT = 'https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}{r}.png';