mirror of
https://github.com/Kpa-clawbot/meshcore-analyzer.git
synced 2026-09-16 09:42:44 +00:00
Implements **PR A** from the #1598 triage fix path (r6 — all three operator decisions locked: confidence ≥0.75 [PR B], **dim-not-delete**, **in-place `getNodeStatus` signature extension**). ## Changes **`public/roles.js` — `getNodeStatus()`** - Now accepts a full node object (preferred); the legacy `(role, lastSeenMs)` signature keeps working unchanged. - For infra roles (repeater/room), freshness = `max(advert-based timestamp, last_relayed)`. Freshness precedence mirrors existing call sites: `_liveSeen` > `_lastHeard` > `last_heard` > `last_seen`. - `last_relayed` is only consulted for infra — companions keep pure advert/heard-based staleness (per @liquidraver's collision caveat; the ≥0.75-confidence `_liveSeen` refresh is PR B). **`public/live.js` — `pruneStaleNodes()`** - Repeater/room markers are **dimmed, never deleted**, regardless of `_fromAPI` origin. WS-only non-infra nodes are still removed to prevent unbounded memory growth. **Call sites** — all seven (`nodes.js` ×3, `map.js` ×3, `live.js` ×1) now pass the node object. The Nodes-page status explanation shows "Last relayed …" when relay participation is the fresher signal, so an Active badge next to an old "last heard" isn't confusing. **Tests** — 16 new `getNodeStatus` unit tests incl. the triage's backbone-repeater fixture (`last_seen`=25h, `last_relayed`=5min → `active`); `pruneStaleNodes` tests updated for dim-not-delete plus a new relay-aware prune test. ## Test results `node test-frontend-helpers.js`: **641 passed, 2 failed** — the 2 failures (favStar ★ assertions) are pre-existing on current master (verified on a clean upstream clone). ## Validation offer live.saarmesh.de currently has **160 infra nodes past `infraSilentMs`=72h while actively relaying** (incl. KatS disaster-relief repeaters) — a ready-made test population. Happy to run this branch there and report before/after node visibility. Refs #1598 (PR A of two; PR B = `_liveSeen` refresh on `resolved_path` ≥0.75 confidence). --------- Co-authored-by: Mathias Kasper <fallisaar@gmail.com> Co-authored-by: Claude <noreply@anthropic.com> Co-authored-by: SaarMesh-Bot <bot@saarmesh.de>
964 lines
39 KiB
JavaScript
964 lines
39 KiB
JavaScript
/* === CoreScope — roles.js (shared config module) === */
|
||
'use strict';
|
||
|
||
/*
|
||
* Centralized roles, thresholds, tile URLs, and UI constants.
|
||
* Loaded BEFORE all page scripts via index.html.
|
||
* Defaults are set synchronously; server config overrides arrive via fetch.
|
||
*/
|
||
|
||
(function () {
|
||
// ─── Role definitions ───
|
||
// #1407 — Wong palette defaults that match the unscoped --mc-role-* CSS
|
||
// vars in :root of style.css. These are FALLBACKS only — the live getter
|
||
// below reads --mc-role-* from documentElement on every access, so any
|
||
// preset switch (cb-presets.js) is reflected immediately without per-page
|
||
// listener wiring. The legacy April palette (#dc2626 etc.) was the bug.
|
||
var WONG_ROLE_DEFAULTS = {
|
||
repeater: '#D55E00',
|
||
companion: '#56B4E9',
|
||
room: '#009E73',
|
||
sensor: '#F0E442',
|
||
observer: '#CC79A7',
|
||
unknown: '#6b7280'
|
||
};
|
||
var WONG_ROLE_TEXT_DEFAULTS = {
|
||
repeater: '#1a1a1a', companion: '#1a1a1a', room: '#1a1a1a',
|
||
sensor: '#1a1a1a', observer: '#1a1a1a', unknown: '#1a1a1a'
|
||
};
|
||
|
||
function _readCssVar(name, fallback) {
|
||
try {
|
||
if (typeof document === 'undefined' || !document.documentElement) return fallback;
|
||
var v = '';
|
||
if (typeof getComputedStyle === 'function') {
|
||
v = getComputedStyle(document.documentElement).getPropertyValue(name);
|
||
}
|
||
if (!v && document.documentElement.style && typeof document.documentElement.style.getPropertyValue === 'function') {
|
||
v = document.documentElement.style.getPropertyValue(name);
|
||
}
|
||
v = (v || '').trim();
|
||
return v || fallback;
|
||
} catch (e) { return fallback; }
|
||
}
|
||
|
||
// Server-config overrides go into this object; the getter prefers them
|
||
// when present so backend-pushed role colors still win over CSS vars.
|
||
var _roleOverrides = {};
|
||
|
||
function _liveRoleColors() {
|
||
var base = {};
|
||
var roles = ['repeater', 'companion', 'room', 'sensor', 'observer'];
|
||
for (var i = 0; i < roles.length; i++) {
|
||
var k = roles[i];
|
||
base[k] = _roleOverrides[k] || _readCssVar('--mc-role-' + k, WONG_ROLE_DEFAULTS[k]);
|
||
}
|
||
base.unknown = _roleOverrides.unknown || WONG_ROLE_DEFAULTS.unknown;
|
||
// Wrap in a Proxy so per-key assignment by legacy callers (customizer:
|
||
// `window.ROLE_COLORS[key] = inp.value`) lands in _roleOverrides and
|
||
// is visible on the NEXT read. Without this, the mutation would be
|
||
// thrown away when the snapshot is GC'd. Falls back to a plain object
|
||
// in environments without Proxy (none we ship to, but cheap).
|
||
if (typeof Proxy === 'function') {
|
||
return new Proxy(base, {
|
||
set: function (t, prop, value) {
|
||
_roleOverrides[prop] = value;
|
||
t[prop] = value;
|
||
return true;
|
||
}
|
||
});
|
||
}
|
||
return base;
|
||
}
|
||
|
||
Object.defineProperty(window, 'ROLE_COLORS', {
|
||
configurable: true,
|
||
enumerable: true,
|
||
get: function () { return _liveRoleColors(); },
|
||
// Setter accepts per-key writes — older callers do
|
||
// `ROLE_COLORS.repeater = '#xxx'`
|
||
// which on a getter-only object would silently no-op in strict mode.
|
||
// We treat any whole-object assignment as an override merge so the
|
||
// legacy customizer code path still works.
|
||
set: function (v) {
|
||
if (v && typeof v === 'object') {
|
||
for (var k in v) if (Object.prototype.hasOwnProperty.call(v, k)) _roleOverrides[k] = v[k];
|
||
}
|
||
}
|
||
});
|
||
// Per-key writes via Proxy not portable enough — expose helper for callers
|
||
// that want to override at runtime (customizer "node colors" path).
|
||
// #1438: snapshot of the cb-preset (or initial) CSS-var value per role
|
||
// so that clearing an override restores the preset, not nothing.
|
||
// We write the override to BOTH documentElement and body inline styles
|
||
// because cb-presets ships stylesheet rules of the form
|
||
// body[data-cb-preset="deut"] { --mc-role-X: #...; }
|
||
// which beats inheritance from :root. Body inline beats both.
|
||
var _presetCssSnapshot = {};
|
||
function _styleTargets() {
|
||
var t = [];
|
||
try { if (document.documentElement && document.documentElement.style) t.push(document.documentElement.style); } catch (e) {}
|
||
try { if (document.body && document.body.style) t.push(document.body.style); } catch (e) {}
|
||
return t.filter(function (s) { return s && typeof s.setProperty === 'function'; });
|
||
}
|
||
window.setRoleColorOverride = function (role, hex) {
|
||
if (!role) return;
|
||
var targets = _styleTargets();
|
||
var varName = '--mc-role-' + role;
|
||
|
||
if (hex == null || hex === '') {
|
||
// Clear override → restore prior CSS var values captured at
|
||
// first-override time, so CSS-var consumers see the preset color
|
||
// again (matches JS getter behavior, preserves #1412 contract).
|
||
delete _roleOverrides[role];
|
||
if (Object.prototype.hasOwnProperty.call(_presetCssSnapshot, role)) {
|
||
var snap = _presetCssSnapshot[role] || {};
|
||
targets.forEach(function (s, i) {
|
||
var prior = snap[i];
|
||
if (prior && prior.length) s.setProperty(varName, prior);
|
||
else s.removeProperty(varName);
|
||
});
|
||
delete _presetCssSnapshot[role];
|
||
} else {
|
||
targets.forEach(function (s) { s.removeProperty(varName); });
|
||
}
|
||
return;
|
||
}
|
||
// Capture the current per-target CSS var values before overwriting,
|
||
// but only on the first override for this role so repeated picks
|
||
// don't lose the original preset value.
|
||
if (!Object.prototype.hasOwnProperty.call(_presetCssSnapshot, role)) {
|
||
_presetCssSnapshot[role] = targets.map(function (s) {
|
||
return s.getPropertyValue ? (s.getPropertyValue(varName) || '').trim() : '';
|
||
});
|
||
}
|
||
_roleOverrides[role] = hex;
|
||
// #1438: drive the CSS var so CSS-var consumers (cluster pills,
|
||
// route lines, all marker SVGs that use fill="var(--mc-role-X)")
|
||
// pick up the operator's hex without a page reload. Writing to
|
||
// body inline style is necessary because body[data-cb-preset="..."]
|
||
// selectors beat :root inheritance.
|
||
//
|
||
// #1446: write with !important so the inline body declaration also
|
||
// beats the body[data-cb-preset="X"] CSS rule on equal specificity.
|
||
// Without !important, the cascade order picks the later-defined
|
||
// stylesheet rule in some browser versions even though specificity
|
||
// (1,0,1) matches the inline body style — operator pick visibly
|
||
// loses to active preset (root cause of #1444).
|
||
targets.forEach(function (s) {
|
||
// documentElement gets the value without !important (used as the
|
||
// canonical readout for the JS getter); body gets !important so it
|
||
// wins the CSS cascade against body[data-cb-preset="X"].
|
||
if (s === (document.body && document.body.style)) {
|
||
s.setProperty(varName, hex, 'important');
|
||
} else {
|
||
s.setProperty(varName, hex);
|
||
}
|
||
});
|
||
};
|
||
// Back-compat: also export the writable override map so customize.js's
|
||
// `window.ROLE_COLORS[key] = inp.value` style mutation works.
|
||
// We intercept by replacing the getter target with a Proxy on access.
|
||
Object.defineProperty(window, 'ROLE_COLORS_OVERRIDES', {
|
||
value: _roleOverrides, writable: false, enumerable: false, configurable: false
|
||
});
|
||
|
||
// PR #1804 r1 item 4 (tufte4+adv5): ACK and UNKNOWN deliberately share
|
||
// the neutral #6b7280 swatch (both render as the "Other" bucket). The
|
||
// legend now identifies rows via data-enum="<ENUM>", so the reverse
|
||
// color→enum lookup is no longer needed for tests or for the runtime
|
||
// — the prior insertion-order workaround is gone, restoring natural
|
||
// declaration order.
|
||
window.TYPE_COLORS = {
|
||
ADVERT: '#22c55e', GRP_TXT: '#3b82f6', GRP_DATA: '#8b5cf6', TXT_MSG: '#f59e0b',
|
||
ACK: '#6b7280',
|
||
REQ: '#a855f7', RESPONSE: '#06b6d4', TRACE: '#ec4899', PATH: '#14b8a6',
|
||
ANON_REQ: '#f43f5e', MULTIPART: '#0d9488', CONTROL: '#b45309', RAW_CUSTOM: '#c026d3',
|
||
UNKNOWN: '#6b7280'
|
||
};
|
||
|
||
// Badge CSS class name mapping
|
||
const TYPE_BADGE_MAP = {
|
||
ADVERT: 'advert', GRP_TXT: 'grp-txt', GRP_DATA: 'grp-data', TXT_MSG: 'txt-msg', ACK: 'ack',
|
||
REQ: 'req', RESPONSE: 'response', TRACE: 'trace', PATH: 'path',
|
||
ANON_REQ: 'anon-req', MULTIPART: 'multipart', CONTROL: 'control', RAW_CUSTOM: 'raw-custom',
|
||
UNKNOWN: 'unknown'
|
||
};
|
||
|
||
// Generate badge CSS from TYPE_COLORS — single source of truth.
|
||
// #1668-M4: pick a readable foreground per badge background, and DARKEN
|
||
// the bg (only for the badge — TYPE_COLORS itself is untouched, so map
|
||
// dots / markers / live-feed dots keep their full-saturation hue) until
|
||
// the fg/bg pair clears WCAG AA (≥4.5:1). The legacy translucent wash
|
||
// `${color}20` with `color: ${color}` failed AA for all 14 payload types
|
||
// (M1 audit; ratios 1.0–4.25, BLOCKER).
|
||
window.syncBadgeColors = function() {
|
||
var el = document.getElementById('type-color-badges');
|
||
if (!el) { el = document.createElement('style'); el.id = 'type-color-badges'; document.head.appendChild(el); }
|
||
function hexToRgb(hex) {
|
||
var h = hex.replace('#','');
|
||
return [parseInt(h.slice(0,2),16), parseInt(h.slice(2,4),16), parseInt(h.slice(4,6),16)];
|
||
}
|
||
function rgbToHex(rgb) {
|
||
return '#' + rgb.map(function(v){ var s = Math.max(0,Math.min(255,Math.round(v))).toString(16); return s.length===1?'0'+s:s; }).join('');
|
||
}
|
||
function relLum(rgb) {
|
||
var lin = function(v){ var s=v/255; return s<=0.03928 ? s/12.92 : Math.pow((s+0.055)/1.055, 2.4); };
|
||
return 0.2126*lin(rgb[0]) + 0.7152*lin(rgb[1]) + 0.0722*lin(rgb[2]);
|
||
}
|
||
function contrast(a, b) {
|
||
var l1 = relLum(a), l2 = relLum(b);
|
||
var hi = l1>l2?l1:l2, lo = l1>l2?l2:l1;
|
||
return (hi+0.05) / (lo+0.05);
|
||
}
|
||
function readableFg(rgb) {
|
||
// perceived luminance (Rec.601) — matches the M4 test's pick
|
||
var lum = (0.299*rgb[0] + 0.587*rgb[1] + 0.114*rgb[2]) / 255;
|
||
return lum > 0.55 ? [26,26,46] : [255,255,255];
|
||
}
|
||
function adjustForAA(bg, fg) {
|
||
// Darken (when white fg) or lighten (when dark fg) the bg until
|
||
// contrast >= 4.5, max 12 steps of 8%. Preserves hue.
|
||
var c = contrast(fg, bg);
|
||
if (c >= 4.5) return bg;
|
||
var darkening = fg[0] > 128; // white fg → darken bg
|
||
var out = bg.slice();
|
||
for (var i=0; i<12 && contrast(fg, out) < 4.5; i++) {
|
||
out = out.map(function(v){ return darkening ? v*0.92 : v + (255-v)*0.08; });
|
||
}
|
||
return out.map(function(v){ return Math.round(v); });
|
||
}
|
||
var css = '';
|
||
for (var type in TYPE_BADGE_MAP) {
|
||
var color = window.TYPE_COLORS[type];
|
||
if (!color) continue;
|
||
var cls = TYPE_BADGE_MAP[type];
|
||
var bg = hexToRgb(color);
|
||
var fg = readableFg(bg);
|
||
var adj = adjustForAA(bg, fg);
|
||
css += '.badge-' + cls + ' { background: ' + rgbToHex(adj) + '; color: rgb(' + fg.join(',') + '); }\n';
|
||
}
|
||
el.textContent = css;
|
||
};
|
||
|
||
// #1668 M5 — public helper for callers that render a badge INLINE
|
||
// (analytics.js / nodes.js role badges where the color isn't known at
|
||
// CSS-author time, e.g. role colors come from server config). Returns
|
||
// "background:#xxx;color:#xxx" with AA-safe pairing — same algorithm
|
||
// as syncBadgeColors above. Avoids the legacy 20%-alpha-on-tinted-bg
|
||
// pattern that fails AA (M5 axe gate caught 90+ instances).
|
||
window.aaBadgeStyle = function(color) {
|
||
if (!color) return '';
|
||
function hexToRgb(hex) {
|
||
var h = String(hex).replace('#','');
|
||
if (h.length !== 6) return [128,128,128];
|
||
return [parseInt(h.slice(0,2),16), parseInt(h.slice(2,4),16), parseInt(h.slice(4,6),16)];
|
||
}
|
||
function rgbToHex(rgb) {
|
||
return '#' + rgb.map(function(v){ var s = Math.max(0,Math.min(255,Math.round(v))).toString(16); return s.length===1?'0'+s:s; }).join('');
|
||
}
|
||
function relLum(rgb) {
|
||
var lin = function(v){ var s=v/255; return s<=0.03928 ? s/12.92 : Math.pow((s+0.055)/1.055, 2.4); };
|
||
return 0.2126*lin(rgb[0]) + 0.7152*lin(rgb[1]) + 0.0722*lin(rgb[2]);
|
||
}
|
||
function contrast(a, b) {
|
||
var l1 = relLum(a), l2 = relLum(b);
|
||
var hi = l1>l2?l1:l2, lo = l1>l2?l2:l1;
|
||
return (hi+0.05) / (lo+0.05);
|
||
}
|
||
function readableFg(rgb) {
|
||
var lum = (0.299*rgb[0] + 0.587*rgb[1] + 0.114*rgb[2]) / 255;
|
||
return lum > 0.55 ? [26,26,46] : [255,255,255];
|
||
}
|
||
function adjustForAA(bg, fg) {
|
||
var c = contrast(fg, bg);
|
||
if (c >= 4.5) return bg;
|
||
var darkening = fg[0] > 128;
|
||
var out = bg.slice();
|
||
for (var i=0; i<12 && contrast(fg, out) < 4.5; i++) {
|
||
out = out.map(function(v){ return darkening ? v*0.92 : v + (255-v)*0.08; });
|
||
}
|
||
return out.map(function(v){ return Math.round(v); });
|
||
}
|
||
var bg = hexToRgb(color);
|
||
var fg = readableFg(bg);
|
||
var adj = adjustForAA(bg, fg);
|
||
return 'background:' + rgbToHex(adj) + ';color:rgb(' + fg.join(',') + ')';
|
||
};
|
||
|
||
// Auto-sync on load
|
||
if (document.readyState === 'loading') {
|
||
document.addEventListener('DOMContentLoaded', window.syncBadgeColors);
|
||
} else {
|
||
window.syncBadgeColors();
|
||
}
|
||
|
||
window.ROLE_LABELS = {
|
||
repeater: 'Repeaters', companion: 'Companions', room: 'Room Servers',
|
||
sensor: 'Sensors', observer: 'Observers'
|
||
};
|
||
|
||
// #1293 — Marker shape per role (WCAG 1.4.1 — shape, not only colour).
|
||
// Single source of truth; ROLE_STYLE.shape is derived from this map.
|
||
window.ROLE_SHAPES = {
|
||
repeater: 'circle',
|
||
companion: 'square',
|
||
room: 'hexagon',
|
||
sensor: 'triangle',
|
||
observer: 'diamond'
|
||
};
|
||
|
||
// #1407 — ROLE_STYLE.color reads live (matches ROLE_COLORS getter).
|
||
// The shape/radius/weight stay static. Stored overrides survive across
|
||
// reads via the closure above.
|
||
var _styleShapes = {
|
||
repeater: { shape: 'circle', radius: 8, weight: 2 },
|
||
companion: { shape: 'square', radius: 8, weight: 2 },
|
||
room: { shape: 'hexagon', radius: 9, weight: 2 },
|
||
sensor: { shape: 'triangle', radius: 8, weight: 2 },
|
||
observer: { shape: 'diamond', radius: 9, weight: 2 }
|
||
};
|
||
function _buildRoleStyle() {
|
||
var out = {};
|
||
var live = _liveRoleColors();
|
||
for (var role in _styleShapes) {
|
||
var s = _styleShapes[role];
|
||
out[role] = {
|
||
color: _roleOverrides[role] || live[role],
|
||
shape: s.shape,
|
||
radius: s.radius,
|
||
weight: s.weight
|
||
};
|
||
}
|
||
return out;
|
||
}
|
||
Object.defineProperty(window, 'ROLE_STYLE', {
|
||
configurable: true,
|
||
enumerable: true,
|
||
get: function () { return _buildRoleStyle(); },
|
||
set: function (v) {
|
||
// Legacy whole-object assignment: copy color overrides only.
|
||
if (v && typeof v === 'object') {
|
||
for (var k in v) if (v[k] && v[k].color) _roleOverrides[k] = v[k].color;
|
||
}
|
||
}
|
||
});
|
||
|
||
// Glyphs mirror the ROLE_SHAPES (used in tooltips, legends, lists).
|
||
window.ROLE_EMOJI = {
|
||
repeater: '●', companion: '■', room: '⬢', sensor: '▲', observer: '◆'
|
||
};
|
||
|
||
/**
|
||
* #1293 — Shared SVG marker generator. Returns a self-contained
|
||
* <svg>...</svg> string for the given role/colour/size, with white
|
||
* stroke for contrast (works on both dark + light tiles). Used by:
|
||
* - public/live.js → addNodeMarker (L.divIcon)
|
||
* - public/live.js → role legend swatches
|
||
* - public/map.js → makeMarkerIcon (legacy switch retained for
|
||
* per-role overrides + observer star overlay)
|
||
*
|
||
* Reads ROLE_SHAPES for the role's geometry; falls back to circle.
|
||
* Caller controls colour to allow theming overrides (matrix mode,
|
||
* stale dim, etc.) without rebuilding the marker.
|
||
*/
|
||
window.makeRoleMarkerSVG = function (role, color, size) {
|
||
var shape = (window.ROLE_SHAPES && window.ROLE_SHAPES[role]) || 'circle';
|
||
size = size || 16;
|
||
var c = size / 2;
|
||
// #1438: default fill resolves through the live CSS var so existing
|
||
// mounted SVG markers recolor when cb-preset switches or the
|
||
// operator picks a per-role override via the customizer. Callers
|
||
// that need a fixed tint (matrix mode, stale dim) keep passing
|
||
// their explicit colour.
|
||
var fill = color || ('var(--mc-role-' + (role || 'companion') + ')');
|
||
// #1488 — stroke routed through CSS vars so operators can dial
|
||
// colour/width without code edits (customizer Colors → Marker Stroke).
|
||
var strokeAttr = ' stroke="var(--mc-marker-stroke-color)" stroke-width="var(--mc-marker-stroke-width)" stroke-opacity="var(--mc-marker-stroke-opacity)"';
|
||
var path;
|
||
switch (shape) {
|
||
case 'square':
|
||
path = '<rect x="3" y="3" width="' + (size - 6) + '" height="' + (size - 6) +
|
||
'" fill="' + fill + '"' + strokeAttr + '/>';
|
||
break;
|
||
case 'triangle':
|
||
path = '<polygon points="' + c + ',2 ' + (size - 2) + ',' + (size - 2) +
|
||
' 2,' + (size - 2) + '" fill="' + fill + '"' + strokeAttr + '/>';
|
||
break;
|
||
case 'diamond':
|
||
path = '<polygon points="' + c + ',2 ' + (size - 2) + ',' + c + ' ' +
|
||
c + ',' + (size - 2) + ' 2,' + c +
|
||
'" fill="' + fill + '"' + strokeAttr + '/>';
|
||
break;
|
||
case 'hexagon': {
|
||
// Pointy-top hexagon centred at (c,c), inscribed radius ≈ c-1.5
|
||
var r = c - 1.5;
|
||
var pts = '';
|
||
for (var i = 0; i < 6; i++) {
|
||
var a = (i * 60 - 90) * Math.PI / 180;
|
||
pts += (c + r * Math.cos(a)).toFixed(2) + ',' +
|
||
(c + r * Math.sin(a)).toFixed(2) + ' ';
|
||
}
|
||
path = '<polygon points="' + pts.trim() + '" fill="' + fill +
|
||
'"' + strokeAttr + '/>';
|
||
break;
|
||
}
|
||
case 'star': {
|
||
var cx = c, cy = c, outer = c - 1, inner = outer * 0.4;
|
||
var spts = '';
|
||
for (var j = 0; j < 5; j++) {
|
||
var aO = (j * 72 - 90) * Math.PI / 180;
|
||
var aI = ((j * 72) + 36 - 90) * Math.PI / 180;
|
||
spts += (cx + outer * Math.cos(aO)) + ',' + (cy + outer * Math.sin(aO)) + ' ';
|
||
spts += (cx + inner * Math.cos(aI)) + ',' + (cy + inner * Math.sin(aI)) + ' ';
|
||
}
|
||
path = '<polygon points="' + spts.trim() + '" fill="' + fill +
|
||
'"' + strokeAttr + '/>';
|
||
break;
|
||
}
|
||
default: // circle
|
||
path = '<circle cx="' + c + '" cy="' + c + '" r="' + (c - 2) +
|
||
'" fill="' + fill + '"' + strokeAttr + '/>';
|
||
}
|
||
return '<svg width="' + size + '" height="' + size +
|
||
'" viewBox="0 0 ' + size + ' ' + size +
|
||
'" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">' + path + '</svg>';
|
||
};
|
||
|
||
window.ROLE_SORT = ['repeater', 'companion', 'room', 'sensor', 'observer'];
|
||
|
||
// ─── Health thresholds (ms) ───
|
||
window.HEALTH_THRESHOLDS = {
|
||
infraDegradedMs: 86400000, // 24h
|
||
infraSilentMs: 259200000, // 72h
|
||
nodeDegradedMs: 3600000, // 1h
|
||
nodeSilentMs: 86400000 // 24h
|
||
};
|
||
|
||
// Helper: get degraded/silent thresholds for a role (backward compat)
|
||
window.getHealthThresholds = function (role) {
|
||
var isInfra = role === 'repeater' || role === 'room';
|
||
return {
|
||
degradedMs: isInfra ? HEALTH_THRESHOLDS.infraDegradedMs : HEALTH_THRESHOLDS.nodeDegradedMs,
|
||
silentMs: isInfra ? HEALTH_THRESHOLDS.infraSilentMs : HEALTH_THRESHOLDS.nodeSilentMs
|
||
};
|
||
};
|
||
|
||
// Simplified two-state helper: returns 'active' or 'stale'.
|
||
// Accepts either a full node object (preferred) or legacy (role, lastSeenMs).
|
||
//
|
||
// #1598: for infra roles (repeater/room), freshness is the max of the
|
||
// ADVERT-based timestamp and last_relayed (resolved path participation).
|
||
// Operators increasingly run long or disabled advert intervals (firmware
|
||
// default trajectory is 47h flood adverts), so an actively-relaying
|
||
// backbone repeater must not be marked stale just because its last
|
||
// ADVERT is old.
|
||
window.getNodeStatus = function (roleOrNode, lastSeenMs) {
|
||
var role, effectiveMs;
|
||
if (roleOrNode && typeof roleOrNode === 'object') {
|
||
var n = roleOrNode;
|
||
role = n.role || 'companion';
|
||
// Freshness precedence mirrors the existing call sites:
|
||
// _liveSeen (live view, ms) > _lastHeard (health API) >
|
||
// last_heard (in-memory packets) > last_seen (DB, ADVERT).
|
||
var seenTime = n._lastHeard || n.last_heard || n.last_seen;
|
||
effectiveMs = (typeof n._liveSeen === 'number' && n._liveSeen) ||
|
||
(seenTime ? new Date(seenTime).getTime() : NaN);
|
||
var infra = String(role).toLowerCase() === 'repeater' ||
|
||
String(role).toLowerCase() === 'room';
|
||
if (infra && n.last_relayed) {
|
||
var relayedMs = new Date(n.last_relayed).getTime();
|
||
if (!(effectiveMs >= relayedMs)) effectiveMs = relayedMs;
|
||
}
|
||
if (isNaN(effectiveMs)) effectiveMs = undefined;
|
||
} else {
|
||
role = roleOrNode;
|
||
effectiveMs = lastSeenMs;
|
||
}
|
||
var isInfra = String(role || '').toLowerCase() === 'repeater' ||
|
||
String(role || '').toLowerCase() === 'room';
|
||
var staleMs = isInfra ? HEALTH_THRESHOLDS.infraSilentMs : HEALTH_THRESHOLDS.nodeSilentMs;
|
||
var age = typeof effectiveMs === 'number' ? (Date.now() - effectiveMs) : 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';
|
||
|
||
window.getTileUrl = function () {
|
||
var isDark = document.documentElement.getAttribute('data-theme') === 'dark' ||
|
||
(document.documentElement.getAttribute('data-theme') !== 'light' &&
|
||
window.matchMedia('(prefers-color-scheme: dark)').matches);
|
||
if (!isDark) return TILE_LIGHT;
|
||
// #1461 followup: honor customizer's dark-tile-provider pick (#1420 / #1430)
|
||
// when the registry is loaded. Falls back to TILE_DARK if absent.
|
||
try {
|
||
if (window.MC_getDarkTileProvider && window.MC_TILE_PROVIDERS) {
|
||
var id = window.MC_getDarkTileProvider();
|
||
var p = window.MC_TILE_PROVIDERS[id];
|
||
if (p && (p.url || p.baseUrl)) {
|
||
// #1614: providers added in #1533 (carto/osm/stamen) declare
|
||
// `url` as a function for lazy config resolution. Invoke it so
|
||
// we always return a string URL template; L.tileLayer otherwise
|
||
// stringifies the function source and every tile request 404s.
|
||
var u = p.url || p.baseUrl;
|
||
return (typeof u === 'function') ? u() : u;
|
||
}
|
||
}
|
||
} catch (_e) {}
|
||
return TILE_DARK;
|
||
};
|
||
/* Helper: get the full provider object (for callers that also need the
|
||
* invertFilter or refUrl/attribution). Returns null when no customizer
|
||
* provider applies (light mode, or registry not loaded). */
|
||
window.getActiveTileProvider = function () {
|
||
var isDark = document.documentElement.getAttribute('data-theme') === 'dark' ||
|
||
(document.documentElement.getAttribute('data-theme') !== 'light' &&
|
||
window.matchMedia('(prefers-color-scheme: dark)').matches);
|
||
if (!isDark) return null;
|
||
try {
|
||
if (window.MC_getDarkTileProvider && window.MC_TILE_PROVIDERS) {
|
||
var id = window.MC_getDarkTileProvider();
|
||
return window.MC_TILE_PROVIDERS[id] || null;
|
||
}
|
||
} catch (_e) {}
|
||
return null;
|
||
};
|
||
|
||
// ─── SNR thresholds ───
|
||
window.SNR_THRESHOLDS = { excellent: 6, good: 0 };
|
||
|
||
// ─── Distance thresholds (km) ───
|
||
window.DIST_THRESHOLDS = { local: 50, regional: 200 };
|
||
|
||
// ─── MAX_HOP_DIST (degrees, ~200km ≈ 1.8°) ───
|
||
window.MAX_HOP_DIST = 1.8;
|
||
|
||
// ─── Result limits ───
|
||
window.LIMITS = {
|
||
topNodes: 15,
|
||
topPairs: 12,
|
||
topRingNodes: 8,
|
||
topSenders: 10,
|
||
topCollisionNodes: 10,
|
||
recentReplay: 8,
|
||
feedMax: 25
|
||
};
|
||
|
||
// ─── Performance thresholds ───
|
||
window.PERF_SLOW_MS = 100;
|
||
|
||
// ─── WebSocket reconnect delay (ms) ───
|
||
window.WS_RECONNECT_MS = 3000;
|
||
|
||
// ─── Propagation buffer (ms) for realistic mode ───
|
||
window.PROPAGATION_BUFFER_MS = 5000;
|
||
|
||
// ─── Cache invalidation debounce (ms) ───
|
||
window.CACHE_INVALIDATE_MS = 5000;
|
||
|
||
// #1574 — operator-configurable cap on /live map node fetch (overridden
|
||
// from /api/config/client below). Default mirrors the historical
|
||
// hardcoded literal in public/live.js.
|
||
window.LIVE_MAP_MAX_NODES = 2000;
|
||
|
||
// ─── External URLs ───
|
||
window.EXTERNAL_URLS = {
|
||
flasher: 'https://flasher.meshcore.io/'
|
||
};
|
||
|
||
// ─── Fetch server overrides ───
|
||
window.MeshConfigReady = fetch('/api/config/client').then(function (r) { return r.json(); }).then(function (cfg) {
|
||
window.MC_CLIENT_RX_COVERAGE = cfg.clientRxCoverage === true;
|
||
// Coverage is opt-in: the nav link is NOT in static HTML (so the default-off
|
||
// nav matches upstream and the nav-overflow tests). Inject it after Analytics
|
||
// only when enabled, then nudge applyNavPriority (it re-runs on 'resize').
|
||
if (window.MC_CLIENT_RX_COVERAGE && !document.querySelector('.nav-links [data-route="rx-coverage"]')) {
|
||
var navAnchor = document.querySelector('.nav-links [data-route="analytics"]');
|
||
if (navAnchor) {
|
||
var covLink = document.createElement('a');
|
||
covLink.href = '#/rx-coverage';
|
||
covLink.className = 'nav-link';
|
||
covLink.setAttribute('data-route', 'rx-coverage');
|
||
covLink.innerHTML = '<svg class="ph-icon" aria-hidden="true" focusable="false"><use href="/icons/phosphor-sprite.svg#ph-broadcast"></use></svg> Coverage';
|
||
navAnchor.insertAdjacentElement('afterend', covLink);
|
||
window.dispatchEvent(new Event('resize'));
|
||
}
|
||
}
|
||
if (cfg.roles) {
|
||
if (cfg.roles.colors) {
|
||
// #1407 — ROLE_COLORS is now a live getter; merge into the override map.
|
||
for (var rk in cfg.roles.colors) _roleOverrides[rk] = cfg.roles.colors[rk];
|
||
}
|
||
if (cfg.roles.labels) Object.assign(ROLE_LABELS, cfg.roles.labels);
|
||
if (cfg.roles.style) {
|
||
// Same: merge color overrides only; shape/radius/weight come from _styleShapes.
|
||
for (var sk in cfg.roles.style) {
|
||
if (cfg.roles.style[sk] && cfg.roles.style[sk].color) _roleOverrides[sk] = cfg.roles.style[sk].color;
|
||
}
|
||
}
|
||
if (cfg.roles.emoji) Object.assign(ROLE_EMOJI, cfg.roles.emoji);
|
||
if (cfg.roles.sort) window.ROLE_SORT = cfg.roles.sort;
|
||
}
|
||
if (cfg.healthThresholds) Object.assign(HEALTH_THRESHOLDS, cfg.healthThresholds);
|
||
if (cfg.map) {
|
||
window.MC_MAP_CFG = cfg.map;
|
||
} else {
|
||
// Fallback for older configs
|
||
window.MC_MAP_CFG = { tiles: { providers: {} } };
|
||
}
|
||
// Backward compat for older tile URL overrides
|
||
if (cfg.tiles) {
|
||
if (cfg.tiles.dark) window.TILE_DARK = cfg.tiles.dark;
|
||
if (cfg.tiles.light) window.TILE_LIGHT = cfg.tiles.light;
|
||
} else if (cfg.map && cfg.map.tiles) {
|
||
if (cfg.map.tiles.darkUrl) window.TILE_DARK = cfg.map.tiles.darkUrl;
|
||
if (cfg.map.tiles.lightUrl) window.TILE_LIGHT = cfg.map.tiles.lightUrl;
|
||
}
|
||
if (typeof window.MC_initTileRegistry === 'function') window.MC_initTileRegistry(true);
|
||
// CARTO raster needs an API key now, so the bare TILE_DARK/TILE_LIGHT
|
||
// fallbacks have to pick it up as well — getTileUrl() returns TILE_LIGHT
|
||
// directly in light mode and never consults the registry. Explicit
|
||
// darkUrl/lightUrl overrides above still win.
|
||
if (typeof window.MC_tileUrlById === 'function') {
|
||
var _tOv = (cfg.tiles || (cfg.map && cfg.map.tiles) || {});
|
||
if (!_tOv.dark && !_tOv.darkUrl) window.TILE_DARK = window.MC_tileUrlById('carto-dark', window.TILE_DARK);
|
||
if (!_tOv.light && !_tOv.lightUrl) window.TILE_LIGHT = window.MC_tileUrlById('carto-light', window.TILE_LIGHT);
|
||
}
|
||
if (cfg.snrThresholds) Object.assign(SNR_THRESHOLDS, cfg.snrThresholds);
|
||
if (cfg.distThresholds) Object.assign(DIST_THRESHOLDS, cfg.distThresholds);
|
||
if (cfg.maxHopDist != null) window.MAX_HOP_DIST = cfg.maxHopDist;
|
||
if (cfg.limits) Object.assign(LIMITS, cfg.limits);
|
||
if (cfg.perfSlowMs != null) window.PERF_SLOW_MS = cfg.perfSlowMs;
|
||
if (cfg.wsReconnectMs != null) window.WS_RECONNECT_MS = cfg.wsReconnectMs;
|
||
if (cfg.cacheInvalidateMs != null) window.CACHE_INVALIDATE_MS = cfg.cacheInvalidateMs;
|
||
if (cfg.externalUrls) Object.assign(EXTERNAL_URLS, cfg.externalUrls);
|
||
if (cfg.propagationBufferMs != null) window.PROPAGATION_BUFFER_MS = cfg.propagationBufferMs;
|
||
// #1508 — expose operator-side customizer knobs to customize-v2.js.
|
||
// Default to an empty disabledTabs list so the .indexOf() guard in
|
||
// _renderTabs is a no-op when the field is absent.
|
||
window.MC_CUSTOMIZER_CFG = (cfg.customizer && typeof cfg.customizer === 'object')
|
||
? { disabledTabs: Array.isArray(cfg.customizer.disabledTabs) ? cfg.customizer.disabledTabs : [] }
|
||
: { disabledTabs: [] };
|
||
// #1574 — operator-configurable cap on /live map node count.
|
||
if (cfg.liveMapMaxNodes != null) window.LIVE_MAP_MAX_NODES = cfg.liveMapMaxNodes;
|
||
// #1784 — path trust threshold: minimum hash bytes for mapping evidence.
|
||
// Default 2 means 1-byte observations are excluded from topology/mapping.
|
||
window.PATH_TRUST = cfg.pathTrust && cfg.pathTrust.minHashBytesForMapping
|
||
? cfg.pathTrust.minHashBytesForMapping
|
||
: 2;
|
||
// Sync ROLE_STYLE colors with ROLE_COLORS
|
||
// #1407 — both are now live getters; no manual sync needed. Kept as no-op for clarity.
|
||
}).catch(function () { /* use defaults */ });
|
||
|
||
// ─── Built-in IATA airport code → city name mapping ───
|
||
window.IATA_CITIES = {
|
||
// United States
|
||
'SEA': 'Seattle, WA',
|
||
'SFO': 'San Francisco, CA',
|
||
'PDX': 'Portland, OR',
|
||
'LAX': 'Los Angeles, CA',
|
||
'DEN': 'Denver, CO',
|
||
'SLC': 'Salt Lake City, UT',
|
||
'PHX': 'Phoenix, AZ',
|
||
'DFW': 'Dallas, TX',
|
||
'ATL': 'Atlanta, GA',
|
||
'ORD': 'Chicago, IL',
|
||
'JFK': 'New York, NY',
|
||
'LGA': 'New York, NY',
|
||
'BOS': 'Boston, MA',
|
||
'MIA': 'Miami, FL',
|
||
'FLL': 'Fort Lauderdale, FL',
|
||
'IAH': 'Houston, TX',
|
||
'HOU': 'Houston, TX',
|
||
'MSP': 'Minneapolis, MN',
|
||
'DTW': 'Detroit, MI',
|
||
'CLT': 'Charlotte, NC',
|
||
'EWR': 'Newark, NJ',
|
||
'IAD': 'Washington, DC',
|
||
'DCA': 'Washington, DC',
|
||
'BWI': 'Baltimore, MD',
|
||
'LAS': 'Las Vegas, NV',
|
||
'MCO': 'Orlando, FL',
|
||
'TPA': 'Tampa, FL',
|
||
'BNA': 'Nashville, TN',
|
||
'AUS': 'Austin, TX',
|
||
'SAT': 'San Antonio, TX',
|
||
'RDU': 'Raleigh, NC',
|
||
'SAN': 'San Diego, CA',
|
||
'OAK': 'Oakland, CA',
|
||
'SJC': 'San Jose, CA',
|
||
'SMF': 'Sacramento, CA',
|
||
'PHL': 'Philadelphia, PA',
|
||
'PIT': 'Pittsburgh, PA',
|
||
'CLE': 'Cleveland, OH',
|
||
'CMH': 'Columbus, OH',
|
||
'CVG': 'Cincinnati, OH',
|
||
'IND': 'Indianapolis, IN',
|
||
'MCI': 'Kansas City, MO',
|
||
'STL': 'St. Louis, MO',
|
||
'MSY': 'New Orleans, LA',
|
||
'MEM': 'Memphis, TN',
|
||
'SDF': 'Louisville, KY',
|
||
'JAX': 'Jacksonville, FL',
|
||
'RIC': 'Richmond, VA',
|
||
'ORF': 'Norfolk, VA',
|
||
'BDL': 'Hartford, CT',
|
||
'PVD': 'Providence, RI',
|
||
'ABQ': 'Albuquerque, NM',
|
||
'OKC': 'Oklahoma City, OK',
|
||
'TUL': 'Tulsa, OK',
|
||
'OMA': 'Omaha, NE',
|
||
'BOI': 'Boise, ID',
|
||
'GEG': 'Spokane, WA',
|
||
'ANC': 'Anchorage, AK',
|
||
'HNL': 'Honolulu, HI',
|
||
'OGG': 'Maui, HI',
|
||
'BUF': 'Buffalo, NY',
|
||
'SYR': 'Syracuse, NY',
|
||
'ROC': 'Rochester, NY',
|
||
'ALB': 'Albany, NY',
|
||
'BTV': 'Burlington, VT',
|
||
'PWM': 'Portland, ME',
|
||
'MKE': 'Milwaukee, WI',
|
||
'DSM': 'Des Moines, IA',
|
||
'LIT': 'Little Rock, AR',
|
||
'BHM': 'Birmingham, AL',
|
||
'CHS': 'Charleston, SC',
|
||
'SAV': 'Savannah, GA',
|
||
// Canada
|
||
'YVR': 'Vancouver, BC',
|
||
'YYZ': 'Toronto, ON',
|
||
'YUL': 'Montreal, QC',
|
||
'YOW': 'Ottawa, ON',
|
||
'YYC': 'Calgary, AB',
|
||
'YEG': 'Edmonton, AB',
|
||
'YWG': 'Winnipeg, MB',
|
||
'YHZ': 'Halifax, NS',
|
||
'YQB': 'Quebec City, QC',
|
||
// Europe
|
||
'LHR': 'London, UK',
|
||
'LGW': 'London, UK',
|
||
'STN': 'London, UK',
|
||
'CDG': 'Paris, FR',
|
||
'ORY': 'Paris, FR',
|
||
'FRA': 'Frankfurt, DE',
|
||
'MUC': 'Munich, DE',
|
||
'BER': 'Berlin, DE',
|
||
'AMS': 'Amsterdam, NL',
|
||
'MAD': 'Madrid, ES',
|
||
'BCN': 'Barcelona, ES',
|
||
'FCO': 'Rome, IT',
|
||
'MXP': 'Milan, IT',
|
||
'ZRH': 'Zurich, CH',
|
||
'GVA': 'Geneva, CH',
|
||
'VIE': 'Vienna, AT',
|
||
'CPH': 'Copenhagen, DK',
|
||
'ARN': 'Stockholm, SE',
|
||
'OSL': 'Oslo, NO',
|
||
'HEL': 'Helsinki, FI',
|
||
'DUB': 'Dublin, IE',
|
||
'LIS': 'Lisbon, PT',
|
||
'ATH': 'Athens, GR',
|
||
'IST': 'Istanbul, TR',
|
||
'WAW': 'Warsaw, PL',
|
||
'PRG': 'Prague, CZ',
|
||
'BUD': 'Budapest, HU',
|
||
'OTP': 'Bucharest, RO',
|
||
'SOF': 'Sofia, BG',
|
||
'ZAG': 'Zagreb, HR',
|
||
'BEG': 'Belgrade, RS',
|
||
'KBP': 'Kyiv, UA',
|
||
'LED': 'St. Petersburg, RU',
|
||
'SVO': 'Moscow, RU',
|
||
'BRU': 'Brussels, BE',
|
||
'EDI': 'Edinburgh, UK',
|
||
'MAN': 'Manchester, UK',
|
||
// Asia
|
||
'NRT': 'Tokyo, JP',
|
||
'HND': 'Tokyo, JP',
|
||
'KIX': 'Osaka, JP',
|
||
'ICN': 'Seoul, KR',
|
||
'PEK': 'Beijing, CN',
|
||
'PVG': 'Shanghai, CN',
|
||
'HKG': 'Hong Kong',
|
||
'TPE': 'Taipei, TW',
|
||
'SIN': 'Singapore',
|
||
'BKK': 'Bangkok, TH',
|
||
'KUL': 'Kuala Lumpur, MY',
|
||
'CGK': 'Jakarta, ID',
|
||
'MNL': 'Manila, PH',
|
||
'DEL': 'New Delhi, IN',
|
||
'BOM': 'Mumbai, IN',
|
||
'BLR': 'Bangalore, IN',
|
||
'CCU': 'Kolkata, IN',
|
||
'SGN': 'Ho Chi Minh City, VN',
|
||
'HAN': 'Hanoi, VN',
|
||
'DOH': 'Doha, QA',
|
||
'DXB': 'Dubai, AE',
|
||
'AUH': 'Abu Dhabi, AE',
|
||
'TLV': 'Tel Aviv, IL',
|
||
// Oceania
|
||
'SYD': 'Sydney, AU',
|
||
'MEL': 'Melbourne, AU',
|
||
'BNE': 'Brisbane, AU',
|
||
'PER': 'Perth, AU',
|
||
'AKL': 'Auckland, NZ',
|
||
'WLG': 'Wellington, NZ',
|
||
'CHC': 'Christchurch, NZ',
|
||
// South America
|
||
'GRU': 'São Paulo, BR',
|
||
'GIG': 'Rio de Janeiro, BR',
|
||
'EZE': 'Buenos Aires, AR',
|
||
'SCL': 'Santiago, CL',
|
||
'BOG': 'Bogota, CO',
|
||
'LIM': 'Lima, PE',
|
||
'UIO': 'Quito, EC',
|
||
'CCS': 'Caracas, VE',
|
||
'MVD': 'Montevideo, UY',
|
||
// Africa
|
||
'JNB': 'Johannesburg, ZA',
|
||
'CPT': 'Cape Town, ZA',
|
||
'CAI': 'Cairo, EG',
|
||
'NBO': 'Nairobi, KE',
|
||
'ADD': 'Addis Ababa, ET',
|
||
'CMN': 'Casablanca, MA',
|
||
'LOS': 'Lagos, NG'
|
||
};
|
||
|
||
// Copy text to clipboard with fallback for Firefox and older browsers
|
||
window.copyToClipboard = function(text, onSuccess, onFail) {
|
||
function fallback() {
|
||
var ta = document.createElement('textarea');
|
||
ta.value = text;
|
||
ta.style.position = 'fixed';
|
||
ta.style.left = '-9999px';
|
||
ta.style.opacity = '0';
|
||
document.body.appendChild(ta);
|
||
ta.focus();
|
||
ta.select();
|
||
try {
|
||
var ok = document.execCommand('copy');
|
||
document.body.removeChild(ta);
|
||
if (ok && onSuccess) onSuccess();
|
||
else if (!ok && onFail) onFail();
|
||
} catch (e) {
|
||
document.body.removeChild(ta);
|
||
if (onFail) onFail();
|
||
}
|
||
}
|
||
if (navigator.clipboard && navigator.clipboard.writeText) {
|
||
navigator.clipboard.writeText(text).then(
|
||
function() { if (onSuccess) onSuccess(); },
|
||
function() { fallback(); }
|
||
);
|
||
} else {
|
||
fallback();
|
||
}
|
||
};
|
||
|
||
// Simple markdown → HTML (bold, italic, links, code, lists, line breaks)
|
||
window.miniMarkdown = function(text) {
|
||
if (!text) return '';
|
||
var html = text
|
||
.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')
|
||
.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>')
|
||
.replace(/\*(.+?)\*/g, '<em>$1</em>')
|
||
.replace(/`(.+?)`/g, '<code>$1</code>')
|
||
.replace(/\[([^\]]+)\]\(([^)]+)\)/g, '<a href="$2" target="_blank" rel="noopener" style="color:var(--link-color)">$1</a>')
|
||
.replace(/^- (.+)/gm, '<li>$1</li>')
|
||
.replace(/\n/g, '<br>');
|
||
// Wrap consecutive <li> in <ul>
|
||
html = html.replace(/((?:<li>.*?<\/li><br>?)+)/g, function(m) {
|
||
return '<ul>' + m.replace(/<br>/g, '') + '</ul>';
|
||
});
|
||
return html;
|
||
};
|
||
|
||
// #690 — Clock Skew shared helpers
|
||
var SKEW_SEVERITY_COLORS = {
|
||
ok: 'var(--status-green)',
|
||
warning: 'var(--status-yellow)',
|
||
critical: 'var(--status-orange)',
|
||
absurd: 'var(--status-purple)',
|
||
bimodal_clock: 'var(--status-amber)',
|
||
no_clock: 'var(--text-muted)'
|
||
};
|
||
var SKEW_SEVERITY_LABELS = {
|
||
ok: 'OK', warning: 'Warning', critical: 'Critical', absurd: 'Absurd', bimodal_clock: 'Bimodal', no_clock: 'No Clock'
|
||
};
|
||
var SKEW_SEVERITY_ORDER = { no_clock: 0, bimodal_clock: 1, absurd: 2, critical: 3, warning: 4, ok: 5 };
|
||
|
||
window.SKEW_SEVERITY_COLORS = SKEW_SEVERITY_COLORS;
|
||
window.SKEW_SEVERITY_LABELS = SKEW_SEVERITY_LABELS;
|
||
window.SKEW_SEVERITY_ORDER = SKEW_SEVERITY_ORDER;
|
||
|
||
/** Format skew seconds into human-readable string like "+2m 34s" or "-15h 22m" */
|
||
window.formatSkew = function(sec) {
|
||
if (sec == null) return '—';
|
||
var abs = Math.abs(sec);
|
||
var sign = sec >= 0 ? '+' : '-';
|
||
if (abs < 60) return sign + Math.round(abs) + 's';
|
||
if (abs < 3600) return sign + Math.floor(abs / 60) + 'm ' + Math.round(abs % 60) + 's';
|
||
if (abs < 86400) return sign + Math.floor(abs / 3600) + 'h ' + Math.round((abs % 3600) / 60) + 'm';
|
||
return sign + Math.floor(abs / 86400) + 'd ' + Math.round((abs % 86400) / 3600) + 'h';
|
||
};
|
||
|
||
/** Format drift rate as "+X.Xs/day" or "—" if falsy */
|
||
window.formatDrift = function(secPerDay) {
|
||
if (!secPerDay) return '—';
|
||
return (secPerDay >= 0 ? '+' : '') + secPerDay.toFixed(1) + ' s/day';
|
||
};
|
||
|
||
/** Pick the skew value that drives current-health UI: prefer the
|
||
* recent-window median (#789, current health) over the all-time median
|
||
* (poisoned by historical bad samples). Falls back gracefully if the
|
||
* field isn't present (older API responses). */
|
||
window.currentSkewValue = function(cs) {
|
||
if (!cs) return null;
|
||
return cs.recentMedianSkewSec != null ? cs.recentMedianSkewSec : cs.medianSkewSec;
|
||
};
|
||
|
||
/** Render a clock skew badge HTML */
|
||
window.renderSkewBadge = function(severity, skewSec, cs) {
|
||
if (!severity) return '';
|
||
var cls = 'skew-badge skew-badge--' + severity;
|
||
if (severity === 'no_clock') {
|
||
return '<span class="' + cls + '" title="Uninitialized RTC — no valid clock"><svg class="ph-icon" aria-hidden="true" focusable="false"><use href="/icons/phosphor-sprite.svg#ph-prohibit"/></svg> No Clock</span>';
|
||
}
|
||
if (severity === 'bimodal_clock' && cs) {
|
||
var badPct = cs.goodFraction != null ? Math.round((1 - cs.goodFraction) * 100) : '?';
|
||
var label = '⏰ ' + window.formatSkew(skewSec);
|
||
return '<span class="' + cls + '" title="Clock skew: ' + window.formatSkew(skewSec) + ' (bimodal: ' + badPct + '% of recent adverts have nonsense timestamps)">' + label + '</span>';
|
||
}
|
||
var label = severity === 'ok' ? '⏰' : '⏰ ' + window.formatSkew(skewSec);
|
||
return '<span class="' + cls + '" title="Clock skew: ' + window.formatSkew(skewSec) + ' (' + (SKEW_SEVERITY_LABELS[severity] || severity) + ')">' + label + '</span>';
|
||
};
|
||
|
||
/** Compute severity for an observer's clock offset (seconds). */
|
||
window.observerSkewSeverity = function(offsetSec) {
|
||
var abs = Math.abs(offsetSec);
|
||
return abs >= 3600 ? 'critical' : abs >= 300 ? 'warning' : 'ok';
|
||
};
|
||
|
||
/** Render a skew sparkline SVG (inline, word-sized) */
|
||
window.renderSkewSparkline = function(samples, w, h) {
|
||
w = w || 120; h = h || 24;
|
||
if (!samples || samples.length < 2) return '';
|
||
var values = samples.map(function(s) { return s.skew; });
|
||
var max = Math.max.apply(null, values.map(function(v) { return Math.abs(v); }).concat([1]));
|
||
var pts = values.map(function(v, i) {
|
||
var x = i * (w / Math.max(values.length - 1, 1));
|
||
var y = h / 2 - (v / max) * (h / 2 - 2);
|
||
return x.toFixed(1) + ',' + y.toFixed(1);
|
||
}).join(' ');
|
||
// Zero line
|
||
var zeroY = h / 2;
|
||
return '<svg viewBox="0 0 ' + w + ' ' + h + '" style="width:' + w + 'px;height:' + h + 'px" role="img" aria-label="Clock skew sparkline">' +
|
||
'<title>Clock skew over time</title>' +
|
||
'<line x1="0" y1="' + zeroY + '" x2="' + w + '" y2="' + zeroY + '" stroke="var(--border)" stroke-width="0.5" stroke-dasharray="2"/>' +
|
||
'<polyline points="' + pts + '" fill="none" stroke="var(--accent)" stroke-width="1.5"/></svg>';
|
||
};
|
||
})();
|