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 @@ [](https://github.com/ipnet-mesh/meshcore-hub/actions/workflows/docker.yml) [](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.  @@ -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`
copyToClipboard(e, ad.public_key)}
+ title="Click to copy">${ad.public_key}
+ ${tagDescription}
` : nothing} +${node.public_key}
+ copyToClipboard(e, node.public_key)}
+ title="Click to copy">${node.public_key}
copyToClipboard(e, node.public_key)}
+ title="Click to copy">${node.public_key}
+