From 32d9da28653d47c29e75ad4345f9f456e69907fe Mon Sep 17 00:00:00 2001 From: l5y <220195275+l5yth@users.noreply.github.com> Date: Sat, 18 Oct 2025 10:53:26 +0200 Subject: [PATCH] Add instance selector dropdown for federation deployments (#382) * Add instance selector for federation regions * Avoid HTML insertion when seeding instance selector --- web/lib/potato_mesh/application/helpers.rb | 1 + .../potato_mesh/application/routes/root.rb | 1 + .../app/__tests__/instance-selector.test.js | 172 ++++++++++++++ web/public/assets/js/app/instance-selector.js | 217 ++++++++++++++++++ web/public/assets/js/app/main.js | 13 ++ web/public/assets/styles/base.css | 67 +++++- web/spec/app_spec.rb | 23 ++ web/views/index.erb | 18 +- 8 files changed, 507 insertions(+), 5 deletions(-) create mode 100644 web/public/assets/js/app/__tests__/instance-selector.test.js create mode 100644 web/public/assets/js/app/instance-selector.js diff --git a/web/lib/potato_mesh/application/helpers.rb b/web/lib/potato_mesh/application/helpers.rb index e4bbe58..44bc304 100644 --- a/web/lib/potato_mesh/application/helpers.rb +++ b/web/lib/potato_mesh/application/helpers.rb @@ -123,6 +123,7 @@ module PotatoMesh maxDistanceKm: PotatoMesh::Config.max_distance_km, tileFilters: PotatoMesh::Config.tile_filters, instanceDomain: app_constant(:INSTANCE_DOMAIN), + instancesFeatureEnabled: federation_enabled? && !private_mode?, } end diff --git a/web/lib/potato_mesh/application/routes/root.rb b/web/lib/potato_mesh/application/routes/root.rb index 1be7909..8ed568d 100644 --- a/web/lib/potato_mesh/application/routes/root.rb +++ b/web/lib/potato_mesh/application/routes/root.rb @@ -62,6 +62,7 @@ module PotatoMesh contact_link_url: sanitized_contact_link_url, version: app_constant(:APP_VERSION), private_mode: private_mode?, + federation_enabled: federation_enabled?, refresh_interval_seconds: PotatoMesh::Config.refresh_interval_seconds, app_config_json: JSON.generate(config), initial_theme: theme, diff --git a/web/public/assets/js/app/__tests__/instance-selector.test.js b/web/public/assets/js/app/__tests__/instance-selector.test.js new file mode 100644 index 0000000..3e4ba3d --- /dev/null +++ b/web/public/assets/js/app/__tests__/instance-selector.test.js @@ -0,0 +1,172 @@ +/* + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { createDomEnvironment } from './dom-environment.js'; + +import { buildInstanceUrl, initializeInstanceSelector, __test__ } from '../instance-selector.js'; + +const { resolveInstanceLabel } = __test__; + +function setupSelectElement(document) { + const select = document.createElement('select'); + const listeners = new Map(); + const options = []; + + Object.defineProperty(select, 'options', { + get() { + return options; + } + }); + + Object.defineProperty(select, 'value', { + get() { + if (typeof select.selectedIndex !== 'number') { + return ''; + } + const current = options[select.selectedIndex]; + return current ? current.value : ''; + }, + set(newValue) { + const index = options.findIndex(option => option.value === newValue); + select.selectedIndex = index >= 0 ? index : -1; + } + }); + + select.selectedIndex = -1; + + select.appendChild = option => { + options.push(option); + if (select.selectedIndex === -1) { + select.selectedIndex = 0; + } + return option; + }; + + select.remove = index => { + if (index >= 0 && index < options.length) { + options.splice(index, 1); + if (options.length === 0) { + select.selectedIndex = -1; + } else if (select.selectedIndex >= options.length) { + select.selectedIndex = options.length - 1; + } + } + }; + + select.addEventListener = (event, handler) => { + listeners.set(event, handler); + }; + select.dispatchEvent = event => { + const key = typeof event === 'string' ? event : event?.type; + const handler = listeners.get(key); + if (handler) { + handler(event); + } + }; + return select; +} + +test('resolveInstanceLabel falls back to the domain when the name is missing', () => { + assert.equal(resolveInstanceLabel({ domain: 'mesh.example' }), 'mesh.example'); + assert.equal(resolveInstanceLabel({ name: ' Mesh Name ' }), 'Mesh Name'); + assert.equal(resolveInstanceLabel(null), ''); +}); + +test('buildInstanceUrl normalises domains into navigable HTTPS URLs', () => { + assert.equal(buildInstanceUrl('mesh.example'), 'https://mesh.example'); + assert.equal(buildInstanceUrl(' https://mesh.example '), 'https://mesh.example'); + assert.equal(buildInstanceUrl(''), null); + assert.equal(buildInstanceUrl(null), null); +}); + +test('initializeInstanceSelector populates options alphabetically and selects the configured domain', async () => { + const env = createDomEnvironment(); + const select = setupSelectElement(env.document); + + const fetchCalls = []; + const fetchImpl = async url => { + fetchCalls.push(url); + return { + ok: true, + async json() { + return [ + { name: 'Zulu Mesh', domain: 'zulu.mesh' }, + { name: 'Alpha Mesh', domain: 'alpha.mesh' }, + { domain: 'beta.mesh' } + ]; + } + }; + }; + + try { + await initializeInstanceSelector({ + selectElement: select, + fetchImpl, + windowObject: env.window, + documentObject: env.document, + instanceDomain: 'beta.mesh', + defaultLabel: 'Select region ...' + }); + + assert.equal(fetchCalls.length, 1); + assert.equal(select.options.length, 4); + assert.equal(select.options[0].textContent, 'Select region ...'); + assert.equal(select.options[1].textContent, 'Alpha Mesh'); + assert.equal(select.options[2].textContent, 'beta.mesh'); + assert.equal(select.options[3].textContent, 'Zulu Mesh'); + assert.equal(select.options[select.selectedIndex].value, 'beta.mesh'); + } finally { + env.cleanup(); + } +}); + +test('initializeInstanceSelector navigates to the chosen instance domain', async () => { + const env = createDomEnvironment(); + const select = setupSelectElement(env.document); + + const fetchImpl = async () => ({ + ok: true, + async json() { + return [{ domain: 'mesh.example' }]; + } + }); + + let navigatedTo = null; + const navigate = url => { + navigatedTo = url; + }; + + try { + await initializeInstanceSelector({ + selectElement: select, + fetchImpl, + windowObject: env.window, + documentObject: env.document, + navigate, + defaultLabel: 'Select region ...' + }); + + assert.equal(select.options.length, 2); + assert.equal(select.options[1].value, 'mesh.example'); + + select.value = 'mesh.example'; + select.dispatchEvent({ type: 'change', target: select }); + + assert.equal(navigatedTo, 'https://mesh.example'); + } finally { + env.cleanup(); + } +}); diff --git a/web/public/assets/js/app/instance-selector.js b/web/public/assets/js/app/instance-selector.js new file mode 100644 index 0000000..4836de3 --- /dev/null +++ b/web/public/assets/js/app/instance-selector.js @@ -0,0 +1,217 @@ +/* + * Copyright (C) 2025 l5yth + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +/** + * Determine the most suitable label for an instance list entry. + * + * @param {{ name?: string, domain?: string }} entry Instance record as returned by the API. + * @returns {string} Preferred display label falling back to the domain. + */ +function resolveInstanceLabel(entry) { + if (!entry || typeof entry !== 'object') { + return ''; + } + + const name = typeof entry.name === 'string' ? entry.name.trim() : ''; + if (name.length > 0) { + return name; + } + + const domain = typeof entry.domain === 'string' ? entry.domain.trim() : ''; + return domain; +} + +/** + * Construct a navigable URL for the provided instance domain. + * + * @param {string} domain Instance domain as returned by the federation catalog. + * @returns {string|null} Navigable absolute URL or ``null`` when the domain is empty. + */ +export function buildInstanceUrl(domain) { + if (typeof domain !== 'string') { + return null; + } + + const trimmed = domain.trim(); + if (!trimmed) { + return null; + } + + if (/^[a-zA-Z][a-zA-Z\d+.-]*:\/\//.test(trimmed)) { + return trimmed; + } + + return `https://${trimmed}`; +} + +/** + * Populate and activate the federation instance selector control. + * + * @param {{ + * selectElement: HTMLSelectElement | null, + * fetchImpl?: typeof fetch, + * windowObject?: Window, + * documentObject?: Document, + * instanceDomain?: string, + * defaultLabel?: string, + * navigate?: (url: string) => void, + * }} options Configuration for the selector behaviour. + * @returns {Promise} Promise resolving once the selector has been initialised. + */ +export async function initializeInstanceSelector(options) { + const { + selectElement, + fetchImpl = typeof fetch === 'function' ? fetch : null, + windowObject = typeof window !== 'undefined' ? window : undefined, + documentObject = typeof document !== 'undefined' ? document : undefined, + instanceDomain, + defaultLabel = 'Select region ...', + navigate, + } = options; + + if (!selectElement || typeof selectElement !== 'object') { + return; + } + + const doc = documentObject || windowObject?.document || null; + + if (selectElement.options.length === 0) { + const optionFactory = + (doc && typeof doc.createElement === 'function') + ? doc.createElement.bind(doc) + : (typeof selectElement.ownerDocument?.createElement === 'function' + ? selectElement.ownerDocument.createElement.bind(selectElement.ownerDocument) + : null); + + if (optionFactory) { + const placeholderOption = optionFactory('option'); + placeholderOption.value = ''; + placeholderOption.textContent = defaultLabel; + selectElement.appendChild(placeholderOption); + } + } else if (selectElement.options[0]) { + selectElement.options[0].textContent = defaultLabel; + selectElement.options[0].value = ''; + } + + if (typeof fetchImpl !== 'function') { + return; + } + + let response; + try { + response = await fetchImpl('/api/instances', { + headers: { Accept: 'application/json' }, + credentials: 'omit', + }); + } catch (error) { + console.warn('Failed to load federation instances', error); + return; + } + + if (!response || typeof response.json !== 'function') { + return; + } + + if (!response.ok) { + return; + } + + let payload; + try { + payload = await response.json(); + } catch (error) { + console.warn('Invalid federation instances payload', error); + return; + } + + if (!Array.isArray(payload)) { + return; + } + + const sanitizedDomain = typeof instanceDomain === 'string' ? instanceDomain.trim().toLowerCase() : null; + + const sortedEntries = payload + .filter(entry => entry && typeof entry.domain === 'string' && entry.domain.trim() !== '') + .map(entry => ({ + domain: entry.domain.trim(), + label: resolveInstanceLabel(entry), + })) + .sort((a, b) => { + const labelA = a.label || a.domain; + const labelB = b.label || b.domain; + return labelA.localeCompare(labelB, undefined, { sensitivity: 'base' }); + }); + + while (selectElement.options.length > 1) { + selectElement.remove(1); + } + + let matchedIndex = 0; + + sortedEntries.forEach((entry, index) => { + if (!doc || typeof doc.createElement !== 'function') { + return; + } + + const option = doc.createElement('option'); + const optionLabel = entry.label && entry.label.trim().length > 0 ? entry.label : entry.domain; + const label = optionLabel.trim(); + + option.value = entry.domain; + option.textContent = label; + option.dataset.instanceDomain = entry.domain; + + selectElement.appendChild(option); + + if (sanitizedDomain && entry.domain.toLowerCase() === sanitizedDomain) { + matchedIndex = index + 1; + } + }); + + if (matchedIndex > 0 && selectElement.options[matchedIndex]) { + selectElement.selectedIndex = matchedIndex; + } else { + selectElement.selectedIndex = 0; + } + + const navigateTo = typeof navigate === 'function' + ? navigate + : url => { + if (!url || !windowObject || !windowObject.location) { + return; + } + if (typeof windowObject.location.assign === 'function') { + windowObject.location.assign(url); + } else { + windowObject.location.href = url; + } + }; + + selectElement.addEventListener('change', event => { + const target = event?.target; + if (!target || typeof target.value !== 'string' || target.value.trim() === '') { + return; + } + + const url = buildInstanceUrl(target.value); + if (url) { + navigateTo(url); + } + }); +} + +export const __test__ = { resolveInstanceLabel }; diff --git a/web/public/assets/js/app/main.js b/web/public/assets/js/app/main.js index 80bf533..e798708 100644 --- a/web/public/assets/js/app/main.js +++ b/web/public/assets/js/app/main.js @@ -27,6 +27,7 @@ import { formatChatChannelTag, formatNodeAnnouncementPrefix } from './chat-format.js'; +import { initializeInstanceSelector } from './instance-selector.js'; /** * Entry point for the interactive dashboard. Wires up event listeners, @@ -63,6 +64,7 @@ export function initializeApp(config) { const headerTitleTextEl = headerEl ? headerEl.querySelector('.site-title-text') : null; const chatEl = document.getElementById('chat'); const refreshInfo = document.getElementById('refreshInfo'); + const instanceSelect = document.getElementById('instanceSelect'); const baseTitle = document.title; const nodesTable = document.getElementById('nodes'); const sortButtons = nodesTable ? Array.from(nodesTable.querySelectorAll('thead .sort-button[data-sort-key]')) : []; @@ -123,8 +125,19 @@ export function initializeApp(config) { const CHAT_RECENT_WINDOW_SECONDS = 7 * 24 * 60 * 60; const REFRESH_MS = config.refreshMs; const CHAT_ENABLED = Boolean(config.chatEnabled); + const instanceSelectorEnabled = Boolean(config.instancesFeatureEnabled); refreshInfo.textContent = `${config.channel} (${config.frequency}) — active nodes: …`; + if (instanceSelectorEnabled && instanceSelect) { + void initializeInstanceSelector({ + selectElement: instanceSelect, + instanceDomain: config.instanceDomain, + defaultLabel: 'Select region ...', + }).catch(error => { + console.warn('Instance selector initialisation failed', error); + }); + } + /** @type {ReturnType|null} */ let refreshTimer = null; diff --git a/web/public/assets/styles/base.css b/web/public/assets/styles/base.css index 8b5e823..5307fdf 100644 --- a/web/public/assets/styles/base.css +++ b/web/public/assets/styles/base.css @@ -131,7 +131,15 @@ body { } h1 { - margin: 0 0 8px; + margin: 0; +} + +.site-header { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 12px; + margin-bottom: 8px; } .site-title { @@ -152,6 +160,63 @@ h1 { margin-bottom: 12px; } +.instance-selector { + display: flex; + align-items: center; +} + +.instance-select { + appearance: none; + -webkit-appearance: none; + -moz-appearance: none; + background-color: var(--input-bg); + color: var(--input-fg); + border: 1px solid var(--input-border); + border-radius: 8px; + padding: 6px 32px 6px 12px; + font-size: 14px; + line-height: 1.4; + min-width: 220px; + background-image: linear-gradient(45deg, transparent 50%, var(--muted) 50%), + linear-gradient(135deg, var(--muted) 50%, transparent 50%); + background-position: calc(100% - 18px) calc(50% - 4px), calc(100% - 12px) calc(50% - 4px); + background-size: 6px 6px, 6px 6px; + background-repeat: no-repeat; +} + +.instance-select:focus { + outline: 2px solid var(--accent); + outline-offset: 2px; +} + +.visually-hidden { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + margin: -1px; + overflow: hidden; + clip: rect(0, 0, 0, 0); + white-space: nowrap; + border: 0; +} + +@media (max-width: 900px) { + .site-header { + flex-direction: column; + align-items: flex-start; + } + + .instance-selector, + .instance-select { + width: 100%; + } + + .instance-select { + min-width: 0; + } +} + .pill { display: inline-block; padding: 2px 8px; diff --git a/web/spec/app_spec.rb b/web/spec/app_spec.rb index 1aeb16b..73e2149 100644 --- a/web/spec/app_spec.rb +++ b/web/spec/app_spec.rb @@ -1062,6 +1062,29 @@ RSpec.describe "Potato Mesh Sinatra app" do expect(last_response.body).to include('class="footer-content"') end + it "renders the federation instance selector when federation is enabled" do + get "/" + + expect(last_response.body).to include('id="instanceSelect"') + expect(last_response.body).to include("Select region ...") + end + + it "omits the instance selector when private mode is active" do + allow(PotatoMesh::Config).to receive(:private_mode_enabled?).and_return(true) + + get "/" + + expect(last_response.body).not_to include('id="instanceSelect"') + end + + it "omits the instance selector when federation is disabled" do + allow(PotatoMesh::Config).to receive(:federation_enabled?).and_return(false) + + get "/" + + expect(last_response.body).not_to include('id="instanceSelect"') + end + it "includes SEO metadata from configuration" do allow(PotatoMesh::Config).to receive(:site_name).and_return("Spec Mesh Title") allow(PotatoMesh::Config).to receive(:channel).and_return("#SpecChannel") diff --git a/web/views/index.erb b/web/views/index.erb index 6203782..1e03717 100644 --- a/web/views/index.erb +++ b/web/views/index.erb @@ -76,10 +76,20 @@ <% body_classes = [] %> <% body_classes << "dark" if initial_theme == "dark" %> " data-app-config="<%= Rack::Utils.escape_html(app_config_json) %>" data-theme="<%= initial_theme %>"> -

- - <%= site_name %> -

+