mirror of
https://github.com/Kpa-clawbot/meshcore-analyzer.git
synced 2026-03-29 13:10:34 +00:00
- app.js: render engine badge with .engine-badge span (was plain text) - test: fix #pktRight waitForSelector to use state:'attached' (hidden by detail-collapsed) - test: fix map heat persist race — wait for async init to restore checkbox state - test: fix live heat persist race — test via localStorage set+reload instead of click - test: fix live matrix toggle race — wait for Leaflet tiles before clicking - test: increase packet detail timeouts for remote server resilience - test: make close-button test self-contained (navigate if #pktRight missing) - bump cache busters Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
861 lines
35 KiB
JavaScript
861 lines
35 KiB
JavaScript
/* === MeshCore Analyzer — 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', 7: 'anon-req', 8: 'path', 9: 'trace' };
|
|
|
|
function routeTypeName(n) { return ROUTE_TYPES[n] || 'UNKNOWN'; }
|
|
function payloadTypeName(n) { return PAYLOAD_TYPES[n] || 'UNKNOWN'; }
|
|
function payloadTypeColor(n) { return PAYLOAD_COLORS[n] || 'unknown'; }
|
|
|
|
// --- 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 s = Math.floor((Date.now() - new Date(iso).getTime()) / 1000);
|
|
if (s < 60) return s + 's ago';
|
|
if (s < 3600) return Math.floor(s / 60) + 'm ago';
|
|
if (s < 86400) return Math.floor(s / 3600) + 'h ago';
|
|
return Math.floor(s / 86400) + 'd ago';
|
|
}
|
|
|
|
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) {
|
|
if (!version && !commit && !engine) return '';
|
|
var port = (typeof location !== 'undefined' && location.port) || '';
|
|
var isProd = !port || port === '80' || port === '443';
|
|
var GH = 'https://github.com/Kpa-clawbot/meshcore-analyzer';
|
|
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>');
|
|
}
|
|
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); }
|
|
|
|
/* 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; }
|
|
|
|
let currentPage = null;
|
|
|
|
function navigate() {
|
|
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';
|
|
}
|
|
|
|
// Update nav active state
|
|
document.querySelectorAll('.nav-link[data-route]').forEach(el => {
|
|
el.classList.toggle('active', el.dataset.route === basePage);
|
|
});
|
|
|
|
if (currentPage && pages[currentPage]?.destroy) {
|
|
pages[currentPage].destroy();
|
|
}
|
|
currentPage = basePage;
|
|
|
|
const app = document.getElementById('app');
|
|
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');
|
|
} 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('DOMContentLoaded', () => {
|
|
connectWS();
|
|
|
|
// --- 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', () => navLinks.classList.toggle('open'));
|
|
// Close menu on nav link click
|
|
navLinks.querySelectorAll('.nav-link').forEach(link => {
|
|
link.addEventListener('click', () => navLinks.classList.remove('open'));
|
|
});
|
|
|
|
// --- 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" onclick="location.hash='#/packets/${p.packet_hash || p.hash || p.id}';document.getElementById('searchOverlay').classList.add('hidden')">
|
|
<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" onclick="location.hash='#/nodes/${n.public_key}';document.getElementById('searchOverlay').classList.add('hidden')">
|
|
<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" onclick="location.hash='#/channels/${c.channel_hash}';document.getElementById('searchOverlay').classList.add('hidden')">
|
|
<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);
|
|
});
|
|
|
|
// --- 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)}`;
|
|
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 branding/colors before first render
|
|
fetch('/api/config/theme', { cache: 'no-store' }).then(r => r.json()).then(cfg => {
|
|
window.SITE_CONFIG = cfg;
|
|
|
|
// User's localStorage preferences take priority over server config
|
|
const userTheme = (() => { try { return JSON.parse(localStorage.getItem('meshcore-user-theme') || '{}'); } catch { return {}; } })();
|
|
|
|
// Apply CSS variable overrides from theme config (skipped if user has local overrides)
|
|
if (!userTheme.theme && !userTheme.themeDark) {
|
|
const dark = document.documentElement.getAttribute('data-theme') === 'dark' ||
|
|
(document.documentElement.getAttribute('data-theme') !== 'light' && window.matchMedia('(prefers-color-scheme: dark)').matches);
|
|
const themeData = dark ? { ...(cfg.theme || {}), ...(cfg.themeDark || {}) } : (cfg.theme || {});
|
|
const root = document.documentElement.style;
|
|
const 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'
|
|
};
|
|
for (const [key, cssVar] of Object.entries(varMap)) {
|
|
if (themeData[key]) root.setProperty(cssVar, themeData[key]);
|
|
}
|
|
// Derived vars
|
|
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) {
|
|
const nav = document.querySelector('.top-nav');
|
|
if (nav) nav.style.background = `linear-gradient(135deg, ${themeData.navBg} 0%, ${themeData.navBg2 || themeData.navBg} 50%, ${themeData.navBg} 100%)`;
|
|
}
|
|
}
|
|
|
|
// Apply node color overrides (skip if user has local preferences)
|
|
if (cfg.nodeColors && !userTheme.nodeColors) {
|
|
for (const [role, color] of Object.entries(cfg.nodeColors)) {
|
|
if (window.ROLE_COLORS && role in window.ROLE_COLORS) window.ROLE_COLORS[role] = color;
|
|
if (window.ROLE_STYLE && window.ROLE_STYLE[role]) window.ROLE_STYLE[role].color = color;
|
|
}
|
|
}
|
|
|
|
// Apply type color overrides (skip if user has local preferences)
|
|
if (cfg.typeColors && !userTheme.typeColors) {
|
|
for (const [type, color] of Object.entries(cfg.typeColors)) {
|
|
if (window.TYPE_COLORS && type in window.TYPE_COLORS) window.TYPE_COLORS[type] = color;
|
|
}
|
|
if (window.syncBadgeColors) window.syncBadgeColors();
|
|
}
|
|
|
|
// Apply branding (skip if user has local preferences)
|
|
if (cfg.branding && !userTheme.branding) {
|
|
if (cfg.branding.siteName) {
|
|
document.title = cfg.branding.siteName;
|
|
const brandText = document.querySelector('.brand-text');
|
|
if (brandText) brandText.textContent = cfg.branding.siteName;
|
|
}
|
|
if (cfg.branding.logoUrl) {
|
|
const brandIcon = document.querySelector('.brand-icon');
|
|
if (brandIcon) {
|
|
const img = document.createElement('img');
|
|
img.src = cfg.branding.logoUrl;
|
|
img.alt = cfg.branding.siteName || 'Logo';
|
|
img.style.height = '24px';
|
|
img.style.width = 'auto';
|
|
brandIcon.replaceWith(img);
|
|
}
|
|
}
|
|
if (cfg.branding.faviconUrl) {
|
|
const favicon = document.querySelector('link[rel="icon"]');
|
|
if (favicon) favicon.href = cfg.branding.faviconUrl;
|
|
}
|
|
}
|
|
}).catch(() => { window.SITE_CONFIG = null; }).finally(() => {
|
|
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;
|
|
th.style.position = 'relative';
|
|
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
|
|
}
|