From f845830848cd08da3d65a56a2eb200edd7779ca1 Mon Sep 17 00:00:00 2001 From: Louis King Date: Fri, 3 Jul 2026 22:35:43 +0100 Subject: [PATCH] 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
 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)
---
 src/meshcore_hub/web/static/js/charts.js      |   7 +-
 src/meshcore_hub/web/static/js/spa/icons.js   |   4 +
 .../web/static/js/spa/json-tree.js            | 111 ++++++++++++++++++
 .../web/static/js/spa/pages/packet-detail.js  |   3 +-
 .../js/spa/pages/packet-group-detail.js       |  64 ++++++----
 src/meshcore_hub/web/static/locales/en.json   |   2 +
 src/meshcore_hub/web/static/locales/nl.json   |   2 +
 7 files changed, 168 insertions(+), 25 deletions(-)
 create mode 100644 src/meshcore_hub/web/static/js/spa/json-tree.js

diff --git a/src/meshcore_hub/web/static/js/charts.js b/src/meshcore_hub/web/static/js/charts.js
index 01fbea7..732091f 100644
--- a/src/meshcore_hub/web/static/js/charts.js
+++ b/src/meshcore_hub/web/static/js/charts.js
@@ -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
diff --git a/src/meshcore_hub/web/static/js/spa/icons.js b/src/meshcore_hub/web/static/js/spa/icons.js
index bcfdd15..cf2c66c 100644
--- a/src/meshcore_hub/web/static/js/spa/icons.js
+++ b/src/meshcore_hub/web/static/js/spa/icons.js
@@ -130,6 +130,10 @@ export function iconPlus(cls = 'h-5 w-5') {
     return html``;
 }
 
+export function iconChevronRight(cls = 'h-5 w-5') {
+    return html``;
+}
+
 export function iconEdit(cls = 'h-5 w-5') {
     return html``;
 }
diff --git a/src/meshcore_hub/web/static/js/spa/json-tree.js b/src/meshcore_hub/web/static/js/spa/json-tree.js
new file mode 100644
index 0000000..637154b
--- /dev/null
+++ b/src/meshcore_hub/web/static/js/spa/json-tree.js
@@ -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`${key}:`;
+    }
+    return html`"${key}":`;
+}
+
+function renderNode(value, key, depth, openDepth) {
+    const isContainer = value !== null && typeof value === 'object';
+
+    if (!isContainer) {
+        return html`
+        
+ ${keyLabel(key)} + ${formatPrimitive(value)} +
`; + } + + 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` +
+ ${keyLabel(key)} + ${open}${close} +
`; + } + + const isExpanded = depth < openDepth; + + return html` +
+ +
+ ${entries.map(([k, v]) => renderNode(v, k, depth + 1, openDepth))} +
+
`; +} + +export function jsonTree(value, { openDepth = 1 } = {}) { + return html` +
+
+ + +
+
+ ${renderNode(value, null, 0, openDepth)} +
+
`; +} diff --git a/src/meshcore_hub/web/static/js/spa/pages/packet-detail.js b/src/meshcore_hub/web/static/js/spa/pages/packet-detail.js index 558ca31..09b9535 100644 --- a/src/meshcore_hub/web/static/js/spa/pages/packet-detail.js +++ b/src/meshcore_hub/web/static/js/spa/pages/packet-detail.js @@ -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`
${t('packets.decoded')} -
${JSON.stringify(p.decoded, null, 2)}
+
${jsonTree(p.decoded, { openDepth: 1 })}
` : nothing; diff --git a/src/meshcore_hub/web/static/js/spa/pages/packet-group-detail.js b/src/meshcore_hub/web/static/js/spa/pages/packet-group-detail.js index 63c9f93..1044c48 100644 --- a/src/meshcore_hub/web/static/js/spa/pages/packet-group-detail.js +++ b/src/meshcore_hub/web/static/js/spa/pages/packet-group-detail.js @@ -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`${parts}`; } -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``; +} + +// Static end-of-path marker: a satellite-dish icon signifying the observer. +function observerEndMarker() { + return html`${iconSatelliteDish('h-4 w-4 opacity-70')}`; +} + +// 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``; + 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``; - 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`${pathLen} ${t('common.hops').toLowerCase()}`]; + } else { + middleParts = [html``]; } - if (pathLen != null) { - return html`${pathLen} ${t('common.hops').toLowerCase()}`; - } - return html``; + + 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`
${recs.map(r => html`
-
${formatPath(r.path_hashes, r.path_len, openPathPopover)}
+
${formatPathFlow(r.path_hashes, r.path_len, sourcePrefix, openPathPopover)}
${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`