From f4514d11508c2f351fce3cf6cc7a9109807fbaa9 Mon Sep 17 00:00:00 2001 From: Louis King Date: Sat, 14 Feb 2026 01:20:52 +0000 Subject: [PATCH] Improve node display with descriptions, members, and emoji extraction MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Enhances the web dashboard's node presentation to match official MeshCore app behavior and provide better user experience: - Extract emoji from node names (e.g., "🏠 Home Gateway" uses 🏠 icon) - Display description tags under node names across all list pages - Add Member column to show network member associations - Add copyable public key columns on Nodes and Advertisements pages - Create reusable renderNodeDisplay() component for consistency - Improve node detail page layout with larger emoji and inline description - Document standard node tags (name, description, member_id, etc.) - Fix documentation: correct Python version requirement and tag examples Co-Authored-By: Claude Sonnet 4.5 --- AGENTS.md | 19 +++ README.md | 11 +- src/meshcore_hub/api/routes/advertisements.py | 12 ++ src/meshcore_hub/common/schemas/messages.py | 3 + .../web/static/js/spa/components.js | 112 ++++++++++++++++++ .../web/static/js/spa/pages/advertisements.js | 50 ++++---- .../web/static/js/spa/pages/node-detail.js | 18 ++- .../web/static/js/spa/pages/nodes.js | 76 ++++++------ 8 files changed, 227 insertions(+), 74 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 5d29768..e19bbd9 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -359,6 +359,25 @@ Examples: - JSON columns for flexible data (path_hashes, parsed_data, etc.) - Foreign keys reference nodes by UUID, not public_key +## Standard Node Tags + +Node tags are flexible key-value pairs that allow custom metadata to be attached to nodes. While tags are completely optional and freeform, the following standard tag keys are recommended for consistent use across the web dashboard: + +| Tag Key | Description | Usage | +|---------|-------------|-------| +| `name` | Node display name | Used as the primary display name throughout the UI (overrides the advertised name) | +| `description` | Short description | Displayed as supplementary text under the node name | +| `member_id` | Member identifier reference | Links the node to a network member (matches `member_id` in Members table) | +| `lat` | GPS latitude override | Overrides node-reported latitude for map display | +| `lon` | GPS longitude override | Overrides node-reported longitude for map display | +| `elevation` | GPS elevation override | Overrides node-reported elevation | +| `role` | Node role/purpose | Used for website presentation and filtering (e.g., "gateway", "repeater", "sensor") | + +**Important Notes:** +- All tags are optional - nodes can function without any tags +- Tag keys are case-sensitive +- The `member_id` tag should reference a valid `member_id` from the Members table + ## Testing Guidelines ### Unit Tests diff --git a/README.md b/README.md index 02d19cb..8a50104 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ [![Docker](https://github.com/ipnet-mesh/meshcore-hub/actions/workflows/docker.yml/badge.svg)](https://github.com/ipnet-mesh/meshcore-hub/actions/workflows/docker.yml) [![BuyMeACoffee](https://raw.githubusercontent.com/pachadotdev/buymeacoffee-badges/main/bmc-donate-yellow.svg)](https://www.buymeacoffee.com/jinglemansweep) -Python 3.14+ platform for managing and orchestrating MeshCore mesh networks. +Python 3.13+ platform for managing and orchestrating MeshCore mesh networks. ![MeshCore Hub Web Dashboard](docs/images/web.png) @@ -476,15 +476,16 @@ Tags are keyed by public key in YAML format: ```yaml # Each key is a 64-character hex public key 0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef: - friendly_name: Gateway Node + name: Gateway Node + description: Main network gateway role: gateway lat: 37.7749 lon: -122.4194 - is_online: true + member_id: alice fedcba9876543210fedcba9876543210fedcba9876543210fedcba9876543210: - friendly_name: Oakland Repeater - altitude: 150 + name: Oakland Repeater + elevation: 150 ``` Tag values can be: diff --git a/src/meshcore_hub/api/routes/advertisements.py b/src/meshcore_hub/api/routes/advertisements.py index 0ba0731..23666f4 100644 --- a/src/meshcore_hub/api/routes/advertisements.py +++ b/src/meshcore_hub/api/routes/advertisements.py @@ -29,6 +29,16 @@ def _get_tag_name(node: Optional[Node]) -> Optional[str]: return None +def _get_tag_description(node: Optional[Node]) -> Optional[str]: + """Extract description tag from a node's tags.""" + if not node or not node.tags: + return None + for tag in node.tags: + if tag.key == "description": + return tag.value + return None + + def _fetch_receivers_for_events( session: DbSession, event_type: str, @@ -210,6 +220,7 @@ async def list_advertisements( "name": adv.name, "node_name": row.source_name, "node_tag_name": _get_tag_name(source_node), + "node_tag_description": _get_tag_description(source_node), "adv_type": adv.adv_type or row.source_adv_type, "flags": adv.flags, "received_at": adv.received_at, @@ -292,6 +303,7 @@ async def get_advertisement( "name": adv.name, "node_name": result.source_name, "node_tag_name": _get_tag_name(source_node), + "node_tag_description": _get_tag_description(source_node), "adv_type": adv.adv_type or result.source_adv_type, "flags": adv.flags, "received_at": adv.received_at, diff --git a/src/meshcore_hub/common/schemas/messages.py b/src/meshcore_hub/common/schemas/messages.py index 7a26900..b207253 100644 --- a/src/meshcore_hub/common/schemas/messages.py +++ b/src/meshcore_hub/common/schemas/messages.py @@ -119,6 +119,9 @@ class AdvertisementRead(BaseModel): node_tag_name: Optional[str] = Field( default=None, description="Node name from tags" ) + node_tag_description: Optional[str] = Field( + default=None, description="Node description from tags" + ) adv_type: Optional[str] = Field(default=None, description="Node type") flags: Optional[int] = Field(default=None, description="Capability flags") received_at: datetime = Field(..., description="When received") diff --git a/src/meshcore_hub/web/static/js/spa/components.js b/src/meshcore_hub/web/static/js/spa/components.js index ea702d1..5eb073e 100644 --- a/src/meshcore_hub/web/static/js/spa/components.js +++ b/src/meshcore_hub/web/static/js/spa/components.js @@ -51,6 +51,32 @@ export function typeEmoji(advType) { } } +/** + * Extract the first emoji from a string. + * Uses a regex pattern that matches emoji characters including compound emojis. + * @param {string|null} str + * @returns {string|null} First emoji found, or null if none + */ +export function extractFirstEmoji(str) { + if (!str) return null; + // Match emoji using Unicode ranges and zero-width joiners + const emojiRegex = /[\u{1F300}-\u{1F9FF}\u{2600}-\u{26FF}\u{2700}-\u{27BF}\u{1F000}-\u{1F02F}\u{1F0A0}-\u{1F0FF}\u{1F100}-\u{1F64F}\u{1F680}-\u{1F6FF}\u{1F900}-\u{1F9FF}\u{1FA00}-\u{1FA6F}\u{1FA70}-\u{1FAFF}\u{231A}-\u{231B}\u{23E9}-\u{23FA}\u{25AA}-\u{25AB}\u{25B6}\u{25C0}\u{25FB}-\u{25FE}\u{2B50}\u{2B55}\u{3030}\u{303D}\u{3297}\u{3299}](?:\u{FE0F})?(?:\u{200D}[\u{1F300}-\u{1F9FF}\u{2600}-\u{26FF}\u{2700}-\u{27BF}](?:\u{FE0F})?)*|\u{00A9}|\u{00AE}|\u{203C}|\u{2049}|\u{2122}|\u{2139}|\u{2194}-\u{2199}|\u{21A9}-\u{21AA}|\u{24C2}|\u{2934}-\u{2935}|\u{2B05}-\u{2B07}|\u{2B1B}-\u{2B1C}/u; + const match = str.match(emojiRegex); + return match ? match[0] : null; +} + +/** + * Get the display emoji for a node. + * Prefers the first emoji from the node name, falls back to type emoji. + * @param {string|null} nodeName - Node's display name + * @param {string|null} advType - Advertisement type + * @returns {string} Emoji character to display + */ +export function getNodeEmoji(nodeName, advType) { + const nameEmoji = extractFirstEmoji(nodeName); + return nameEmoji || typeEmoji(advType); +} + /** * Format an ISO datetime string to the configured timezone. * @param {string|null} isoString @@ -146,8 +172,94 @@ export function escapeHtml(str) { return div.innerHTML; } +/** + * Copy text to clipboard with visual feedback. + * Updates the target element to show "Copied!" temporarily. + * Falls back to execCommand for browsers without Clipboard API. + * @param {Event} e - Click event + * @param {string} text - Text to copy to clipboard + */ +export function copyToClipboard(e, text) { + e.preventDefault(); + e.stopPropagation(); + + const showSuccess = (target) => { + const originalText = target.textContent; + target.textContent = 'Copied!'; + target.classList.add('text-success'); + setTimeout(() => { + target.textContent = originalText; + target.classList.remove('text-success'); + }, 1500); + }; + + // Try modern Clipboard API first + if (navigator.clipboard && navigator.clipboard.writeText) { + navigator.clipboard.writeText(text).then(() => { + showSuccess(e.currentTarget); + }).catch(err => { + console.error('Clipboard API failed:', err); + fallbackCopy(text, e.currentTarget); + }); + } else { + // Fallback for older browsers or non-secure contexts + fallbackCopy(text, e.currentTarget); + } + + function fallbackCopy(text, target) { + const textArea = document.createElement('textarea'); + textArea.value = text; + textArea.style.position = 'fixed'; + textArea.style.left = '-999999px'; + textArea.style.top = '-999999px'; + document.body.appendChild(textArea); + textArea.focus(); + textArea.select(); + try { + document.execCommand('copy'); + showSuccess(target); + } catch (err) { + console.error('Fallback copy failed:', err); + } + document.body.removeChild(textArea); + } +} + // --- UI Components (return lit-html TemplateResult) --- +/** + * Render a node display with emoji, name, and optional description. + * Used for consistent node representation across lists (nodes, advertisements, messages, etc.). + * + * @param {Object} options - Node display options + * @param {string|null} options.name - Node display name (from tag or advertised name) + * @param {string|null} options.description - Node description from tags + * @param {string} options.publicKey - Node public key (for fallback display) + * @param {string|null} options.advType - Advertisement type (chat, repeater, room) + * @param {string} [options.size='base'] - Size variant: 'sm' (small lists) or 'base' (normal) + * @returns {TemplateResult} lit-html template + */ +export function renderNodeDisplay({ name, description, publicKey, advType, size = 'base' }) { + const displayName = name || null; + const emoji = getNodeEmoji(name, advType); + const emojiSize = size === 'sm' ? 'text-lg' : 'text-lg'; + const nameSize = size === 'sm' ? 'text-sm' : 'text-base'; + const descSize = size === 'sm' ? 'text-xs' : 'text-xs'; + + const nameBlock = displayName + ? html`
${displayName}
+ ${description ? html`
${description}
` : nothing}` + : html`
${publicKey.slice(0, 16)}...
`; + + return html` +
+ ${emoji} +
+ ${nameBlock} +
+
`; +} + /** * Render a loading spinner. * @returns {TemplateResult} diff --git a/src/meshcore_hub/web/static/js/spa/pages/advertisements.js b/src/meshcore_hub/web/static/js/spa/pages/advertisements.js index 328dcc5..ff72f34 100644 --- a/src/meshcore_hub/web/static/js/spa/pages/advertisements.js +++ b/src/meshcore_hub/web/static/js/spa/pages/advertisements.js @@ -1,9 +1,9 @@ import { apiGet } from '../api.js'; import { html, litRender, nothing, t, - getConfig, typeEmoji, formatDateTime, formatDateTimeShort, + getConfig, formatDateTime, formatDateTimeShort, truncateKey, errorAlert, - pagination, createFilterHandler, autoSubmit, submitOnEnter + pagination, createFilterHandler, autoSubmit, submitOnEnter, copyToClipboard, renderNodeDisplay } from '../components.js'; export async function render(container, params, router) { @@ -82,12 +82,8 @@ ${content}`, container); const mobileCards = advertisements.length === 0 ? html`
${t('common.no_entity_found', { entity: t('entities.advertisements').toLowerCase() })}
` : advertisements.map(ad => { - const emoji = typeEmoji(ad.adv_type); const adName = ad.node_tag_name || ad.node_name || ad.name; - const nameBlock = adName - ? html`
${adName}
-
${ad.public_key.slice(0, 16)}...
` - : html`
${ad.public_key.slice(0, 16)}...
`; + const adDescription = ad.node_tag_description; let receiversBlock = nothing; if (ad.receivers && ad.receivers.length >= 1) { receiversBlock = html`
@@ -103,12 +99,13 @@ ${content}`, container); return html`
-
- ${emoji} -
- ${nameBlock} -
-
+ ${renderNodeDisplay({ + name: adName, + description: adDescription, + publicKey: ad.public_key, + advType: ad.adv_type, + size: 'sm' + })}
${formatDateTimeShort(ad.received_at)}
${receiversBlock} @@ -119,14 +116,10 @@ ${content}`, container); }); const tableRows = advertisements.length === 0 - ? html`${t('common.no_entity_found', { entity: t('entities.advertisements').toLowerCase() })}` + ? html`${t('common.no_entity_found', { entity: t('entities.advertisements').toLowerCase() })}` : advertisements.map(ad => { - const emoji = typeEmoji(ad.adv_type); const adName = ad.node_tag_name || ad.node_name || ad.name; - const nameBlock = adName - ? html`
${adName}
-
${ad.public_key.slice(0, 16)}...
` - : html`${ad.public_key.slice(0, 16)}...`; + const adDescription = ad.node_tag_description; let receiversBlock; if (ad.receivers && ad.receivers.length >= 1) { receiversBlock = html`
@@ -143,13 +136,21 @@ ${content}`, container); } return html` - - ${emoji} -
- ${nameBlock} -
+
+ ${renderNodeDisplay({ + name: adName, + description: adDescription, + publicKey: ad.public_key, + advType: ad.adv_type, + size: 'base' + })} + + copyToClipboard(e, ad.public_key)} + title="Click to copy">${ad.public_key} + ${formatDateTime(ad.received_at)} ${receiversBlock} `; @@ -188,6 +189,7 @@ ${content}`, container); ${t('entities.node')} + ${t('common.public_key')} ${t('common.time')} ${t('common.receivers')} diff --git a/src/meshcore_hub/web/static/js/spa/pages/node-detail.js b/src/meshcore_hub/web/static/js/spa/pages/node-detail.js index f7483e3..62f2794 100644 --- a/src/meshcore_hub/web/static/js/spa/pages/node-detail.js +++ b/src/meshcore_hub/web/static/js/spa/pages/node-detail.js @@ -2,7 +2,7 @@ import { apiGet } from '../api.js'; import { html, litRender, nothing, getConfig, typeEmoji, formatDateTime, - truncateKey, errorAlert, t, + truncateKey, errorAlert, copyToClipboard, t, } from '../components.js'; import { iconError } from '../icons.js'; @@ -30,6 +30,7 @@ export async function render(container, params, router) { const config = getConfig(); const tagName = node.tags?.find(t => t.key === 'name')?.value; + const tagDescription = node.tags?.find(t => t.key === 'description')?.value; const displayName = tagName || node.name || t('common.unnamed_node'); const emoji = typeEmoji(node.adv_type); @@ -140,10 +141,13 @@ export async function render(container, params, router) {
-

- ${emoji} - ${displayName} -

+
+ ${emoji} +
+

${displayName}

+ ${tagDescription ? html`

${tagDescription}

` : nothing} +
+
${heroHtml} @@ -151,7 +155,9 @@ ${heroHtml}

${t('common.public_key')}

- ${node.public_key} + copyToClipboard(e, node.public_key)} + title="Click to copy">${node.public_key}
${t('common.first_seen_label')} ${formatDateTime(node.first_seen)}
diff --git a/src/meshcore_hub/web/static/js/spa/pages/nodes.js b/src/meshcore_hub/web/static/js/spa/pages/nodes.js index 623ae0c..d21bc2c 100644 --- a/src/meshcore_hub/web/static/js/spa/pages/nodes.js +++ b/src/meshcore_hub/web/static/js/spa/pages/nodes.js @@ -1,10 +1,10 @@ import { apiGet } from '../api.js'; import { html, litRender, nothing, - getConfig, typeEmoji, formatDateTime, formatDateTimeShort, + getConfig, formatDateTime, formatDateTimeShort, truncateKey, errorAlert, pagination, timezoneIndicator, - createFilterHandler, autoSubmit, submitOnEnter, t + createFilterHandler, autoSubmit, submitOnEnter, copyToClipboard, renderNodeDisplay, t } from '../components.js'; export async function render(container, params, router) { @@ -64,32 +64,27 @@ ${content}`, container); ? html`
${t('common.no_entity_found', { entity: t('entities.nodes').toLowerCase() })}
` : nodes.map(node => { const tagName = node.tags?.find(tag => tag.key === 'name')?.value; + const tagDescription = node.tags?.find(tag => tag.key === 'description')?.value; const displayName = tagName || node.name; - const emoji = typeEmoji(node.adv_type); - const nameBlock = displayName - ? html`
${displayName}
-
${node.public_key.slice(0, 16)}...
` - : html`
${node.public_key.slice(0, 16)}...
`; const lastSeen = node.last_seen ? formatDateTimeShort(node.last_seen) : '-'; - const tags = node.tags || []; - const tagsBlock = tags.length > 0 - ? html`
- ${tags.slice(0, 2).map(tag => html`${tag.key}`)} - ${tags.length > 2 ? html`+${tags.length - 2}` : nothing} -
` + const memberIdTag = node.tags?.find(tag => tag.key === 'member_id')?.value; + const member = memberIdTag ? members.find(m => m.member_id === memberIdTag) : null; + const memberBlock = member + ? html`
${member.name}
` : nothing; return html`
-
- ${emoji} -
- ${nameBlock} -
-
+ ${renderNodeDisplay({ + name: displayName, + description: tagDescription, + publicKey: node.public_key, + advType: node.adv_type, + size: 'sm' + })}
${lastSeen}
- ${tagsBlock} + ${memberBlock}
@@ -97,34 +92,36 @@ ${content}`, container); }); const tableRows = nodes.length === 0 - ? html`${t('common.no_entity_found', { entity: t('entities.nodes').toLowerCase() })}` + ? html`${t('common.no_entity_found', { entity: t('entities.nodes').toLowerCase() })}` : nodes.map(node => { const tagName = node.tags?.find(tag => tag.key === 'name')?.value; + const tagDescription = node.tags?.find(tag => tag.key === 'description')?.value; const displayName = tagName || node.name; - const emoji = typeEmoji(node.adv_type); - const nameBlock = displayName - ? html`
${displayName}
-
${node.public_key.slice(0, 16)}...
` - : html`${node.public_key.slice(0, 16)}...`; const lastSeen = node.last_seen ? formatDateTime(node.last_seen) : '-'; - const tags = node.tags || []; - const tagsBlock = tags.length > 0 - ? html`
- ${tags.slice(0, 3).map(tag => html`${tag.key}`)} - ${tags.length > 3 ? html`+${tags.length - 3}` : nothing} -
` + const memberIdTag = node.tags?.find(tag => tag.key === 'member_id')?.value; + const member = memberIdTag ? members.find(m => m.member_id === memberIdTag) : null; + const memberBlock = member + ? html`${member.name}${member.callsign ? html` (${member.callsign})` : nothing}` : html`-`; return html` -
- ${emoji} -
- ${nameBlock} -
+
+ ${renderNodeDisplay({ + name: displayName, + description: tagDescription, + publicKey: node.public_key, + advType: node.adv_type, + size: 'base' + })} + + copyToClipboard(e, node.public_key)} + title="Click to copy">${node.public_key} + ${lastSeen} - ${tagsBlock} + ${memberBlock} `; }); @@ -171,8 +168,9 @@ ${content}`, container); ${t('entities.node')} + ${t('common.public_key')} ${t('common.last_seen')} - ${t('common.tags')} + ${t('entities.member')}