mirror of
https://github.com/Kpa-clawbot/meshcore-analyzer.git
synced 2026-06-01 19:14:08 +00:00
89d644dd72
Red commit: 8ac568bac3 (CI run: pending)
## Summary
Implements AC #4 of #1056: row-detail **slide-over panel** at narrow
viewports for the Packets, Nodes, and Observers tables.
ACs #1–#3, #5 already shipped in #1099; this PR closes the remaining
criterion.
## Approach
- Shared `window.SlideOver` helper (`packets.js`, top of file next to
`TableResponsive`) — singleton overlay (`.slide-over-backdrop` +
`.slide-over-panel`) injected into `<body>`. Close affordances: X button
(`.slide-over-close`), backdrop click, Escape key. `aria-modal="true"`,
focus moved to close button on open.
- Breakpoint: `window.innerWidth <= 1023` (matches the
`data-priority="3"` threshold reused by `TableResponsive`). At `>=1024`
the existing right-side panel / full-screen behavior is preserved — no
regression.
- Each page (`packets.js`, `nodes.js`, `observers.js`) checks the
breakpoint at row-click time and routes the same detail content into
`SlideOver.open(node)` instead of the side panel / full-screen
navigation.
- Reuses the existing `slideInRight` keyframe in `style.css`.
- CSS additions live in the table section of `style.css` only.
## E2E
`test-slideover-1056-e2e.js` — at 800x800 clicks the first row of each
of the three tables, asserts `.slide-over-panel` +
`.slide-over-backdrop` are visible and the close X exists; verifies
Escape, backdrop click, and X click all dismiss; verifies that at 1440
the slide-over does NOT appear.
E2E assertion added: `test-slideover-1056-e2e.js:71`
## TDD
- Red commit: `8ac568b` — E2E asserts on `.slide-over-panel` which does
not exist yet.
- Green commit: forthcoming in this PR.
Fixes #1056
---------
Co-authored-by: openclaw-bot <bot@openclaw.local>
Co-authored-by: Kpa-clawbot <bot@kpa-clawbot.local>
Co-authored-by: corescope-bot <bot@corescope.local>
Co-authored-by: Kpa-clawbot <bot@kpa-clawbot>
223 lines
11 KiB
JavaScript
223 lines
11 KiB
JavaScript
/* === CoreScope — observers.js === */
|
|
'use strict';
|
|
|
|
(function () {
|
|
let observers = [];
|
|
let obsSkewMap = {}; // observerID → {offsetSec, samples}
|
|
let wsHandler = null;
|
|
let refreshTimer = null;
|
|
let regionChangeHandler = null;
|
|
|
|
function init(app) {
|
|
app.innerHTML = `
|
|
<div class="observers-page">
|
|
<div class="page-header">
|
|
<h2>Observer Status</h2>
|
|
<a href="#/compare" class="btn-icon" title="Compare observers" aria-label="Compare observers" style="text-decoration:none">🔍</a>
|
|
<button class="btn-icon" data-action="obs-refresh" title="Refresh" aria-label="Refresh observers">🔄</button>
|
|
</div>
|
|
<div id="obsRegionFilter" class="region-filter-container"></div>
|
|
<div id="obsContent"><div class="text-center text-muted" style="padding:40px">Loading…</div></div>
|
|
</div>`;
|
|
RegionFilter.init(document.getElementById('obsRegionFilter'));
|
|
regionChangeHandler = RegionFilter.onChange(function () { render(); });
|
|
loadObservers();
|
|
// Event delegation for data-action buttons
|
|
app.addEventListener('click', function (e) {
|
|
var btn = e.target.closest('[data-action]');
|
|
if (btn && btn.dataset.action === 'obs-refresh') loadObservers();
|
|
var row = e.target.closest('tr[data-action="navigate"]');
|
|
if (row) {
|
|
// #1056 AC#4: at narrow widths, open detail in slide-over instead of
|
|
// navigating to a separate page.
|
|
if (window.SlideOver && window.SlideOver.shouldUse()) {
|
|
e.preventDefault();
|
|
openObserverSlideOver(row.dataset.value);
|
|
return;
|
|
}
|
|
location.hash = row.dataset.value;
|
|
}
|
|
});
|
|
// #209 — Keyboard accessibility for observer rows
|
|
app.addEventListener('keydown', function (e) {
|
|
var row = e.target.closest('tr[data-action="navigate"]');
|
|
if (!row) return;
|
|
if (e.key !== 'Enter' && e.key !== ' ') return;
|
|
e.preventDefault();
|
|
if (window.SlideOver && window.SlideOver.shouldUse()) {
|
|
openObserverSlideOver(row.dataset.value);
|
|
return;
|
|
}
|
|
location.hash = row.dataset.value;
|
|
});
|
|
// Auto-refresh every 30s
|
|
refreshTimer = setInterval(loadObservers, 30000);
|
|
wsHandler = debouncedOnWS(function (msgs) {
|
|
if (msgs.some(function (m) { return m.type === 'packet'; })) loadObservers();
|
|
});
|
|
}
|
|
|
|
function destroy() {
|
|
if (wsHandler) offWS(wsHandler);
|
|
wsHandler = null;
|
|
if (refreshTimer) clearInterval(refreshTimer);
|
|
refreshTimer = null;
|
|
if (regionChangeHandler) RegionFilter.offChange(regionChangeHandler);
|
|
regionChangeHandler = null;
|
|
observers = [];
|
|
obsSkewMap = {};
|
|
}
|
|
|
|
async function loadObservers() {
|
|
try {
|
|
const [data, skewData] = await Promise.all([
|
|
api('/observers', { ttl: CLIENT_TTL.observers }),
|
|
api('/observers/clock-skew', { ttl: 30000 }).catch(function() { return []; })
|
|
]);
|
|
observers = data.observers || [];
|
|
obsSkewMap = {};
|
|
(Array.isArray(skewData) ? skewData : []).forEach(function(s) {
|
|
if (s && s.observerID) obsSkewMap[s.observerID] = s;
|
|
});
|
|
render();
|
|
} catch (e) {
|
|
document.getElementById('obsContent').innerHTML =
|
|
`<div class="text-muted" role="alert" aria-live="polite" style="padding:40px">Error loading observers: ${e.message}</div>`;
|
|
}
|
|
}
|
|
|
|
// NOTE: Comparing server timestamps to Date.now() can skew if client/server
|
|
// clocks differ. We add ±30s tolerance to thresholds to reduce false positives.
|
|
function healthStatus(lastSeen) {
|
|
if (!lastSeen) return { cls: 'health-red', label: 'Unknown' };
|
|
const ago = Date.now() - new Date(lastSeen).getTime();
|
|
const tolerance = 30000; // 30s tolerance for clock skew
|
|
if (ago < 600000 + tolerance) return { cls: 'health-green', label: 'Online' }; // < 10 min + tolerance
|
|
if (ago < 3600000 + tolerance) return { cls: 'health-yellow', label: 'Stale' }; // < 1 hour + tolerance
|
|
return { cls: 'health-red', label: 'Offline' };
|
|
}
|
|
|
|
function packetBadge(o) {
|
|
if (!o.last_packet_at) return '<span title="No packets ever observed">📡⚠ never</span>';
|
|
const pktAgo = Date.now() - new Date(o.last_packet_at).getTime();
|
|
const statusAgo = o.last_seen ? Date.now() - new Date(o.last_seen).getTime() : Infinity;
|
|
const gap = pktAgo - statusAgo;
|
|
if (gap > 600000) {
|
|
return `<span title="Last packet ${timeAgo(o.last_packet_at)} — status is newer by ${Math.round(gap/60000)}min. Observer may be alive but not forwarding packets.">📡⚠ ${timeAgo(o.last_packet_at)}</span>`;
|
|
}
|
|
return timeAgo(o.last_packet_at);
|
|
}
|
|
|
|
function uptimeStr(firstSeen) {
|
|
if (!firstSeen) return '—';
|
|
const ms = Date.now() - new Date(firstSeen).getTime();
|
|
const d = Math.floor(ms / 86400000);
|
|
const h = Math.floor((ms % 86400000) / 3600000);
|
|
if (d > 0) return `${d}d ${h}h`;
|
|
const m = Math.floor((ms % 3600000) / 60000);
|
|
return h > 0 ? `${h}h ${m}m` : `${m}m`;
|
|
}
|
|
|
|
function sparkBar(count, max) {
|
|
if (max === 0) return `<span class="text-muted">0/hr</span>`;
|
|
const pct = Math.min(100, Math.round((count / max) * 100));
|
|
return `<span style="display:inline-flex;align-items:center;gap:6px;white-space:nowrap"><span style="display:inline-block;width:60px;height:12px;background:var(--border);border-radius:3px;overflow:hidden;vertical-align:middle"><span style="display:block;height:100%;width:${pct}%;background:linear-gradient(90deg,#3b82f6,#60a5fa);border-radius:3px"></span></span><span style="font-size:11px">${count}/hr</span></span>`;
|
|
}
|
|
|
|
function render() {
|
|
const el = document.getElementById('obsContent');
|
|
if (!el) return;
|
|
|
|
// Apply region filter
|
|
const selectedRegions = RegionFilter.getSelected();
|
|
const filtered = selectedRegions
|
|
? observers.filter(o => o.iata && selectedRegions.includes(o.iata))
|
|
: observers;
|
|
|
|
if (filtered.length === 0) {
|
|
el.innerHTML = '<div class="text-center text-muted" style="padding:40px">No observers found.</div>';
|
|
return;
|
|
}
|
|
|
|
const maxPktsHr = Math.max(1, ...filtered.map(o => o.packetsLastHour || 0));
|
|
|
|
// Summary counts
|
|
const online = filtered.filter(o => healthStatus(o.last_seen).cls === 'health-green').length;
|
|
const stale = filtered.filter(o => healthStatus(o.last_seen).cls === 'health-yellow').length;
|
|
const offline = filtered.filter(o => healthStatus(o.last_seen).cls === 'health-red').length;
|
|
|
|
el.innerHTML = `
|
|
<div class="obs-summary">
|
|
<span class="obs-stat"><span class="health-dot health-green">●</span> ${online} Online</span>
|
|
<span class="obs-stat"><span class="health-dot health-yellow">▲</span> ${stale} Stale</span>
|
|
<span class="obs-stat"><span class="health-dot health-red">✕</span> ${offline} Offline</span>
|
|
<span class="obs-stat">📡 ${filtered.length} Total</span>
|
|
</div>
|
|
<div class="obs-table-scroll table-fluid-wrap"><table class="data-table obs-table" id="obsTable">
|
|
<caption class="sr-only">Observer status and statistics</caption>
|
|
<thead><tr>
|
|
<th scope="col" data-priority="1">Status</th><th scope="col" data-priority="1">Name</th><th scope="col" data-priority="3">Region</th><th scope="col" data-priority="2">Last Status</th><th scope="col" data-priority="2">Last Packet</th>
|
|
<th scope="col" data-priority="3">Packet Health</th><th scope="col" data-priority="4">Total Packets</th><th scope="col" data-priority="3">Packets/Hour</th><th scope="col" data-priority="4">Clock Offset</th><th scope="col" data-priority="4">Uptime</th>
|
|
</tr></thead>
|
|
<tbody>${filtered.map(o => {
|
|
const h = healthStatus(o.last_seen);
|
|
const shape = h.cls === 'health-green' ? '●' : h.cls === 'health-yellow' ? '▲' : '✕';
|
|
return `<tr style="cursor:pointer" tabindex="0" role="row" data-action="navigate" data-value="#/observers/${encodeURIComponent(o.id)}" onclick="location.hash='#/observers/${encodeURIComponent(o.id)}'">
|
|
<td><span class="health-dot ${h.cls}" title="${h.label}">${shape}</span> ${h.label}</td>
|
|
<td class="mono">${o.name || o.id}</td>
|
|
<td>${o.iata ? `<span class="badge-region">${o.iata}</span>` : '—'}</td>
|
|
<td>${timeAgo(o.last_seen)}</td>
|
|
<td>${o.last_packet_at ? timeAgo(o.last_packet_at) : '<span class="text-muted">—</span>'}</td>
|
|
<td>${packetBadge(o)}</td>
|
|
<td>${(o.packet_count || 0).toLocaleString()}</td>
|
|
<td>${sparkBar(o.packetsLastHour || 0, maxPktsHr)}</td>
|
|
<td>${(function() {
|
|
var sk = obsSkewMap[o.id];
|
|
if (!sk || sk.samples == null || sk.samples === 0) return '<span class="text-muted">—</span>';
|
|
var sev = observerSkewSeverity(sk.offsetSec);
|
|
return renderSkewBadge(sev, sk.offsetSec) + ' <span class="text-muted" title="Computed from ' + sk.samples + ' multi-observer packets. Positive = observer ahead of consensus.">(' + sk.samples + ')</span>';
|
|
})()}</td>
|
|
<td>${uptimeStr(o.first_seen)}</td>
|
|
</tr>`;
|
|
}).join('')}</tbody>
|
|
</table></div>`;
|
|
makeColumnsResizable('#obsTable', 'meshcore-obs-col-widths');
|
|
// #1056: fluid columns + +N hidden pill
|
|
if (window.TableResponsive) {
|
|
var _obsTbl = document.getElementById('obsTable');
|
|
if (_obsTbl) window.TableResponsive.register(_obsTbl);
|
|
}
|
|
}
|
|
|
|
|
|
registerPage('observers', { init, destroy });
|
|
|
|
// #1056 AC#4: row-detail slide-over (narrow viewports). Renders a compact
|
|
// summary from the in-memory observer + a link to the full page.
|
|
function openObserverSlideOver(hashHref) {
|
|
if (!window.SlideOver) return;
|
|
var m = String(hashHref || '').match(/#\/observers\/(.+)$/);
|
|
if (!m) return;
|
|
var id = decodeURIComponent(m[1]);
|
|
var o = (observers || []).find(function (x) { return String(x.id) === id; });
|
|
if (!o) return;
|
|
var h = healthStatus(o.last_seen);
|
|
var sk = obsSkewMap[o.id];
|
|
var skewLine = (sk && sk.samples) ? renderSkewBadge(observerSkewSeverity(sk.offsetSec), sk.offsetSec) + ' (' + sk.samples + ' samples)' : '—';
|
|
var pkts = sparkBar(o.packetsLastHour || 0, Math.max(1, o.packetsLastHour || 1));
|
|
var content = window.SlideOver.open({ title: o.name || o.id });
|
|
content.innerHTML =
|
|
'<dl class="slide-over-dl" style="margin:0;display:grid;grid-template-columns:auto 1fr;gap:6px 12px;font-size:13px">' +
|
|
'<dt>Status</dt><dd><span class="health-dot ' + h.cls + '">●</span> ' + h.label + '</dd>' +
|
|
'<dt>Region</dt><dd>' + (o.iata ? '<span class="badge-region">' + o.iata + '</span>' : '—') + '</dd>' +
|
|
'<dt>Last status</dt><dd>' + timeAgo(o.last_seen) + '</dd>' +
|
|
'<dt>Last packet</dt><dd>' + (o.last_packet_at ? timeAgo(o.last_packet_at) : '—') + '</dd>' +
|
|
'<dt>Total packets</dt><dd>' + (o.packet_count || 0).toLocaleString() + '</dd>' +
|
|
'<dt>Packets/hr</dt><dd>' + pkts + '</dd>' +
|
|
'<dt>Clock offset</dt><dd>' + skewLine + '</dd>' +
|
|
'<dt>Uptime</dt><dd>' + uptimeStr(o.first_seen) + '</dd>' +
|
|
'</dl>' +
|
|
'<p style="margin-top:14px"><a class="btn-primary" href="' + hashHref + '">Open full detail →</a></p>';
|
|
}
|
|
})();
|