From a8cb20fea5763c5a2f4b2148596321169863bb95 Mon Sep 17 00:00:00 2001 From: Louis King Date: Wed, 18 Feb 2026 14:37:33 +0000 Subject: [PATCH] Add configurable auto-refresh for list pages Nodes, advertisements, and messages pages now auto-refresh on a configurable interval (WEB_AUTO_REFRESH_SECONDS, default 30s). A pause/play toggle in the page header lets users control it. Setting the interval to 0 disables auto-refresh entirely. Co-Authored-By: Claude Opus 4.6 --- .env.example | 5 + AGENTS.md | 1 + README.md | 1 + src/meshcore_hub/common/config.py | 7 + src/meshcore_hub/web/app.py | 4 + .../web/static/js/spa/auto-refresh.js | 87 ++++++ .../web/static/js/spa/pages/advertisements.js | 263 +++++++++--------- .../web/static/js/spa/pages/messages.js | 203 +++++++------- .../web/static/js/spa/pages/nodes.js | 203 +++++++------- src/meshcore_hub/web/static/locales/en.json | 4 + .../web/static/locales/languages.md | 39 ++- 11 files changed, 487 insertions(+), 330 deletions(-) create mode 100644 src/meshcore_hub/web/static/js/spa/auto-refresh.js diff --git a/.env.example b/.env.example index 2fd3396..03e5a09 100644 --- a/.env.example +++ b/.env.example @@ -216,6 +216,11 @@ WEB_PORT=8080 # Supported: en (see src/meshcore_hub/web/static/locales/ for available translations) # WEB_LOCALE=en +# Auto-refresh interval in seconds for list pages (nodes, advertisements, messages) +# Set to 0 to disable auto-refresh +# Default: 30 +# WEB_AUTO_REFRESH_SECONDS=30 + # Enable admin interface at /a/ (requires auth proxy in front) # Default: false # WEB_ADMIN_ENABLED=false diff --git a/AGENTS.md b/AGENTS.md index e19bbd9..cd94a80 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -606,6 +606,7 @@ Key variables: - `API_READ_KEY`, `API_ADMIN_KEY` - API authentication keys - `WEB_ADMIN_ENABLED` - Enable admin interface at /a/ (default: `false`, requires auth proxy) - `WEB_THEME` - Default theme for the web dashboard (default: `dark`, options: `dark`, `light`). Users can override via the theme toggle in the navbar, which persists their preference in browser localStorage. +- `WEB_AUTO_REFRESH_SECONDS` - Auto-refresh interval in seconds for list pages (default: `30`, `0` to disable) - `TZ` - Timezone for web dashboard date/time display (default: `UTC`, e.g., `America/New_York`, `Europe/London`) - `FEATURE_DASHBOARD`, `FEATURE_NODES`, `FEATURE_ADVERTISEMENTS`, `FEATURE_MESSAGES`, `FEATURE_MAP`, `FEATURE_MEMBERS`, `FEATURE_PAGES` - Feature flags to enable/disable specific web dashboard pages (default: all `true`). Dependencies: Dashboard auto-disables when all of Nodes/Advertisements/Messages are disabled. Map auto-disables when Nodes is disabled. - `LOG_LEVEL` - Logging verbosity diff --git a/README.md b/README.md index 8a50104..c49b7c5 100644 --- a/README.md +++ b/README.md @@ -348,6 +348,7 @@ The collector automatically cleans up old event data and inactive nodes: | `API_KEY` | *(none)* | API key for web dashboard queries (optional) | | `WEB_THEME` | `dark` | Default theme (`dark` or `light`). Users can override via theme toggle in navbar. | | `WEB_LOCALE` | `en` | Locale/language for the web dashboard (e.g., `en`, `es`, `fr`) | +| `WEB_AUTO_REFRESH_SECONDS` | `30` | Auto-refresh interval in seconds for list pages (0 to disable) | | `WEB_ADMIN_ENABLED` | `false` | Enable admin interface at /a/ (requires auth proxy) | | `TZ` | `UTC` | Timezone for displaying dates/times (e.g., `America/New_York`, `Europe/London`) | | `NETWORK_DOMAIN` | *(none)* | Network domain name (optional) | diff --git a/src/meshcore_hub/common/config.py b/src/meshcore_hub/common/config.py index f21f545..96e732e 100644 --- a/src/meshcore_hub/common/config.py +++ b/src/meshcore_hub/common/config.py @@ -268,6 +268,13 @@ class WebSettings(CommonSettings): description="Locale/language for the web dashboard (e.g. 'en')", ) + # Auto-refresh interval for list pages + web_auto_refresh_seconds: int = Field( + default=30, + description="Auto-refresh interval in seconds for list pages (0 to disable)", + ge=0, + ) + # Admin interface (disabled by default for security) web_admin_enabled: bool = Field( default=False, diff --git a/src/meshcore_hub/web/app.py b/src/meshcore_hub/web/app.py index 26a8555..bb75ab8 100644 --- a/src/meshcore_hub/web/app.py +++ b/src/meshcore_hub/web/app.py @@ -117,6 +117,7 @@ def _build_config_json(app: FastAPI, request: Request) -> str: "is_authenticated": bool(request.headers.get("X-Forwarded-User")), "default_theme": app.state.web_theme, "locale": app.state.web_locale, + "auto_refresh_seconds": app.state.auto_refresh_seconds, } return json.dumps(config) @@ -184,6 +185,9 @@ def create_app( app.state.web_locale = settings.web_locale or "en" load_locale(app.state.web_locale) + # Auto-refresh interval + app.state.auto_refresh_seconds = settings.web_auto_refresh_seconds + # Store configuration in app state (use args if provided, else settings) app.state.web_theme = ( settings.web_theme if settings.web_theme in ("dark", "light") else "dark" diff --git a/src/meshcore_hub/web/static/js/spa/auto-refresh.js b/src/meshcore_hub/web/static/js/spa/auto-refresh.js new file mode 100644 index 0000000..5ef6ba7 --- /dev/null +++ b/src/meshcore_hub/web/static/js/spa/auto-refresh.js @@ -0,0 +1,87 @@ +/** + * Auto-refresh utility for list pages. + * + * Reads `auto_refresh_seconds` from the app config. When the interval is > 0 + * it sets up a periodic timer that calls the provided `fetchAndRender` callback + * and renders a pause/play toggle button into the given container element. + */ + +import { html, litRender, getConfig, t } from './components.js'; + +/** + * Create an auto-refresh controller. + * + * @param {Object} options + * @param {Function} options.fetchAndRender - Async function that fetches data and re-renders the page. + * @param {HTMLElement} options.toggleContainer - Element to render the pause/play toggle into. + * @returns {{ cleanup: Function }} cleanup function to stop the timer. + */ +export function createAutoRefresh({ fetchAndRender, toggleContainer }) { + const config = getConfig(); + const intervalSeconds = config.auto_refresh_seconds || 0; + + if (!intervalSeconds || !toggleContainer) { + return { cleanup() {} }; + } + + let paused = false; + let isPending = false; + let timerId = null; + + function renderToggle() { + const pauseIcon = html``; + const playIcon = html``; + + const tooltip = paused ? t('auto_refresh.resume') : t('auto_refresh.pause'); + const icon = paused ? playIcon : pauseIcon; + + litRender(html` + + `, toggleContainer); + } + + function onToggle() { + paused = !paused; + if (paused) { + clearInterval(timerId); + timerId = null; + } else { + startTimer(); + } + renderToggle(); + } + + async function tick() { + if (isPending || paused) return; + isPending = true; + try { + await fetchAndRender(); + } catch (_e) { + // Errors are handled inside fetchAndRender; don't stop the timer. + } finally { + isPending = false; + } + } + + function startTimer() { + timerId = setInterval(tick, intervalSeconds * 1000); + } + + // Initial render and start + renderToggle(); + startTimer(); + + return { + cleanup() { + if (timerId) { + clearInterval(timerId); + timerId = null; + } + }, + }; +} 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 3ad4841..45abf16 100644 --- a/src/meshcore_hub/web/static/js/spa/pages/advertisements.js +++ b/src/meshcore_hub/web/static/js/spa/pages/advertisements.js @@ -5,6 +5,7 @@ import { truncateKey, errorAlert, pagination, createFilterHandler, autoSubmit, submitOnEnter, copyToClipboard, renderNodeDisplay } from '../components.js'; +import { createAutoRefresh } from '../auto-refresh.js'; export async function render(container, params, router) { const query = params.query || {}; @@ -27,6 +28,7 @@ export async function render(container, params, router) {

