feat(web): expandable JSON tree, packet path flow, chart polish

- Add reusable lit-html JSON tree component (json-tree.js) with
  expand/collapse-all toolbar and type-coloured primitives; replace
  flat <pre> decoded blocks on packet detail and packet group detail
- Render packet path as a complete flow with static sender (green dot)
  and observer (satellite dish) terminators around hop badges
- Fill homepage chart areas and move legend to top-right to match
  dashboard charts
- Add iconChevronRight helper and expand_all/collapse_all strings (en, nl)
This commit is contained in:
Louis King
2026-07-03 22:35:43 +01:00
parent 0073723269
commit f845830848
7 changed files with 168 additions and 25 deletions
+4 -3
View File
@@ -63,7 +63,8 @@ function createChartOptions(showLegend) {
plugins: {
legend: {
display: showLegend,
position: 'bottom',
position: 'top',
align: 'end',
labels: {
color: ChartColors.text,
boxWidth: 12,
@@ -183,7 +184,7 @@ function createActivityChart(canvasId, advertData, messageData) {
data: advertData.data.map(function(d) { return d.count; }),
borderColor: ChartColors.adverts,
backgroundColor: ChartColors.advertsFill,
fill: false,
fill: true,
tension: 0.3,
pointRadius: 2,
pointHoverRadius: 5
@@ -197,7 +198,7 @@ function createActivityChart(canvasId, advertData, messageData) {
data: messageData.data.map(function(d) { return d.count; }),
borderColor: ChartColors.messages,
backgroundColor: ChartColors.messagesFill,
fill: false,
fill: true,
tension: 0.3,
pointRadius: 2,
pointHoverRadius: 5
@@ -130,6 +130,10 @@ export function iconPlus(cls = 'h-5 w-5') {
return html`<svg xmlns="http://www.w3.org/2000/svg" class=${cls} fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4" /></svg>`;
}
export function iconChevronRight(cls = 'h-5 w-5') {
return html`<svg xmlns="http://www.w3.org/2000/svg" class=${cls} fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7" /></svg>`;
}
export function iconEdit(cls = 'h-5 w-5') {
return html`<svg xmlns="http://www.w3.org/2000/svg" class=${cls} fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" /></svg>`;
}
@@ -0,0 +1,111 @@
/**
* MeshCore Hub SPA - JSON Tree
*
* Renders an arbitrary JSON value as an expandable/collapsible tree.
* Imperative toggling (class flips): the host page renders once after load,
* so no re-render loop is required.
*/
import { html, nothing } from 'lit-html';
import { t } from './components.js';
import { iconChevronRight } from './icons.js';
function toggleNode(e) {
const btn = e.currentTarget;
const children = btn.nextElementSibling;
if (!children) return;
const nowHidden = children.classList.toggle('hidden');
btn.querySelector('.json-caret').classList.toggle('rotate-90', !nowHidden);
}
function expandAll(e) {
const root = e.currentTarget.closest('.json-tree-root');
if (!root) return;
root.querySelectorAll('.json-children').forEach((el) => el.classList.remove('hidden'));
root.querySelectorAll('.json-caret').forEach((el) => el.classList.add('rotate-90'));
}
function collapseAll(e) {
const root = e.currentTarget.closest('.json-tree-root');
if (!root) return;
root.querySelectorAll('.json-children').forEach((el) => el.classList.add('hidden'));
root.querySelectorAll('.json-caret').forEach((el) => el.classList.remove('rotate-90'));
}
function primitiveClass(val) {
if (val === null) return 'italic opacity-50';
switch (typeof val) {
case 'string': return 'text-success';
case 'number': return 'text-warning';
case 'boolean': return 'text-info';
default: return '';
}
}
function formatPrimitive(val) {
if (val === null) return 'null';
if (typeof val === 'string') return `"${val}"`;
return String(val);
}
function keyLabel(key) {
if (key == null) return nothing;
if (typeof key === 'number') {
return html`<span class="text-primary/50">${key}:</span>`;
}
return html`<span class="text-primary/70">"${key}":</span>`;
}
function renderNode(value, key, depth, openDepth) {
const isContainer = value !== null && typeof value === 'object';
if (!isContainer) {
return html`
<div class="flex gap-2 py-0.5">
${keyLabel(key)}
<span class=${primitiveClass(value)}>${formatPrimitive(value)}</span>
</div>`;
}
const isArray = Array.isArray(value);
const entries = isArray
? value.map((v, i) => [i, v])
: Object.entries(value);
const open = isArray ? '[' : '{';
const close = isArray ? ']' : '}';
const hint = isArray ? `${entries.length}` : `${entries.length}`;
if (entries.length === 0) {
return html`
<div class="flex gap-2 py-0.5">
${keyLabel(key)}
<span class="opacity-60">${open}${close}</span>
</div>`;
}
const isExpanded = depth < openDepth;
return html`
<div class="json-node">
<button type="button" class="json-toggle inline-flex items-center gap-1 hover:opacity-70" @click=${toggleNode}>
<span class="json-caret inline-block transition-transform ${isExpanded ? 'rotate-90' : ''}">${iconChevronRight('h-3 w-3')}</span>
${keyLabel(key)}
<span class="opacity-50 text-[10px]">${open}${hint}${close}</span>
</button>
<div class="json-children ml-2 border-l border-base-200 pl-2 ${isExpanded ? '' : 'hidden'}">
${entries.map(([k, v]) => renderNode(v, k, depth + 1, openDepth))}
</div>
</div>`;
}
export function jsonTree(value, { openDepth = 1 } = {}) {
return html`
<div class="json-tree-root font-mono text-xs">
<div class="flex items-center gap-2 mb-2">
<button type="button" class="btn btn-xs btn-ghost" @click=${expandAll}>${t('packets.expand_all')}</button>
<button type="button" class="btn btn-xs btn-ghost" @click=${collapseAll}>${t('packets.collapse_all')}</button>
</div>
<div class="overflow-x-auto">
${renderNode(value, null, 0, openDepth)}
</div>
</div>`;
}
@@ -3,6 +3,7 @@ import {
html, litRender, nothing, t,
getConfig, formatDateTime, warningBadge, copyToClipboard
} from '../components.js';
import { jsonTree } from '../json-tree.js';
function field(label, value) {
return html`
@@ -79,7 +80,7 @@ ${content}`, container);
? html`
<div class="mt-4">
<span class="text-xs uppercase opacity-60">${t('packets.decoded')}</span>
<pre class="bg-base-200 rounded p-3 text-xs overflow-x-auto">${JSON.stringify(p.decoded, null, 2)}</pre>
<div class="bg-base-200 rounded p-3">${jsonTree(p.decoded, { openDepth: 1 })}</div>
</div>`
: nothing;
@@ -1,9 +1,11 @@
import { apiGet, isAbortError } from '../api.js';
import { iconSatelliteDish } from '../icons.js';
import {
html, litRender, nothing, t,
getConfig, formatDateTime, formatRelativeTime, formatNumber, warningBadge, copyToClipboard,
loading, truncateKey
} from '../components.js';
import { jsonTree } from '../json-tree.js';
function field(label, value) {
return html`
@@ -43,25 +45,44 @@ function pathRow(badges) {
return html`<span class="flex flex-wrap items-center gap-1">${parts}</span>`;
}
function formatPath(pathHashes, pathLen, onBadgeClick) {
// Static start-of-path marker: a filled green dot signifying the origin node.
function senderMarker(prefix) {
const title = prefix
? `${t('packets.col_source')}: ${prefix}`
: t('packets.col_source');
return html`<span class="inline-block h-3 w-3 rounded-full bg-success flex-shrink-0" title=${title}></span>`;
}
// Static end-of-path marker: a satellite-dish icon signifying the observer.
function observerEndMarker() {
return html`<span class="flex-shrink-0">${iconSatelliteDish('h-4 w-4 opacity-70')}</span>`;
}
// Render the full path as a flow: sender -> [hops] -> observer. The endpoints are
// static markers; the intermediate hops keep their existing hash badges (with the
// same truncation + popover-click behaviour), joined together by pathRow.
function formatPathFlow(pathHashes, pathLen, sourcePrefix, onBadgeClick) {
const badge = (h) => pathBadge(h, onBadgeClick);
let middleParts;
if (pathHashes && pathHashes.length > 0) {
if (pathHashes.length <= PATH_MAX_BADGES) {
return pathRow(pathHashes.map(badge));
middleParts = pathHashes.map(badge);
} else {
const hidden = pathHashes.length - PATH_HEAD - PATH_TAIL;
const ellipsis = html`<span class="badge badge-sm badge-ghost cursor-help" title=${t('packets.hops_hidden', { count: hidden })}>…</span>`;
middleParts = [
...pathHashes.slice(0, PATH_HEAD).map(badge),
ellipsis,
...pathHashes.slice(-PATH_TAIL).map(badge),
];
}
const hidden = pathHashes.length - PATH_HEAD - PATH_TAIL;
const ellipsis = html`<span class="badge badge-sm badge-ghost cursor-help" title=${t('packets.hops_hidden', { count: hidden })}>…</span>`;
const badges = [
...pathHashes.slice(0, PATH_HEAD).map(badge),
ellipsis,
...pathHashes.slice(-PATH_TAIL).map(badge),
];
return pathRow(badges);
} else if (pathLen != null) {
middleParts = [html`<span class="text-xs opacity-60">${pathLen} ${t('common.hops').toLowerCase()}</span>`];
} else {
middleParts = [html`<span class="opacity-50">—</span>`];
}
if (pathLen != null) {
return html`${pathLen} ${t('common.hops').toLowerCase()}`;
}
return html`<span class="opacity-50">—</span>`;
return pathRow([senderMarker(sourcePrefix), ...middleParts, observerEndMarker()]);
}
function groupByObserver(receptions) {
@@ -194,11 +215,11 @@ export async function render(container, params, router) {
}
// Mobile (< lg): one card per reception, path full-width on top, stats below.
function receptionCards(recs) {
function receptionCards(recs, sourcePrefix) {
return html`<div class="lg:hidden space-y-2">
${recs.map(r => html`
<div class="rounded-box bg-base-200/60 p-3">
<div class="mb-2">${formatPath(r.path_hashes, r.path_len, openPathPopover)}</div>
<div class="mb-2">${formatPathFlow(r.path_hashes, r.path_len, sourcePrefix, openPathPopover)}</div>
<div class="grid grid-cols-3 gap-2">
${stat(t('common.time'), timeValue(r))}
${stat(t('common.hops'), hopsValue(r))}
@@ -210,7 +231,7 @@ export async function render(container, params, router) {
// Desktop (lg+): table-fixed so the right-aligned stat columns line up across
// every observer block regardless of path length.
function receptionTable(recs) {
function receptionTable(recs, sourcePrefix) {
return html`<div class="hidden lg:block overflow-x-auto">
<table class="table table-xs table-fixed w-full">
<thead>
@@ -224,7 +245,7 @@ export async function render(container, params, router) {
<tbody>
${recs.map(r => html`
<tr>
<td class="whitespace-normal align-top">${formatPath(r.path_hashes, r.path_len, openPathPopover)}</td>
<td class="whitespace-normal align-top">${formatPathFlow(r.path_hashes, r.path_len, sourcePrefix, openPathPopover)}</td>
<td class="w-16 text-right text-sm align-top">${hopsValue(r)}</td>
<td class="w-20 text-right text-sm align-top">${snrValue(r)}</td>
<td class="w-32 text-right text-xs opacity-60 align-top whitespace-nowrap">${timeValue(r)}</td>
@@ -289,11 +310,12 @@ ${content}`, container);
? html`
<div class="mt-4">
<span class="text-xs uppercase opacity-60">${t('packets.decoded')}</span>
<pre class="bg-base-200 rounded p-3 text-xs overflow-x-auto">${JSON.stringify(g.decoded, null, 2)}</pre>
<div class="bg-base-200 rounded p-3">${jsonTree(g.decoded, { openDepth: 1 })}</div>
</div>`
: nothing;
const receptions = g.receptions || [];
const sourcePrefix = g.source_pubkey_prefix || null;
const observerGroups = groupByObserver(receptions);
const receptionsSection = receptions.length > 0
@@ -318,8 +340,8 @@ ${content}`, container);
? html`<span class="text-xs opacity-50 ml-1">(${formatNumber(recs.length)} ${t('packets.reception_plural')})</span>`
: nothing}
</div>
${receptionCards(recs)}
${receptionTable(recs)}
${receptionCards(recs, sourcePrefix)}
${receptionTable(recs, sourcePrefix)}
</div>`;
})}
</div>`
@@ -226,6 +226,8 @@
"detail_title": "Packet Detail",
"decoded": "Decoded",
"copy_raw": "Copy raw hex",
"expand_all": "Expand all",
"collapse_all": "Collapse all",
"view_raw": "View raw packets",
"packet_hash": "Packet Hash",
"packet_type": "Packet Type",
@@ -168,6 +168,8 @@
"detail_title": "Pakketdetail",
"decoded": "Gedecodeerd",
"copy_raw": "Ruwe hex kopiëren",
"expand_all": "Alles uitklappen",
"collapse_all": "Alles inklappen",
"view_raw": "Ruwe pakketten bekijken",
"packet_hash": "Pakkethash",
"packet_type": "Pakkettype",