mirror of
https://github.com/Kpa-clawbot/meshcore-analyzer.git
synced 2026-10-15 17:49:59 +00:00
## Frontend: Timestamp Display Enhancement Refs #286 — implements P0 frontend scope from the [final spec](https://github.com/Kpa-clawbot/CoreScope/issues/286#issuecomment-4158891089). ### What changed **Shared formatter (`public/app.js`)** - `formatTimestamp(isoString, mode)` — returns formatted string ("ago" or absolute) - `formatTimestampWithTooltip(isoString, mode)` — returns `{ text, tooltip, isFuture }` for dual-format hover - `timeAgo()` fixed: null → `"—"`, future timestamps shown with actual value (not clamped) **All surfaces updated** - `public/packets.js` — table rows + detail pane use shared formatter, hover shows opposite format - `public/live.js` — fixed inconsistency (`toLocaleTimeString` → shared formatter), same tooltip treatment - `public/nodes.js` — node timestamps use shared formatter **Future clock skew** - ⚠️ icon shown when timestamp is in the future, tooltip: "Timestamp is in the future — node clock may be skewed" **Customizer (`public/customize.js`)** - New "UI Settings" section with timestamp mode toggle (ago ↔ absolute) - Labeled as global setting - Persists to localStorage (`meshcore-timestamp-mode`), falls back to server default **CSS (`public/style.css`)** - `col-time`: min-width + nowrap for ISO timestamps - Mobile: shorter format (time only) instead of hiding column ### Testing - `node test-frontend-helpers.js` — formatter unit tests (null, ago, absolute, future skew) - `node test-packet-filter.js` — existing tests pass - `node test-aging.js` — existing tests pass Cache busters bumped in `public/index.html`. Co-authored-by: Kpa-clawbot <259247574+Kpa-clawbot@users.noreply.github.com> Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
co-authored by
Kpa-clawbot
Copilot
parent
568e3904ba
commit
6a3b8967b4
+57
-7
@@ -88,11 +88,53 @@ window.apiPerf = function() {
|
||||
|
||||
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';
|
||||
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 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 formatAbsoluteTimestamp(iso) {
|
||||
if (!iso) return '—';
|
||||
const d = new Date(iso);
|
||||
if (!isFinite(d.getTime())) return '—';
|
||||
const mobileShort = typeof window !== 'undefined' && window.innerWidth <= 640;
|
||||
if (mobileShort) {
|
||||
return d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
|
||||
}
|
||||
return d.toISOString();
|
||||
}
|
||||
|
||||
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 };
|
||||
}
|
||||
|
||||
function truncate(str, len) {
|
||||
@@ -347,6 +389,9 @@ window.addEventListener('theme-changed', () => {
|
||||
window.dispatchEvent(new CustomEvent('theme-refresh'));
|
||||
}, 300);
|
||||
});
|
||||
window.addEventListener('timestamp-mode-changed', () => {
|
||||
window.dispatchEvent(new CustomEvent('theme-refresh'));
|
||||
});
|
||||
window.addEventListener('DOMContentLoaded', () => {
|
||||
connectWS();
|
||||
|
||||
@@ -603,7 +648,12 @@ window.addEventListener('DOMContentLoaded', () => {
|
||||
// --- 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;
|
||||
window.SITE_CONFIG = cfg || {};
|
||||
if (!window.SITE_CONFIG.timestamps) {
|
||||
window.SITE_CONFIG.timestamps = { defaultMode: 'ago' };
|
||||
} else if (window.SITE_CONFIG.timestamps.defaultMode !== 'absolute' && window.SITE_CONFIG.timestamps.defaultMode !== 'ago') {
|
||||
window.SITE_CONFIG.timestamps.defaultMode = 'ago';
|
||||
}
|
||||
|
||||
// User's localStorage preferences take priority over server config
|
||||
const userTheme = (() => { try { return JSON.parse(localStorage.getItem('meshcore-user-theme') || '{}'); } catch { return {}; } })();
|
||||
@@ -677,7 +727,7 @@ window.addEventListener('DOMContentLoaded', () => {
|
||||
if (favicon) favicon.href = cfg.branding.faviconUrl;
|
||||
}
|
||||
}
|
||||
}).catch(() => { window.SITE_CONFIG = null; }).finally(() => {
|
||||
}).catch(() => { window.SITE_CONFIG = { timestamps: { defaultMode: 'ago' } }; }).finally(() => {
|
||||
if (!location.hash || location.hash === '#/') location.hash = '#/home';
|
||||
else navigate();
|
||||
});
|
||||
|
||||
+42
-1
@@ -63,6 +63,9 @@
|
||||
{ label: '📡 All Nodes', url: '#/nodes' },
|
||||
{ label: '💬 Channels', url: '#/channels' }
|
||||
]
|
||||
},
|
||||
ui: {
|
||||
timestampMode: 'ago'
|
||||
}
|
||||
};
|
||||
|
||||
@@ -441,6 +444,12 @@
|
||||
// Merge: DEFAULTS → server config → localStorage saved values
|
||||
var local = {};
|
||||
try { var s = localStorage.getItem('meshcore-user-theme'); if (s) local = JSON.parse(s); } catch {}
|
||||
var localTsMode = localStorage.getItem('meshcore-timestamp-mode');
|
||||
var serverTsMode = (cfg.timestamps && cfg.timestamps.defaultMode === 'absolute') ? 'absolute' : 'ago';
|
||||
var mergedUi = Object.assign({}, DEFAULTS.ui, cfg.ui || {}, local.ui || {});
|
||||
mergedUi.timestampMode = (localTsMode === 'ago' || localTsMode === 'absolute')
|
||||
? localTsMode
|
||||
: (mergedUi.timestampMode === 'absolute' || serverTsMode === 'absolute' ? 'absolute' : 'ago');
|
||||
state = {
|
||||
branding: Object.assign({}, DEFAULTS.branding, cfg.branding || {}, local.branding || {}),
|
||||
theme: Object.assign({}, DEFAULTS.theme, cfg.theme || {}, local.theme || {}),
|
||||
@@ -453,7 +462,8 @@
|
||||
steps: deepClone((local.home && local.home.steps) || (cfg.home && cfg.home.steps) || DEFAULTS.home.steps),
|
||||
checklist: deepClone((local.home && local.home.checklist) || (cfg.home && cfg.home.checklist) || DEFAULTS.home.checklist),
|
||||
footerLinks: deepClone((local.home && local.home.footerLinks) || (cfg.home && cfg.home.footerLinks) || DEFAULTS.home.footerLinks)
|
||||
}
|
||||
},
|
||||
ui: mergedUi
|
||||
};
|
||||
}
|
||||
|
||||
@@ -641,11 +651,21 @@
|
||||
function renderBranding() {
|
||||
var b = state.branding;
|
||||
var logoPreview = b.logoUrl ? '<img class="cust-preview-img" src="' + escAttr(b.logoUrl) + '" alt="Logo preview" onerror="this.style.display=\'none\'">' : '';
|
||||
var tsMode = state.ui.timestampMode === 'absolute' ? 'absolute' : 'ago';
|
||||
return '<div class="cust-panel' + (activeTab === 'branding' ? ' active' : '') + '" data-panel="branding">' +
|
||||
'<div class="cust-field"><label for="cust-siteName">Site Name</label><input type="text" id="cust-siteName" data-key="branding.siteName" value="' + escAttr(b.siteName) + '"></div>' +
|
||||
'<div class="cust-field"><label for="cust-tagline">Tagline</label><input type="text" id="cust-tagline" data-key="branding.tagline" value="' + escAttr(b.tagline) + '"></div>' +
|
||||
'<div class="cust-field"><label for="cust-logoUrl">Logo URL</label><input type="text" id="cust-logoUrl" data-key="branding.logoUrl" value="' + escAttr(b.logoUrl) + '" placeholder="https://...">' + logoPreview + '</div>' +
|
||||
'<div class="cust-field"><label for="cust-faviconUrl">Favicon URL</label><input type="text" id="cust-faviconUrl" data-key="branding.faviconUrl" value="' + escAttr(b.faviconUrl) + '" placeholder="https://..."></div>' +
|
||||
'<hr style="border:none;border-top:1px solid var(--border);margin:16px 0">' +
|
||||
'<p class="cust-section-title">UI Settings</p>' +
|
||||
'<p style="font-size:12px;color:var(--text-muted);margin-bottom:8px">Global setting — applies to all pages.</p>' +
|
||||
'<div class="cust-field"><label for="custTimestampMode">Timestamp Display</label>' +
|
||||
'<select id="custTimestampMode" data-ui="timestampMode" style="width:100%;padding:6px 8px;border:1px solid var(--border);border-radius:6px;background:var(--input-bg);color:var(--text)">' +
|
||||
'<option value="ago"' + (tsMode === 'ago' ? ' selected' : '') + '>Relative (3m ago)</option>' +
|
||||
'<option value="absolute"' + (tsMode === 'absolute' ? ' selected' : '') + '>Absolute (ISO timestamp)</option>' +
|
||||
'</select>' +
|
||||
'</div>' +
|
||||
'</div>';
|
||||
}
|
||||
|
||||
@@ -851,6 +871,11 @@
|
||||
if (JSON.stringify(state.home.footerLinks) !== JSON.stringify(DEFAULTS.home.footerLinks)) hm.footerLinks = state.home.footerLinks;
|
||||
if (Object.keys(hm).length) out.home = hm;
|
||||
|
||||
// UI
|
||||
var ui = {};
|
||||
if ((state.ui.timestampMode || 'ago') !== DEFAULTS.ui.timestampMode) ui.timestampMode = state.ui.timestampMode;
|
||||
if (Object.keys(ui).length) out.ui = ui;
|
||||
|
||||
return out;
|
||||
}
|
||||
|
||||
@@ -938,6 +963,22 @@
|
||||
});
|
||||
});
|
||||
|
||||
// UI settings
|
||||
container.querySelectorAll('select[data-ui]').forEach(function (sel) {
|
||||
sel.addEventListener('change', function () {
|
||||
var key = sel.dataset.ui;
|
||||
state.ui[key] = sel.value;
|
||||
if (key === 'timestampMode') {
|
||||
localStorage.setItem('meshcore-timestamp-mode', sel.value);
|
||||
if (!window.SITE_CONFIG) window.SITE_CONFIG = {};
|
||||
if (!window.SITE_CONFIG.timestamps) window.SITE_CONFIG.timestamps = {};
|
||||
window.SITE_CONFIG.timestamps.defaultMode = sel.value;
|
||||
window.dispatchEvent(new CustomEvent('timestamp-mode-changed'));
|
||||
}
|
||||
autoSave();
|
||||
});
|
||||
});
|
||||
|
||||
// Theme color pickers
|
||||
container.querySelectorAll('input[data-theme]').forEach(function (inp) {
|
||||
inp.addEventListener('input', function () {
|
||||
|
||||
+28
-28
@@ -22,9 +22,9 @@
|
||||
<meta name="twitter:title" content="CoreScope">
|
||||
<meta name="twitter:description" content="Real-time MeshCore LoRa mesh network analyzer — live packet visualization, node tracking, channel decryption, and route analysis.">
|
||||
<meta name="twitter:image" content="https://raw.githubusercontent.com/Kpa-clawbot/corescope/master/public/og-image.png">
|
||||
<link rel="stylesheet" href="style.css?v=1774786041">
|
||||
<link rel="stylesheet" href="home.css?v=1774786038">
|
||||
<link rel="stylesheet" href="live.css?v=1774786038">
|
||||
<link rel="stylesheet" href="style.css?v=1774915797">
|
||||
<link rel="stylesheet" href="home.css?v=1774915797">
|
||||
<link rel="stylesheet" href="live.css?v=1774915797">
|
||||
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css"
|
||||
integrity="sha256-p4NxAoJBhIIN+hmNHrzRCf9tD/miZyoHS5obTRR9BMY="
|
||||
crossorigin="anonymous">
|
||||
@@ -81,30 +81,30 @@
|
||||
<main id="app" role="main"></main>
|
||||
|
||||
<script src="vendor/qrcode.js"></script>
|
||||
<script src="roles.js?v=1774786038"></script>
|
||||
<script src="customize.js?v=1774786038" onerror="console.error('Failed to load:', this.src)"></script>
|
||||
<script src="region-filter.js?v=1774786038"></script>
|
||||
<script src="hop-resolver.js?v=1774786038"></script>
|
||||
<script src="hop-display.js?v=1774786038"></script>
|
||||
<script src="app.js?v=1774786038"></script>
|
||||
<script src="home.js?v=1774786038"></script>
|
||||
<script src="packet-filter.js?v=1774786038"></script>
|
||||
<script src="packets.js?v=1774786038"></script>
|
||||
<script src="geo-filter-overlay.js?v=1774786038"></script>
|
||||
<script src="map.js?v=1774786038" onerror="console.error('Failed to load:', this.src)"></script>
|
||||
<script src="channels.js?v=1774786038" onerror="console.error('Failed to load:', this.src)"></script>
|
||||
<script src="nodes.js?v=1774786038" onerror="console.error('Failed to load:', this.src)"></script>
|
||||
<script src="traces.js?v=1774786038" onerror="console.error('Failed to load:', this.src)"></script>
|
||||
<script src="analytics.js?v=1774786041" onerror="console.error('Failed to load:', this.src)"></script>
|
||||
<script src="audio.js?v=1774786038" onerror="console.error('Failed to load:', this.src)"></script>
|
||||
<script src="audio-v1-constellation.js?v=1774786038" onerror="console.error('Failed to load:', this.src)"></script>
|
||||
<script src="audio-v2-constellation.js?v=1774786038" onerror="console.error('Failed to load:', this.src)"></script>
|
||||
<script src="audio-lab.js?v=1774786038" onerror="console.error('Failed to load:', this.src)"></script>
|
||||
<script src="live.js?v=1774786038" onerror="console.error('Failed to load:', this.src)"></script>
|
||||
<script src="observers.js?v=1774786038" onerror="console.error('Failed to load:', this.src)"></script>
|
||||
<script src="observer-detail.js?v=1774786038" onerror="console.error('Failed to load:', this.src)"></script>
|
||||
<script src="compare.js?v=1774786038" onerror="console.error('Failed to load:', this.src)"></script>
|
||||
<script src="node-analytics.js?v=1774786039" onerror="console.error('Failed to load:', this.src)"></script>
|
||||
<script src="perf.js?v=1774786038" onerror="console.error('Failed to load:', this.src)"></script>
|
||||
<script src="roles.js?v=1774915797"></script>
|
||||
<script src="customize.js?v=1774915797" onerror="console.error('Failed to load:', this.src)"></script>
|
||||
<script src="region-filter.js?v=1774915797"></script>
|
||||
<script src="hop-resolver.js?v=1774915797"></script>
|
||||
<script src="hop-display.js?v=1774915797"></script>
|
||||
<script src="app.js?v=1774915797"></script>
|
||||
<script src="home.js?v=1774915797"></script>
|
||||
<script src="packet-filter.js?v=1774915797"></script>
|
||||
<script src="packets.js?v=1774915797"></script>
|
||||
<script src="geo-filter-overlay.js?v=1774915797"></script>
|
||||
<script src="map.js?v=1774915797" onerror="console.error('Failed to load:', this.src)"></script>
|
||||
<script src="channels.js?v=1774915797" onerror="console.error('Failed to load:', this.src)"></script>
|
||||
<script src="nodes.js?v=1774915797" onerror="console.error('Failed to load:', this.src)"></script>
|
||||
<script src="traces.js?v=1774915797" onerror="console.error('Failed to load:', this.src)"></script>
|
||||
<script src="analytics.js?v=1774915797" onerror="console.error('Failed to load:', this.src)"></script>
|
||||
<script src="audio.js?v=1774915797" onerror="console.error('Failed to load:', this.src)"></script>
|
||||
<script src="audio-v1-constellation.js?v=1774915797" onerror="console.error('Failed to load:', this.src)"></script>
|
||||
<script src="audio-v2-constellation.js?v=1774915797" onerror="console.error('Failed to load:', this.src)"></script>
|
||||
<script src="audio-lab.js?v=1774915797" onerror="console.error('Failed to load:', this.src)"></script>
|
||||
<script src="live.js?v=1774915797" onerror="console.error('Failed to load:', this.src)"></script>
|
||||
<script src="observers.js?v=1774915797" onerror="console.error('Failed to load:', this.src)"></script>
|
||||
<script src="observer-detail.js?v=1774915797" onerror="console.error('Failed to load:', this.src)"></script>
|
||||
<script src="compare.js?v=1774915797" onerror="console.error('Failed to load:', this.src)"></script>
|
||||
<script src="node-analytics.js?v=1774915797" onerror="console.error('Failed to load:', this.src)"></script>
|
||||
<script src="perf.js?v=1774915797" onerror="console.error('Failed to load:', this.src)"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
+26
-6
@@ -25,6 +25,7 @@
|
||||
let _lcdClockInterval = null;
|
||||
let _rateCounterInterval = null;
|
||||
let _pruneInterval = null;
|
||||
let activeNodeDetailKey = null;
|
||||
|
||||
// === VCR State Machine ===
|
||||
const VCR = {
|
||||
@@ -51,6 +52,19 @@
|
||||
REQUEST: '❓', RESPONSE: '📨', TRACE: '🔍', PATH: '🛤️'
|
||||
};
|
||||
|
||||
function formatLiveTimestampHtml(isoLike) {
|
||||
if (typeof formatTimestampWithTooltip !== 'function' || typeof getTimestampMode !== 'function') {
|
||||
return escapeHtml(typeof timeAgo === 'function' ? timeAgo(isoLike) : '—');
|
||||
}
|
||||
const d = isoLike ? new Date(isoLike) : null;
|
||||
const iso = d && isFinite(d.getTime()) ? d.toISOString() : null;
|
||||
const f = formatTimestampWithTooltip(iso, getTimestampMode());
|
||||
const warn = f.isFuture
|
||||
? ' <span class="timestamp-future-icon" title="Timestamp is in the future — node clock may be skewed">⚠️</span>'
|
||||
: '';
|
||||
return `<span class="timestamp-text" title="${escapeHtml(f.tooltip)}">${escapeHtml(f.text)}</span>${warn}`;
|
||||
}
|
||||
|
||||
function initResizeHandler() {
|
||||
let resizeTimer = null;
|
||||
_onResize = function() {
|
||||
@@ -935,6 +949,7 @@
|
||||
const nodeDetailPanel = document.getElementById('liveNodeDetail');
|
||||
const nodeDetailContent = document.getElementById('nodeDetailContent');
|
||||
document.getElementById('nodeDetailClose').addEventListener('click', () => {
|
||||
activeNodeDetailKey = null;
|
||||
nodeDetailPanel.classList.add('hidden');
|
||||
});
|
||||
|
||||
@@ -1159,6 +1174,7 @@
|
||||
}
|
||||
|
||||
async function showNodeDetail(pubkey) {
|
||||
activeNodeDetailKey = pubkey;
|
||||
const panel = document.getElementById('liveNodeDetail');
|
||||
const content = document.getElementById('nodeDetailContent');
|
||||
panel.classList.remove('hidden');
|
||||
@@ -1176,7 +1192,7 @@
|
||||
const roleColor = ROLE_COLORS[n.role] || '#6b7280';
|
||||
const roleLabel = (ROLE_LABELS[n.role] || n.role || 'unknown').replace(/s$/, '');
|
||||
const hasLoc = n.lat != null && n.lon != null;
|
||||
const lastSeen = n.last_seen ? timeAgo(n.last_seen) : '—';
|
||||
const lastSeen = formatLiveTimestampHtml(n.last_seen);
|
||||
const thresholds = window.getHealthThresholds ? getHealthThresholds(n.role) : { degradedMs: 3600000, silentMs: 86400000 };
|
||||
const ageMs = n.last_seen ? Date.now() - new Date(n.last_seen).getTime() : Infinity;
|
||||
const statusDot = ageMs < thresholds.degradedMs ? 'health-green' : ageMs < thresholds.silentMs ? 'health-yellow' : 'health-red';
|
||||
@@ -1217,7 +1233,7 @@
|
||||
<div style="font-size:11px;max-height:200px;overflow-y:auto;">` +
|
||||
recent.slice(0, 10).map(p => `<div style="padding:2px 0;display:flex;justify-content:space-between;">
|
||||
<a href="#/packets/${encodeURIComponent(p.hash || '')}" style="color:var(--accent);text-decoration:none;">${escapeHtml(p.payload_type || '?')}${p.observation_count > 1 ? ' <span class="badge badge-obs" style="font-size:9px">👁 ' + p.observation_count + '</span>' : ''}</a>
|
||||
<span style="color:var(--text-muted)">${p.timestamp ? timeAgo(p.timestamp) : '—'}</span>
|
||||
<span style="color:var(--text-muted)">${formatLiveTimestampHtml(p.timestamp)}</span>
|
||||
</div>`).join('') +
|
||||
'</div>';
|
||||
}
|
||||
@@ -1403,7 +1419,7 @@
|
||||
<span class="feed-type" style="color:${color}">${typeName}</span>
|
||||
${hopStr}${obsBadge}
|
||||
<span class="feed-text">${escapeHtml(preview)}</span>
|
||||
<span class="feed-time">${new Date(group.latestTs || Date.now()).toLocaleTimeString([], {hour:'2-digit',minute:'2-digit',second:'2-digit'})}</span>
|
||||
<span class="feed-time">${formatLiveTimestampHtml(group.latestTs || Date.now())}</span>
|
||||
`;
|
||||
item.addEventListener('click', () => showFeedCard(item, pkt, color));
|
||||
feed.appendChild(item);
|
||||
@@ -2267,7 +2283,7 @@
|
||||
<span class="feed-type" style="color:${color}">${typeName}</span>
|
||||
${hopStr}${obsBadge}
|
||||
<span class="feed-text">${escapeHtml(preview)}</span>
|
||||
<span class="feed-time">${new Date(pkt._ts || Date.now()).toLocaleTimeString([], {hour:'2-digit',minute:'2-digit',second:'2-digit'})}</span>
|
||||
<span class="feed-time">${formatLiveTimestampHtml(pkt._ts || Date.now())}</span>
|
||||
`;
|
||||
item.addEventListener('click', () => showFeedCard(item, pkt, color));
|
||||
feed.appendChild(item);
|
||||
@@ -2335,7 +2351,7 @@
|
||||
<span class="feed-type" style="color:${color}">${typeName}</span>
|
||||
${hopStr}${obsBadge}
|
||||
<span class="feed-text">${escapeHtml(preview)}</span>
|
||||
<span class="feed-time">${new Date(pkt._ts || Date.now()).toLocaleTimeString([], {hour:'2-digit',minute:'2-digit',second:'2-digit'})}</span>
|
||||
<span class="feed-time">${formatLiveTimestampHtml(pkt._ts || Date.now())}</span>
|
||||
`;
|
||||
item.addEventListener('click', () => showFeedCard(item, pkt, color));
|
||||
feed.prepend(item);
|
||||
@@ -2438,6 +2454,7 @@
|
||||
nodesLayer = pathsLayer = animLayer = heatLayer = geoFilterLayer = null;
|
||||
stopMatrixRain();
|
||||
nodeMarkers = {}; nodeData = {};
|
||||
activeNodeDetailKey = null;
|
||||
recentPaths = [];
|
||||
packetCount = 0; activeAnims = 0;
|
||||
nodeActivity = {}; pktTimestamps = [];
|
||||
@@ -2449,7 +2466,10 @@
|
||||
|
||||
registerPage('live', {
|
||||
init: function(app, routeParam) {
|
||||
_themeRefreshHandler = () => { /* live map rebuilds on next packet */ };
|
||||
_themeRefreshHandler = () => {
|
||||
rebuildFeedList();
|
||||
if (activeNodeDetailKey) showNodeDetail(activeNodeDetailKey);
|
||||
};
|
||||
window.addEventListener('theme-refresh', _themeRefreshHandler);
|
||||
return init(app, routeParam);
|
||||
},
|
||||
|
||||
+48
-12
@@ -85,6 +85,24 @@
|
||||
{ key: 'sensor', label: 'Sensors' },
|
||||
];
|
||||
|
||||
function renderNodeTimestampHtml(isoString) {
|
||||
if (typeof formatTimestampWithTooltip !== 'function' || typeof getTimestampMode !== 'function') {
|
||||
return escapeHtml(typeof timeAgo === 'function' ? timeAgo(isoString) : '—');
|
||||
}
|
||||
const f = formatTimestampWithTooltip(isoString, getTimestampMode());
|
||||
const warn = f.isFuture
|
||||
? ' <span class="timestamp-future-icon" title="Timestamp is in the future — node clock may be skewed">⚠️</span>'
|
||||
: '';
|
||||
return `<span class="timestamp-text" title="${escapeHtml(f.tooltip)}">${escapeHtml(f.text)}</span>${warn}`;
|
||||
}
|
||||
|
||||
function renderNodeTimestampText(isoString) {
|
||||
if (typeof formatTimestamp !== 'function' || typeof getTimestampMode !== 'function') {
|
||||
return typeof timeAgo === 'function' ? timeAgo(isoString) : '—';
|
||||
}
|
||||
return formatTimestamp(isoString, getTimestampMode());
|
||||
}
|
||||
|
||||
/* === Shared helper functions for node detail rendering === */
|
||||
|
||||
function getStatusTooltip(role, status) {
|
||||
@@ -117,7 +135,7 @@
|
||||
|
||||
let explanation = '';
|
||||
if (status === 'active') {
|
||||
explanation = 'Last heard ' + (lastHeardTime ? timeAgo(lastHeardTime) : 'unknown');
|
||||
explanation = 'Last heard ' + (lastHeardTime ? renderNodeTimestampText(lastHeardTime) : 'unknown');
|
||||
} else {
|
||||
const ageDays = Math.floor(statusAge / 86400000);
|
||||
const ageHours = Math.floor(statusAge / 3600000);
|
||||
@@ -274,8 +292,8 @@
|
||||
|
||||
<table class="node-stats-table" id="node-stats">
|
||||
<tr><td>Status</td><td><span title="${si.statusTooltip}">${statusLabel}</span> <span style="font-size:11px;color:var(--text-muted);margin-left:4px">${statusExplanation}</span></td></tr>
|
||||
<tr><td>Last Heard</td><td>${lastHeard ? timeAgo(lastHeard) : (n.last_seen ? timeAgo(n.last_seen) : '—')}</td></tr>
|
||||
<tr><td>First Seen</td><td>${n.first_seen ? new Date(n.first_seen).toLocaleString() : '—'}</td></tr>
|
||||
<tr><td>Last Heard</td><td>${renderNodeTimestampHtml(lastHeard || n.last_seen)}</td></tr>
|
||||
<tr><td>First Seen</td><td>${renderNodeTimestampHtml(n.first_seen)}</td></tr>
|
||||
<tr><td>Total Packets</td><td>${stats.totalTransmissions || stats.totalPackets || n.advert_count || 0}${stats.totalObservations && stats.totalObservations !== (stats.totalTransmissions || stats.totalPackets) ? ' <span class="text-muted" style="font-size:0.85em">(seen ' + stats.totalObservations + '×)</span>' : ''}</td></tr>
|
||||
<tr><td>Packets Today</td><td>${stats.packetsToday || 0}</td></tr>
|
||||
${stats.avgSnr != null ? `<tr><td>Avg SNR</td><td>${Number(stats.avgSnr).toFixed(1)} dB</td></tr>` : ''}
|
||||
@@ -327,7 +345,7 @@
|
||||
hashSizeBadge = ` <span class="badge" style="background:${hsColor};color:${hsFg};font-size:9px;font-family:var(--mono)">${hs}B</span>`;
|
||||
}
|
||||
return `<div class="node-activity-item">
|
||||
<span class="node-activity-time">${timeAgo(p.timestamp)}</span>
|
||||
<span class="node-activity-time">${renderNodeTimestampHtml(p.timestamp)}</span>
|
||||
<span>${typeLabel}${detail}${hashSizeBadge}${obsBadge}${obs ? ' via ' + escapeHtml(obs) : ''}${snr}${rssi}</span>
|
||||
<a href="#/packets/${p.hash}" class="ch-analyze-link" style="margin-left:8px;font-size:0.8em">Analyze →</a>
|
||||
</div>`;
|
||||
@@ -406,7 +424,7 @@
|
||||
}).join(' → ');
|
||||
return `<div style="padding:6px 0;border-bottom:1px solid var(--border);font-size:12px">
|
||||
<div>${chain}</div>
|
||||
<div style="color:var(--text-muted);margin-top:2px">${p.count}× · last ${timeAgo(p.lastSeen)} · <a href="#/packets/${p.sampleHash}" class="ch-analyze-link">Analyze →</a></div>
|
||||
<div style="color:var(--text-muted);margin-top:2px">${p.count}× · last ${renderNodeTimestampHtml(p.lastSeen)} · <a href="#/packets/${p.sampleHash}" class="ch-analyze-link">Analyze →</a></div>
|
||||
</div>`;
|
||||
}).join('');
|
||||
}
|
||||
@@ -429,7 +447,7 @@
|
||||
}
|
||||
}
|
||||
|
||||
function destroy() {
|
||||
function destroy() {
|
||||
if (wsHandler) offWS(wsHandler);
|
||||
wsHandler = null;
|
||||
if (detailMap) { detailMap.remove(); detailMap = null; }
|
||||
@@ -439,6 +457,8 @@
|
||||
selectedKey = null;
|
||||
}
|
||||
|
||||
let _themeRefreshHandler = null;
|
||||
|
||||
let _allNodes = null; // cached full node list
|
||||
|
||||
// Build a map of lowercased name → count of distinct pubkeys sharing that name
|
||||
@@ -677,7 +697,7 @@
|
||||
<td>${favStar(n.public_key, 'node-fav')}${isClaimed ? '<span class="claimed-badge" title="My Mesh">★</span> ' : ''}<strong>${n.name || '(unnamed)'}</strong>${dupNameBadge(n.name, n.public_key, dupMap)}</td>
|
||||
<td class="mono col-pubkey">${truncate(n.public_key, 16)}</td>
|
||||
<td><span class="badge" style="background:${roleColor}20;color:${roleColor}">${n.role}</span></td>
|
||||
<td class="${lastSeenClass}">${timeAgo(n.last_heard || n.last_seen)}</td>
|
||||
<td class="${lastSeenClass}">${renderNodeTimestampHtml(n.last_heard || n.last_seen)}</td>
|
||||
<td>${n.advert_count || 0}</td>
|
||||
</tr>`;
|
||||
}).join('');
|
||||
@@ -750,8 +770,8 @@
|
||||
<div class="node-detail-section">
|
||||
<h4>Overview</h4>
|
||||
<dl class="detail-meta">
|
||||
<dt>Last Heard</dt><dd>${lastHeard ? timeAgo(lastHeard) : (n.last_seen ? timeAgo(n.last_seen) : '—')}</dd>
|
||||
<dt>First Seen</dt><dd>${n.first_seen ? new Date(n.first_seen).toLocaleString() : '—'}</dd>
|
||||
<dt>Last Heard</dt><dd>${renderNodeTimestampHtml(lastHeard || n.last_seen)}</dd>
|
||||
<dt>First Seen</dt><dd>${renderNodeTimestampHtml(n.first_seen)}</dd>
|
||||
<dt>Total Packets</dt><dd>${totalPackets}</dd>
|
||||
<dt>Packets Today</dt><dd>${stats.packetsToday || 0}</dd>
|
||||
${stats.avgSnr != null ? `<dt>Avg SNR</dt><dd>${Number(stats.avgSnr).toFixed(1)} dB</dd>` : ''}
|
||||
@@ -789,7 +809,7 @@
|
||||
return `<div class="advert-entry">
|
||||
<span class="advert-dot" style="background:${roleColor}"></span>
|
||||
<div class="advert-info">
|
||||
<strong>${timeAgo(a.timestamp)}</strong> ${icon} ${pType}${detail}
|
||||
<strong>${renderNodeTimestampHtml(a.timestamp)}</strong> ${icon} ${pType}${detail}
|
||||
${a.observation_count > 1 ? ' <span class="badge badge-obs">👁 ' + a.observation_count + '</span>' : ''}
|
||||
${obs ? ' via ' + escapeHtml(obs) : ''}
|
||||
${a.snr != null ? ` · SNR ${a.snr}dB` : ''}${a.rssi != null ? ` · RSSI ${a.rssi}dBm` : ''}
|
||||
@@ -863,7 +883,7 @@
|
||||
}).join(' → ');
|
||||
return `<div style="padding:6px 0;border-bottom:1px solid var(--border);font-size:12px">
|
||||
<div>${chain}</div>
|
||||
<div style="color:var(--text-muted);margin-top:2px">${p.count}× · last ${timeAgo(p.lastSeen)} · <a href="#/packets/${p.sampleHash}" class="ch-analyze-link">Analyze →</a></div>
|
||||
<div style="color:var(--text-muted);margin-top:2px">${p.count}× · last ${renderNodeTimestampHtml(p.lastSeen)} · <a href="#/packets/${p.sampleHash}" class="ch-analyze-link">Analyze →</a></div>
|
||||
</div>`;
|
||||
}).join('');
|
||||
}
|
||||
@@ -889,7 +909,23 @@
|
||||
return false;
|
||||
}
|
||||
|
||||
registerPage('nodes', { init, destroy });
|
||||
registerPage('nodes', {
|
||||
init: function(app, routeParam) {
|
||||
_themeRefreshHandler = () => {
|
||||
if (directNode) loadFullNode(directNode);
|
||||
else {
|
||||
renderRows();
|
||||
if (selectedKey) selectNode(selectedKey);
|
||||
}
|
||||
};
|
||||
window.addEventListener('theme-refresh', _themeRefreshHandler);
|
||||
return init(app, routeParam);
|
||||
},
|
||||
destroy: function() {
|
||||
if (_themeRefreshHandler) { window.removeEventListener('theme-refresh', _themeRefreshHandler); _themeRefreshHandler = null; }
|
||||
return destroy();
|
||||
}
|
||||
});
|
||||
|
||||
// Test hooks
|
||||
window._nodesIsAdvertMessage = isAdvertMessage;
|
||||
|
||||
+15
-4
@@ -160,6 +160,17 @@
|
||||
|
||||
let directObsId = null;
|
||||
|
||||
function renderTimestampCell(isoString) {
|
||||
if (typeof formatTimestampWithTooltip !== 'function' || typeof getTimestampMode !== 'function') {
|
||||
return escapeHtml(typeof timeAgo === 'function' ? timeAgo(isoString) : '—');
|
||||
}
|
||||
const f = formatTimestampWithTooltip(isoString, getTimestampMode());
|
||||
const warn = f.isFuture
|
||||
? ' <span class="timestamp-future-icon" title="Timestamp is in the future — node clock may be skewed">⚠️</span>'
|
||||
: '';
|
||||
return `<span class="timestamp-text" title="${escapeHtml(f.tooltip)}">${escapeHtml(f.text)}</span>${warn}`;
|
||||
}
|
||||
|
||||
async function init(app, routeParam) {
|
||||
const gen = ++initGeneration;
|
||||
// Parse ?obs=OBSERVER_ID from routeParam
|
||||
@@ -1024,7 +1035,7 @@
|
||||
html += `<tr class="${isSingle ? '' : 'group-header'} ${isExpanded ? 'expanded' : ''}" data-hash="${p.hash}" data-action="${isSingle ? 'select-hash' : 'toggle-select'}" data-value="${p.hash}" tabindex="0" role="row">
|
||||
<td style="width:28px;text-align:center;cursor:pointer">${isSingle ? '' : (isExpanded ? '▼' : '▶')}</td>
|
||||
<td class="col-region">${groupRegion ? `<span class="badge-region">${groupRegion}</span>` : '—'}</td>
|
||||
<td class="col-time">${timeAgo(p.latest)}</td>
|
||||
<td class="col-time">${renderTimestampCell(p.latest)}</td>
|
||||
<td class="mono col-hash">${truncate(p.hash || '—', 8)}</td>
|
||||
<td class="col-size">${groupSize ? groupSize + 'B' : '—'}</td>
|
||||
<td class="col-type">${p.payload_type != null ? `<span class="badge badge-${groupTypeClass}">${groupTypeName}</span>` : '—'}</td>
|
||||
@@ -1051,7 +1062,7 @@
|
||||
const childPathStr = renderPath(childPath, c.observer_id);
|
||||
html += `<tr class="group-child" data-id="${c.id}" data-hash="${c.hash || ''}" data-action="select-observation" data-value="${c.id}" data-parent-hash="${p.hash}" tabindex="0" role="row">
|
||||
<td></td><td class="col-region">${childRegion ? `<span class="badge-region">${childRegion}</span>` : '—'}</td>
|
||||
<td class="col-time">${timeAgo(c.timestamp)}</td>
|
||||
<td class="col-time">${renderTimestampCell(c.timestamp)}</td>
|
||||
<td class="mono col-hash">${truncate(c.hash || '', 8)}</td>
|
||||
<td class="col-size">${size}B</td>
|
||||
<td class="col-type"><span class="badge badge-${typeClass}">${typeName}</span></td>
|
||||
@@ -1080,7 +1091,7 @@
|
||||
|
||||
return `<tr data-id="${p.id}" data-hash="${p.hash || ''}" data-action="select-hash" data-value="${p.hash || p.id}" tabindex="0" role="row" class="${selectedId === p.id ? 'selected' : ''}">
|
||||
<td></td><td class="col-region">${region ? `<span class="badge-region">${region}</span>` : '—'}</td>
|
||||
<td class="col-time">${timeAgo(p.timestamp)}</td>
|
||||
<td class="col-time">${renderTimestampCell(p.timestamp)}</td>
|
||||
<td class="mono col-hash">${truncate(p.hash || String(p.id), 8)}</td>
|
||||
<td class="col-size">${size}B</td>
|
||||
<td class="col-type"><span class="badge badge-${typeClass}">${typeName}</span></td>
|
||||
@@ -1330,7 +1341,7 @@
|
||||
<dt>Route Type</dt><dd>${routeTypeName(pkt.route_type)}</dd>
|
||||
<dt>Payload Type</dt><dd><span class="badge badge-${payloadTypeColor(pkt.payload_type)}">${typeName}</span></dd>
|
||||
${hashSize ? `<dt>Hash Size</dt><dd>${hashSize} byte${hashSize !== 1 ? 's' : ''}</dd>` : ''}
|
||||
<dt>Timestamp</dt><dd>${pkt.timestamp}</dd>
|
||||
<dt>Timestamp</dt><dd>${renderTimestampCell(pkt.timestamp)}</dd>
|
||||
<dt>Propagation</dt><dd>${propagationHtml}</dd>
|
||||
<dt>Path</dt><dd>${pathHops.length ? renderPath(pathHops, pkt.observer_id) : '—'}</dd>
|
||||
</dl>
|
||||
|
||||
@@ -280,6 +280,8 @@ a:focus-visible, button:focus-visible, input:focus-visible, select:focus-visible
|
||||
}
|
||||
.data-table td.col-details { white-space: normal; word-break: break-word; }
|
||||
.data-table td:has(.spark-bar), .data-table td.col-spark { max-width: none; overflow: visible; min-width: 80px; }
|
||||
.data-table .col-time { min-width: 108px; white-space: nowrap; }
|
||||
.timestamp-future-icon { margin-left: 4px; cursor: help; }
|
||||
.data-table tbody tr:nth-child(even) { background: var(--row-stripe); }
|
||||
.data-table tbody tr:hover { background: var(--row-hover); cursor: pointer; }
|
||||
.data-table tbody tr.selected { background: var(--selected-bg); }
|
||||
@@ -879,6 +881,7 @@ button.ch-item.selected { background: var(--selected-bg); }
|
||||
.data-table { font-size: 11px; min-width: 0; }
|
||||
.data-table td { padding: 5px 4px; max-width: 100px; }
|
||||
.data-table th { padding: 5px 4px; font-size: 10px; }
|
||||
.data-table .col-time { min-width: 64px; }
|
||||
.panel-left { overflow-x: auto; }
|
||||
|
||||
/* Filters: collapse on mobile */
|
||||
|
||||
+40
-21
@@ -104,6 +104,45 @@ console.log('\n=== app.js: timeAgo ===');
|
||||
const d = new Date(Date.now() - 259200000).toISOString();
|
||||
assert.strictEqual(timeAgo(d), '3d ago');
|
||||
});
|
||||
test('future timestamp returns in-format', () => {
|
||||
const d = new Date(Date.now() + 120000).toISOString();
|
||||
assert.strictEqual(timeAgo(d), 'in 2m');
|
||||
});
|
||||
}
|
||||
|
||||
console.log('\n=== app.js: formatTimestamp / formatTimestampWithTooltip ===');
|
||||
{
|
||||
const ctx = makeSandbox();
|
||||
loadInCtx(ctx, 'public/roles.js');
|
||||
loadInCtx(ctx, 'public/app.js');
|
||||
const formatTimestamp = ctx.formatTimestamp;
|
||||
const formatTimestampWithTooltip = ctx.formatTimestampWithTooltip;
|
||||
|
||||
test('formatTimestamp null returns dash', () => {
|
||||
assert.strictEqual(formatTimestamp(null, 'ago'), '—');
|
||||
});
|
||||
test('formatTimestamp ago returns relative string', () => {
|
||||
const d = new Date(Date.now() - 300000).toISOString();
|
||||
assert.strictEqual(formatTimestamp(d, 'ago'), '5m ago');
|
||||
});
|
||||
test('formatTimestamp absolute returns ISO string', () => {
|
||||
const d = '2024-01-02T03:04:05.000Z';
|
||||
assert.strictEqual(formatTimestamp(d, 'absolute'), d);
|
||||
});
|
||||
test('formatTimestampWithTooltip future returns isFuture true', () => {
|
||||
const d = new Date(Date.now() + 120000).toISOString();
|
||||
const out = formatTimestampWithTooltip(d, 'ago');
|
||||
assert.strictEqual(out.isFuture, true);
|
||||
assert.strictEqual(out.text, d);
|
||||
assert.strictEqual(out.tooltip, 'in 2m');
|
||||
});
|
||||
test('tooltip is opposite format', () => {
|
||||
const d = '2024-01-02T03:04:05.000Z';
|
||||
const ago = formatTimestampWithTooltip(d, 'ago');
|
||||
const absolute = formatTimestampWithTooltip(d, 'absolute');
|
||||
assert.strictEqual(ago.tooltip, d);
|
||||
assert.ok(absolute.tooltip.endsWith('ago') || absolute.tooltip.startsWith('in '));
|
||||
});
|
||||
}
|
||||
|
||||
console.log('\n=== app.js: escapeHtml ===');
|
||||
@@ -151,27 +190,7 @@ console.log('\n=== app.js: truncate ===');
|
||||
// ===== NODES.JS TESTS =====
|
||||
console.log('\n=== nodes.js: getStatusInfo ===');
|
||||
{
|
||||
const ctx = makeSandbox();
|
||||
loadInCtx(ctx, 'public/roles.js');
|
||||
// nodes.js is an IIFE that registers a page — we need to mock registerPage and other globals
|
||||
ctx.registerPage = () => {};
|
||||
ctx.api = () => Promise.resolve([]);
|
||||
ctx.timeAgo = vm.runInContext(`(${fs.readFileSync('public/app.js', 'utf8').match(/function timeAgo[^}]+}/)[0]})`, ctx);
|
||||
// Actually, let's load app.js first for its globals
|
||||
loadInCtx(ctx, 'public/app.js');
|
||||
ctx.RegionFilter = { init: () => {}, getSelected: () => null, onRegionChange: () => {} };
|
||||
ctx.onWS = () => {};
|
||||
ctx.offWS = () => {};
|
||||
ctx.invalidateApiCache = () => {};
|
||||
ctx.favStar = () => '';
|
||||
ctx.bindFavStars = () => {};
|
||||
ctx.getFavorites = () => [];
|
||||
ctx.isFavorite = () => false;
|
||||
ctx.connectWS = () => {};
|
||||
loadInCtx(ctx, 'public/nodes.js');
|
||||
|
||||
// getStatusInfo is inside the IIFE, not on window. We need to extract it differently.
|
||||
// Let's use a modified approach - inject a hook before loading
|
||||
// Placeholder header for continuity; actual nodes tests are below using injected exports.
|
||||
}
|
||||
|
||||
// Since nodes.js functions are inside an IIFE, we need to extract them.
|
||||
|
||||
Reference in New Issue
Block a user