Add instance selector dropdown for federation deployments (#382)

* Add instance selector for federation regions

* Avoid HTML insertion when seeding instance selector
This commit is contained in:
l5y
2025-10-18 10:53:26 +02:00
committed by GitHub
parent 61e8c92f62
commit 32d9da2865
8 changed files with 507 additions and 5 deletions

View File

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

View File

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

View File

@@ -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();
}
});

View File

@@ -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<void>} 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 };

View File

@@ -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<typeof setTimeout>|null} */
let refreshTimer = null;

View File

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

View File

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

View File

@@ -76,10 +76,20 @@
<% body_classes = [] %>
<% body_classes << "dark" if initial_theme == "dark" %>
<body class="<%= body_classes.join(" ") %>" data-app-config="<%= Rack::Utils.escape_html(app_config_json) %>" data-theme="<%= initial_theme %>">
<h1 class="site-title">
<img src="/potatomesh-logo.svg" alt="" aria-hidden="true" />
<span class="site-title-text"><%= site_name %></span>
</h1>
<div class="site-header">
<h1 class="site-title">
<img src="/potatomesh-logo.svg" alt="" aria-hidden="true" />
<span class="site-title-text"><%= site_name %></span>
</h1>
<% if !private_mode && federation_enabled %>
<div class="instance-selector">
<label class="visually-hidden" for="instanceSelect">Select a region</label>
<select id="instanceSelect" class="instance-select" aria-label="Select instance region">
<option value=""><%= Rack::Utils.escape_html("Select region ...") %></option>
</select>
</div>
<% end %>
</div>
<div class="row meta">
<div class="meta-info">
<div class="refresh-row">