Improve node display with descriptions, members, and emoji extraction

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 <noreply@anthropic.com>
This commit is contained in:
Louis King
2026-02-14 01:20:52 +00:00
parent 7be5f6afdf
commit f4514d1150
8 changed files with 227 additions and 74 deletions

View File

@@ -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,

View File

@@ -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")

View File

@@ -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`<div class="font-medium ${nameSize} truncate">${displayName}</div>
${description ? html`<div class="${descSize} opacity-70 truncate">${description}</div>` : nothing}`
: html`<div class="font-mono ${nameSize} truncate">${publicKey.slice(0, 16)}...</div>`;
return html`
<div class="flex items-center gap-2 min-w-0">
<span class="${emojiSize} flex-shrink-0" title=${advType || t('node_types.unknown')}>${emoji}</span>
<div class="min-w-0">
${nameBlock}
</div>
</div>`;
}
/**
* Render a loading spinner.
* @returns {TemplateResult}

View File

@@ -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`<div class="text-center py-8 opacity-70">${t('common.no_entity_found', { entity: t('entities.advertisements').toLowerCase() })}</div>`
: advertisements.map(ad => {
const emoji = typeEmoji(ad.adv_type);
const adName = ad.node_tag_name || ad.node_name || ad.name;
const nameBlock = adName
? html`<div class="font-medium text-sm truncate">${adName}</div>
<div class="text-xs font-mono opacity-60 truncate">${ad.public_key.slice(0, 16)}...</div>`
: html`<div class="font-mono text-sm truncate">${ad.public_key.slice(0, 16)}...</div>`;
const adDescription = ad.node_tag_description;
let receiversBlock = nothing;
if (ad.receivers && ad.receivers.length >= 1) {
receiversBlock = html`<div class="flex gap-0.5 justify-end mt-1">
@@ -103,12 +99,13 @@ ${content}`, container);
return html`<a href="/nodes/${ad.public_key}" class="card bg-base-100 shadow-sm block">
<div class="card-body p-3">
<div class="flex items-center justify-between gap-2">
<div class="flex items-center gap-2 min-w-0">
<span class="text-lg flex-shrink-0" title=${ad.adv_type || t('node_types.unknown')}>${emoji}</span>
<div class="min-w-0">
${nameBlock}
</div>
</div>
${renderNodeDisplay({
name: adName,
description: adDescription,
publicKey: ad.public_key,
advType: ad.adv_type,
size: 'sm'
})}
<div class="text-right flex-shrink-0">
<div class="text-xs opacity-60">${formatDateTimeShort(ad.received_at)}</div>
${receiversBlock}
@@ -119,14 +116,10 @@ ${content}`, container);
});
const tableRows = advertisements.length === 0
? html`<tr><td colspan="3" class="text-center py-8 opacity-70">${t('common.no_entity_found', { entity: t('entities.advertisements').toLowerCase() })}</td></tr>`
? html`<tr><td colspan="4" class="text-center py-8 opacity-70">${t('common.no_entity_found', { entity: t('entities.advertisements').toLowerCase() })}</td></tr>`
: advertisements.map(ad => {
const emoji = typeEmoji(ad.adv_type);
const adName = ad.node_tag_name || ad.node_name || ad.name;
const nameBlock = adName
? html`<div class="font-medium">${adName}</div>
<div class="text-xs font-mono opacity-70">${ad.public_key.slice(0, 16)}...</div>`
: html`<span class="font-mono text-sm">${ad.public_key.slice(0, 16)}...</span>`;
const adDescription = ad.node_tag_description;
let receiversBlock;
if (ad.receivers && ad.receivers.length >= 1) {
receiversBlock = html`<div class="flex gap-1">
@@ -143,13 +136,21 @@ ${content}`, container);
}
return html`<tr class="hover">
<td>
<a href="/nodes/${ad.public_key}" class="link link-hover flex items-center gap-2">
<span class="text-lg" title=${ad.adv_type || t('node_types.unknown')}>${emoji}</span>
<div>
${nameBlock}
</div>
<a href="/nodes/${ad.public_key}" class="link link-hover">
${renderNodeDisplay({
name: adName,
description: adDescription,
publicKey: ad.public_key,
advType: ad.adv_type,
size: 'base'
})}
</a>
</td>
<td>
<code class="font-mono text-xs cursor-pointer hover:bg-base-200 px-1 py-0.5 rounded select-all"
@click=${(e) => copyToClipboard(e, ad.public_key)}
title="Click to copy">${ad.public_key}</code>
</td>
<td class="text-sm whitespace-nowrap">${formatDateTime(ad.received_at)}</td>
<td>${receiversBlock}</td>
</tr>`;
@@ -188,6 +189,7 @@ ${content}`, container);
<thead>
<tr>
<th>${t('entities.node')}</th>
<th>${t('common.public_key')}</th>
<th>${t('common.time')}</th>
<th>${t('common.receivers')}</th>
</tr>

View File

@@ -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) {
</ul>
</div>
<h1 class="text-3xl font-bold mb-6">
<span title=${node.adv_type || t('node_types.unknown')}>${emoji}</span>
${displayName}
</h1>
<div class="flex items-start gap-4 mb-6">
<span class="text-6xl flex-shrink-0" title=${node.adv_type || t('node_types.unknown')}>${emoji}</span>
<div class="flex-1 min-w-0">
<h1 class="text-3xl font-bold">${displayName}</h1>
${tagDescription ? html`<p class="text-base-content/70 mt-2">${tagDescription}</p>` : nothing}
</div>
</div>
${heroHtml}
@@ -151,7 +155,9 @@ ${heroHtml}
<div class="card-body">
<div>
<h3 class="font-semibold opacity-70 mb-2">${t('common.public_key')}</h3>
<code class="text-sm bg-base-200 p-2 rounded block break-all">${node.public_key}</code>
<code class="text-sm bg-base-200 p-2 rounded block break-all cursor-pointer hover:bg-base-300 select-all"
@click=${(e) => copyToClipboard(e, node.public_key)}
title="Click to copy">${node.public_key}</code>
</div>
<div class="flex flex-wrap gap-x-8 gap-y-2 mt-4 text-sm">
<div><span class="opacity-70">${t('common.first_seen_label')}</span> ${formatDateTime(node.first_seen)}</div>

View File

@@ -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`<div class="text-center py-8 opacity-70">${t('common.no_entity_found', { entity: t('entities.nodes').toLowerCase() })}</div>`
: 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`<div class="font-medium text-sm truncate">${displayName}</div>
<div class="text-xs font-mono opacity-60 truncate">${node.public_key.slice(0, 16)}...</div>`
: html`<div class="font-mono text-sm truncate">${node.public_key.slice(0, 16)}...</div>`;
const lastSeen = node.last_seen ? formatDateTimeShort(node.last_seen) : '-';
const tags = node.tags || [];
const tagsBlock = tags.length > 0
? html`<div class="flex gap-1 justify-end mt-1">
${tags.slice(0, 2).map(tag => html`<span class="badge badge-ghost badge-xs">${tag.key}</span>`)}
${tags.length > 2 ? html`<span class="badge badge-ghost badge-xs">+${tags.length - 2}</span>` : nothing}
</div>`
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`<div class="text-xs opacity-60">${member.name}</div>`
: nothing;
return html`<a href="/nodes/${node.public_key}" class="card bg-base-100 shadow-sm block">
<div class="card-body p-3">
<div class="flex items-center justify-between gap-2">
<div class="flex items-center gap-2 min-w-0">
<span class="text-lg flex-shrink-0" title=${node.adv_type || t('node_types.unknown')}>${emoji}</span>
<div class="min-w-0">
${nameBlock}
</div>
</div>
${renderNodeDisplay({
name: displayName,
description: tagDescription,
publicKey: node.public_key,
advType: node.adv_type,
size: 'sm'
})}
<div class="text-right flex-shrink-0">
<div class="text-xs opacity-60">${lastSeen}</div>
${tagsBlock}
${memberBlock}
</div>
</div>
</div>
@@ -97,34 +92,36 @@ ${content}`, container);
});
const tableRows = nodes.length === 0
? html`<tr><td colspan="3" class="text-center py-8 opacity-70">${t('common.no_entity_found', { entity: t('entities.nodes').toLowerCase() })}</td></tr>`
? html`<tr><td colspan="4" class="text-center py-8 opacity-70">${t('common.no_entity_found', { entity: t('entities.nodes').toLowerCase() })}</td></tr>`
: 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`<div class="font-medium">${displayName}</div>
<div class="text-xs font-mono opacity-70">${node.public_key.slice(0, 16)}...</div>`
: html`<span class="font-mono text-sm">${node.public_key.slice(0, 16)}...</span>`;
const lastSeen = node.last_seen ? formatDateTime(node.last_seen) : '-';
const tags = node.tags || [];
const tagsBlock = tags.length > 0
? html`<div class="flex gap-1 flex-wrap">
${tags.slice(0, 3).map(tag => html`<span class="badge badge-ghost badge-xs">${tag.key}</span>`)}
${tags.length > 3 ? html`<span class="badge badge-ghost badge-xs">+${tags.length - 3}</span>` : nothing}
</div>`
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` <span class="opacity-60">(${member.callsign})</span>` : nothing}`
: html`<span class="opacity-50">-</span>`;
return html`<tr class="hover">
<td>
<a href="/nodes/${node.public_key}" class="link link-hover flex items-center gap-2">
<span class="text-lg" title=${node.adv_type || t('node_types.unknown')}>${emoji}</span>
<div>
${nameBlock}
</div>
<a href="/nodes/${node.public_key}" class="link link-hover">
${renderNodeDisplay({
name: displayName,
description: tagDescription,
publicKey: node.public_key,
advType: node.adv_type,
size: 'base'
})}
</a>
</td>
<td>
<code class="font-mono text-xs cursor-pointer hover:bg-base-200 px-1 py-0.5 rounded select-all"
@click=${(e) => copyToClipboard(e, node.public_key)}
title="Click to copy">${node.public_key}</code>
</td>
<td class="text-sm whitespace-nowrap">${lastSeen}</td>
<td>${tagsBlock}</td>
<td class="text-sm">${memberBlock}</td>
</tr>`;
});
@@ -171,8 +168,9 @@ ${content}`, container);
<thead>
<tr>
<th>${t('entities.node')}</th>
<th>${t('common.public_key')}</th>
<th>${t('common.last_seen')}</th>
<th>${t('common.tags')}</th>
<th>${t('entities.member')}</th>
</tr>
</thead>
<tbody>