mirror of
https://github.com/ipnet-mesh/meshcore-hub.git
synced 2026-03-28 17:42:56 +01:00
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:
@@ -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,
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user