mirror of
https://github.com/l5yth/potato-mesh.git
synced 2026-03-28 17:42:48 +01:00
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:
@@ -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
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
172
web/public/assets/js/app/__tests__/instance-selector.test.js
Normal file
172
web/public/assets/js/app/__tests__/instance-selector.test.js
Normal 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();
|
||||
}
|
||||
});
|
||||
217
web/public/assets/js/app/instance-selector.js
Normal file
217
web/public/assets/js/app/instance-selector.js
Normal 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 };
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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">
|
||||
|
||||
Reference in New Issue
Block a user