mirror of
https://github.com/Kpa-clawbot/meshcore-analyzer.git
synced 2026-05-26 01:14:01 +00:00
fix: analytics — async race, guards, legend CSS, dedup API, responsive layout (closes #75, #76, #77, #78, #79, #80, #81)
This commit is contained in:
+16
-22
@@ -121,7 +121,7 @@
|
||||
}
|
||||
}
|
||||
|
||||
function renderTab(tab) {
|
||||
async function renderTab(tab) {
|
||||
const el = document.getElementById('analyticsContent');
|
||||
const d = _analyticsData;
|
||||
switch (tab) {
|
||||
@@ -130,14 +130,14 @@
|
||||
case 'topology': renderTopology(el, d.topoData); break;
|
||||
case 'channels': renderChannels(el, d.chanData); break;
|
||||
case 'hashsizes': renderHashSizes(el, d.hashData); break;
|
||||
case 'collisions': renderCollisionTab(el, d.hashData); break;
|
||||
case 'subpaths': renderSubpaths(el); break;
|
||||
case 'collisions': await renderCollisionTab(el, d.hashData); break;
|
||||
case 'subpaths': await renderSubpaths(el); break;
|
||||
}
|
||||
// Auto-apply column resizing to all analytics tables
|
||||
requestAnimationFrame(() => {
|
||||
el.querySelectorAll('.analytics-table').forEach((tbl, i) => {
|
||||
tbl.id = tbl.id || `analytics-tbl-${tab}-${i}`;
|
||||
makeColumnsResizable('#' + tbl.id, `meshcore-analytics-${tab}-${i}-col-widths`);
|
||||
if (typeof makeColumnsResizable === 'function') makeColumnsResizable('#' + tbl.id, `meshcore-analytics-${tab}-${i}-col-widths`);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -728,7 +728,7 @@
|
||||
`;
|
||||
}
|
||||
|
||||
function renderCollisionTab(el, data) {
|
||||
async function renderCollisionTab(el, data) {
|
||||
el.innerHTML = `
|
||||
<div class="analytics-card">
|
||||
<h3>1-Byte Hash Usage Matrix</h3>
|
||||
@@ -741,8 +741,10 @@
|
||||
<div id="collisionList"><div class="text-muted" style="padding:8px">Loading…</div></div>
|
||||
</div>
|
||||
`;
|
||||
renderHashMatrix(data.topHops);
|
||||
renderCollisions(data.topHops);
|
||||
let allNodes = [];
|
||||
try { const nd = await api('/nodes?limit=2000'); allNodes = nd.nodes || []; } catch {}
|
||||
renderHashMatrix(data.topHops, allNodes);
|
||||
renderCollisions(data.topHops, allNodes);
|
||||
}
|
||||
|
||||
function renderHashTimeline(hourly) {
|
||||
@@ -769,16 +771,9 @@
|
||||
return svg;
|
||||
}
|
||||
|
||||
async function renderHashMatrix(topHops) {
|
||||
async function renderHashMatrix(topHops, allNodes) {
|
||||
const el = document.getElementById('hashMatrix');
|
||||
|
||||
// Fetch all nodes for lookup
|
||||
let allNodes = [];
|
||||
try {
|
||||
const nd = await api('/nodes?limit=2000');
|
||||
allNodes = nd.nodes || [];
|
||||
} catch {}
|
||||
|
||||
// Build prefix → node count map
|
||||
const prefixNodes = {};
|
||||
for (let i = 0; i < 256; i++) {
|
||||
@@ -824,10 +819,10 @@
|
||||
html += '</table></div>';
|
||||
html += `<div id="hashDetail" style="flex:1;min-width:200px;max-width:400px;font-size:0.85em"></div></div>
|
||||
<div style="margin-top:8px;font-size:0.8em;display:flex;gap:16px;align-items:center">
|
||||
<span><span style="display:inline-block;width:12px;height:12px;background:#166534;border:1px solid var(--border);vertical-align:middle"></span> 0 — Available</span>
|
||||
<span><span style="display:inline-block;width:12px;height:12px;background:#854d0e;border:1px solid var(--border);vertical-align:middle"></span> 1 — One node</span>
|
||||
<span><span style="display:inline-block;width:12px;height:12px;background:rgb(200,80,30);border:1px solid var(--border);vertical-align:middle"></span> ⚠2 — Two nodes (collision)</span>
|
||||
<span><span style="display:inline-block;width:12px;height:12px;background:rgb(200,0,30);border:1px solid var(--border);vertical-align:middle"></span> ⚠3+ — Three+ nodes (collision)</span>
|
||||
<span><span class="legend-swatch" style="background:#166534"></span> 0 — Available</span>
|
||||
<span><span class="legend-swatch" style="background:#854d0e"></span> 1 — One node</span>
|
||||
<span><span class="legend-swatch" style="background:rgb(200,80,30)"></span> ⚠2 — Two nodes (collision)</span>
|
||||
<span><span class="legend-swatch" style="background:rgb(200,0,30)"></span> ⚠3+ — Three+ nodes (collision)</span>
|
||||
</div>`;
|
||||
el.innerHTML = html;
|
||||
|
||||
@@ -855,13 +850,12 @@
|
||||
});
|
||||
}
|
||||
|
||||
async function renderCollisions(topHops) {
|
||||
async function renderCollisions(topHops, allNodes) {
|
||||
const el = document.getElementById('collisionList');
|
||||
const oneByteHops = topHops.filter(h => h.size === 1);
|
||||
if (!oneByteHops.length) { el.innerHTML = '<div class="text-muted">No 1-byte hops</div>'; return; }
|
||||
try {
|
||||
const nodesData = await api('/nodes?limit=2000');
|
||||
const nodes = nodesData.nodes || [];
|
||||
const nodes = allNodes;
|
||||
const collisions = [];
|
||||
for (const hop of oneByteHops) {
|
||||
const prefix = hop.hex.toLowerCase();
|
||||
|
||||
+65
-9
@@ -39,6 +39,7 @@
|
||||
const tip = document.createElement('div');
|
||||
tip.id = 'chNodeTooltip';
|
||||
tip.className = 'ch-node-tooltip';
|
||||
tip.setAttribute('role', 'tooltip');
|
||||
const role = node.is_repeater ? '📡 Repeater' : node.is_room ? '🏠 Room' : node.is_sensor ? '🌡 Sensor' : '📻 Companion';
|
||||
const lastSeen = node.last_seen ? timeAgo(node.last_seen) : 'unknown';
|
||||
tip.innerHTML = `<div class="ch-tooltip-name">${escapeHtml(node.name)}</div>
|
||||
@@ -46,12 +47,16 @@
|
||||
<div class="ch-tooltip-meta">Last seen: ${lastSeen}</div>
|
||||
<div class="ch-tooltip-key mono">${(node.public_key || '').slice(0, 16)}…</div>`;
|
||||
document.body.appendChild(tip);
|
||||
const rect = e.target.getBoundingClientRect();
|
||||
var trigger = e.target.closest('[data-node]') || e.target;
|
||||
trigger.setAttribute('aria-describedby', 'chNodeTooltip');
|
||||
const rect = trigger.getBoundingClientRect();
|
||||
tip.style.left = Math.min(rect.left, window.innerWidth - 220) + 'px';
|
||||
tip.style.top = (rect.bottom + 4) + 'px';
|
||||
}
|
||||
|
||||
function hideNodeTooltip() {
|
||||
var trigger = document.querySelector('[aria-describedby="chNodeTooltip"]');
|
||||
if (trigger) trigger.removeAttribute('aria-describedby');
|
||||
const tip = document.getElementById('chNodeTooltip');
|
||||
if (tip) tip.remove();
|
||||
}
|
||||
@@ -205,13 +210,14 @@
|
||||
|
||||
function init(app) {
|
||||
app.innerHTML = `<div class="ch-layout">
|
||||
<div class="ch-sidebar" role="navigation" aria-label="Channel list">
|
||||
<div class="ch-sidebar" aria-label="Channel list">
|
||||
<div class="ch-sidebar-header">
|
||||
<div class="ch-sidebar-title"><span class="ch-icon">💬</span> Channels</div>
|
||||
</div>
|
||||
<div class="ch-channel-list" id="chList">
|
||||
<div class="ch-channel-list" id="chList" role="listbox" aria-label="Channels">
|
||||
<div class="ch-loading">Loading channels…</div>
|
||||
</div>
|
||||
<div class="ch-sidebar-resize" aria-hidden="true"></div>
|
||||
</div>
|
||||
<div class="ch-main" role="region" aria-label="Channel messages">
|
||||
<div class="ch-main-header" id="chHeader">
|
||||
@@ -221,12 +227,45 @@
|
||||
<div class="ch-messages" id="chMessages">
|
||||
<div class="ch-empty">Choose a channel from the sidebar to view messages</div>
|
||||
</div>
|
||||
<button class="ch-scroll-btn hidden" id="chScrollBtn" aria-live="polite">↓ New messages</button>
|
||||
<span id="chAriaLive" class="sr-only" aria-live="polite"></span>
|
||||
<button class="ch-scroll-btn hidden" id="chScrollBtn">↓ New messages</button>
|
||||
</div>
|
||||
</div>`;
|
||||
|
||||
loadChannels();
|
||||
|
||||
// #89: Sidebar resize handle
|
||||
(function () {
|
||||
var sidebar = app.querySelector('.ch-sidebar');
|
||||
var handle = app.querySelector('.ch-sidebar-resize');
|
||||
var saved = localStorage.getItem('channels-sidebar-width');
|
||||
if (saved) { var w = parseInt(saved, 10); if (w >= 180 && w <= 600) { sidebar.style.width = w + 'px'; sidebar.style.minWidth = w + 'px'; } }
|
||||
var dragging = false, startX, startW;
|
||||
handle.addEventListener('mousedown', function (e) { dragging = true; startX = e.clientX; startW = sidebar.getBoundingClientRect().width; e.preventDefault(); });
|
||||
document.addEventListener('mousemove', function (e) { if (!dragging) return; var w = Math.max(180, Math.min(600, startW + e.clientX - startX)); sidebar.style.width = w + 'px'; sidebar.style.minWidth = w + 'px'; });
|
||||
document.addEventListener('mouseup', function () { if (!dragging) return; dragging = false; localStorage.setItem('channels-sidebar-width', parseInt(sidebar.style.width, 10)); });
|
||||
})();
|
||||
|
||||
// #90: Theme change observer — re-render messages on theme toggle
|
||||
var _themeObserver = new MutationObserver(function (muts) {
|
||||
for (var i = 0; i < muts.length; i++) {
|
||||
if (muts[i].attributeName === 'data-theme') { if (selectedHash) renderMessages(); break; }
|
||||
}
|
||||
});
|
||||
_themeObserver.observe(document.documentElement, { attributes: true, attributeFilter: ['data-theme'] });
|
||||
|
||||
// #87: Fix pointer-events during mobile slide transition
|
||||
var chMain = app.querySelector('.ch-main');
|
||||
var chSidebar = app.querySelector('.ch-sidebar');
|
||||
chMain.addEventListener('transitionend', function () {
|
||||
var layout = app.querySelector('.ch-layout');
|
||||
if (layout && layout.classList.contains('ch-show-main')) {
|
||||
chSidebar.style.pointerEvents = 'none';
|
||||
} else {
|
||||
chSidebar.style.pointerEvents = '';
|
||||
}
|
||||
});
|
||||
|
||||
// Event delegation for data-action buttons
|
||||
app.addEventListener('click', function (e) {
|
||||
var btn = e.target.closest('[data-action]');
|
||||
@@ -304,6 +343,21 @@
|
||||
hoverTimeout = setTimeout(hideNodeTooltip, 100);
|
||||
}
|
||||
});
|
||||
// #86: Show tooltip on focus for keyboard users
|
||||
msgEl.addEventListener('focusin', (e) => {
|
||||
const el = e.target.closest('[data-node]');
|
||||
if (el) {
|
||||
clearTimeout(hoverTimeout);
|
||||
const name = decodeURIComponent(atob(el.dataset.node));
|
||||
showNodeTooltip(e, name);
|
||||
}
|
||||
});
|
||||
msgEl.addEventListener('focusout', (e) => {
|
||||
const el = e.target.closest('[data-node]');
|
||||
if (el) {
|
||||
hoverTimeout = setTimeout(hideNodeTooltip, 100);
|
||||
}
|
||||
});
|
||||
|
||||
wsHandler = debouncedOnWS(function (msgs) {
|
||||
var dominated = msgs.some(function (m) {
|
||||
@@ -366,7 +420,7 @@
|
||||
const encClass = ch.encrypted ? ' ch-item-encrypted' : '';
|
||||
const abbr = name.startsWith('#') ? name.slice(0, 3) : name.slice(0, 2).toUpperCase();
|
||||
|
||||
return `<button class="ch-item${sel}${encClass}" data-hash="${ch.hash}" type="button" aria-label="${escapeHtml(name)}">
|
||||
return `<button class="ch-item${sel}${encClass}" data-hash="${ch.hash}" type="button" role="option" aria-selected="${selectedHash === ch.hash ? 'true' : 'false'}" aria-label="${escapeHtml(name)}">
|
||||
<div class="ch-badge" style="background:${color}" aria-hidden="true">${escapeHtml(abbr)}</div>
|
||||
<div class="ch-item-body">
|
||||
<div class="ch-item-top">
|
||||
@@ -411,15 +465,17 @@
|
||||
try {
|
||||
const data = await api(`/channels/${selectedHash}/messages?limit=200`);
|
||||
const newMsgs = data.messages || [];
|
||||
// Compare last message timestamp instead of count — count stays same at limit
|
||||
const lastOld = messages.length ? messages[messages.length - 1]?.timestamp : null;
|
||||
const lastNew = newMsgs.length ? newMsgs[newMsgs.length - 1]?.timestamp : null;
|
||||
if (newMsgs.length === messages.length && lastOld === lastNew) return;
|
||||
// #92: Use message ID/hash for change detection instead of count + timestamp
|
||||
var _getLastId = function (arr) { var m = arr.length ? arr[arr.length - 1] : null; return m ? (m.id || m.packetId || m.timestamp || '') : ''; };
|
||||
if (newMsgs.length === messages.length && _getLastId(newMsgs) === _getLastId(messages)) return;
|
||||
var prevLen = messages.length;
|
||||
messages = newMsgs;
|
||||
renderMessages();
|
||||
if (wasAtBottom) scrollToBottom();
|
||||
else {
|
||||
document.getElementById('chScrollBtn')?.classList.remove('hidden');
|
||||
var liveEl = document.getElementById('chAriaLive');
|
||||
if (liveEl) liveEl.textContent = Math.max(1, newMsgs.length - prevLen) + ' new messages';
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
|
||||
+27
-16
@@ -83,8 +83,10 @@
|
||||
}
|
||||
|
||||
let directPacketId = null;
|
||||
let initGeneration = 0;
|
||||
|
||||
async function init(app, routeParam) {
|
||||
const gen = ++initGeneration;
|
||||
// Detect route param type: "id/123" for direct packet, short hex for hash, long hex for node
|
||||
if (routeParam) {
|
||||
if (routeParam.startsWith('id/')) {
|
||||
@@ -120,6 +122,7 @@
|
||||
directPacketId = null;
|
||||
try {
|
||||
const data = await api(`/packets/${pktId}`);
|
||||
if (gen !== initGeneration) return;
|
||||
if (data.packet?.hash) {
|
||||
filters.hash = data.packet.hash;
|
||||
const hashInput = document.getElementById('fHash');
|
||||
@@ -158,6 +161,14 @@
|
||||
selectedId = null;
|
||||
filtersBuilt = false;
|
||||
delete filters.node;
|
||||
expandedHashes = new Set();
|
||||
hopNameCache = {};
|
||||
totalCount = 0;
|
||||
observers = [];
|
||||
directPacketId = null;
|
||||
groupByHash = true;
|
||||
filters = {};
|
||||
regionMap = {};
|
||||
}
|
||||
|
||||
async function loadObservers() {
|
||||
@@ -243,8 +254,8 @@
|
||||
</div>
|
||||
<table class="data-table" id="pktTable">
|
||||
<thead><tr>
|
||||
<th></th><th class="col-region">Region</th><th>Time</th><th>Hash</th><th class="col-size">Size</th>
|
||||
<th>Type</th><th>Observer</th><th>Path</th><th class="col-rpt">Rpt</th><th>Details</th>
|
||||
<th></th><th class="col-region">Region</th><th class="col-time">Time</th><th class="col-hash">Hash</th><th class="col-size">Size</th>
|
||||
<th class="col-type">Type</th><th class="col-observer">Observer</th><th class="col-path">Path</th><th class="col-rpt">Rpt</th><th class="col-details">Details</th>
|
||||
</tr></thead>
|
||||
<tbody id="pktBody"></tbody>
|
||||
</table>
|
||||
@@ -412,14 +423,14 @@
|
||||
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>${timeAgo(p.latest)}</td>
|
||||
<td class="mono">${truncate(p.hash || '—', 8)}</td>
|
||||
<td class="col-time">${timeAgo(p.latest)}</td>
|
||||
<td class="mono col-hash">${truncate(p.hash || '—', 8)}</td>
|
||||
<td class="col-size">${groupSize ? groupSize + 'B' : '—'}</td>
|
||||
<td>${p.payload_type != null ? `<span class="badge badge-${groupTypeClass}">${groupTypeName}</span>` : '—'}</td>
|
||||
<td>${isSingle ? truncate(p.observer_name || p.observer_id || '—', 16) : truncate(p.observer_name || p.observer_id || '—', 10) + (p.observer_count > 1 ? ' +' + (p.observer_count - 1) : '')}</td>
|
||||
<td><span class="path-hops">${groupPathStr}</span></td>
|
||||
<td class="col-type">${p.payload_type != null ? `<span class="badge badge-${groupTypeClass}">${groupTypeName}</span>` : '—'}</td>
|
||||
<td class="col-observer">${isSingle ? truncate(p.observer_name || p.observer_id || '—', 16) : truncate(p.observer_name || p.observer_id || '—', 10) + (p.observer_count > 1 ? ' +' + (p.observer_count - 1) : '')}</td>
|
||||
<td class="col-path"><span class="path-hops">${groupPathStr}</span></td>
|
||||
<td class="col-rpt">${isSingle ? '' : p.count}</td>
|
||||
<td>${getDetailPreview((() => { try { return JSON.parse(p.decoded_json || '{}'); } catch { return {}; } })())}</td>
|
||||
<td class="col-details">${getDetailPreview((() => { try { return JSON.parse(p.decoded_json || '{}'); } catch { return {}; } })())}</td>
|
||||
</tr>`;
|
||||
// Child rows (loaded async when expanded)
|
||||
if (isExpanded && p._children) {
|
||||
@@ -433,14 +444,14 @@
|
||||
const childPathStr = renderPath(childPath);
|
||||
html += `<tr class="group-child" data-id="${c.id}" data-action="select" data-value="${c.id}" tabindex="0" role="row">
|
||||
<td></td><td class="col-region">${childRegion ? `<span class="badge-region">${childRegion}</span>` : '—'}</td>
|
||||
<td>${timeAgo(c.timestamp)}</td>
|
||||
<td class="mono">${truncate(c.hash || '', 8)}</td>
|
||||
<td class="col-time">${timeAgo(c.timestamp)}</td>
|
||||
<td class="mono col-hash">${truncate(c.hash || '', 8)}</td>
|
||||
<td class="col-size">${size}B</td>
|
||||
<td><span class="badge badge-${typeClass}">${typeName}</span></td>
|
||||
<td>${truncate(c.observer_name || c.observer_id || '—', 16)}</td>
|
||||
<td><span class="path-hops">${childPathStr}</span></td>
|
||||
<td class="col-type"><span class="badge badge-${typeClass}">${typeName}</span></td>
|
||||
<td class="col-observer">${truncate(c.observer_name || c.observer_id || '—', 16)}</td>
|
||||
<td class="col-path"><span class="path-hops">${childPathStr}</span></td>
|
||||
<td class="col-rpt"></td>
|
||||
<td>${getDetailPreview((() => { try { return JSON.parse(c.decoded_json); } catch { return {}; } })())}</td>
|
||||
<td class="col-details">${getDetailPreview((() => { try { return JSON.parse(c.decoded_json); } catch { return {}; } })())}</td>
|
||||
</tr>`;
|
||||
}
|
||||
}
|
||||
@@ -463,8 +474,8 @@
|
||||
|
||||
return `<tr data-id="${p.id}" data-action="select" data-value="${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>${timeAgo(p.timestamp)}</td>
|
||||
<td class="mono">${truncate(p.hash || String(p.id), 8)}</td>
|
||||
<td class="col-time">${timeAgo(p.timestamp)}</td>
|
||||
<td class="mono col-hash">${truncate(p.hash || String(p.id), 8)}</td>
|
||||
<td class="col-size">${size}B</td>
|
||||
<td><span class="badge badge-${typeClass}">${typeName}</span></td>
|
||||
<td>${truncate(p.observer_name || p.observer_id || '—', 16)}</td>
|
||||
|
||||
+20
-5
@@ -433,6 +433,14 @@ button.ch-item.selected { background: var(--selected-bg); }
|
||||
.ch-item-preview { font-size: 12px; color: var(--text-muted); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
|
||||
.ch-main { flex: 1; display: flex; flex-direction: column; overflow: hidden; position: relative; }
|
||||
|
||||
/* Sidebar resize handle (#89) */
|
||||
.ch-sidebar-resize {
|
||||
position: absolute; top: 0; right: -3px; width: 6px; height: 100%;
|
||||
cursor: col-resize; z-index: 10; background: transparent;
|
||||
}
|
||||
.ch-sidebar-resize:hover { background: var(--accent); opacity: 0.3; }
|
||||
.ch-sidebar { position: relative; }
|
||||
.ch-main-header {
|
||||
padding: 14px 20px; font-size: 16px; font-weight: 700;
|
||||
border-bottom: 1px solid var(--border); background: var(--card-bg);
|
||||
@@ -488,7 +496,7 @@ button.ch-item.selected { background: var(--selected-bg); }
|
||||
.ch-node-tooltip {
|
||||
position: fixed; z-index: 1000; background: var(--card-bg); border: 1px solid var(--border);
|
||||
border-radius: 8px; padding: 10px 14px; box-shadow: 0 4px 16px rgba(0,0,0,.15);
|
||||
min-width: 180px; max-width: 260px; pointer-events: none;
|
||||
min-width: 180px; max-width: 260px;
|
||||
}
|
||||
.ch-tooltip-name { font-weight: 700; font-size: 14px; margin-bottom: 4px; }
|
||||
.ch-tooltip-role { font-size: 12px; color: var(--text-muted); margin-bottom: 2px; }
|
||||
@@ -815,7 +823,6 @@ button.ch-item.selected { background: var(--selected-bg); }
|
||||
z-index: 3; background: var(--content-bg);
|
||||
}
|
||||
.ch-layout.ch-show-main .ch-main { transform: translateX(0); }
|
||||
.ch-layout.ch-show-main .ch-sidebar { pointer-events: none; }
|
||||
.ch-back-btn { display: flex; }
|
||||
.ch-main-header { display: flex; align-items: center; gap: 8px; }
|
||||
|
||||
@@ -977,7 +984,7 @@ button.ch-item.ch-item-encrypted .ch-badge { filter: grayscale(0.6); }
|
||||
min-height: 36px;
|
||||
font-size: 14px;
|
||||
}
|
||||
.ch-avatar.ch-tappable { min-width: 40px; min-height: 40px; width: 40px; height: 40px; }
|
||||
.ch-avatar.ch-tappable { min-width: 44px; min-height: 44px; width: 44px; height: 44px; }
|
||||
}
|
||||
|
||||
/* Full-screen node detail */
|
||||
@@ -1044,7 +1051,7 @@ button.ch-item.ch-item-encrypted .ch-badge { filter: grayscale(0.6); }
|
||||
.tab-btn { padding: 6px 14px; border: 1px solid var(--border); border-radius: 6px; background: var(--card-bg); color: var(--text); cursor: pointer; font-size: 13px; transition: all .15s; }
|
||||
.tab-btn:hover { background: var(--hover-bg, rgba(0,0,0,.04)); }
|
||||
.tab-btn.active { background: var(--accent); color: #fff; border-color: var(--accent); }
|
||||
.stats-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(160px, 1fr)); gap: 12px; margin-bottom: 16px; }
|
||||
.stats-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(160px, 240px)); gap: 12px; margin-bottom: 16px; }
|
||||
.stat-card { background: var(--card-bg); border: 1px solid var(--border); border-radius: 8px; padding: 14px; text-align: center; }
|
||||
.stat-value { font-size: 24px; font-weight: 700; color: var(--text); }
|
||||
.stat-label { font-size: 12px; color: var(--text-muted); margin-top: 2px; }
|
||||
@@ -1173,7 +1180,7 @@ button.ch-item.ch-item-encrypted .ch-badge { filter: grayscale(0.6); }
|
||||
.hop-prefix { color: #9ca3af; font-size: 0.8em; }
|
||||
|
||||
/* Subpath split layout */
|
||||
.subpath-layout { display: flex; gap: 0; height: calc(100vh - 160px); position: relative; }
|
||||
.subpath-layout { display: flex; gap: 0; flex: 1; min-height: 0; overflow: auto; position: relative; }
|
||||
.subpath-list { flex: 1; overflow-y: auto; padding: 16px; min-width: 0; }
|
||||
.subpath-detail { width: 420px; min-width: 360px; max-width: 50vw; border-left: 1px solid var(--border, #e5e7eb); overflow-y: auto; padding: 16px; transition: width 0.2s; }
|
||||
.subpath-detail.collapsed { width: 0; min-width: 0; padding: 0; overflow: hidden; border: none; }
|
||||
@@ -1198,6 +1205,14 @@ tr[data-hops]:hover { background: rgba(59,130,246,0.1); }
|
||||
.subpath-detail { width: 100%; border-left: none; border-top: 1px solid var(--border, #e5e7eb); }
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.subpath-detail { min-width: 100%; width: 100%; max-width: 100%; }
|
||||
.subpath-layout { flex-direction: column; }
|
||||
}
|
||||
|
||||
/* Legend swatches */
|
||||
.legend-swatch { display: inline-block; width: 12px; height: 12px; border: 1px solid var(--border); vertical-align: middle; }
|
||||
|
||||
/* Subpath jump nav */
|
||||
.subpath-jump-nav { display: flex; gap: 8px; align-items: center; margin-bottom: 16px; font-size: 0.9em; flex-wrap: wrap; }
|
||||
.subpath-jump-nav span { color: #9ca3af; }
|
||||
|
||||
Reference in New Issue
Block a user