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`
+
+
+ ${iconChevronRight('h-3 w-3')}
+ ${keyLabel(key)}
+ ${open}${hint}${close}
+
+
+ ${entries.map(([k, v]) => renderNode(v, k, depth + 1, openDepth))}
+
+
`;
+}
+
+export function jsonTree(value, { openDepth = 1 } = {}) {
+ return html`
+
+
+ ${t('packets.expand_all')}
+ ${t('packets.collapse_all')}
+
+
+ ${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`
@@ -224,7 +245,7 @@ export async function render(container, params, router) {
${recs.map(r => html`
- ${formatPath(r.path_hashes, r.path_len, openPathPopover)}
+ ${formatPathFlow(r.path_hashes, r.path_len, sourcePrefix, openPathPopover)}
${hopsValue(r)}
${snrValue(r)}
${timeValue(r)}
@@ -289,11 +310,12 @@ ${content}`, container);
? html`
${t('packets.decoded')}
-
${JSON.stringify(g.decoded, null, 2)}
+
${jsonTree(g.decoded, { openDepth: 1 })}
`
: 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`(${formatNumber(recs.length)} ${t('packets.reception_plural')}) `
: nothing}
- ${receptionCards(recs)}
- ${receptionTable(recs)}
+ ${receptionCards(recs, sourcePrefix)}
+ ${receptionTable(recs, sourcePrefix)}
`;
})}
`
diff --git a/src/meshcore_hub/web/static/locales/en.json b/src/meshcore_hub/web/static/locales/en.json
index 8395e11..e6a4105 100644
--- a/src/meshcore_hub/web/static/locales/en.json
+++ b/src/meshcore_hub/web/static/locales/en.json
@@ -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",
diff --git a/src/meshcore_hub/web/static/locales/nl.json b/src/meshcore_hub/web/static/locales/nl.json
index 91fbc0a..e46d29e 100644
--- a/src/meshcore_hub/web/static/locales/nl.json
+++ b/src/meshcore_hub/web/static/locales/nl.json
@@ -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",