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`