mirror of
https://github.com/Kpa-clawbot/meshcore-analyzer.git
synced 2026-05-12 10:55:14 +00:00
bf68a99acf
Fixes #1105. Polish follow-ups from #1104's independent review (https://github.com/Kpa-clawbot/CoreScope/pull/1104#issuecomment-4381850096). All 9 MINORs addressed. ## Hardening (`public/app.js`, commitfa58cb6) 1. **`GUTTER = 24` magic constant** → live `getComputedStyle(navLeft).columnGap` read. The "matches `--space-lg`" assertion now lives in CSS, not a stale JS literal. 2. **`fits()` conflated two distinct gaps** → reads `.nav-left`'s gap (between brand/links/more/right cells) and `.nav-links`'s gap (between link items) separately. Today both are `--space-lg=24px`, but a future divergence won't silently miscompute fit. 3. **Implicit 1101px media-query flip dependency** → comment added explaining that `.nav-stats` toggles `display:none ↔ flex` at the boundary, and the rAF-debounced resize handler runs *after* the layout flip so `navRightEl.scrollWidth` reflects the post-flip value. 4. **Outer null-guard widened** → now also covers `linksContainer`, `navRightEl`, `navLeft`, `navTop`. Belt-and-braces. 5. **Cloned link listener parity** → More-menu clones now also get `closeNav()` in addition to `closeMoreMenu()`, matching the listener inline links get at hamburger init. Clicks from the More menu now collapse the hamburger panel just like inline link clicks. 6. **`overflowQueue` ordering** → comment added documenting the `data-priority="high"` signal + reverse construction; explicit numeric-priority migration path noted. 7. **`moreW` hard-coded `70` fallback** → now caches the live measured width the first time the More button is rendered visible; `MORE_BTN_RESERVE_PX = 70` only used as the conservative initial guess until that capture happens. ## Tests (`test-nav-priority-1102-e2e.js`, commit5e9872c) 8. **Identity, not cardinality** (MINOR 7): at 1080/800px the test asserts the visible set is EXACTLY `[#/home, #/packets, #/map, #/live, #/nodes]`. A buggy queue that hid Home and showed Lab would still pass `visibleCount >= 5` — that's no longer enough. 9. **Active-mirroring** (MINOR 9): new case navigates to `#/observers` at 1080px (a route whose link overflows into the More menu) and asserts the inline link is overflowed, the More-menu clone has `.active`, and `#navMoreBtn` has `.active`. Exercises `rebuildMoreMenu`'s active-mirroring path, which depends on `applyNavPriority` running on `hashchange` after the route handler. 10. **CI hookup** (MINOR 8): `deploy.yml` now runs `test-nav-priority-1102-e2e.js` with `CHROMIUM_REQUIRE=1`, so a Chromium provisioning regression fails the build instead of silently SKIPing (matching the existing `test-nav-fluid-1055-e2e.js` invocation). ## Why no red-then-green Per AGENTS.md TDD section: hardening commit is a pure code-quality/null-guard refactor — existing tests stay green and unaltered (the loose `visibleCount >=` assertions still pass against the new code). Test-improvement commit tightens assertions for behaviour that already works (high-priority pinning, active-mirroring); there's no production change to gate. Both branches of "exempt from red→green" are documented in the commit messages. ## E2E / browser validation Test runs against the Go server fixture (`-port 13581 -db test-fixtures/e2e-fixture.db`). All 5 cases (4 viewport cases + new active-mirror case) expected to pass; CI will run them with `CHROMIUM_REQUIRE=1` so any Chromium provisioning regression hard-fails. --------- Co-authored-by: openclaw-bot <bot@openclaw.local>
1542 lines
64 KiB
JavaScript
1542 lines
64 KiB
JavaScript
/* === 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; }
|
||
function transportBadge(rt) { return isTransportRoute(rt) ? ' <span class="badge badge-transport" title="' + routeTypeName(rt) + '">T</span>' : ''; }
|
||
|
||
/**
|
||
* 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 () => {
|
||
const res = await fetch('/api' + path);
|
||
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;
|
||
})();
|
||
_inflight.set(path, promise);
|
||
promise.finally(() => _inflight.delete(path));
|
||
return promise;
|
||
}
|
||
|
||
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] || '');
|
||
}
|
||
|
||
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;
|
||
}
|
||
|
||
function formatEngineBadge(engine) {
|
||
if (!engine) return '';
|
||
return ` <span class="engine-badge">${engine}</span>`;
|
||
}
|
||
|
||
function formatVersionBadge(version, commit, engine, buildTime) {
|
||
if (!version && !commit && !engine) return '';
|
||
var buildAge = '';
|
||
if (buildTime && buildTime !== 'unknown') {
|
||
var age = timeAgo(buildTime);
|
||
if (age && age !== '—') buildAge = ' <span class="build-age">(' + age + ')</span>';
|
||
}
|
||
var port = (typeof location !== 'undefined' && location.port) || '';
|
||
var isProd = !port || port === '80' || port === '443';
|
||
var GH = 'https://github.com/Kpa-clawbot/corescope';
|
||
var parts = [];
|
||
if (version && isProd) {
|
||
var vTag = version.charAt(0) === 'v' ? version : 'v' + version;
|
||
parts.push('<a href="' + GH + '/releases/tag/' + vTag + '" target="_blank" rel="noopener">' + vTag + '</a>');
|
||
}
|
||
if (commit && commit !== 'unknown') {
|
||
var short = commit.length > 7 ? commit.slice(0, 7) : commit;
|
||
parts.push('<a href="' + GH + '/commit/' + commit + '" target="_blank" rel="noopener">' + short + '</a>' + buildAge);
|
||
}
|
||
if (engine) parts.push('<span class="engine-badge">' + engine + '</span>');
|
||
if (parts.length === 0) return '';
|
||
return ' <span class="version-badge">' + parts.join(' · ') + '</span>';
|
||
}
|
||
|
||
// --- 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 favStar(pubkey, cls) {
|
||
const on = isFavorite(pubkey);
|
||
return '<button class="fav-star ' + (cls || '') + (on ? ' on' : '') + '" data-fav="' + pubkey + '" title="' + (on ? 'Remove from favorites' : 'Add to favorites') + '">' + (on ? '★' : '☆') + '</button>';
|
||
}
|
||
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.textContent = nowOn ? '★' : '☆';
|
||
btn.classList.toggle('on', nowOn);
|
||
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 `<span class="hex-byte">${formatHex(hex)}</span>`;
|
||
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 += '</span>';
|
||
html += `<span class="hex-byte ${cls}">`;
|
||
prevCls = cls;
|
||
} else {
|
||
html += ' ';
|
||
}
|
||
html += bytes[i];
|
||
}
|
||
if (prevCls !== null) html += '</span>';
|
||
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 += `<span><span class="swatch" style="background:${bg}"></span>${r.label}</span>`;
|
||
}
|
||
return html;
|
||
}
|
||
|
||
// --- WebSocket ---
|
||
let ws = null;
|
||
let wsListeners = [];
|
||
|
||
function connectWS() {
|
||
const proto = location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||
ws = new WebSocket(`${proto}//${location.host}`);
|
||
ws.onopen = () => document.getElementById('liveDot')?.classList.add('connected');
|
||
ws.onclose = () => {
|
||
document.getElementById('liveDot')?.classList.remove('connected');
|
||
setTimeout(connectWS, 3000);
|
||
};
|
||
ws.onerror = () => ws.close();
|
||
ws.onmessage = (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 = '<span class="prr-icon">⟳</span>';
|
||
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 */
|
||
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 =
|
||
'<div class="tools-landing">' +
|
||
'<h2>Tools</h2>' +
|
||
'<div class="tools-menu">' +
|
||
'<a href="#/tools/path-inspector" class="tools-card"><h3>🔍 Path Inspector</h3><p>Resolve prefix paths to candidate full-pubkey routes with confidence scoring.</p></a>' +
|
||
'<a href="#/tools/trace/" class="tools-card"><h3>📡 Trace Viewer</h3><p>View detailed packet traces by hash.</p></a>' +
|
||
'</div>' +
|
||
'</div>';
|
||
},
|
||
destroy: function () {}
|
||
});
|
||
|
||
let currentPage = null;
|
||
|
||
function closeNav() {
|
||
document.querySelector('.nav-links')?.classList.remove('open');
|
||
document.body.classList.remove('nav-open');
|
||
var btn = document.getElementById('hamburger');
|
||
if (btn) btn.setAttribute('aria-expanded', 'false');
|
||
closeMoreMenu();
|
||
}
|
||
|
||
function closeMoreMenu() {
|
||
var menu = document.getElementById('navMoreMenu');
|
||
var btn = document.getElementById('navMoreBtn');
|
||
if (menu) menu.classList.remove('open');
|
||
if (btn) btn.setAttribute('aria-expanded', 'false');
|
||
}
|
||
|
||
function navigate() {
|
||
closeNav();
|
||
|
||
// Backward-compat redirect: #/traces/<hash> → #/tools/trace/<hash> (issue #944).
|
||
if (location.hash.startsWith('#/traces/')) {
|
||
location.hash = location.hash.replace('#/traces/', '#/tools/trace/');
|
||
return;
|
||
}
|
||
|
||
// Backward-compat redirect: #/roles → #/analytics?tab=roles (issue #1085).
|
||
// The Roles page was folded into the Analytics tab strip; old links and
|
||
// bookmarks must keep working.
|
||
if (location.hash === '#/roles' || location.hash.startsWith('#/roles?') || location.hash.startsWith('#/roles/')) {
|
||
location.hash = '#/analytics?tab=roles';
|
||
return;
|
||
}
|
||
|
||
const hash = location.hash.replace('#/', '') || 'packets';
|
||
const route = hash.split('?')[0];
|
||
|
||
// Handle parameterized routes: nodes/<pubkey> → nodes page + select
|
||
let basePage = route;
|
||
let routeParam = null;
|
||
const slashIdx = route.indexOf('/');
|
||
if (slashIdx > 0) {
|
||
basePage = route.substring(0, slashIdx);
|
||
routeParam = decodeURIComponent(route.substring(slashIdx + 1));
|
||
}
|
||
|
||
// Special route: nodes/PUBKEY/analytics → node-analytics page
|
||
if (basePage === 'nodes' && routeParam && routeParam.endsWith('/analytics')) {
|
||
basePage = 'node-analytics';
|
||
}
|
||
|
||
// Special route: packet/123 → standalone packet detail page
|
||
if (basePage === 'packet' && routeParam) {
|
||
basePage = 'packet-detail';
|
||
}
|
||
|
||
// Special route: observers/ID → observer detail page
|
||
if (basePage === 'observers' && routeParam) {
|
||
basePage = 'observer-detail';
|
||
}
|
||
|
||
// Tools sub-routing (issue #944): tools/trace/<hash>, tools/path-inspector
|
||
if (basePage === 'tools') {
|
||
if (routeParam && routeParam.startsWith('trace/')) {
|
||
basePage = 'traces';
|
||
routeParam = routeParam.substring(6); // strip "trace/"
|
||
} else if (routeParam === 'path-inspector' || (routeParam && routeParam.startsWith('path-inspector'))) {
|
||
basePage = 'path-inspector';
|
||
routeParam = null;
|
||
} else if (!routeParam) {
|
||
// Default tools landing shows menu with both entries.
|
||
basePage = 'tools-landing';
|
||
}
|
||
}
|
||
// Also support old #/traces (no sub-path) → traces page.
|
||
if (basePage === 'traces' && !routeParam) {
|
||
basePage = 'traces';
|
||
}
|
||
|
||
// Update nav active state
|
||
document.querySelectorAll('.nav-link[data-route]').forEach(el => {
|
||
el.classList.toggle('active', el.dataset.route === basePage || (el.dataset.route === 'tools' && (basePage === 'traces' || basePage === 'path-inspector' || basePage === 'tools-landing')));
|
||
});
|
||
// Update "More" button to show active state if a low-priority page is selected
|
||
var moreBtn = document.getElementById('navMoreBtn');
|
||
if (moreBtn) {
|
||
var moreMenu = document.getElementById('navMoreMenu');
|
||
var hasActiveMore = moreMenu && moreMenu.querySelector('.nav-link.active');
|
||
moreBtn.classList.toggle('active', !!hasActiveMore);
|
||
}
|
||
|
||
if (currentPage && pages[currentPage]?.destroy) {
|
||
pages[currentPage].destroy();
|
||
}
|
||
currentPage = basePage;
|
||
|
||
const app = document.getElementById('app');
|
||
// Pages with fixed-height containers (maps, virtual-scroll, split-panels)
|
||
const fixedPages = { packets: 1, nodes: 1, map: 1, live: 1, channels: 1, 'audio-lab': 1 };
|
||
app.classList.toggle('app-fixed', basePage in fixedPages);
|
||
if (pages[basePage]?.init) {
|
||
const t0 = performance.now();
|
||
pages[basePage].init(app, routeParam);
|
||
const ms = performance.now() - t0;
|
||
if (ms > 100) console.warn(`[SLOW PAGE] ${basePage} init took ${Math.round(ms)}ms`);
|
||
app.classList.remove('page-enter'); void app.offsetWidth; app.classList.add('page-enter');
|
||
// #630-7: SPA focus management — move focus to first heading or main content
|
||
requestAnimationFrame(function() {
|
||
var heading = app.querySelector('h1, h2, h3, [role="heading"]');
|
||
if (heading) { heading.setAttribute('tabindex', '-1'); heading.focus({ preventScroll: true }); }
|
||
else { app.setAttribute('tabindex', '-1'); app.focus({ preventScroll: true }); }
|
||
});
|
||
} else {
|
||
app.innerHTML = `<div style="padding:40px;text-align:center;color:#6b7280"><h2>${route}</h2><p>Page not yet implemented.</p></div>`;
|
||
}
|
||
}
|
||
|
||
window.addEventListener('hashchange', navigate);
|
||
let _themeRefreshTimer = null;
|
||
window.addEventListener('theme-changed', () => {
|
||
if (_themeRefreshTimer) clearTimeout(_themeRefreshTimer);
|
||
_themeRefreshTimer = setTimeout(() => {
|
||
_themeRefreshTimer = null;
|
||
window.dispatchEvent(new CustomEvent('theme-refresh'));
|
||
}, 300);
|
||
});
|
||
window.addEventListener('timestamp-mode-changed', () => {
|
||
window.dispatchEvent(new CustomEvent('theme-refresh'));
|
||
});
|
||
window.addEventListener('DOMContentLoaded', () => {
|
||
connectWS();
|
||
setupPullToReconnect();
|
||
|
||
// --- Dark Mode ---
|
||
const darkToggle = document.getElementById('darkModeToggle');
|
||
const savedTheme = localStorage.getItem('meshcore-theme');
|
||
function applyTheme(theme) {
|
||
document.documentElement.setAttribute('data-theme', theme);
|
||
darkToggle.textContent = theme === 'dark' ? '🌙' : '☀️';
|
||
localStorage.setItem('meshcore-theme', theme);
|
||
// Re-apply user theme CSS vars for the correct mode (light/dark)
|
||
reapplyUserThemeVars(theme === 'dark');
|
||
}
|
||
function reapplyUserThemeVars(dark) {
|
||
try {
|
||
var userTheme = JSON.parse(localStorage.getItem('meshcore-user-theme') || '{}');
|
||
if (!userTheme.theme && !userTheme.themeDark) {
|
||
// Fall back to server config
|
||
var cfg = window.SITE_CONFIG || {};
|
||
if (!cfg.theme && !cfg.themeDark) return;
|
||
userTheme = cfg;
|
||
}
|
||
var themeData = dark ? Object.assign({}, userTheme.theme || {}, userTheme.themeDark || {}) : (userTheme.theme || {});
|
||
if (!Object.keys(themeData).length) return;
|
||
var varMap = {
|
||
accent: '--accent', accentHover: '--accent-hover',
|
||
navBg: '--nav-bg', navBg2: '--nav-bg2', navText: '--nav-text', navTextMuted: '--nav-text-muted',
|
||
background: '--surface-0', text: '--text', textMuted: '--text-muted', border: '--border',
|
||
statusGreen: '--status-green', statusYellow: '--status-yellow', statusRed: '--status-red',
|
||
surface1: '--surface-1', surface2: '--surface-2', surface3: '--surface-3',
|
||
cardBg: '--card-bg', contentBg: '--content-bg', inputBg: '--input-bg',
|
||
rowStripe: '--row-stripe', rowHover: '--row-hover', detailBg: '--detail-bg',
|
||
selectedBg: '--selected-bg', sectionBg: '--section-bg',
|
||
font: '--font', mono: '--mono'
|
||
};
|
||
var root = document.documentElement.style;
|
||
for (var key in varMap) {
|
||
if (themeData[key]) root.setProperty(varMap[key], themeData[key]);
|
||
}
|
||
if (themeData.background) root.setProperty('--content-bg', themeData.contentBg || themeData.background);
|
||
if (themeData.surface1) root.setProperty('--card-bg', themeData.cardBg || themeData.surface1);
|
||
// Nav gradient
|
||
if (themeData.navBg) {
|
||
var nav = document.querySelector('.top-nav');
|
||
if (nav) { nav.style.background = ''; void nav.offsetHeight; }
|
||
}
|
||
} catch (e) { console.error('[theme] reapply error:', e); }
|
||
}
|
||
// On load: respect saved pref, else OS pref, else light
|
||
if (savedTheme) {
|
||
applyTheme(savedTheme);
|
||
} else if (window.matchMedia('(prefers-color-scheme: dark)').matches) {
|
||
applyTheme('dark');
|
||
} else {
|
||
applyTheme('light');
|
||
}
|
||
darkToggle.addEventListener('click', () => {
|
||
const isDark = document.documentElement.getAttribute('data-theme') === 'dark';
|
||
applyTheme(isDark ? 'light' : 'dark');
|
||
});
|
||
|
||
// --- Hamburger Menu ---
|
||
const hamburger = document.getElementById('hamburger');
|
||
const navLinks = document.querySelector('.nav-links');
|
||
hamburger.addEventListener('click', () => {
|
||
const opening = !navLinks.classList.contains('open');
|
||
navLinks.classList.toggle('open');
|
||
document.body.classList.toggle('nav-open');
|
||
hamburger.setAttribute('aria-expanded', String(opening));
|
||
});
|
||
navLinks.querySelectorAll('.nav-link').forEach(link => {
|
||
link.addEventListener('click', closeNav);
|
||
});
|
||
|
||
// --- "More" dropdown — JS-driven Priority+ (Issue #1102) ---
|
||
const navMoreBtn = document.getElementById('navMoreBtn');
|
||
const navMoreMenu = document.getElementById('navMoreMenu');
|
||
const navMoreWrap = document.querySelector('.nav-more-wrap');
|
||
const navTop = document.querySelector('.top-nav');
|
||
const navLeft = document.querySelector('.nav-left');
|
||
const navRightEl = document.querySelector('.nav-right');
|
||
const linksContainer = document.querySelector('.nav-links');
|
||
// Belt-and-braces null guards (#1105 MINOR 4): the outer block measures
|
||
// and mutates all of these; if any are missing the layout math throws
|
||
// before we can fall back gracefully.
|
||
if (navMoreBtn && navMoreMenu && navMoreWrap && navLeft && navRightEl && linksContainer && navTop) {
|
||
// Measure available room and decide which links overflow.
|
||
// Algorithm: try to fit all links inline. If the link strip doesn't
|
||
// fit alongside .nav-right + .nav-brand, hide non-priority links one
|
||
// at a time (right-to-left, lowest priority first) until it does.
|
||
// Then mirror the hidden links into the "More ▾" menu so nothing
|
||
// disappears from the user's reach.
|
||
const allLinks = Array.from(linksContainer.querySelectorAll('.nav-link'));
|
||
// overflowQueue (#1105 MINOR 6): the order links are removed from the
|
||
// inline strip when space runs out. Built right-to-left from
|
||
// non-priority links (lowest priority dropped first) and then high-
|
||
// priority links as a last-resort tail. `data-priority="high"` is the
|
||
// only signal — if you ever need finer ordering, switch to a numeric
|
||
// attribute (e.g. data-overflow-order="3") rather than re-shuffling
|
||
// index in HTML.
|
||
const overflowQueue = allLinks.filter(a => a.dataset.priority !== 'high')
|
||
.reverse() // right-to-left
|
||
.concat(allLinks.filter(a => a.dataset.priority === 'high').reverse());
|
||
|
||
function rebuildMoreMenu() {
|
||
navMoreMenu.innerHTML = '';
|
||
const hidden = allLinks.filter(a => a.classList.contains('is-overflow'));
|
||
hidden.forEach(function(link) {
|
||
var clone = link.cloneNode(true);
|
||
// The clone is in the overflow menu, not the inline strip.
|
||
clone.classList.remove('is-overflow');
|
||
clone.setAttribute('role', 'menuitem');
|
||
// cloneNode(true) preserves DOM but NOT event listeners. The
|
||
// originals get `closeNav` attached up above (#1105 MINOR 5);
|
||
// mirror that here so a click on the More-menu clone behaves
|
||
// identically to a click on the inline link (closes the
|
||
// hamburger panel + dismisses the More menu).
|
||
clone.addEventListener('click', closeNav);
|
||
clone.addEventListener('click', closeMoreMenu);
|
||
navMoreMenu.appendChild(clone);
|
||
});
|
||
// If nothing overflows, hide the More button entirely so wide
|
||
// viewports don't show a useless dropdown trigger.
|
||
navMoreWrap.classList.toggle('is-hidden', hidden.length === 0);
|
||
// Refresh active state on the More button (a hidden active link
|
||
// means the More menu currently "is" the active section).
|
||
var hasActiveMore = navMoreMenu.querySelector('.nav-link.active');
|
||
navMoreBtn.classList.toggle('active', !!hasActiveMore);
|
||
}
|
||
|
||
// #1105 MINOR 1: cached intrinsic width of the More button. Captured
|
||
// the first time `fits()` sees navMoreWrap rendered (display:flex).
|
||
// Falls back to MORE_BTN_RESERVE_PX (a conservative initial guess
|
||
// sized for "More ▾" at default font/padding) until that happens.
|
||
var cachedMoreW = 0;
|
||
var MORE_BTN_RESERVE_PX = 70;
|
||
|
||
function applyNavPriority() {
|
||
// Skip on mobile (<768px) — hamburger CSS owns that layout.
|
||
if (window.innerWidth < 768) {
|
||
allLinks.forEach(a => a.classList.remove('is-overflow'));
|
||
navMoreWrap.classList.add('is-hidden');
|
||
return;
|
||
}
|
||
// Reset: show everything, then hide as needed.
|
||
allLinks.forEach(a => a.classList.remove('is-overflow'));
|
||
navMoreWrap.classList.remove('is-hidden');
|
||
// #1106: in the 768-1100px narrow-desktop band the CSS already
|
||
// hides .nav-stats and tightens .nav-link padding (see the
|
||
// "Nav narrow-desktop tightening" media query in style.css).
|
||
// The design intent of that band is "show exactly the 5 high-
|
||
// priority links + More". Pure measurement says everything fits
|
||
// (~981px needed in a 1080px viewport once nav-stats is gone),
|
||
// but the design contract — locked by test-nav-priority-1102-
|
||
// e2e.js #1105 MINOR 7 — is exact identity, not "fits". Force-
|
||
// collapse all non-high-priority links inside this band so the
|
||
// overflow menu is non-empty and the high-priority set is the
|
||
// only thing inline. Above 1100px the measurement loop below
|
||
// owns the decision (and at 2560px nothing overflows).
|
||
if (window.innerWidth <= 1100) {
|
||
allLinks.forEach(a => {
|
||
if (a.dataset.priority !== 'high') a.classList.add('is-overflow');
|
||
});
|
||
rebuildMoreMenu();
|
||
return;
|
||
}
|
||
// Iteratively hide low-priority links until the link strip fits.
|
||
// .top-nav has overflow:hidden and .nav-left has flex-shrink:1, so
|
||
// an overflowing strip silently clips rather than pushing
|
||
// nav-right out — bounding-rect math on .nav-left lies. Instead
|
||
// measure the *intrinsic* widths of the parts (independent of
|
||
// current clipping) and compare to the viewport. SAFETY absorbs
|
||
// the .top-nav side padding + nav-right inner gaps + sub-pixel
|
||
// rounding (the historic #1055 bug was a 6–20px overlap).
|
||
//
|
||
// #1105 MINOR 3: at the 1101px media-query flip `.nav-stats`
|
||
// toggles from display:none → flex (and vice-versa). The resize
|
||
// handler is rAF-debounced and runs *after* the layout flip, so
|
||
// navRightEl.scrollWidth measured here reflects the post-flip
|
||
// intrinsic width — not stale pre-flip width.
|
||
const navBrand = document.querySelector('.nav-brand');
|
||
const SAFETY = 32;
|
||
// #1105 MINOR 1+2: read both gap values from CSS rather than a
|
||
// shared `GUTTER = 24` constant. Today `.nav-left` (gap between
|
||
// brand/links/more/right cells) and `.nav-links` (gap between
|
||
// individual link items) both resolve to --space-lg = 24px, but
|
||
// they're conceptually distinct gaps. If --space-lg or .nav-left's
|
||
// gap diverges in the future, the fit math must follow.
|
||
const navLeftGap = parseFloat(getComputedStyle(navLeft).columnGap ||
|
||
getComputedStyle(navLeft).gap || '0') || 0;
|
||
// #1105 MINOR 1: compute the More-button reserve from its actual
|
||
// rendered width on first measure, instead of a hard-coded 70px
|
||
// fallback. Cached so we don't re-measure (offsetWidth is 0 when
|
||
// display:none; we capture the value the first time it's visible).
|
||
function fits() {
|
||
const visibleLinks = allLinks.filter(a => !a.classList.contains('is-overflow'));
|
||
let linkW = 0;
|
||
visibleLinks.forEach(a => { linkW += a.getBoundingClientRect().width; });
|
||
const linkGapPx = parseFloat(getComputedStyle(linksContainer).columnGap ||
|
||
getComputedStyle(linksContainer).gap || '0') || 0;
|
||
const linksGap = Math.max(0, visibleLinks.length - 1) * linkGapPx;
|
||
const brandW = navBrand ? navBrand.getBoundingClientRect().width : 0;
|
||
// Always reserve space for the More button if anything could
|
||
// overflow. Measure the live width when visible and cache it
|
||
// for use when the button is currently hidden (display:none →
|
||
// getBoundingClientRect() returns 0). MORE_BTN_RESERVE_PX is
|
||
// the conservative initial fallback used until we get a real
|
||
// measurement.
|
||
const moreVis = !navMoreWrap.classList.contains('is-hidden');
|
||
const liveMoreW = moreVis ? navMoreWrap.getBoundingClientRect().width : 0;
|
||
if (liveMoreW > 0) cachedMoreW = liveMoreW;
|
||
const moreW = liveMoreW > 0 ? liveMoreW
|
||
: (cachedMoreW > 0 ? cachedMoreW : MORE_BTN_RESERVE_PX);
|
||
const rightW = navRightEl.scrollWidth; // intrinsic, ignores clipping
|
||
const needed = brandW + navLeftGap + linkW + linksGap + navLeftGap + moreW + navLeftGap + rightW + SAFETY;
|
||
return needed <= window.innerWidth;
|
||
}
|
||
let i = 0;
|
||
while (!fits() && i < overflowQueue.length) {
|
||
overflowQueue[i].classList.add('is-overflow');
|
||
i++;
|
||
}
|
||
rebuildMoreMenu();
|
||
}
|
||
|
||
// Run once on load, again after fonts settle (label widths shift),
|
||
// and on resize (debounced via rAF).
|
||
applyNavPriority();
|
||
if (document.fonts && document.fonts.ready) {
|
||
document.fonts.ready.then(applyNavPriority);
|
||
}
|
||
let rafId = 0;
|
||
window.addEventListener('resize', function() {
|
||
if (rafId) cancelAnimationFrame(rafId);
|
||
rafId = requestAnimationFrame(applyNavPriority);
|
||
});
|
||
// Re-apply on route change too: the active link gets bigger padding
|
||
// (background pill), so which links fit can shift between pages.
|
||
window.addEventListener('hashchange', function() {
|
||
// Defer so the route handler's class toggles run first.
|
||
requestAnimationFrame(applyNavPriority);
|
||
});
|
||
|
||
navMoreBtn.addEventListener('click', (e) => {
|
||
e.stopPropagation();
|
||
const opening = !navMoreMenu.classList.contains('open');
|
||
navMoreMenu.classList.toggle('open');
|
||
navMoreBtn.setAttribute('aria-expanded', String(opening));
|
||
if (opening) {
|
||
var firstLink = navMoreMenu.querySelector('.nav-link');
|
||
if (firstLink) firstLink.focus();
|
||
}
|
||
});
|
||
}
|
||
|
||
document.addEventListener('keydown', (e) => {
|
||
if (e.key === 'Escape') {
|
||
if (navMoreMenu && navMoreMenu.classList.contains('open')) closeMoreMenu();
|
||
if (navLinks.classList.contains('open')) closeNav();
|
||
}
|
||
});
|
||
document.addEventListener('click', (e) => {
|
||
if (navLinks.classList.contains('open') &&
|
||
!navLinks.contains(e.target) &&
|
||
!hamburger.contains(e.target)) {
|
||
closeNav();
|
||
}
|
||
if (navMoreMenu && navMoreMenu.classList.contains('open') &&
|
||
!navMoreMenu.contains(e.target) &&
|
||
!navMoreBtn.contains(e.target)) {
|
||
closeMoreMenu();
|
||
}
|
||
});
|
||
|
||
// --- Favorites dropdown ---
|
||
const favToggle = document.getElementById('favToggle');
|
||
const favDropdown = document.getElementById('favDropdown');
|
||
let favOpen = false;
|
||
|
||
favToggle.addEventListener('click', (e) => {
|
||
e.stopPropagation();
|
||
favOpen = !favOpen;
|
||
if (favOpen) {
|
||
renderFavDropdown();
|
||
favDropdown.classList.add('open');
|
||
} else {
|
||
favDropdown.classList.remove('open');
|
||
}
|
||
});
|
||
|
||
document.addEventListener('click', (e) => {
|
||
if (favOpen && !e.target.closest('.nav-fav-wrap')) {
|
||
favOpen = false;
|
||
favDropdown.classList.remove('open');
|
||
}
|
||
});
|
||
|
||
async function renderFavDropdown() {
|
||
const favs = getFavorites();
|
||
if (!favs.length) {
|
||
favDropdown.innerHTML = '<div class="fav-dd-empty">No favorites yet.<br><small>Click ☆ on any node to add it.</small></div>';
|
||
return;
|
||
}
|
||
favDropdown.innerHTML = '<div class="fav-dd-loading">Loading...</div>';
|
||
const items = await Promise.all(favs.map(async (pk) => {
|
||
try {
|
||
const h = await api('/nodes/' + pk + '/health', { ttl: CLIENT_TTL.nodeHealth });
|
||
const age = h.stats.lastHeard ? Date.now() - new Date(h.stats.lastHeard).getTime() : null;
|
||
const status = age === null ? '🔴' : age < HEALTH_THRESHOLDS.nodeDegradedMs ? '🟢' : age < HEALTH_THRESHOLDS.nodeSilentMs ? '🟡' : '🔴';
|
||
return '<a href="#/nodes/' + pk + '" class="fav-dd-item" data-key="' + pk + '">'
|
||
+ '<span class="fav-dd-status">' + status + '</span>'
|
||
+ '<span class="fav-dd-name">' + (h.node.name || truncate(pk, 12)) + '</span>'
|
||
+ '<span class="fav-dd-meta">' + (h.stats.lastHeard ? timeAgo(h.stats.lastHeard) : 'never') + '</span>'
|
||
+ favStar(pk, 'fav-dd-star')
|
||
+ '</a>';
|
||
} catch {
|
||
return '<a href="#/nodes/' + pk + '" class="fav-dd-item" data-key="' + pk + '">'
|
||
+ '<span class="fav-dd-status">❓</span>'
|
||
+ '<span class="fav-dd-name">' + truncate(pk, 16) + '</span>'
|
||
+ '<span class="fav-dd-meta">not found</span>'
|
||
+ favStar(pk, 'fav-dd-star')
|
||
+ '</a>';
|
||
}
|
||
}));
|
||
favDropdown.innerHTML = items.join('');
|
||
bindFavStars(favDropdown, () => renderFavDropdown());
|
||
// Close dropdown on link click
|
||
favDropdown.querySelectorAll('.fav-dd-item').forEach(a => {
|
||
a.addEventListener('click', (e) => {
|
||
if (e.target.closest('.fav-star')) { e.preventDefault(); return; }
|
||
favOpen = false;
|
||
favDropdown.classList.remove('open');
|
||
});
|
||
});
|
||
}
|
||
|
||
// --- Search ---
|
||
const searchToggle = document.getElementById('searchToggle');
|
||
const searchOverlay = document.getElementById('searchOverlay');
|
||
const searchInput = document.getElementById('searchInput');
|
||
const searchResults = document.getElementById('searchResults');
|
||
let searchTimeout = null;
|
||
|
||
searchToggle.addEventListener('click', () => {
|
||
searchOverlay.classList.toggle('hidden');
|
||
if (!searchOverlay.classList.contains('hidden')) {
|
||
searchInput.value = '';
|
||
searchResults.innerHTML = '';
|
||
searchInput.focus();
|
||
}
|
||
});
|
||
searchOverlay.addEventListener('click', (e) => {
|
||
if (e.target === searchOverlay) searchOverlay.classList.add('hidden');
|
||
});
|
||
document.addEventListener('keydown', (e) => {
|
||
if ((e.metaKey || e.ctrlKey) && e.key === 'k') {
|
||
e.preventDefault();
|
||
searchOverlay.classList.remove('hidden');
|
||
searchInput.value = '';
|
||
searchResults.innerHTML = '';
|
||
searchInput.focus();
|
||
}
|
||
if (e.key === 'Escape') searchOverlay.classList.add('hidden');
|
||
});
|
||
|
||
searchInput.addEventListener('input', () => {
|
||
clearTimeout(searchTimeout);
|
||
const q = searchInput.value.trim();
|
||
if (!q) { searchResults.innerHTML = ''; return; }
|
||
searchTimeout = setTimeout(async () => {
|
||
try {
|
||
const [packets, nodes, channels] = await Promise.all([
|
||
fetch('/api/packets?limit=5&hash=' + encodeURIComponent(q)).then(r => r.json()).catch(() => ({ packets: [] })),
|
||
fetch('/api/nodes?search=' + encodeURIComponent(q)).then(r => r.json()).catch(() => []),
|
||
fetch('/api/channels').then(r => r.json()).catch(() => [])
|
||
]);
|
||
let html = '';
|
||
const pktList = packets.packets || packets;
|
||
if (Array.isArray(pktList)) {
|
||
for (const p of pktList.slice(0, 5)) {
|
||
html += `<div class="search-result-item" tabindex="0" role="option" data-href="#/packets/${p.packet_hash || p.hash || p.id}">
|
||
<span class="search-result-type">Packet</span>${truncate(p.packet_hash || '', 16)} — ${payloadTypeName(p.payload_type)}</div>`;
|
||
}
|
||
}
|
||
const nodeList = Array.isArray(nodes) ? nodes : (nodes.nodes || []);
|
||
for (const n of nodeList.slice(0, 5)) {
|
||
if (n.name && n.name.toLowerCase().includes(q.toLowerCase())) {
|
||
html += `<div class="search-result-item" tabindex="0" role="option" data-href="#/nodes/${n.public_key}">
|
||
<span class="search-result-type">Node</span>${n.name} — ${truncate(n.public_key || '', 16)}</div>`;
|
||
}
|
||
}
|
||
const chList = Array.isArray(channels) ? channels : [];
|
||
for (const c of chList) {
|
||
if (c.name && c.name.toLowerCase().includes(q.toLowerCase())) {
|
||
html += `<div class="search-result-item" tabindex="0" role="option" data-href="#/channels/${c.channel_hash}">
|
||
<span class="search-result-type">Channel</span>${c.name}</div>`;
|
||
}
|
||
}
|
||
if (!html) html = '<div class="search-no-results">No results found</div>';
|
||
searchResults.innerHTML = html;
|
||
} catch { searchResults.innerHTML = '<div class="search-no-results">Search error</div>'; }
|
||
}, 300);
|
||
});
|
||
|
||
// #208 — Search results keyboard: click, Enter/Space, arrow-key navigation
|
||
function activateSearchItem(item) {
|
||
if (!item || !item.dataset.href) return;
|
||
location.hash = item.dataset.href;
|
||
searchOverlay.classList.add('hidden');
|
||
}
|
||
searchResults.addEventListener('click', (e) => {
|
||
activateSearchItem(e.target.closest('.search-result-item'));
|
||
});
|
||
searchResults.addEventListener('keydown', (e) => {
|
||
const item = e.target.closest('.search-result-item');
|
||
if (!item) return;
|
||
if (e.key === 'Enter' || e.key === ' ') {
|
||
e.preventDefault();
|
||
activateSearchItem(item);
|
||
} else if (e.key === 'ArrowDown') {
|
||
e.preventDefault();
|
||
const next = item.nextElementSibling;
|
||
if (next && next.classList.contains('search-result-item')) next.focus();
|
||
} else if (e.key === 'ArrowUp') {
|
||
e.preventDefault();
|
||
const prev = item.previousElementSibling;
|
||
if (prev && prev.classList.contains('search-result-item')) prev.focus();
|
||
else searchInput.focus();
|
||
}
|
||
});
|
||
searchInput.addEventListener('keydown', (e) => {
|
||
if (e.key === 'ArrowDown') {
|
||
e.preventDefault();
|
||
const first = searchResults.querySelector('.search-result-item');
|
||
if (first) first.focus();
|
||
}
|
||
});
|
||
|
||
// --- Login ---
|
||
// (removed — no auth yet)
|
||
|
||
// --- Nav Stats ---
|
||
async function updateNavStats() {
|
||
try {
|
||
const stats = await api('/stats', { ttl: CLIENT_TTL.stats });
|
||
const el = document.getElementById('navStats');
|
||
if (el) {
|
||
el.innerHTML = `<span class="stat-val">${stats.totalPackets}</span> pkts · <span class="stat-val">${stats.totalNodes}</span> nodes · <span class="stat-val">${stats.totalObservers}</span> obs${formatVersionBadge(stats.version, stats.commit, stats.engine, stats.buildTime)}`;
|
||
el.querySelectorAll('.stat-val').forEach(s => s.classList.add('updated'));
|
||
setTimeout(() => { el.querySelectorAll('.stat-val').forEach(s => s.classList.remove('updated')); }, 600);
|
||
}
|
||
} catch {}
|
||
}
|
||
updateNavStats();
|
||
setInterval(updateNavStats, 15000);
|
||
debouncedOnWS(function () { updateNavStats(); });
|
||
|
||
// --- Theme Customization ---
|
||
// Fetch theme config and apply via customizer v2 pipeline
|
||
fetch('/api/config/theme', { cache: 'no-store' }).then(r => r.json()).then(cfg => {
|
||
// Normalize timestamp defaults
|
||
cfg = cfg || {};
|
||
if (!cfg.timestamps) cfg.timestamps = {};
|
||
const tsCfg = cfg.timestamps;
|
||
if (tsCfg.defaultMode !== 'absolute' && tsCfg.defaultMode !== 'ago') tsCfg.defaultMode = 'ago';
|
||
if (tsCfg.timezone !== 'utc' && tsCfg.timezone !== 'local') tsCfg.timezone = 'local';
|
||
if (tsCfg.formatPreset !== 'iso' && tsCfg.formatPreset !== 'iso-seconds' && tsCfg.formatPreset !== 'locale') tsCfg.formatPreset = 'iso';
|
||
if (typeof tsCfg.customFormat !== 'string') tsCfg.customFormat = '';
|
||
tsCfg.allowCustomFormat = tsCfg.allowCustomFormat === true;
|
||
|
||
// Customizer v2: set server defaults and run full pipeline
|
||
// (reads localStorage overrides → merges → sets SITE_CONFIG → applies CSS → dispatches theme-changed)
|
||
if (window._customizerV2) {
|
||
window._customizerV2.init(cfg);
|
||
} else {
|
||
// Fallback if customize-v2.js didn't load
|
||
window.SITE_CONFIG = cfg;
|
||
}
|
||
}).catch(() => {
|
||
window.SITE_CONFIG = { timestamps: { defaultMode: 'ago', timezone: 'local', formatPreset: 'iso', customFormat: '', allowCustomFormat: false } };
|
||
if (window._customizerV2) window._customizerV2.init(window.SITE_CONFIG);
|
||
});
|
||
|
||
// Navigate immediately — don't gate data-fetching pages on cosmetic theme fetch
|
||
if (!location.hash || location.hash === '#/') location.hash = '#/home';
|
||
else navigate();
|
||
});
|
||
|
||
/**
|
||
* Reusable ARIA tab-bar initialiser.
|
||
* Adds role="tablist" to container, role="tab" + aria-selected to each button,
|
||
* and arrow-key navigation between tabs.
|
||
* @param {HTMLElement} container - the tab bar element
|
||
* @param {Function} [onChange] - optional callback(activeBtn) on tab change
|
||
*/
|
||
function initTabBar(container, onChange) {
|
||
if (!container || container.getAttribute('role') === 'tablist') return;
|
||
container.setAttribute('role', 'tablist');
|
||
const tabs = Array.from(container.querySelectorAll('button, [data-tab], [data-obs]'));
|
||
tabs.forEach(btn => {
|
||
btn.setAttribute('role', 'tab');
|
||
const isActive = btn.classList.contains('active');
|
||
btn.setAttribute('aria-selected', String(isActive));
|
||
btn.setAttribute('tabindex', isActive ? '0' : '-1');
|
||
// Link to panel if aria-controls target exists
|
||
const panelId = btn.dataset.tab || btn.dataset.obs;
|
||
if (panelId && document.getElementById(panelId)) {
|
||
btn.setAttribute('aria-controls', panelId);
|
||
}
|
||
});
|
||
container.addEventListener('click', (e) => {
|
||
const btn = e.target.closest('[role="tab"]');
|
||
if (!btn || !container.contains(btn)) return;
|
||
tabs.forEach(b => { b.setAttribute('aria-selected', 'false'); b.setAttribute('tabindex', '-1'); });
|
||
btn.setAttribute('aria-selected', 'true');
|
||
btn.setAttribute('tabindex', '0');
|
||
if (onChange) onChange(btn);
|
||
});
|
||
container.addEventListener('keydown', (e) => {
|
||
const btn = e.target.closest('[role="tab"]');
|
||
if (!btn) return;
|
||
let idx = tabs.indexOf(btn), next = -1;
|
||
if (e.key === 'ArrowRight' || e.key === 'ArrowDown') next = (idx + 1) % tabs.length;
|
||
else if (e.key === 'ArrowLeft' || e.key === 'ArrowUp') next = (idx - 1 + tabs.length) % tabs.length;
|
||
else if (e.key === 'Home') next = 0;
|
||
else if (e.key === 'End') next = tabs.length - 1;
|
||
if (next < 0) return;
|
||
e.preventDefault();
|
||
tabs.forEach(b => { b.setAttribute('aria-selected', 'false'); b.setAttribute('tabindex', '-1'); });
|
||
tabs[next].setAttribute('aria-selected', 'true');
|
||
tabs[next].setAttribute('tabindex', '0');
|
||
tabs[next].focus();
|
||
tabs[next].click();
|
||
});
|
||
}
|
||
|
||
/**
|
||
* Make table columns resizable with drag handles. Widths saved to localStorage.
|
||
* Call after table is in DOM. Re-call safe (idempotent per table).
|
||
* @param {string} tableSelector - CSS selector for the table
|
||
* @param {string} storageKey - localStorage key for persisted widths
|
||
*/
|
||
function makeColumnsResizable(tableSelector, storageKey) {
|
||
const table = document.querySelector(tableSelector);
|
||
if (!table) return;
|
||
const thead = table.querySelector('thead');
|
||
if (!thead) return;
|
||
const ths = Array.from(thead.querySelectorAll('tr:first-child th'));
|
||
if (ths.length < 2) return;
|
||
|
||
if (table.dataset.resizable) return;
|
||
table.dataset.resizable = '1';
|
||
table.style.tableLayout = 'fixed';
|
||
|
||
const containerW = table.parentElement.clientWidth;
|
||
const saved = localStorage.getItem(storageKey);
|
||
let widths;
|
||
|
||
if (saved) {
|
||
try { widths = JSON.parse(saved); } catch { widths = null; }
|
||
// Validate: must be array of correct length with values summing to ~100 (percentages)
|
||
if (widths && Array.isArray(widths) && widths.length === ths.length) {
|
||
const sum = widths.reduce((s, w) => s + w, 0);
|
||
if (sum > 90 && sum < 110) {
|
||
// Saved percentages — apply directly
|
||
table.style.tableLayout = 'fixed';
|
||
table.style.width = '100%';
|
||
ths.forEach((th, i) => { th.style.width = widths[i] + '%'; });
|
||
// Skip measurement, jump to adding handles
|
||
addResizeHandles();
|
||
return;
|
||
}
|
||
}
|
||
widths = null; // Force remeasure
|
||
}
|
||
|
||
if (!widths) {
|
||
// Measure actual max content width per column by scanning visible rows
|
||
const tbody = table.querySelector('tbody');
|
||
const rows = tbody ? Array.from(tbody.querySelectorAll('tr')).slice(0, 30) : [];
|
||
|
||
// Temporarily set auto layout to measure
|
||
table.style.tableLayout = 'auto';
|
||
table.style.width = 'auto';
|
||
// Remove nowrap temporarily so we get true content width
|
||
const cells = table.querySelectorAll('td, th');
|
||
cells.forEach(c => { c.dataset.origWs = c.style.whiteSpace || ''; c.style.whiteSpace = 'nowrap'; });
|
||
|
||
// Measure each column's max content width across header + rows
|
||
widths = ths.map((th, i) => {
|
||
let maxW = th.scrollWidth;
|
||
rows.forEach(row => {
|
||
const td = row.children[i];
|
||
if (td) maxW = Math.max(maxW, td.scrollWidth);
|
||
});
|
||
return maxW + 4; // small padding buffer
|
||
});
|
||
|
||
cells.forEach(c => { c.style.whiteSpace = c.dataset.origWs || ''; delete c.dataset.origWs; });
|
||
}
|
||
|
||
// Now fit to container: if total > container, squish widest first
|
||
const totalNeeded = widths.reduce((s, w) => s + w, 0);
|
||
const finalWidths = [...widths];
|
||
|
||
if (totalNeeded > containerW) {
|
||
let excess = totalNeeded - containerW;
|
||
const MIN_COL = 28;
|
||
// Iteratively shave from widest columns
|
||
while (excess > 0) {
|
||
// Find current max width
|
||
const maxW = Math.max(...finalWidths);
|
||
if (maxW <= MIN_COL) break;
|
||
// Find second-max to know our target
|
||
const sorted = [...new Set(finalWidths)].sort((a, b) => b - a);
|
||
const target = sorted.length > 1 ? Math.max(sorted[1], MIN_COL) : MIN_COL;
|
||
// How many columns are at maxW?
|
||
const atMax = finalWidths.filter(w => w >= maxW).length;
|
||
const canShavePerCol = maxW - target;
|
||
const neededPerCol = Math.ceil(excess / atMax);
|
||
const shavePerCol = Math.min(canShavePerCol, neededPerCol);
|
||
|
||
for (let i = 0; i < finalWidths.length; i++) {
|
||
if (finalWidths[i] >= maxW) {
|
||
const shave = Math.min(shavePerCol, excess);
|
||
finalWidths[i] -= shave;
|
||
excess -= shave;
|
||
if (excess <= 0) break;
|
||
}
|
||
}
|
||
}
|
||
} else if (totalNeeded < containerW) {
|
||
// Give surplus to the 2 widest columns (content-heavy ones)
|
||
const surplus = containerW - totalNeeded;
|
||
const indexed = finalWidths.map((w, i) => ({ w, i })).sort((a, b) => b.w - a.w);
|
||
const topN = indexed.slice(0, Math.min(2, indexed.length));
|
||
const topTotal = topN.reduce((s, x) => s + x.w, 0);
|
||
topN.forEach(x => { finalWidths[x.i] += Math.round(surplus * (x.w / topTotal)); });
|
||
}
|
||
|
||
table.style.width = '100%';
|
||
const totalFinal = finalWidths.reduce((s, w) => s + w, 0);
|
||
ths.forEach((th, i) => { th.style.width = (finalWidths[i] / totalFinal * 100) + '%'; });
|
||
|
||
addResizeHandles();
|
||
|
||
function addResizeHandles() {
|
||
// Add resize handles
|
||
ths.forEach((th, i) => {
|
||
if (i === ths.length - 1) return;
|
||
const handle = document.createElement('div');
|
||
handle.className = 'col-resize-handle';
|
||
handle.addEventListener('mousedown', (e) => {
|
||
e.preventDefault();
|
||
e.stopPropagation();
|
||
const startX = e.clientX;
|
||
const startW = th.offsetWidth;
|
||
const startTableW = table.offsetWidth;
|
||
handle.classList.add('active');
|
||
document.body.style.cursor = 'col-resize';
|
||
document.body.style.userSelect = 'none';
|
||
|
||
function onMove(e2) {
|
||
const dx = e2.clientX - startX;
|
||
const newW = Math.max(50, startW + dx);
|
||
const delta = newW - th.offsetWidth;
|
||
if (delta === 0) return;
|
||
// Steal/give space from columns to the right, proportionally
|
||
const rightThs = ths.slice(i + 1);
|
||
const rightWidths = rightThs.map(t => t.offsetWidth);
|
||
const rightTotal = rightWidths.reduce((s, w) => s + w, 0);
|
||
if (rightTotal - delta < rightThs.length * 50) return; // can't squeeze below 50px each
|
||
th.style.width = newW + 'px';
|
||
const scale = (rightTotal - delta) / rightTotal;
|
||
rightThs.forEach(t => { t.style.width = Math.max(50, t.offsetWidth * scale) + 'px'; });
|
||
}
|
||
function onUp() {
|
||
handle.classList.remove('active');
|
||
document.body.style.cursor = '';
|
||
document.body.style.userSelect = '';
|
||
// Save as percentages
|
||
const tableW = table.offsetWidth;
|
||
const ws = ths.map(t => (t.offsetWidth / tableW * 100));
|
||
localStorage.setItem(storageKey, JSON.stringify(ws));
|
||
// Re-apply as percentages
|
||
ths.forEach((t, j) => { t.style.width = ws[j] + '%'; });
|
||
document.removeEventListener('mousemove', onMove);
|
||
document.removeEventListener('mouseup', onUp);
|
||
}
|
||
document.addEventListener('mousemove', onMove);
|
||
document.addEventListener('mouseup', onUp);
|
||
});
|
||
th.appendChild(handle);
|
||
});
|
||
} // end addResizeHandles
|
||
}
|