${t('entities.advertisements')}

+ ${tzBadge} ${total !== null ? html`${t('common.total', { count: total })}` : nothing}
@@ -37,139 +39,140 @@ ${content}`, container); // Render page header immediately (old content stays visible until data loads) renderPage(nothing); - try { - const requests = [ - apiGet('/api/v1/advertisements', { limit, offset, search, public_key, member_id }), - apiGet('/api/v1/nodes', { limit: 500 }), - ]; - if (showMembers) { - requests.push(apiGet('/api/v1/members', { limit: 100 })); - } + async function fetchAndRenderData() { + try { + const requests = [ + apiGet('/api/v1/advertisements', { limit, offset, search, public_key, member_id }), + apiGet('/api/v1/nodes', { limit: 500 }), + ]; + if (showMembers) { + requests.push(apiGet('/api/v1/members', { limit: 100 })); + } - const results = await Promise.all(requests); - const data = results[0]; - const nodesData = results[1]; - const membersData = showMembers ? results[2] : null; + const results = await Promise.all(requests); + const data = results[0]; + const nodesData = results[1]; + const membersData = showMembers ? results[2] : null; - const advertisements = data.items || []; - const total = data.total || 0; - const totalPages = Math.ceil(total / limit); - const allNodes = nodesData.items || []; - const members = membersData?.items || []; + const advertisements = data.items || []; + const total = data.total || 0; + const totalPages = Math.ceil(total / limit); + const allNodes = nodesData.items || []; + const members = membersData?.items || []; - const sortedNodes = allNodes.map(n => { - const tagName = n.tags?.find(t => t.key === 'name')?.value; - return { ...n, _sortName: (tagName || n.name || '').toLowerCase(), _displayName: tagName || n.name || n.public_key.slice(0, 12) + '...' }; - }).sort((a, b) => a._sortName.localeCompare(b._sortName)); + const sortedNodes = allNodes.map(n => { + const tagName = n.tags?.find(t => t.key === 'name')?.value; + return { ...n, _sortName: (tagName || n.name || '').toLowerCase(), _displayName: tagName || n.name || n.public_key.slice(0, 12) + '...' }; + }).sort((a, b) => a._sortName.localeCompare(b._sortName)); - const nodesFilter = sortedNodes.length > 0 - ? html` -
- - -
` - : nothing; + const nodesFilter = sortedNodes.length > 0 + ? html` +
+ + +
` + : nothing; - const membersFilter = (showMembers && members.length > 0) - ? html` -
- - -
` - : nothing; + const membersFilter = (showMembers && members.length > 0) + ? html` +
+ + +
` + : nothing; - const mobileCards = advertisements.length === 0 - ? html`
${t('common.no_entity_found', { entity: t('entities.advertisements').toLowerCase() })}
` - : advertisements.map(ad => { - const adName = ad.node_tag_name || ad.node_name || ad.name; - const adDescription = ad.node_tag_description; - let receiversBlock = nothing; - if (ad.receivers && ad.receivers.length >= 1) { - receiversBlock = html`
- ${ad.receivers.map(recv => { - const recvName = recv.tag_name || recv.name || truncateKey(recv.public_key, 12); - return html`\u{1F4E1}`; - })} -
`; - } else if (ad.received_by) { - const recvTitle = ad.receiver_tag_name || ad.receiver_name || truncateKey(ad.received_by, 12); - receiversBlock = html`\u{1F4E1}`; - } - return html` -
-
- ${renderNodeDisplay({ - name: adName, - description: adDescription, - publicKey: ad.public_key, - advType: ad.adv_type, - size: 'sm' - })} - - `; + `; + }); + + const tableRows = advertisements.length === 0 + ? html`${t('common.no_entity_found', { entity: t('entities.advertisements').toLowerCase() })}` + : advertisements.map(ad => { + const adName = ad.node_tag_name || ad.node_name || ad.name; + const adDescription = ad.node_tag_description; + let receiversBlock; + if (ad.receivers && ad.receivers.length >= 1) { + receiversBlock = html`
+ ${ad.receivers.map(recv => { + const recvName = recv.tag_name || recv.name || truncateKey(recv.public_key, 12); + return html`\u{1F4E1}`; + })} +
`; + } else if (ad.received_by) { + const recvTitle = ad.receiver_tag_name || ad.receiver_name || truncateKey(ad.received_by, 12); + receiversBlock = html`\u{1F4E1}`; + } else { + receiversBlock = html`-`; + } + return html` + + + ${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} + `; + }); + + const paginationBlock = pagination(page, totalPages, '/advertisements', { + search, public_key, member_id, limit, }); - const tableRows = advertisements.length === 0 - ? html`${t('common.no_entity_found', { entity: t('entities.advertisements').toLowerCase() })}` - : advertisements.map(ad => { - const adName = ad.node_tag_name || ad.node_name || ad.name; - const adDescription = ad.node_tag_description; - let receiversBlock; - if (ad.receivers && ad.receivers.length >= 1) { - receiversBlock = html`
- ${ad.receivers.map(recv => { - const recvName = recv.tag_name || recv.name || truncateKey(recv.public_key, 12); - return html`\u{1F4E1}`; - })} -
`; - } else if (ad.received_by) { - const recvTitle = ad.receiver_tag_name || ad.receiver_name || truncateKey(ad.received_by, 12); - receiversBlock = html`\u{1F4E1}`; - } else { - receiversBlock = html`-`; - } - return html` - - - ${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} - `; - }); - - const paginationBlock = pagination(page, totalPages, '/advertisements', { - search, public_key, member_id, limit, - }); - - renderPage(html` + renderPage(html`
@@ -211,7 +214,17 @@ ${content}`, container); ${paginationBlock}`, { total }); - } catch (e) { - renderPage(errorAlert(e.message)); + } catch (e) { + renderPage(errorAlert(e.message)); + } } + + await fetchAndRenderData(); + + const toggleEl = container.querySelector('#auto-refresh-toggle'); + const { cleanup } = createAutoRefresh({ + fetchAndRender: fetchAndRenderData, + toggleContainer: toggleEl, + }); + return cleanup; } diff --git a/src/meshcore_hub/web/static/js/spa/pages/messages.js b/src/meshcore_hub/web/static/js/spa/pages/messages.js index 612a744..57a95c6 100644 --- a/src/meshcore_hub/web/static/js/spa/pages/messages.js +++ b/src/meshcore_hub/web/static/js/spa/pages/messages.js @@ -6,6 +6,7 @@ import { pagination, timezoneIndicator, createFilterHandler, autoSubmit, submitOnEnter } from '../components.js'; +import { createAutoRefresh } from '../auto-refresh.js'; export async function render(container, params, router) { const query = params.query || {}; @@ -24,6 +25,7 @@ export async function render(container, params, router) {

${t('entities.messages')}

+ ${tzBadge} ${total !== null ? html`${t('common.total', { count: total })}` : nothing}
@@ -34,111 +36,112 @@ ${content}`, container); // Render page header immediately (old content stays visible until data loads) renderPage(nothing); - try { - const data = await apiGet('/api/v1/messages', { limit, offset, message_type }); - const messages = data.items || []; - const total = data.total || 0; - const totalPages = Math.ceil(total / limit); + async function fetchAndRenderData() { + try { + const data = await apiGet('/api/v1/messages', { limit, offset, message_type }); + const messages = data.items || []; + const total = data.total || 0; + const totalPages = Math.ceil(total / limit); - const mobileCards = messages.length === 0 - ? html`
${t('common.no_entity_found', { entity: t('entities.messages').toLowerCase() })}
` - : messages.map(msg => { - const isChannel = msg.message_type === 'channel'; - const typeIcon = isChannel ? '\u{1F4FB}' : '\u{1F464}'; - const typeTitle = isChannel ? t('messages.type_channel') : t('messages.type_contact'); - let senderBlock; - if (isChannel) { - senderBlock = html`${t('messages.type_public')}`; - } else { - const senderName = msg.sender_tag_name || msg.sender_name; - if (senderName) { - senderBlock = senderName; + const mobileCards = messages.length === 0 + ? html`
${t('common.no_entity_found', { entity: t('entities.messages').toLowerCase() })}
` + : messages.map(msg => { + const isChannel = msg.message_type === 'channel'; + const typeIcon = isChannel ? '\u{1F4FB}' : '\u{1F464}'; + const typeTitle = isChannel ? t('messages.type_channel') : t('messages.type_contact'); + let senderBlock; + if (isChannel) { + senderBlock = html`${t('messages.type_public')}`; } else { - senderBlock = html`${(msg.pubkey_prefix || '-').slice(0, 12)}`; + const senderName = msg.sender_tag_name || msg.sender_name; + if (senderName) { + senderBlock = senderName; + } else { + senderBlock = html`${(msg.pubkey_prefix || '-').slice(0, 12)}`; + } } - } - let receiversBlock = nothing; - if (msg.receivers && msg.receivers.length >= 1) { - receiversBlock = html`
- ${msg.receivers.map(recv => { - const recvName = recv.tag_name || recv.name || truncateKey(recv.public_key, 12); - return html`\u{1F4E1}`; - })} -
`; - } else if (msg.received_by) { - const recvTitle = msg.receiver_tag_name || msg.receiver_name || truncateKey(msg.received_by, 12); - receiversBlock = html`\u{1F4E1}`; - } - return html`
-
-
-
- - ${typeIcon} - -
-
- ${senderBlock} -
-
- ${formatDateTimeShort(msg.received_at)} + let receiversBlock = nothing; + if (msg.receivers && msg.receivers.length >= 1) { + receiversBlock = html`
+ ${msg.receivers.map(recv => { + const recvName = recv.tag_name || recv.name || truncateKey(recv.public_key, 12); + return html`\u{1F4E1}`; + })} +
`; + } else if (msg.received_by) { + const recvTitle = msg.receiver_tag_name || msg.receiver_name || truncateKey(msg.received_by, 12); + receiversBlock = html`\u{1F4E1}`; + } + return html`
+
+
+
+ + ${typeIcon} + +
+
+ ${senderBlock} +
+
+ ${formatDateTimeShort(msg.received_at)} +
+
+ ${receiversBlock} +
-
- ${receiversBlock} -
+

${msg.text || '-'}

-

${msg.text || '-'}

-
-
`; - }); +
`; + }); - const tableRows = messages.length === 0 - ? html`${t('common.no_entity_found', { entity: t('entities.messages').toLowerCase() })}` - : messages.map(msg => { - const isChannel = msg.message_type === 'channel'; - const typeIcon = isChannel ? '\u{1F4FB}' : '\u{1F464}'; - const typeTitle = isChannel ? t('messages.type_channel') : t('messages.type_contact'); - let senderBlock; - if (isChannel) { - senderBlock = html`${t('messages.type_public')}`; - } else { - const senderName = msg.sender_tag_name || msg.sender_name; - if (senderName) { - senderBlock = html`${senderName}`; + const tableRows = messages.length === 0 + ? html`${t('common.no_entity_found', { entity: t('entities.messages').toLowerCase() })}` + : messages.map(msg => { + const isChannel = msg.message_type === 'channel'; + const typeIcon = isChannel ? '\u{1F4FB}' : '\u{1F464}'; + const typeTitle = isChannel ? t('messages.type_channel') : t('messages.type_contact'); + let senderBlock; + if (isChannel) { + senderBlock = html`${t('messages.type_public')}`; } else { - senderBlock = html`${(msg.pubkey_prefix || '-').slice(0, 12)}`; + const senderName = msg.sender_tag_name || msg.sender_name; + if (senderName) { + senderBlock = html`${senderName}`; + } else { + senderBlock = html`${(msg.pubkey_prefix || '-').slice(0, 12)}`; + } } - } - let receiversBlock; - if (msg.receivers && msg.receivers.length >= 1) { - receiversBlock = html`
- ${msg.receivers.map(recv => { - const recvName = recv.tag_name || recv.name || truncateKey(recv.public_key, 12); - return html`\u{1F4E1}`; - })} -
`; - } else if (msg.received_by) { - const recvTitle = msg.receiver_tag_name || msg.receiver_name || truncateKey(msg.received_by, 12); - receiversBlock = html`\u{1F4E1}`; - } else { - receiversBlock = html`-`; - } - return html` - ${typeIcon} - ${formatDateTime(msg.received_at)} - ${senderBlock} - ${msg.text || '-'} - ${receiversBlock} - `; + let receiversBlock; + if (msg.receivers && msg.receivers.length >= 1) { + receiversBlock = html`
+ ${msg.receivers.map(recv => { + const recvName = recv.tag_name || recv.name || truncateKey(recv.public_key, 12); + return html`\u{1F4E1}`; + })} +
`; + } else if (msg.received_by) { + const recvTitle = msg.receiver_tag_name || msg.receiver_name || truncateKey(msg.received_by, 12); + receiversBlock = html`\u{1F4E1}`; + } else { + receiversBlock = html`-`; + } + return html` + ${typeIcon} + ${formatDateTime(msg.received_at)} + ${senderBlock} + ${msg.text || '-'} + ${receiversBlock} + `; + }); + + const paginationBlock = pagination(page, totalPages, '/messages', { + message_type, limit, }); - const paginationBlock = pagination(page, totalPages, '/messages', { - message_type, limit, - }); - - renderPage(html` + renderPage(html`
@@ -183,7 +186,17 @@ ${content}`, container); ${paginationBlock}`, { total }); - } catch (e) { - renderPage(errorAlert(e.message)); + } catch (e) { + renderPage(errorAlert(e.message)); + } } + + await fetchAndRenderData(); + + const toggleEl = container.querySelector('#auto-refresh-toggle'); + const { cleanup } = createAutoRefresh({ + fetchAndRender: fetchAndRenderData, + toggleContainer: toggleEl, + }); + return cleanup; } 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 dad02b0..3553cb4 100644 --- a/src/meshcore_hub/web/static/js/spa/pages/nodes.js +++ b/src/meshcore_hub/web/static/js/spa/pages/nodes.js @@ -6,6 +6,7 @@ import { pagination, timezoneIndicator, createFilterHandler, autoSubmit, submitOnEnter, copyToClipboard, renderNodeDisplay, t } from '../components.js'; +import { createAutoRefresh } from '../auto-refresh.js'; export async function render(container, params, router) { const query = params.query || {}; @@ -28,6 +29,7 @@ export async function render(container, params, router) {

${t('entities.nodes')}

+ ${tzBadge} ${total !== null ? html`${t('common.total', { count: total })}` : nothing}
@@ -38,107 +40,108 @@ ${content}`, container); // Render page header immediately (old content stays visible until data loads) renderPage(nothing); - try { - const requests = [ - apiGet('/api/v1/nodes', { limit, offset, search, adv_type, member_id }), - ]; - if (showMembers) { - requests.push(apiGet('/api/v1/members', { limit: 100 })); - } + async function fetchAndRenderData() { + try { + const requests = [ + apiGet('/api/v1/nodes', { limit, offset, search, adv_type, member_id }), + ]; + if (showMembers) { + requests.push(apiGet('/api/v1/members', { limit: 100 })); + } - const results = await Promise.all(requests); - const data = results[0]; - const membersData = showMembers ? results[1] : null; + const results = await Promise.all(requests); + const data = results[0]; + const membersData = showMembers ? results[1] : null; - const nodes = data.items || []; - const total = data.total || 0; - const totalPages = Math.ceil(total / limit); - const members = membersData?.items || []; + const nodes = data.items || []; + const total = data.total || 0; + const totalPages = Math.ceil(total / limit); + const members = membersData?.items || []; - const membersFilter = (showMembers && members.length > 0) - ? html` -
- - -
` - : nothing; + const membersFilter = (showMembers && members.length > 0) + ? html` +
+ + +
` + : nothing; - const mobileCards = nodes.length === 0 - ? 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 lastSeen = node.last_seen ? formatDateTimeShort(node.last_seen) : '-'; - const memberIdTag = showMembers ? node.tags?.find(tag => tag.key === 'member_id')?.value : null; - const member = memberIdTag ? members.find(m => m.member_id === memberIdTag) : null; - const memberBlock = (showMembers && member) - ? html`
${member.name}
` - : nothing; - return html` -
-
- ${renderNodeDisplay({ - name: displayName, - description: tagDescription, - publicKey: node.public_key, - advType: node.adv_type, - size: 'sm' - })} - - `; + `; + }); + + const tableColspan = showMembers ? 4 : 3; + const tableRows = nodes.length === 0 + ? 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 lastSeen = node.last_seen ? formatDateTime(node.last_seen) : '-'; + const memberIdTag = showMembers ? node.tags?.find(tag => tag.key === 'member_id')?.value : null; + 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` + + + ${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} + ${showMembers ? html`${memberBlock}` : nothing} + `; + }); + + const paginationBlock = pagination(page, totalPages, '/nodes', { + search, adv_type, member_id, limit, }); - const tableColspan = showMembers ? 4 : 3; - const tableRows = nodes.length === 0 - ? 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 lastSeen = node.last_seen ? formatDateTime(node.last_seen) : '-'; - const memberIdTag = showMembers ? node.tags?.find(tag => tag.key === 'member_id')?.value : null; - 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` - - - ${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} - ${showMembers ? html`${memberBlock}` : nothing} - `; - }); - - const paginationBlock = pagination(page, totalPages, '/nodes', { - search, adv_type, member_id, limit, - }); - - renderPage(html` + renderPage(html`
@@ -190,7 +193,17 @@ ${content}`, container); ${paginationBlock}`, { total }); - } catch (e) { - renderPage(errorAlert(e.message)); + } catch (e) { + renderPage(errorAlert(e.message)); + } } + + await fetchAndRenderData(); + + const toggleEl = container.querySelector('#auto-refresh-toggle'); + const { cleanup } = createAutoRefresh({ + fetchAndRender: fetchAndRenderData, + toggleContainer: toggleEl, + }); + return cleanup; } diff --git a/src/meshcore_hub/web/static/locales/en.json b/src/meshcore_hub/web/static/locales/en.json index 0b1ec39..2a66f94 100644 --- a/src/meshcore_hub/web/static/locales/en.json +++ b/src/meshcore_hub/web/static/locales/en.json @@ -105,6 +105,10 @@ "youtube": "YouTube", "profile": "Profile" }, + "auto_refresh": { + "pause": "Pause auto-refresh", + "resume": "Resume auto-refresh" + }, "time": { "days_ago": "{{count}}d ago", "hours_ago": "{{count}}h ago", diff --git a/src/meshcore_hub/web/static/locales/languages.md b/src/meshcore_hub/web/static/locales/languages.md index d314060..4877a2c 100644 --- a/src/meshcore_hub/web/static/locales/languages.md +++ b/src/meshcore_hub/web/static/locales/languages.md @@ -191,7 +191,16 @@ Platform and external link labels: | `youtube` | YouTube | YouTube link label (preserve capitalization) | | `profile` | Profile | Radio profile label | -### 4. `time` +### 4. `auto_refresh` + +Auto-refresh controls for list pages (nodes, advertisements, messages): + +| Key | English | Context | +|-----|---------|---------| +| `pause` | Pause auto-refresh | Tooltip on pause button when auto-refresh is active | +| `resume` | Resume auto-refresh | Tooltip on play button when auto-refresh is paused | + +### 5. `time` Time-related labels and formats: @@ -206,7 +215,7 @@ Time-related labels and formats: | `over_time_last_7_days` | Over time (last 7 days) | Over time last 7 days | | `activity_per_day_last_7_days` | Activity per day (last 7 days) | Activity chart label | -### 5. `node_types` +### 6. `node_types` Mesh network node type labels: @@ -217,7 +226,7 @@ Mesh network node type labels: | `room` | Room | Room/group node type | | `unknown` | Unknown | Unknown node type fallback | -### 6. `home` +### 7. `home` Homepage-specific content: @@ -238,7 +247,7 @@ Homepage-specific content: **Note:** MeshCore tagline "Connecting people and things, without using the internet" is hardcoded in English and should not be translated (trademark). -### 7. `dashboard` +### 8. `dashboard` Dashboard page content: @@ -248,7 +257,7 @@ Dashboard page content: | `recent_channel_messages` | Recent Channel Messages | Recent messages card title | | `channel` | Channel {{number}} | Channel label with number | -### 8. `nodes` +### 9. `nodes` Node-specific labels: @@ -256,11 +265,11 @@ Node-specific labels: |-----|---------|---------| | `scan_to_add` | Scan to add as contact | QR code instruction | -### 9. `advertisements` +### 10. `advertisements` Currently empty - advertisements page uses common patterns. -### 10. `messages` +### 11. `messages` Message type labels: @@ -271,7 +280,7 @@ Message type labels: | `type_contact` | Contact | Contact message type | | `type_public` | Public | Public message type | -### 11. `map` +### 12. `map` Map page content: @@ -289,7 +298,7 @@ Map page content: | `role` | Role: | Member role label | | `select_destination_node` | -- Select destination node -- | Dropdown placeholder | -### 12. `members` +### 13. `members` Members page content: @@ -300,7 +309,7 @@ Members page content: | `members_file_description` | Create a YAML file at $SEED_HOME/members.yaml with the following structure: | File creation instructions | | `members_import_instructions` | Run meshcore-hub collector seed to import members.
To associate nodes with members, add a member_id tag to nodes in node_tags.yaml. | Import instructions (HTML allowed) | -### 13. `not_found` +### 14. `not_found` 404 page content: @@ -308,7 +317,7 @@ Members page content: |-----|---------|---------| | `description` | The page you're looking for doesn't exist or has been moved. | 404 description | -### 14. `custom_page` +### 15. `custom_page` Custom markdown page errors: @@ -316,7 +325,7 @@ Custom markdown page errors: |-----|---------|---------| | `failed_to_load` | Failed to load page | Page load error | -### 15. `admin` +### 16. `admin` Admin panel content: @@ -331,7 +340,7 @@ Admin panel content: | `members_description` | Manage network members and operators. | Members card description | | `tags_description` | Manage custom tags and metadata for network nodes. | Tags card description | -### 16. `admin_members` +### 17. `admin_members` Admin members page: @@ -344,7 +353,7 @@ Admin members page: **Note:** Confirmation and success messages use `common.*` patterns. -### 17. `admin_node_tags` +### 18. `admin_node_tags` Admin node tags page: @@ -368,7 +377,7 @@ Admin node tags page: **Note:** Titles, confirmations, and success messages use `common.*` patterns. -### 18. `footer` +### 19. `footer` Footer content: