mirror of
https://github.com/ipnet-mesh/meshcore-hub.git
synced 2026-07-26 19:42:47 +02:00
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:
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user