/* === CoreScope — app.js === */ 'use strict'; // --- Route/Payload name maps --- const ROUTE_TYPES = { 0: 'TRANSPORT_FLOOD', 1: 'FLOOD', 2: 'DIRECT', 3: 'TRANSPORT_DIRECT' }; const PAYLOAD_TYPES = { 0: 'Request', 1: 'Response', 2: 'Direct Msg', 3: 'ACK', 4: 'Advert', 5: 'Channel Msg', 6: 'Group Data', 7: 'Anon Req', 8: 'Path', 9: 'Trace', 10: 'Multipart', 11: 'Control', 15: 'Raw Custom' }; const PAYLOAD_COLORS = { 0: 'req', 1: 'response', 2: 'txt-msg', 3: 'ack', 4: 'advert', 5: 'grp-txt', 6: 'grp-data', 7: 'anon-req', 8: 'path', 9: 'trace', 10: 'multipart', 11: 'control', 15: 'raw-custom' }; function routeTypeName(n) { return ROUTE_TYPES[n] || 'UNKNOWN'; } function payloadTypeName(n) { return PAYLOAD_TYPES[n] || 'UNKNOWN'; } function payloadTypeColor(n) { return PAYLOAD_COLORS[n] || 'unknown'; } function isTransportRoute(rt) { return rt === 0 || rt === 3; } /** Byte offset of path_len in raw_hex: 5 for transport routes (4 bytes of next/last hop codes precede it), 1 otherwise. */ function getPathLenOffset(routeType) { return isTransportRoute(routeType) ? 5 : 1; } /** * scopeName is optional (callers that don't pass it get the original * unscoped "T" badge). Pass a packet's scope_name to also surface the * region-scope state directly in the badge label (not just on hover): a * non-empty string is appended to the label as "T·#region", an empty * string (transport-eligible but no region matched, or an HMAC collision * made the match ambiguous) renders as "T?" with a distinct muted badge * style so it's visually distinguishable from a resolved scope without * relying on the tooltip or on color alone. The full name always stays * in the title too, for the (rare) case a long region name gets * ellipsis-truncated by the badge's CSS max-width. */ function transportBadge(rt, scopeName) { if (!isTransportRoute(rt)) return ''; var title = routeTypeName(rt); var cls = 'badge-transport'; var label = 'T'; if (scopeName !== undefined) { if (scopeName) { title += ' · Scope: ' + scopeName; label = 'T·' + scopeName; } else { title += ' · Scope: unknown'; cls += ' badge-transport-unknown'; label = 'T?'; } } return ' ' + escapeHtml(label) + ''; } /** * Compute breakdown byte ranges from raw_hex on the client. * Mirrors cmd/server/decoder.go BuildBreakdown(). Used so per-observation raw_hex * (which can differ in path length from the top-level packet) gets accurate * highlighted byte ranges, instead of using the server-supplied breakdown * computed once from the top-level raw_hex. */ function computeBreakdownRanges(hexString, routeType, payloadType) { if (!hexString) return []; const clean = hexString.replace(/\s+/g, ''); const bytes = clean.length / 2; if (bytes < 2) return []; const ranges = []; // Header ranges.push({ start: 0, end: 0, label: 'Header' }); let offset = 1; if (isTransportRoute(routeType)) { if (bytes < offset + 4) return ranges; ranges.push({ start: offset, end: offset + 3, label: 'Transport Codes' }); offset += 4; } if (offset >= bytes) return ranges; // Path Length byte ranges.push({ start: offset, end: offset, label: 'Path Length' }); const pathByte = parseInt(clean.slice(offset * 2, offset * 2 + 2), 16); offset += 1; if (isNaN(pathByte)) return ranges; const hashSize = (pathByte >> 6) + 1; const hashCount = pathByte & 0x3F; const pathBytes = hashSize * hashCount; if (hashCount > 0 && offset + pathBytes <= bytes) { ranges.push({ start: offset, end: offset + pathBytes - 1, label: 'Path' }); } offset += pathBytes; if (offset >= bytes) return ranges; const payloadStart = offset; // ADVERT (payload_type 4) gets sub-fields when full record present if (payloadType === 4 && bytes - payloadStart >= 100) { ranges.push({ start: payloadStart, end: payloadStart + 31, label: 'PubKey' }); ranges.push({ start: payloadStart + 32, end: payloadStart + 35, label: 'Timestamp' }); ranges.push({ start: payloadStart + 36, end: payloadStart + 99, label: 'Signature' }); const appStart = payloadStart + 100; if (appStart < bytes) { ranges.push({ start: appStart, end: appStart, label: 'Flags' }); const appFlags = parseInt(clean.slice(appStart * 2, appStart * 2 + 2), 16); let fOff = appStart + 1; if (!isNaN(appFlags)) { if ((appFlags & 0x10) && fOff + 8 <= bytes) { ranges.push({ start: fOff, end: fOff + 3, label: 'Latitude' }); ranges.push({ start: fOff + 4, end: fOff + 7, label: 'Longitude' }); fOff += 8; } if ((appFlags & 0x20) && fOff + 2 <= bytes) fOff += 2; if ((appFlags & 0x40) && fOff + 2 <= bytes) fOff += 2; if ((appFlags & 0x80) && fOff < bytes) { ranges.push({ start: fOff, end: bytes - 1, label: 'Name' }); } } } } else { ranges.push({ start: payloadStart, end: bytes - 1, label: 'Payload' }); } return ranges; } // --- Utilities --- const _apiPerf = { calls: 0, totalMs: 0, log: [], cacheHits: 0 }; const _apiCache = new Map(); const _inflight = new Map(); // Client-side TTLs (ms) — loaded from server config, with defaults const CLIENT_TTL = { stats: 10000, nodeDetail: 240000, nodeHealth: 240000, nodeList: 90000, bulkHealth: 300000, networkStatus: 300000, observers: 120000, channels: 15000, channelMessages: 10000, analyticsRF: 300000, analyticsTopology: 300000, analyticsChannels: 300000, analyticsHashSizes: 300000, analyticsSubpaths: 300000, analyticsSubpathDetail: 300000, nodeAnalytics: 60000, nodeSearch: 10000 }; // Fetch server cache config and use as client TTLs (server values are in seconds) fetch('/api/config/cache').then(r => r.json()).then(cfg => { for (const [k, v] of Object.entries(cfg)) { if (k in CLIENT_TTL && typeof v === 'number') CLIENT_TTL[k] = v * 1000; } }).catch(() => {}); async function api(path, { ttl = 0, bust = false } = {}) { const t0 = performance.now(); if (!bust && ttl > 0) { const cached = _apiCache.get(path); if (cached && Date.now() < cached.expires) { _apiPerf.calls++; _apiPerf.cacheHits++; _apiPerf.log.push({ path, ms: 0, time: Date.now(), cached: true }); if (_apiPerf.log.length > 200) _apiPerf.log.shift(); return cached.data; } } // Deduplicate in-flight requests if (_inflight.has(path)) return _inflight.get(path); const promise = (async () => { // Issue #1659: 503 with Retry-After indicates server-side warm-up // (analytics recomputer first-pass, index build, etc.). Retry with // exponential backoff capped at 30s, up to 6 attempts (~63s total), // so the analytics cards never display the stale post-restart slice. // // PR #1688 r1 (adv #1 + munger #4): use a single `notified` flag // per outer call + `try / finally` so the in-flight banner counter // is decremented exactly once regardless of retry count or // exception path (success, retry-exhausted throw, network throw). // Previously, multi-attempt retries leaked the counter (incremented // per attempt, decremented at most once) and exhausted-retries // threw without decrementing at all — banner stuck across three // analytics endpoints, multiplied. let attempt = 0; let delay = 1000; const maxAttempts = 6; let notified = false; try { while (true) { const res = await fetch('/api' + path); if (res.status === 503 && attempt < maxAttempts) { const ra = parseInt(res.headers.get('Retry-After'), 10); const wait = isFinite(ra) && ra > 0 ? ra * 1000 : delay; if (!notified) { _warmupNotify_1659(true); notified = true; } await new Promise(r => setTimeout(r, Math.min(wait, 30000))); delay = Math.min(delay * 2, 30000); attempt++; continue; } if (!res.ok) throw new Error(`API ${res.status}: ${path}`); const data = await res.json(); const ms = performance.now() - t0; _apiPerf.calls++; _apiPerf.totalMs += ms; _apiPerf.log.push({ path, ms: Math.round(ms), time: Date.now() }); if (_apiPerf.log.length > 200) _apiPerf.log.shift(); if (ms > 500) console.warn(`[SLOW API] ${path} took ${Math.round(ms)}ms`); if (ttl > 0) _apiCache.set(path, { data, expires: Date.now() + ttl }); return data; } } finally { // Decrement exactly once iff we incremented. Runs on return, // throw, or retry-exhausted throw — counter is balanced. if (notified) _warmupNotify_1659(false); } })(); _inflight.set(path, promise); promise.finally(() => _inflight.delete(path)); return promise; } // Issue #1659: minimal "Computing…" indicator while an analytics // endpoint is serving 503-warmup. We expose a small fixed banner; if a // page later wants to wire its own indicator it can override // window.onWarmup_1659 to receive the boolean state. let _warmupBannerEl_1659 = null; let _warmupInflight_1659 = 0; function _warmupNotify_1659(active) { if (active) _warmupInflight_1659++; else _warmupInflight_1659 = Math.max(0, _warmupInflight_1659 - 1); const visible = _warmupInflight_1659 > 0; if (typeof window !== 'undefined' && typeof window.onWarmup_1659 === 'function') { try { window.onWarmup_1659(visible); } catch (_) { /* ignore */ } } if (typeof document === 'undefined') return; if (!_warmupBannerEl_1659) { const el = document.createElement('div'); el.id = 'cs-warmup-banner-1659'; el.style.cssText = 'position:fixed;top:8px;right:8px;z-index:9999;padding:6px 10px;background:var(--bg-elev,#222);color:var(--fg,#eee);border:1px solid var(--border,#444);border-radius:4px;font-size:12px;font-family:sans-serif;display:none;'; el.textContent = 'Computing analytics…'; if (document.body) document.body.appendChild(el); _warmupBannerEl_1659 = el; } _warmupBannerEl_1659.style.display = visible ? 'block' : 'none'; } // Fetch the COMPLETE /api/nodes set, transparently paging around the server's // per-request row cap. /api/nodes clamps ?limit to `listLimits.nodesMax` // (default 2000, operator-configurable; originally a hard 500 in PR #1540, // raised/made configurable in PR #1589). A single ?limit=N fetch therefore // silently truncates to the top nodesMax rows by last_seen DESC, so on a mesh // with more nodes than that cap every node-list consumer (map, live, // analytics, packets, area-map) loses the older-advert tail — a node that // relays constantly but last self-advertised hours ago drops off the map even // though it is plainly alive. #1606 fixed this for the Nodes page; this helper // generalizes the same loop for all callers, using a fixed client page size // well under the server cap. // // extraQuery: query fragment appended after the paged limit/offset, each piece // already '&'-prefixed exactly as callers build it today // (e.g. '&lastHeard=30d&area=x', '&sortBy=lastSeen®ion=y'); pass '' for none. // safetyCap: hard ceiling on BOTH pages fetched and nodes returned — the result // is sliced to it (callers like live.js pass their render ceiling here). // Returns { nodes, counts, total }: counts is from the first page; total is the // real deduped/capped node count (NOT the server's per-query `total`). async function fetchAllNodes(extraQuery = '', { ttl = 0, pageSize = 500, safetyCap = 10000 } = {}) { const accumulated = []; let counts = {}; for (let offset = 0; offset < safetyCap; offset += pageSize) { const data = await api(`/nodes?limit=${pageSize}&offset=${offset}${extraQuery}`, { ttl }); const page = data && Array.isArray(data.nodes) ? data.nodes : (Array.isArray(data) ? data : []); accumulated.push.apply(accumulated, page); if (offset === 0) counts = (data && data.counts) || {}; // Canonical stop: a short page is the end. The server's `total` is a real // COUNT(*) for the query, but the handler overwrites it with the filtered // length under area/geo/blacklist filtering — so we never loop on it, nor // surface it; a short page is the reliable end-of-data signal. See #1606. if (page.length < pageSize) break; } // Dedup by public_key: the sort window (last_seen DESC by default) can shift // under concurrent ingest, repeating a row across a page boundary. Rows // missing a public_key get a unique synthetic key so they are NOT collapsed. const seen = new Map(); for (let i = 0; i < accumulated.length; i++) { const n = accumulated[i]; seen.set((n && n.public_key) || ('__nokey' + i), n); } // Enforce safetyCap as a real node-count ceiling (the page loop only bounds // it to the next pageSize multiple), so e.g. live.js's LIVE_MAP_MAX_NODES is // honored exactly rather than overshooting by up to pageSize-1. const nodes = Array.from(seen.values()).slice(0, safetyCap); return { nodes, counts, total: nodes.length }; } function invalidateApiCache(prefix) { for (const key of _apiCache.keys()) { if (key.startsWith(prefix || '')) _apiCache.delete(key); } } // Expose for console debugging: apiPerf() window.apiPerf = function() { const byPath = {}; _apiPerf.log.forEach(e => { if (!byPath[e.path]) byPath[e.path] = { count: 0, totalMs: 0, maxMs: 0 }; byPath[e.path].count++; byPath[e.path].totalMs += e.ms; if (e.ms > byPath[e.path].maxMs) byPath[e.path].maxMs = e.ms; }); const rows = Object.entries(byPath).map(([p, s]) => ({ path: p, count: s.count, avgMs: Math.round(s.totalMs / s.count), maxMs: s.maxMs, totalMs: Math.round(s.totalMs) })).sort((a, b) => b.totalMs - a.totalMs); console.table(rows); const hitRate = _apiPerf.calls ? Math.round(_apiPerf.cacheHits / _apiPerf.calls * 100) : 0; const misses = _apiPerf.calls - _apiPerf.cacheHits; console.log(`Cache: ${_apiPerf.cacheHits} hits / ${misses} misses (${hitRate}% hit rate)`); return { calls: _apiPerf.calls, avgMs: Math.round(_apiPerf.totalMs / (misses || 1)), cacheHits: _apiPerf.cacheHits, cacheMisses: misses, cacheHitRate: hitRate, endpoints: rows }; }; function timeAgo(iso) { if (!iso) return '—'; const ms = new Date(iso).getTime(); if (!isFinite(ms)) return '—'; const s = Math.floor((Date.now() - ms) / 1000); const abs = Math.abs(s); let value; let suffix; if (abs < 60) { value = abs; suffix = 's'; } else if (abs < 3600) { value = Math.floor(abs / 60); suffix = 'm'; } else if (abs < 86400) { value = Math.floor(abs / 3600); suffix = 'h'; } else { value = Math.floor(abs / 86400); suffix = 'd'; } if (s < 0) return 'in ' + value + suffix; return value + suffix + ' ago'; } function getHashParams() { return new URLSearchParams(location.hash.split('?')[1] || ''); } // parseViewportHash — issue #1709. Parses lat/lon/zoom viewport params from a // hash query string and returns {lat, lon, zoom} if BOTH lat and lon are valid // (and zoom, if present, is numeric), otherwise null. Partial lat-only or // lon-only inputs are intentionally rejected (the issue explicitly forbids // partial application of a center). When zoom is missing, defaults to 12. When // zoom is out of the [minZoom, maxZoom] range it is clamped to that range. // // `hashOrSearch` may be either a full `location.hash` (e.g. `#/live?lat=...`) // or a bare query string (e.g. `lat=...&lon=...`). Either is accepted. // // Bounds: lat ∈ [-90, 90], lon ∈ [-180, 180]; zoom defaults clamp to [1, 20] // when bounds not supplied (sensible Leaflet fallback when tile-provider // minZoom/maxZoom is unknown). function parseViewportHash(hashOrSearch, opts) { if (hashOrSearch == null) return null; var s = String(hashOrSearch); if (s === '') return null; // Strip leading '#...?' if present so callers can pass raw location.hash. var qIdx = s.indexOf('?'); if (qIdx >= 0) s = s.slice(qIdx + 1); // Also tolerate a leading '?' on a bare search string. if (s.charAt(0) === '?') s = s.slice(1); var params; try { params = new URLSearchParams(s); } catch (_) { return null; } var latStr = params.get('lat'); var lonStr = params.get('lon'); if (latStr == null || lonStr == null || latStr === '' || lonStr === '') return null; var lat = parseFloat(latStr); var lon = parseFloat(lonStr); if (!isFinite(lat) || !isFinite(lon)) return null; if (lat < -90 || lat > 90) return null; if (lon < -180 || lon > 180) return null; var minZ = (opts && typeof opts.minZoom === 'number') ? opts.minZoom : 1; var maxZ = (opts && typeof opts.maxZoom === 'number') ? opts.maxZoom : 20; var zoomStr = params.get('zoom'); var zoom; if (zoomStr == null || zoomStr === '') { zoom = (opts && typeof opts.defaultZoom === 'number') ? opts.defaultZoom : 12; } else { zoom = parseFloat(zoomStr); if (!isFinite(zoom)) return null; } if (zoom < minZ) zoom = minZ; if (zoom > maxZ) zoom = maxZ; return { lat: lat, lon: lon, zoom: zoom }; } if (typeof window !== 'undefined') { window.parseViewportHash = parseViewportHash; } // shouldEmbedRoute — issue #1369. Returns true when the SPA should render in // "embed" mode (chrome suppressed: no top-nav, no bottom-nav, no side drawer, // content full-bleed). Triggered by ?embed=1 in the hash query string. // // Allowlisted to /#/map and /#/channels — the two surfaces operators asked // for in the cross-domain embed scenario. Other pages have chrome assumptions // we are not committing to in embed mode (Tufte: ship narrow, expand later // only when there is a real ask). function shouldEmbedRoute(basePage, hashSearch) { if (basePage !== 'map' && basePage !== 'channels') return false; if (!hashSearch) return false; var params = new URLSearchParams(hashSearch); return params.get('embed') === '1'; } function getDistanceUnit() { var stored = localStorage.getItem('meshcore-distance-unit'); if (stored === 'km') return 'km'; if (stored === 'mi') return 'mi'; // 'auto' or no value — locale detection var milesLocales = ['en-us', 'en-gb']; var lang = (typeof navigator !== 'undefined' && navigator.language || '').toLowerCase(); for (var i = 0; i < milesLocales.length; i++) { if (lang === milesLocales[i] || lang.startsWith(milesLocales[i] + '-')) return 'mi'; } return 'km'; } window.getDistanceUnit = getDistanceUnit; function formatDistance(km) { if (km == null || isNaN(+km)) return '—'; var d = +km; var unit = getDistanceUnit(); if (unit === 'mi') { var mi = d / 1.60934; if (mi < 0.1) return Math.round(mi * 5280) + ' ft'; return mi.toFixed(1) + ' mi'; } if (d < 1) return Math.round(d * 1000) + ' m'; return d.toFixed(1) + ' km'; } window.formatDistance = formatDistance; function formatDistanceRound(km) { if (km == null || isNaN(+km)) return '—'; var unit = getDistanceUnit(); if (unit === 'mi') return Math.round(+km / 1.60934) + ' mi'; return Math.round(+km) + ' km'; } window.formatDistanceRound = formatDistanceRound; function getTimestampMode() { const saved = localStorage.getItem('meshcore-timestamp-mode'); if (saved === 'ago' || saved === 'absolute') return saved; const serverDefault = window.SITE_CONFIG?.timestamps?.defaultMode; return serverDefault === 'absolute' ? 'absolute' : 'ago'; } function getTimestampTimezone() { const saved = localStorage.getItem('meshcore-timestamp-timezone'); if (saved === 'utc' || saved === 'local') return saved; const serverDefault = window.SITE_CONFIG?.timestamps?.timezone; return serverDefault === 'utc' ? 'utc' : 'local'; } function getTimestampFormatPreset() { const saved = localStorage.getItem('meshcore-timestamp-format'); if (saved === 'iso' || saved === 'iso-seconds' || saved === 'locale') return saved; const serverDefault = window.SITE_CONFIG?.timestamps?.formatPreset; return (serverDefault === 'iso' || serverDefault === 'iso-seconds' || serverDefault === 'locale') ? serverDefault : 'iso'; } function getTimestampCustomFormat() { if (window.SITE_CONFIG?.timestamps?.allowCustomFormat !== true) return ''; const saved = localStorage.getItem('meshcore-timestamp-custom-format'); if (saved != null) return String(saved); const serverDefault = window.SITE_CONFIG?.timestamps?.customFormat; return serverDefault == null ? '' : String(serverDefault); } function pad2(v) { return String(v).padStart(2, '0'); } function pad3(v) { return String(v).padStart(3, '0'); } function formatIsoLike(d, timezone, includeMs) { const useUtc = timezone === 'utc'; const year = useUtc ? d.getUTCFullYear() : d.getFullYear(); const month = useUtc ? d.getUTCMonth() + 1 : d.getMonth() + 1; const day = useUtc ? d.getUTCDate() : d.getDate(); const hour = useUtc ? d.getUTCHours() : d.getHours(); const minute = useUtc ? d.getUTCMinutes() : d.getMinutes(); const second = useUtc ? d.getUTCSeconds() : d.getSeconds(); const ms = useUtc ? d.getUTCMilliseconds() : d.getMilliseconds(); let out = year + '-' + pad2(month) + '-' + pad2(day) + ' ' + pad2(hour) + ':' + pad2(minute) + ':' + pad2(second); if (includeMs) out += '.' + pad3(ms); return out; } function formatTimestampCustom(d, formatString, timezone) { if (!/YYYY|MM|DD|HH|mm|ss|SSS|Z/.test(String(formatString))) return ''; const useUtc = timezone === 'utc'; const replacements = { YYYY: String(useUtc ? d.getUTCFullYear() : d.getFullYear()), MM: pad2((useUtc ? d.getUTCMonth() : d.getMonth()) + 1), DD: pad2(useUtc ? d.getUTCDate() : d.getDate()), HH: pad2(useUtc ? d.getUTCHours() : d.getHours()), mm: pad2(useUtc ? d.getUTCMinutes() : d.getMinutes()), ss: pad2(useUtc ? d.getUTCSeconds() : d.getSeconds()), SSS: pad3(useUtc ? d.getUTCMilliseconds() : d.getMilliseconds()), Z: (timezone === 'utc' ? 'UTC' : 'local') }; return String(formatString).replace(/YYYY|MM|DD|HH|mm|ss|SSS|Z/g, token => replacements[token] || token); } function formatAbsoluteTimestamp(iso) { if (!iso) return '—'; const d = new Date(iso); if (!isFinite(d.getTime())) return '—'; const timezone = getTimestampTimezone(); const preset = getTimestampFormatPreset(); const customFormat = getTimestampCustomFormat().trim(); if (customFormat) { const customOut = formatTimestampCustom(d, customFormat, timezone); if (customOut && !/Invalid Date|NaN|undefined|null/.test(customOut)) return customOut; } if (preset === 'iso-seconds') return formatIsoLike(d, timezone, true); if (preset === 'locale') { if (timezone === 'utc') return d.toLocaleString([], { timeZone: 'UTC' }); return d.toLocaleString(); } return formatIsoLike(d, timezone, false); } function formatTimestamp(isoString, mode) { return formatTimestampWithTooltip(isoString, mode).text; } function formatTimestampWithTooltip(isoString, mode) { if (!isoString) return { text: '—', tooltip: '—', isFuture: false }; const d = new Date(isoString); if (!isFinite(d.getTime())) return { text: '—', tooltip: '—', isFuture: false }; const activeMode = mode === 'absolute' || mode === 'ago' ? mode : getTimestampMode(); const isFuture = d.getTime() > Date.now(); const absolute = formatAbsoluteTimestamp(isoString); const relative = timeAgo(isoString); const text = isFuture ? absolute : (activeMode === 'absolute' ? absolute : relative); const tooltip = isFuture ? relative : (activeMode === 'absolute' ? relative : absolute); return { text, tooltip, isFuture }; } // Format a Date for chart axis labels, respecting customizer timestamp settings. // shortForm: true = time only (for intra-day), false = date+time (multi-day). function formatChartAxisLabel(d, shortForm) { if (!(d instanceof Date) || !isFinite(d.getTime())) return '—'; var timezone = (typeof getTimestampTimezone === 'function') ? getTimestampTimezone() : 'local'; var preset = (typeof getTimestampFormatPreset === 'function') ? getTimestampFormatPreset() : 'iso'; var useUtc = timezone === 'utc'; if (preset === 'locale') { if (shortForm) { var opts = { hour: '2-digit', minute: '2-digit' }; if (useUtc) opts.timeZone = 'UTC'; return d.toLocaleTimeString([], opts); } var opts2 = { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' }; if (useUtc) opts2.timeZone = 'UTC'; return d.toLocaleString([], opts2); } // ISO-style (iso or iso-seconds) var hour = useUtc ? d.getUTCHours() : d.getHours(); var minute = useUtc ? d.getUTCMinutes() : d.getMinutes(); var timeStr = pad2(hour) + ':' + pad2(minute); if (preset === 'iso-seconds') { var sec = useUtc ? d.getUTCSeconds() : d.getSeconds(); timeStr += ':' + pad2(sec); } if (shortForm) return timeStr; var month = useUtc ? d.getUTCMonth() + 1 : d.getMonth() + 1; var day = useUtc ? d.getUTCDate() : d.getDate(); return pad2(month) + '-' + pad2(day) + ' ' + timeStr; } function truncate(str, len) { if (!str) return ''; return str.length > len ? str.slice(0, len) + '…' : str; } // --- Favorites --- const FAV_KEY = 'meshcore-favorites'; function getFavorites() { try { return JSON.parse(localStorage.getItem(FAV_KEY) || '[]'); } catch { return []; } } function isFavorite(pubkey) { return getFavorites().includes(pubkey); } function toggleFavorite(pubkey) { const favs = getFavorites(); const idx = favs.indexOf(pubkey); if (idx >= 0) favs.splice(idx, 1); else favs.push(pubkey); localStorage.setItem(FAV_KEY, JSON.stringify(favs)); return idx < 0; // true if now favorited } function favStarIconHtml(on) { var id = on ? 'ph-star-fill' : 'ph-star'; return ''; } function favStar(pubkey, cls) { const on = isFavorite(pubkey); return ''; } function bindFavStars(container, onToggle) { container.querySelectorAll('.fav-star').forEach(btn => { btn.addEventListener('click', (e) => { e.stopPropagation(); const pk = btn.dataset.fav; const nowOn = toggleFavorite(pk); btn.innerHTML = favStarIconHtml(nowOn); btn.classList.toggle('on', nowOn); btn.setAttribute('aria-pressed', nowOn ? 'true' : 'false'); btn.title = nowOn ? 'Remove from favorites' : 'Add to favorites'; if (onToggle) onToggle(pk, nowOn); }); }); } function formatHex(hex) { if (!hex) return ''; return hex.match(/.{1,2}/g).join(' '); } function createColoredHexDump(hex, ranges) { if (!hex || !ranges || !ranges.length) return `${formatHex(hex)}`; const bytes = hex.match(/.{1,2}/g) || []; // Build per-byte class map; later ranges override earlier const classMap = new Array(bytes.length).fill(''); const LABEL_CLASS = { 'Header': 'hex-header', 'Path Length': 'hex-pathlen', 'Transport Codes': 'hex-transport', 'Path': 'hex-path', 'Payload': 'hex-payload', 'PubKey': 'hex-pubkey', 'Timestamp': 'hex-timestamp', 'Signature': 'hex-signature', 'Flags': 'hex-flags', 'Latitude': 'hex-location', 'Longitude': 'hex-location', 'Name': 'hex-name', }; for (const r of ranges) { const cls = LABEL_CLASS[r.label] || 'hex-payload'; for (let i = r.start; i <= Math.min(r.end, bytes.length - 1); i++) classMap[i] = cls; } let html = '', prevCls = null; for (let i = 0; i < bytes.length; i++) { const cls = classMap[i]; if (cls !== prevCls) { if (prevCls !== null) html += ''; html += ``; prevCls = cls; } else { html += ' '; } html += bytes[i]; } if (prevCls !== null) html += ''; return html; } function buildHexLegend(ranges) { if (!ranges || !ranges.length) return ''; const LABEL_CLASS = { 'Header': 'hex-header', 'Path Length': 'hex-pathlen', 'Transport Codes': 'hex-transport', 'Path': 'hex-path', 'Payload': 'hex-payload', 'PubKey': 'hex-pubkey', 'Timestamp': 'hex-timestamp', 'Signature': 'hex-signature', 'Flags': 'hex-flags', 'Latitude': 'hex-location', 'Longitude': 'hex-location', 'Name': 'hex-name', }; const BG_COLORS = { 'hex-header': '#f38ba8', 'hex-pathlen': '#fab387', 'hex-transport': '#89b4fa', 'hex-path': '#a6e3a1', 'hex-payload': '#f9e2af', 'hex-pubkey': '#f9e2af', 'hex-timestamp': '#fab387', 'hex-signature': '#f38ba8', 'hex-flags': '#94e2d5', 'hex-location': '#89b4fa', 'hex-name': '#cba6f7', }; const seen = new Set(); let html = ''; for (const r of ranges) { if (seen.has(r.label)) continue; seen.add(r.label); const cls = LABEL_CLASS[r.label] || 'hex-payload'; const bg = BG_COLORS[cls] || '#f9e2af'; html += `${r.label}`; } return html; } // --- WebSocket --- let ws = null; let wsListeners = []; // --- Brand-logo packet-driven pulse (#1173) --- // Replaces the legacy live-dot indicator. Class-toggle only (CSS animations); colors come from // --logo-accent / --logo-accent-hi tokens. Test seam at window.__corescopeLogo. // // Cache the prefers-reduced-motion MediaQueryList ONCE at module load (#1177 // Carmack must-fix #2). Calling window.matchMedia on every pulse() allocates // a new MQL + parses the query string — wasteful at 15Hz. The CSS @media rule // already handles render-time switching, so we just cache and read .matches. var _reducedMotionMQL = null; try { if (typeof window !== 'undefined' && typeof window.matchMedia === 'function') { _reducedMotionMQL = window.matchMedia('(prefers-reduced-motion: reduce)'); } } catch (_) { _reducedMotionMQL = null; } const Logo = (function () { const RATE_GAP_MS = 66; // 15/sec (≤16 toggles per second). const HALF_MS = 80; // each half of a ping ≤80ms. const stats = { triggered: 0, dropped: 0 }; let lastPingTs = 0; let flip = 0; // 0 → A→B, 1 → B→A. let lastDirection = null; // 'a' or 'b' (source circle). let connected = true; // WS state — gates in-flight chained pulses. let generation = 0; // bumped on setConnected(false) / visibilitychange to cancel scheduled halves. function reducedMotion() { return _reducedMotionMQL ? !!_reducedMotionMQL.matches : false; } function $all(sel) { return Array.prototype.slice.call(document.querySelectorAll(sel)); } function clearAll() { $all('.brand-logo circle.logo-node-a, .brand-mark-only circle.logo-node-a,' + '.brand-logo circle.logo-node-b, .brand-mark-only circle.logo-node-b').forEach((el) => { el.classList.remove('logo-pulse-active', 'logo-pulse-blip'); }); } function pulseChained(srcSel, dstSel) { const gen = generation; // Source half: ~80ms. $all(srcSel).forEach((el) => el.classList.add('logo-pulse-active')); setTimeout(() => { $all(srcSel).forEach((el) => el.classList.remove('logo-pulse-active')); // Destination half: scheduled via rAF then ~80ms. // Bail if WS dropped (or another disconnect cycle ran) since this ping started — // otherwise a zombie pulse fires on a logo that's already showing the // .logo-disconnected sustained state. if (gen !== generation || !connected) return; requestAnimationFrame(() => { if (gen !== generation || !connected) return; $all(dstSel).forEach((el) => el.classList.add('logo-pulse-active')); setTimeout(() => { $all(dstSel).forEach((el) => el.classList.remove('logo-pulse-active')); }, HALF_MS); }); }, HALF_MS); } function pulseBlip(dstSel) { // Reduced-motion: single-step opacity blip on destination only. $all(dstSel).forEach((el) => el.classList.add('logo-pulse-blip')); setTimeout(() => { $all(dstSel).forEach((el) => el.classList.remove('logo-pulse-blip')); }, 140); } function pulse(_msg) { // Hidden-tab gate (#1177 Carmack must-fix #1): drop the pulse BEFORE // mutating lastPingTs and BEFORE scheduling any rAF/setTimeout chain. // Background tabs throttle timers but still ran the source-class toggle // and queued a chain that fired in a clump on tab focus — wasted work // and a visible storm. Returning early here makes the gate cost ~1 // property read per WS message. if (typeof document !== 'undefined' && document.hidden) { stats.dropped++; return false; } if (!connected) { stats.dropped++; return false; } const now = (typeof performance !== 'undefined' && performance.now) ? performance.now() : Date.now(); if (now - lastPingTs < RATE_GAP_MS) { stats.dropped++; return false; } lastPingTs = now; stats.triggered++; const aToB = (flip === 0); flip ^= 1; lastDirection = aToB ? 'a' : 'b'; const srcSel = aToB ? '.brand-logo circle.logo-node-a, .brand-mark-only circle.logo-node-a' : '.brand-logo circle.logo-node-b, .brand-mark-only circle.logo-node-b'; const dstSel = aToB ? '.brand-logo circle.logo-node-b, .brand-mark-only circle.logo-node-b' : '.brand-logo circle.logo-node-a, .brand-mark-only circle.logo-node-a'; if (reducedMotion()) { pulseBlip(dstSel); } else { pulseChained(srcSel, dstSel); } return true; } function setConnected(isConnected) { connected = !!isConnected; // Bump generation so any in-flight chained-pulse callbacks bail before // toggling classes on the destination circle (otherwise a zombie pulse // briefly fights the .logo-disconnected sustained desaturate state). generation++; $all('.brand-logo, .brand-mark-only').forEach((el) => { if (connected) el.classList.remove('logo-disconnected'); else el.classList.add('logo-disconnected'); }); // #1174 mesh-op review: mirror connected state onto the bottom-nav so // the 2px top-border indicator (see bottom-nav.css) goes red on // disconnect. Mesh-alive is otherwise invisible at ≤768 because // .nav-stats is hidden at that breakpoint. var bn = document.querySelector('[data-bottom-nav]'); if (bn) { if (connected) bn.classList.remove('disconnected'); else bn.classList.add('disconnected'); } if (!connected) clearAll(); } // Expose hook for E2E + customizer/devtools introspection. // Frozen so consumers can't replace .pulse / .setConnected from outside // (the seam is read-only — invocation only). const api = Object.freeze({ pulse: pulse, setConnected: setConnected, get lastDirection() { return lastDirection; }, get stats() { return { triggered: stats.triggered, dropped: stats.dropped }; }, }); try { window.__corescopeLogo = api; } catch (_) {} // Visibility gate (#1177 Carmack must-fix #1): when the tab becomes // hidden, bump generation so any in-flight chained pulse halves bail // out before they paint, and clear any active pulse classes. The // pulse() entry already early-returns on document.hidden — this handles // pulses already mid-flight at the moment the tab is backgrounded. try { if (typeof document !== 'undefined' && typeof document.addEventListener === 'function') { document.addEventListener('visibilitychange', function () { if (document.hidden) { generation++; clearAll(); } }); } } catch (_) {} return api; })(); function connectWS() { const proto = location.protocol === 'https:' ? 'wss:' : 'ws:'; ws = new WebSocket(`${proto}//${location.host}`); ws.onopen = () => Logo.setConnected(true); ws.onclose = () => { Logo.setConnected(false); setTimeout(connectWS, 3000); }; ws.onerror = () => ws.close(); ws.onmessage = (e) => { Logo.pulse(e); try { const msg = JSON.parse(e.data); // Debounce cache invalidation — don't nuke on every packet if (!api._invalidateTimer) { api._invalidateTimer = setTimeout(() => { api._invalidateTimer = null; invalidateApiCache('/stats'); invalidateApiCache('/nodes'); }, 5000); } wsListeners.forEach(fn => fn(msg)); } catch {} }; } function onWS(fn) { wsListeners.push(fn); } function offWS(fn) { wsListeners = wsListeners.filter(f => f !== fn); } // --- Pull-to-reconnect (#1063) --- // Touch-device pull-down at scrollTop=0 reconnects the WebSocket // (instead of triggering native pull-to-refresh full-page reload). // Visual indicator pulses during pull; toast confirms result. const PULL_THRESHOLD_PX = 140; let _pullToast = null; let _pullToastTimer = null; let _pullIndicator = null; function _ensurePullIndicator() { if (_pullIndicator && document.body && typeof document.body.contains === 'function' && document.body.contains(_pullIndicator)) return _pullIndicator; if (_pullIndicator) return _pullIndicator; const el = document.createElement('div'); el.id = 'pullReconnectIndicator'; el.setAttribute('aria-hidden', 'true'); el.innerHTML = ''; el.style.cssText = [ 'position:fixed', 'top:0', 'left:50%', 'transform:translate(-50%,-100%)', 'z-index:99999', 'padding:8px 14px', 'border-radius:0 0 12px 12px', 'background:var(--accent,#2563eb)', 'color:#fff', 'font:14px/1 var(--font,system-ui)', 'box-shadow:0 2px 8px rgba(0,0,0,.2)', 'pointer-events:none', 'transition:transform .15s ease, opacity .15s ease', 'opacity:0', ].join(';'); document.body.appendChild(el); _pullIndicator = el; return el; } function _showPullToast(msg, ok) { try { if (_pullToast && _pullToast.remove) _pullToast.remove(); } catch (e) {} if (_pullToastTimer) { try { clearTimeout(_pullToastTimer); } catch (e) {} _pullToastTimer = null; } const el = document.createElement('div'); el.className = 'pull-reconnect-toast'; el.textContent = msg; el.style.cssText = [ 'position:fixed', 'top:12px', 'left:50%', 'transform:translateX(-50%)', 'z-index:99999', 'padding:8px 16px', 'border-radius:8px', 'background:' + (ok ? 'var(--status-green,#16a34a)' : 'var(--status-red,#dc2626)'), 'color:#fff', 'font:14px/1.2 var(--font,system-ui)', 'box-shadow:0 2px 8px rgba(0,0,0,.2)', 'pointer-events:none', ].join(';'); document.body.appendChild(el); _pullToast = el; _pullToastTimer = setTimeout(function () { _pullToastTimer = null; try { el.remove(); } catch (e) {} }, 1800); } function pullReconnect() { // If WS is connected (readyState OPEN), give a brief "Connected" // confirmation but still cycle so the user sees fresh data. const wasOpen = ws && ws.readyState === 1; if (wasOpen) { _showPullToast('Connected', true); // Fast cycle: close and let onclose reconnect immediately try { ws.close(); } catch (e) {} } else { _showPullToast('Reconnecting…', true); try { if (ws) ws.close(); } catch (e) {} // onclose handler schedules reconnect; force one now in case ws was null try { connectWS(); } catch (e) {} } } function _isTouchDevice() { try { return ('ontouchstart' in window) || (navigator && (navigator.maxTouchPoints > 0 || navigator.msMaxTouchPoints > 0)); } catch (e) { return false; } } function setupPullToReconnect() { // Always attach listeners (tests + future-proof). Inside the handler we // gate on _isTouchDevice() AND scrollTop=0 so desktop/scrolled pages are // unaffected. let startY = null; let pulling = false; let dist = 0; function getScrollTop() { return (document.documentElement && document.documentElement.scrollTop) || (document.body && document.body.scrollTop) || 0; } function onStart(e) { if (!_isTouchDevice()) return; // Strict scrollTop === 0: ignore any negative overscroll, ignore any scrolled state if (getScrollTop() !== 0) { startY = null; pulling = false; return; } const t = e.touches && e.touches[0]; startY = t ? t.clientY : null; pulling = false; dist = 0; } function onMove(e) { if (startY == null) return; // Cancel gesture if scrollTop leaves 0 (page scrolled mid-pull) if (getScrollTop() !== 0) { startY = null; pulling = false; dist = 0; return; } const t = e.touches && e.touches[0]; if (!t) return; const dy = t.clientY - startY; if (dy <= 0) { // Upward swipe / retract. If we were past the commit threshold and the // user retracts back, cancel the gesture so a subsequent touchend does // NOT fire reconnect. if (pulling) { pulling = false; dist = 0; if (_pullIndicator) { _pullIndicator.style.opacity = '0'; _pullIndicator.style.transform = 'translate(-50%, -100%)'; } } return; } dist = dy; if (dy > 8) { pulling = true; const ind = _ensurePullIndicator(); const pct = Math.min(1, dy / PULL_THRESHOLD_PX); ind.style.opacity = String(pct); ind.style.transform = 'translate(-50%, ' + (-100 + pct * 100) + '%)'; const icon = ind.querySelector && ind.querySelector('.prr-icon'); if (icon) icon.style.transform = 'rotate(' + Math.round(pct * 360) + 'deg)'; // Only block native pull-to-refresh once we've crossed the commit // threshold — below that, let the browser handle natural scroll/bounce. if (dy >= PULL_THRESHOLD_PX && typeof e.preventDefault === 'function' && e.cancelable !== false) { try { e.preventDefault(); } catch (_) {} } } } function onEnd() { const wasPulling = pulling; const finalDist = dist; const stillAtTop = getScrollTop() === 0; startY = null; pulling = false; dist = 0; if (_pullIndicator) { _pullIndicator.style.opacity = '0'; _pullIndicator.style.transform = 'translate(-50%, -100%)'; } // Trigger only if: gesture was active, crossed threshold, and page is still at scrollTop=0. if (wasPulling && finalDist >= PULL_THRESHOLD_PX && stillAtTop) { try { (window.pullReconnect || pullReconnect)(); } catch (e) {} } } document.addEventListener('touchstart', onStart, { passive: true }); document.addEventListener('touchmove', onMove, { passive: false }); document.addEventListener('touchend', onEnd, { passive: true }); document.addEventListener('touchcancel', onEnd, { passive: true }); } window.pullReconnect = pullReconnect; window.setupPullToReconnect = setupPullToReconnect; window.connectWS = connectWS; /* Global escapeHtml — used by multiple pages. 5-char OWASP set: escapes ' too so this helper is safe in both double-quoted AND single-quoted attribute contexts (e.g. the data-conflict='${escapeHtml(JSON.stringify(...))}' attr in hop-display.js, where JSON containing a single quote would otherwise break out of the attribute). Fixes #1536. CANONICAL ESCAPE for HTML sinks that interpolate node-controlled or MQTT-controlled fields (name, adv_name, observer, sender, channel, pubkey, body, …). Enforced at PR-creation time by: - scripts/check-xss-sinks.sh (local mirror) - ~/.openclaw/skills/pr-preflight/scripts/check-xss-sinks.sh (canonical) - test-preflight-xss-gate.js (CI gate) See also: escapeAttr (public/home.js, public/path-inspector.js) for attribute-only contexts. */ function escapeHtml(s) { if (s == null) return ''; return String(s).replace(/&/g,'&').replace(//g,'>').replace(/"/g,'"').replace(/'/g,'''); } /* Global debounce */ function debounce(fn, ms) { let t; return (...args) => { clearTimeout(t); t = setTimeout(() => fn(...args), ms); }; } /* Debounced WS helper — batches rapid messages, calls fn with array of msgs */ function debouncedOnWS(fn, ms) { if (typeof ms === 'undefined') ms = 250; let pending = []; let timer = null; function handler(msg) { pending.push(msg); if (!timer) { timer = setTimeout(function () { const batch = pending; pending = []; timer = null; fn(batch); }, ms); } } onWS(handler); return handler; // caller stores this to pass to offWS() in destroy } // --- Router --- const pages = {}; function registerPage(name, mod) { pages[name] = mod; } // Tools landing page — shows sub-menu with Trace and Path Inspector (spec §2.8, M1 fix). registerPage('tools-landing', { init: function (container) { container.innerHTML = '