mirror of
https://github.com/l5yth/potato-mesh.git
synced 2026-08-07 09:22:53 +02:00
web: further refine the federation page (#534)
* web: further refine the federation page * web: address review comments * web: address review comments
This commit is contained in:
@@ -104,6 +104,7 @@ class MockElement {
|
||||
this.style = {};
|
||||
this.textContent = '';
|
||||
this.classList = new MockClassList();
|
||||
this.childNodes = [];
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -129,6 +130,113 @@ class MockElement {
|
||||
getAttribute(name) {
|
||||
return this.attributes.has(name) ? this.attributes.get(name) : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove an attribute from the element.
|
||||
*
|
||||
* @param {string} name Attribute identifier.
|
||||
* @returns {void}
|
||||
*/
|
||||
removeAttribute(name) {
|
||||
this.attributes.delete(name);
|
||||
}
|
||||
|
||||
/**
|
||||
* Append a child node to this element.
|
||||
*
|
||||
* @param {Object} node Child node to append.
|
||||
* @returns {Object} Appended node.
|
||||
*/
|
||||
appendChild(node) {
|
||||
this.childNodes.push(node);
|
||||
return node;
|
||||
}
|
||||
|
||||
/**
|
||||
* Replace all existing children with the provided nodes.
|
||||
*
|
||||
* @param {...Object} nodes Child nodes to set on the element.
|
||||
* @returns {void}
|
||||
*/
|
||||
replaceChildren(...nodes) {
|
||||
const expanded = [];
|
||||
nodes.forEach(node => {
|
||||
if (node && node.tagName === 'FRAGMENT' && Array.isArray(node.childNodes)) {
|
||||
expanded.push(...node.childNodes);
|
||||
} else {
|
||||
expanded.push(node);
|
||||
}
|
||||
});
|
||||
this.childNodes = expanded;
|
||||
}
|
||||
|
||||
/**
|
||||
* Serialize the element's children into a naive HTML string for test
|
||||
* assertions. This intentionally covers only the subset of markup produced
|
||||
* in unit tests.
|
||||
*
|
||||
* @returns {string} Serialized HTML content.
|
||||
*/
|
||||
get innerHTML() {
|
||||
return this.childNodes
|
||||
.map(node => {
|
||||
if (typeof node === 'string') return node;
|
||||
if (node && node.tagName) {
|
||||
const attrs = [];
|
||||
if (node.attributes.size) {
|
||||
node.attributes.forEach((value, key) => {
|
||||
attrs.push(`${key}="${value}"`);
|
||||
});
|
||||
}
|
||||
const classAttr = node.classList && node.classList._values && node.classList._values.size
|
||||
? `class="${Array.from(node.classList._values).join(' ')}"`
|
||||
: null;
|
||||
if (classAttr) attrs.push(classAttr);
|
||||
const children = node.innerHTML || '';
|
||||
return `<${node.tagName.toLowerCase()}${attrs.length ? ' ' + attrs.join(' ') : ''}>${children}</${node.tagName.toLowerCase()}>`;
|
||||
}
|
||||
return '';
|
||||
})
|
||||
.join('');
|
||||
}
|
||||
|
||||
/**
|
||||
* Setter to overwrite children from a raw HTML string in tests. This is a
|
||||
* minimal stub and only supports plain text content insertion.
|
||||
*
|
||||
* @param {string} value Raw HTML content.
|
||||
* @returns {void}
|
||||
*/
|
||||
set innerHTML(value) {
|
||||
this.childNodes = [String(value)];
|
||||
}
|
||||
|
||||
/**
|
||||
* Very small querySelectorAll implementation that supports ``.class`` lookups
|
||||
* used in unit tests.
|
||||
*
|
||||
* @param {string} selector CSS selector (class names only).
|
||||
* @returns {Array<MockElement>} Matching child nodes.
|
||||
*/
|
||||
querySelectorAll(selector) {
|
||||
if (!selector || typeof selector !== 'string') return [];
|
||||
const classMatch = selector.match(/^\.(.+)$/);
|
||||
if (!classMatch) return [];
|
||||
const className = classMatch[1];
|
||||
const matches = [];
|
||||
const visit = node => {
|
||||
if (node && node.classList && typeof node.classList.contains === 'function') {
|
||||
if (node.classList.contains(className)) {
|
||||
matches.push(node);
|
||||
}
|
||||
}
|
||||
if (node && Array.isArray(node.childNodes)) {
|
||||
node.childNodes.forEach(child => visit(child));
|
||||
}
|
||||
};
|
||||
visit(this);
|
||||
return matches;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -182,8 +290,9 @@ export function createDomEnvironment(options = {}) {
|
||||
documentListeners.delete(event);
|
||||
},
|
||||
dispatchEvent(event) {
|
||||
const handler = documentListeners.get(event);
|
||||
if (handler) handler();
|
||||
const key = typeof event === 'string' ? event : event?.type;
|
||||
const handler = documentListeners.get(key);
|
||||
if (handler) handler(event);
|
||||
},
|
||||
getElementById(id) {
|
||||
return registry.get(id) || null;
|
||||
@@ -193,6 +302,18 @@ export function createDomEnvironment(options = {}) {
|
||||
},
|
||||
createElement(tagName) {
|
||||
return new MockElement(tagName, registry);
|
||||
},
|
||||
createDocumentFragment() {
|
||||
const fragment = new MockElement('fragment', null);
|
||||
fragment.childNodes = [];
|
||||
fragment.appendChild = node => {
|
||||
fragment.childNodes.push(node);
|
||||
return node;
|
||||
};
|
||||
fragment.replaceChildren = (...nodes) => {
|
||||
fragment.childNodes = [...nodes];
|
||||
};
|
||||
return fragment;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -218,8 +339,9 @@ export function createDomEnvironment(options = {}) {
|
||||
windowListeners.delete(event);
|
||||
},
|
||||
dispatchEvent(event) {
|
||||
const handler = windowListeners.get(event);
|
||||
if (handler) handler();
|
||||
const key = typeof event === 'string' ? event : event?.type;
|
||||
const handler = windowListeners.get(key);
|
||||
if (handler) handler(event);
|
||||
},
|
||||
getComputedStyle(target) {
|
||||
if (typeof computedStyleImpl === 'function') {
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
/*
|
||||
* Copyright © 2025-26 l5yth & contributors
|
||||
*
|
||||
* 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';
|
||||
|
||||
test('dom environment supports class queries and innerHTML setter', () => {
|
||||
const env = createDomEnvironment({ includeBody: true });
|
||||
const { document, createElement, cleanup } = env;
|
||||
|
||||
const parent = createElement('div');
|
||||
const child = createElement('span');
|
||||
child.classList.add('leaflet-tile');
|
||||
child.setAttribute('data-test', 'ok');
|
||||
parent.appendChild(child);
|
||||
|
||||
const matches = parent.querySelectorAll('.leaflet-tile');
|
||||
assert.equal(matches.length, 1);
|
||||
assert.equal(matches[0], child);
|
||||
|
||||
const target = createElement('div');
|
||||
target.innerHTML = '<b>hello</b>';
|
||||
assert.match(target.innerHTML, /hello/);
|
||||
|
||||
const fragment = document.createDocumentFragment();
|
||||
fragment.replaceChildren(createElement('p'));
|
||||
const container = createElement('section');
|
||||
const decorated = createElement('span');
|
||||
decorated.setAttribute('data-id', '123');
|
||||
decorated.classList.add('foo');
|
||||
container.appendChild(decorated);
|
||||
assert.match(container.innerHTML, /data-id="123"/);
|
||||
assert.match(container.innerHTML, /class="foo"/);
|
||||
container.replaceChildren(createElement('div')); // cover non-fragment path
|
||||
container.childNodes.push({}); // cover empty serialization branch
|
||||
assert.ok(container.innerHTML.includes('<div'));
|
||||
|
||||
cleanup();
|
||||
});
|
||||
@@ -0,0 +1,220 @@
|
||||
/*
|
||||
* Copyright © 2025-26 l5yth & contributors
|
||||
*
|
||||
* 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 { initializeFederationPage } from '../federation-page.js';
|
||||
|
||||
test('federation map centers on configured coordinates and follows theme filters', async () => {
|
||||
const env = createDomEnvironment({ includeBody: true, bodyHasDarkClass: true });
|
||||
const { document, window, createElement, registerElement, cleanup } = env;
|
||||
|
||||
const mapEl = createElement('div', 'map');
|
||||
registerElement('map', mapEl);
|
||||
const statusEl = createElement('div', 'status');
|
||||
registerElement('status', statusEl);
|
||||
const tableEl = createElement('table', 'instances');
|
||||
const tbodyEl = createElement('tbody');
|
||||
registerElement('instances', tableEl);
|
||||
|
||||
const configPayload = {
|
||||
mapCenter: { lat: 10, lon: 20 },
|
||||
mapZoom: 7,
|
||||
tileFilters: { light: 'brightness(1)', dark: 'invert(1)' }
|
||||
};
|
||||
const configEl = createElement('div');
|
||||
configEl.setAttribute('data-app-config', JSON.stringify(configPayload));
|
||||
|
||||
document.querySelector = selector => {
|
||||
if (selector === '[data-app-config]') return configEl;
|
||||
if (selector === '#instances tbody') return tbodyEl;
|
||||
return null;
|
||||
};
|
||||
|
||||
const tileContainer = createElement('div');
|
||||
const tilePane = createElement('div');
|
||||
const tileImage = createElement('img');
|
||||
tileImage.classList.add('leaflet-tile');
|
||||
tileContainer.appendChild(tileImage);
|
||||
tilePane.appendChild(tileImage);
|
||||
const mapSetViewCalls = [];
|
||||
const mapFitBoundsCalls = [];
|
||||
const tileLayerStub = {
|
||||
addTo() {
|
||||
return this;
|
||||
},
|
||||
getContainer() {
|
||||
return tileContainer;
|
||||
},
|
||||
on(event, handler) {
|
||||
if (event === 'load') {
|
||||
this._onLoad = handler;
|
||||
}
|
||||
}
|
||||
};
|
||||
const mapStub = {
|
||||
setView(...args) {
|
||||
mapSetViewCalls.push(args);
|
||||
},
|
||||
on() {},
|
||||
getPane(name) {
|
||||
return name === 'tilePane' ? tilePane : null;
|
||||
},
|
||||
fitBounds(...args) {
|
||||
mapFitBoundsCalls.push(args);
|
||||
}
|
||||
};
|
||||
const leafletStub = {
|
||||
map() {
|
||||
return mapStub;
|
||||
},
|
||||
tileLayer() {
|
||||
return tileLayerStub;
|
||||
},
|
||||
layerGroup() {
|
||||
return {
|
||||
addLayer() {},
|
||||
addTo() {
|
||||
return this;
|
||||
}
|
||||
};
|
||||
},
|
||||
circleMarker() {
|
||||
return {
|
||||
bindPopup() {
|
||||
return this;
|
||||
}
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
const fetchImpl = async () => ({
|
||||
ok: true,
|
||||
json: async () => [
|
||||
{
|
||||
domain: 'alpha.mesh',
|
||||
contactLink: 'https://chat.alpha',
|
||||
version: '1.0.0',
|
||||
latitude: 10.12345,
|
||||
longitude: -20.98765,
|
||||
lastUpdateTime: Math.floor(Date.now() / 1000) - 90
|
||||
},
|
||||
{
|
||||
domain: 'bravo.mesh',
|
||||
contactLink: null,
|
||||
version: '2.0.0',
|
||||
lastUpdateTime: Math.floor(Date.now() / 1000) - (2 * 86400)
|
||||
}
|
||||
]
|
||||
});
|
||||
|
||||
try {
|
||||
await initializeFederationPage({ config: configPayload, fetchImpl, leaflet: leafletStub });
|
||||
|
||||
assert.deepEqual(mapSetViewCalls[0], [[10, 20], 7]);
|
||||
assert.equal(tileContainer.style.filter, 'invert(1)');
|
||||
assert.equal(tilePane.style.filter, 'invert(1)');
|
||||
assert.equal(tileImage.style.filter, 'invert(1)');
|
||||
|
||||
document.body.classList.remove('dark');
|
||||
document.documentElement.setAttribute('data-theme', 'light');
|
||||
window.dispatchEvent({ type: 'themechange', detail: { theme: 'light' } });
|
||||
assert.equal(tileContainer.style.filter, 'brightness(1)');
|
||||
assert.equal(tilePane.style.filter, 'brightness(1)');
|
||||
assert.equal(tileImage.style.filter, 'brightness(1)');
|
||||
|
||||
document.documentElement.removeAttribute('data-theme');
|
||||
document.body.classList.remove('dark');
|
||||
window.dispatchEvent({ type: 'themechange', detail: { theme: null } });
|
||||
assert.equal(tileContainer.style.filter, 'invert(1)');
|
||||
|
||||
const rows = tbodyEl.childNodes;
|
||||
assert.equal(rows.length, 2);
|
||||
const firstRowHtml = rows[0].innerHTML;
|
||||
assert.match(firstRowHtml, /alpha\.mesh/);
|
||||
assert.match(firstRowHtml, /https:\/\/chat\.alpha/);
|
||||
assert.match(firstRowHtml, /10\.12345/);
|
||||
assert.match(firstRowHtml, /-20\.98765/);
|
||||
assert.match(firstRowHtml, /ago/);
|
||||
|
||||
const secondRowHtml = rows[1].innerHTML;
|
||||
assert.match(secondRowHtml, /bravo\.mesh/);
|
||||
assert.match(secondRowHtml, /<em>—<\/em>/); // no contact link
|
||||
assert.match(secondRowHtml, /2\.0\.0/);
|
||||
assert.match(secondRowHtml, /d ago/);
|
||||
assert.deepEqual(mapFitBoundsCalls[0][0], [[10.12345, -20.98765]]);
|
||||
} finally {
|
||||
cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test('federation page tolerates fetch failures', async () => {
|
||||
const env = createDomEnvironment({ includeBody: true, bodyHasDarkClass: false });
|
||||
const { document, createElement, registerElement, cleanup } = env;
|
||||
|
||||
const mapEl = createElement('div', 'map');
|
||||
registerElement('map', mapEl);
|
||||
const statusEl = createElement('div', 'status');
|
||||
registerElement('status', statusEl);
|
||||
const tableEl = createElement('table', 'instances');
|
||||
const tbodyEl = createElement('tbody');
|
||||
registerElement('instances', tableEl);
|
||||
const configEl = createElement('div');
|
||||
configEl.setAttribute('data-app-config', JSON.stringify({}));
|
||||
document.querySelector = selector => {
|
||||
if (selector === '[data-app-config]') return configEl;
|
||||
if (selector === '#instances tbody') return tbodyEl;
|
||||
return null;
|
||||
};
|
||||
|
||||
const leafletStub = {
|
||||
map() {
|
||||
return {
|
||||
setView() {},
|
||||
on() {},
|
||||
getPane() {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
},
|
||||
tileLayer() {
|
||||
return {
|
||||
addTo() {
|
||||
return this;
|
||||
},
|
||||
getContainer() {
|
||||
return null;
|
||||
},
|
||||
on() {}
|
||||
};
|
||||
},
|
||||
layerGroup() {
|
||||
return { addLayer() {}, addTo() { return this; } };
|
||||
},
|
||||
circleMarker() {
|
||||
return { bindPopup() { return this; } };
|
||||
}
|
||||
};
|
||||
|
||||
const fetchImpl = async () => {
|
||||
throw new Error('boom');
|
||||
};
|
||||
|
||||
await initializeFederationPage({ config: {}, fetchImpl, leaflet: leafletStub });
|
||||
cleanup();
|
||||
});
|
||||
@@ -78,66 +78,110 @@ function buildInstanceUrl(domain) {
|
||||
return `https://${trimmed}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Leaflet map instance for the federation page.
|
||||
*
|
||||
* @type {L.Map|null}
|
||||
*/
|
||||
let map = null;
|
||||
|
||||
/**
|
||||
* Leaflet layer group for instance markers.
|
||||
*
|
||||
* @type {L.LayerGroup|null}
|
||||
*/
|
||||
let markersLayer = null;
|
||||
const TILE_LAYER_URL = 'https://{s}.tile.openstreetmap.fr/hot/{z}/{x}/{y}.png';
|
||||
|
||||
/**
|
||||
* Initialize the federation page by fetching instances, rendering the map,
|
||||
* and populating the table.
|
||||
*
|
||||
* @param {{
|
||||
* config?: object,
|
||||
* fetchImpl?: typeof fetch,
|
||||
* leaflet?: typeof L
|
||||
* }} [options] Optional overrides for testing.
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
export async function initializeFederationPage() {
|
||||
const rawConfig = readAppConfig();
|
||||
export async function initializeFederationPage(options = {}) {
|
||||
const rawConfig = options.config || readAppConfig();
|
||||
const config = mergeConfig(rawConfig);
|
||||
const fetchImpl = options.fetchImpl || fetch;
|
||||
const leaflet = options.leaflet || (typeof window !== 'undefined' ? window.L : null);
|
||||
const mapContainer = document.getElementById('map');
|
||||
const tableBody = document.querySelector('#instances tbody');
|
||||
const statusEl = document.getElementById('status');
|
||||
|
||||
const hasLeaflet =
|
||||
typeof window !== 'undefined' &&
|
||||
typeof window.L === 'object' &&
|
||||
window.L &&
|
||||
typeof window.L.map === 'function';
|
||||
typeof leaflet === 'object' &&
|
||||
leaflet &&
|
||||
typeof leaflet.map === 'function' &&
|
||||
typeof leaflet.tileLayer === 'function';
|
||||
|
||||
let map = null;
|
||||
let markersLayer = null;
|
||||
let tileLayer = null;
|
||||
|
||||
/**
|
||||
* Resolve the active theme based on the DOM state.
|
||||
*
|
||||
* @returns {'dark' | 'light'}
|
||||
*/
|
||||
const resolveTheme = () => {
|
||||
if (document.body && document.body.classList.contains('dark')) return 'dark';
|
||||
const htmlTheme = document.documentElement?.getAttribute('data-theme');
|
||||
if (htmlTheme === 'dark' || htmlTheme === 'light') return htmlTheme;
|
||||
return 'dark';
|
||||
};
|
||||
|
||||
/**
|
||||
* Apply the configured CSS filter to the active tile container.
|
||||
*
|
||||
* @returns {void}
|
||||
*/
|
||||
const applyTileFilter = () => {
|
||||
if (!tileLayer) return;
|
||||
const theme = resolveTheme();
|
||||
const filterValue = theme === 'dark' ? config.tileFilters.dark : config.tileFilters.light;
|
||||
const container =
|
||||
typeof tileLayer.getContainer === 'function' ? tileLayer.getContainer() : null;
|
||||
if (container && container.style) {
|
||||
container.style.filter = filterValue;
|
||||
container.style.webkitFilter = filterValue;
|
||||
}
|
||||
const tilePane = map && typeof map.getPane === 'function' ? map.getPane('tilePane') : null;
|
||||
if (tilePane && tilePane.style) {
|
||||
tilePane.style.filter = filterValue;
|
||||
tilePane.style.webkitFilter = filterValue;
|
||||
}
|
||||
const tileNodes = [];
|
||||
if (container && typeof container.querySelectorAll === 'function') {
|
||||
tileNodes.push(...container.querySelectorAll('.leaflet-tile'));
|
||||
}
|
||||
if (tilePane && typeof tilePane.querySelectorAll === 'function') {
|
||||
tileNodes.push(...tilePane.querySelectorAll('.leaflet-tile'));
|
||||
}
|
||||
tileNodes.forEach(tile => {
|
||||
if (tile && tile.style) {
|
||||
tile.style.filter = filterValue;
|
||||
tile.style.webkitFilter = filterValue;
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
// Initialize the map if Leaflet is available
|
||||
if (hasLeaflet && mapContainer) {
|
||||
map = L.map(mapContainer, { worldCopyJump: true, attributionControl: false });
|
||||
map.setView([config.mapCenter.lat, config.mapCenter.lon], 3);
|
||||
const initialZoom = Number.isFinite(config.mapZoom) ? config.mapZoom : 5;
|
||||
map = leaflet.map(mapContainer, { worldCopyJump: true, attributionControl: false });
|
||||
map.setView([config.mapCenter.lat, config.mapCenter.lon], initialZoom);
|
||||
|
||||
// Determine theme and apply appropriate tile filter
|
||||
const currentTheme = document.documentElement.getAttribute('data-theme') || 'dark';
|
||||
const tileFilter =
|
||||
currentTheme === 'dark' ? config.tileFilters.dark : config.tileFilters.light;
|
||||
tileLayer = leaflet
|
||||
.tileLayer(TILE_LAYER_URL, {
|
||||
maxZoom: 19,
|
||||
className: 'map-tiles',
|
||||
crossOrigin: 'anonymous'
|
||||
})
|
||||
.addTo(map);
|
||||
|
||||
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
|
||||
maxZoom: 19,
|
||||
className: 'map-tiles'
|
||||
}).addTo(map);
|
||||
tileLayer.on?.('load', applyTileFilter);
|
||||
applyTileFilter();
|
||||
|
||||
// Apply CSS filter to tiles
|
||||
const style = document.createElement('style');
|
||||
style.textContent = `.map-tiles { filter: ${tileFilter}; }`;
|
||||
document.head.appendChild(style);
|
||||
|
||||
markersLayer = L.layerGroup().addTo(map);
|
||||
window.addEventListener('themechange', applyTileFilter);
|
||||
markersLayer = leaflet.layerGroup().addTo(map);
|
||||
}
|
||||
|
||||
// Fetch instances data
|
||||
let instances = [];
|
||||
try {
|
||||
const response = await fetch('/api/instances', {
|
||||
const response = await fetchImpl('/api/instances', {
|
||||
headers: { Accept: 'application/json' },
|
||||
credentials: 'omit'
|
||||
});
|
||||
@@ -177,7 +221,7 @@ export async function initializeFederationPage() {
|
||||
${instance.version ? `Version: ${escapeHtml(instance.version)}` : ''}`
|
||||
: `<strong>${escapeHtml(name)}</strong>`;
|
||||
|
||||
const marker = L.circleMarker([lat, lon], {
|
||||
const marker = leaflet.circleMarker([lat, lon], {
|
||||
radius: 8,
|
||||
fillColor: '#4CAF50',
|
||||
color: '#2E7D32',
|
||||
@@ -190,12 +234,11 @@ export async function initializeFederationPage() {
|
||||
markersLayer.addLayer(marker);
|
||||
}
|
||||
|
||||
// Fit bounds if we have markers
|
||||
if (bounds.length > 0) {
|
||||
if (bounds.length > 0 && typeof map.fitBounds === 'function') {
|
||||
try {
|
||||
map.fitBounds(bounds, { padding: [50, 50], maxZoom: 10 });
|
||||
} catch (err) {
|
||||
console.warn('Failed to fit map bounds', err);
|
||||
console.warn('Failed to fit federation map bounds', err);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -213,10 +256,13 @@ export async function initializeFederationPage() {
|
||||
const domainHtml = url
|
||||
? `<a href="${escapeHtml(url)}" target="_blank" rel="noopener">${escapeHtml(instance.domain || '')}</a>`
|
||||
: escapeHtml(instance.domain || '');
|
||||
const contact = instance.contactLink ? escapeHtml(instance.contactLink) : '';
|
||||
const contactHtml = contact ? `<span class="mono">${contact}</span>` : '<em>—</em>';
|
||||
|
||||
tr.innerHTML = `
|
||||
<td class="instances-col instances-col--name">${nameHtml}</td>
|
||||
<td class="instances-col instances-col--domain mono">${domainHtml}</td>
|
||||
<td class="instances-col instances-col--contact">${contactHtml}</td>
|
||||
<td class="instances-col instances-col--version mono">${escapeHtml(instance.version || '')}</td>
|
||||
<td class="instances-col instances-col--channel">${escapeHtml(instance.channel || '')}</td>
|
||||
<td class="instances-col instances-col--frequency">${escapeHtml(instance.frequency || '')}</td>
|
||||
|
||||
@@ -433,6 +433,7 @@ export function initializeApp(config) {
|
||||
const mapPanel = document.getElementById('mapPanel');
|
||||
const mapFullscreenToggle = document.getElementById('mapFullscreenToggle');
|
||||
const fullscreenContainer = mapPanel || mapContainer;
|
||||
const isFederationView = bodyClassList ? bodyClassList.contains('view-federation') : false;
|
||||
let mapStatusEl = null;
|
||||
let map = null;
|
||||
let mapCenterLatLng = null;
|
||||
@@ -1170,7 +1171,9 @@ export function initializeApp(config) {
|
||||
applyFiltersToAllTiles();
|
||||
}
|
||||
|
||||
if (hasLeaflet && mapContainer) {
|
||||
const mapAlreadyInitialized = mapContainer && mapContainer._leaflet_id;
|
||||
|
||||
if (hasLeaflet && mapContainer && !isFederationView && !mapAlreadyInitialized) {
|
||||
map = L.map(mapContainer, { worldCopyJump: true, attributionControl: false });
|
||||
showMapStatus('Loading map tiles…');
|
||||
tiles = L.tileLayer(TILE_LAYER_URL, {
|
||||
@@ -1246,7 +1249,7 @@ export function initializeApp(config) {
|
||||
if (typeof navigator !== 'undefined' && navigator && navigator.onLine === false) {
|
||||
activateOfflineTiles('Offline mode detected. Using placeholder basemap.');
|
||||
}
|
||||
} else if (mapContainer) {
|
||||
} else if (mapContainer && !isFederationView) {
|
||||
setMapPlaceholder('Leaflet assets are unavailable. Data will continue to refresh without a live map.');
|
||||
}
|
||||
|
||||
|
||||
@@ -1977,33 +1977,13 @@ body.dark #map .leaflet-tile.map-tiles {
|
||||
|
||||
.federation-page {
|
||||
padding: 24px var(--pad) 48px;
|
||||
max-width: 1600px;
|
||||
margin: 0 auto;
|
||||
max-width: none;
|
||||
margin: 0;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.federation-page__intro {
|
||||
padding: 0 4px 16px;
|
||||
}
|
||||
|
||||
.federation-page__intro h2 {
|
||||
margin: 0 0 6px;
|
||||
font-size: 1.6rem;
|
||||
}
|
||||
|
||||
.federation-page__intro p {
|
||||
margin: 0;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.federation-page__back-link {
|
||||
color: var(--accent);
|
||||
text-decoration: none;
|
||||
margin-left: 12px;
|
||||
}
|
||||
|
||||
.federation-page__back-link:hover {
|
||||
text-decoration: underline;
|
||||
.federation-page--full-width {
|
||||
padding-top: 0;
|
||||
}
|
||||
|
||||
.federation-page__content {
|
||||
|
||||
@@ -1337,6 +1337,38 @@ RSpec.describe "Potato Mesh Sinatra app" do
|
||||
end
|
||||
end
|
||||
|
||||
describe "GET /federation" do
|
||||
it "returns 404 when federation is disabled" do
|
||||
allow(PotatoMesh::Config).to receive(:federation_enabled?).and_return(false)
|
||||
|
||||
get "/federation"
|
||||
|
||||
expect(last_response.status).to eq(404)
|
||||
end
|
||||
|
||||
it "renders the federation subpage when enabled" do
|
||||
allow(PotatoMesh::Config).to receive(:federation_enabled?).and_return(true)
|
||||
|
||||
get "/federation"
|
||||
|
||||
expect(last_response).to be_ok
|
||||
expect(last_response.body).to include('class="federation-page federation-page--full-width"')
|
||||
expect(last_response.body).to include("initializeFederationPage")
|
||||
end
|
||||
|
||||
it "hides dashboard-only refresh controls while keeping manual refresh and theme toggle" do
|
||||
allow(PotatoMesh::Config).to receive(:federation_enabled?).and_return(true)
|
||||
|
||||
get "/federation"
|
||||
|
||||
expect(last_response).to be_ok
|
||||
expect(last_response.body).not_to include('id="autoRefresh"')
|
||||
expect(last_response.body).not_to include('id="filterInput"')
|
||||
expect(last_response.body).to include('id="refreshBtn"')
|
||||
expect(last_response.body).to include('id="themeToggle"')
|
||||
end
|
||||
end
|
||||
|
||||
describe "GET /chat" do
|
||||
it "renders the chat container when chat is enabled" do
|
||||
get "/chat"
|
||||
|
||||
@@ -183,4 +183,20 @@ RSpec.describe PotatoMesh::App::Database do
|
||||
hop_columns = column_names_for("trace_hops")
|
||||
expect(hop_columns).to include("trace_id", "hop_index", "node_id")
|
||||
end
|
||||
|
||||
it "adds the contact_link column to existing instances tables" do
|
||||
SQLite3::Database.new(PotatoMesh::Config.db_path) do |db|
|
||||
db.execute("CREATE TABLE nodes(node_id TEXT)")
|
||||
db.execute("CREATE TABLE messages(id INTEGER PRIMARY KEY)")
|
||||
db.execute(
|
||||
"CREATE TABLE instances(id TEXT PRIMARY KEY, domain TEXT, pubkey TEXT, last_update_time INTEGER, is_private INTEGER)",
|
||||
)
|
||||
end
|
||||
|
||||
expect(column_names_for("instances")).not_to include("contact_link")
|
||||
|
||||
harness_class.ensure_schema_upgrades
|
||||
|
||||
expect(column_names_for("instances")).to include("contact_link")
|
||||
end
|
||||
end
|
||||
|
||||
@@ -17,6 +17,7 @@
|
||||
require "spec_helper"
|
||||
require "net/http"
|
||||
require "openssl"
|
||||
require "sqlite3"
|
||||
require "set"
|
||||
require "uri"
|
||||
require "socket"
|
||||
@@ -322,6 +323,85 @@ RSpec.describe PotatoMesh::App::Federation do
|
||||
end
|
||||
end
|
||||
|
||||
describe ".upsert_instance_record" do
|
||||
let(:application_class) { PotatoMesh::Application }
|
||||
let(:base_attributes) do
|
||||
{
|
||||
id: "remote-instance",
|
||||
domain: "Remote.Mesh",
|
||||
pubkey: PotatoMesh::Application::INSTANCE_PUBLIC_KEY_PEM,
|
||||
name: "Remote Mesh",
|
||||
version: "1.0.0",
|
||||
channel: "longfox",
|
||||
frequency: "915",
|
||||
latitude: 45.0,
|
||||
longitude: -122.0,
|
||||
last_update_time: Time.now.to_i,
|
||||
is_private: false,
|
||||
contact_link: "https://example.org/contact",
|
||||
}
|
||||
end
|
||||
|
||||
def with_db
|
||||
db = SQLite3::Database.new(PotatoMesh::Config.db_path)
|
||||
db.busy_timeout = PotatoMesh::Config.db_busy_timeout_ms
|
||||
db.execute("PRAGMA foreign_keys = ON")
|
||||
yield db
|
||||
ensure
|
||||
db&.close
|
||||
end
|
||||
|
||||
before do
|
||||
FileUtils.mkdir_p(File.dirname(PotatoMesh::Config.db_path))
|
||||
application_class.init_db unless application_class.db_schema_present?
|
||||
application_class.ensure_schema_upgrades
|
||||
with_db do |db|
|
||||
db.execute("DELETE FROM instances")
|
||||
end
|
||||
allow(federation_helpers).to receive(:ip_from_domain).and_return(nil)
|
||||
end
|
||||
|
||||
it "inserts the contact_link for new records" do
|
||||
with_db do |db|
|
||||
federation_helpers.send(:upsert_instance_record, db, base_attributes, "sig-1")
|
||||
|
||||
stored = db.get_first_value("SELECT contact_link FROM instances WHERE id = ?", base_attributes[:id])
|
||||
expect(stored).to eq("https://example.org/contact")
|
||||
end
|
||||
end
|
||||
|
||||
it "updates the contact_link on conflict" do
|
||||
with_db do |db|
|
||||
federation_helpers.send(:upsert_instance_record, db, base_attributes, "sig-1")
|
||||
|
||||
federation_helpers.send(
|
||||
:upsert_instance_record,
|
||||
db,
|
||||
base_attributes.merge(contact_link: "https://example.org/new-contact", name: "Renamed Mesh"),
|
||||
"sig-2",
|
||||
)
|
||||
|
||||
row =
|
||||
db.get_first_row("SELECT contact_link, name, signature FROM instances WHERE id = ?", base_attributes[:id])
|
||||
expect(row[0]).to eq("https://example.org/new-contact")
|
||||
expect(row[1]).to eq("Renamed Mesh")
|
||||
expect(row[2]).to eq("sig-2")
|
||||
end
|
||||
end
|
||||
|
||||
it "allows the contact_link to be cleared" do
|
||||
with_db do |db|
|
||||
federation_helpers.send(:upsert_instance_record, db, base_attributes, "sig-1")
|
||||
|
||||
federation_helpers.send(:upsert_instance_record, db, base_attributes.merge(contact_link: nil), "sig-3")
|
||||
|
||||
row = db.get_first_row("SELECT contact_link, signature FROM instances WHERE id = ?", base_attributes[:id])
|
||||
expect(row[0]).to be_nil
|
||||
expect(row[1]).to eq("sig-3")
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe ".federation_user_agent_header" do
|
||||
it "combines the version and sanitized domain" do
|
||||
allow(federation_helpers).to receive(:app_constant).and_call_original
|
||||
|
||||
@@ -95,5 +95,42 @@ RSpec.describe PotatoMesh::App::Instances do
|
||||
expect(domains).not_to include("missing.mesh.test")
|
||||
expect(payload.all? { |row| row["lastUpdateTime"] >= lower_bound }).to be(true)
|
||||
end
|
||||
|
||||
it "exposes contactLink when present and omits blank values" do
|
||||
fixed_time = Time.utc(2025, 2, 1, 12, 0, 0)
|
||||
allow(Time).to receive(:now).and_return(fixed_time)
|
||||
|
||||
with_db do |db|
|
||||
db.execute(
|
||||
"INSERT INTO instances (id, domain, pubkey, last_update_time, is_private, contact_link) VALUES (?, ?, ?, ?, ?, ?)",
|
||||
[
|
||||
"instance-with-contact",
|
||||
"alpha.mesh.test",
|
||||
PotatoMesh::Application::INSTANCE_PUBLIC_KEY_PEM,
|
||||
fixed_time.to_i,
|
||||
0,
|
||||
" https://example.org/contact ",
|
||||
],
|
||||
)
|
||||
db.execute(
|
||||
"INSERT INTO instances (id, domain, pubkey, last_update_time, is_private, contact_link) VALUES (?, ?, ?, ?, ?, ?)",
|
||||
[
|
||||
"instance-without-contact",
|
||||
"beta.mesh.test",
|
||||
PotatoMesh::Application::INSTANCE_PUBLIC_KEY_PEM,
|
||||
fixed_time.to_i,
|
||||
0,
|
||||
" \t ",
|
||||
],
|
||||
)
|
||||
end
|
||||
|
||||
payload = application_class.load_instances_for_api
|
||||
with_contact = payload.find { |row| row["domain"] == "alpha.mesh.test" }
|
||||
without_contact = payload.find { |row| row["domain"] == "beta.mesh.test" }
|
||||
|
||||
expect(with_contact["contactLink"]).to eq("https://example.org/contact")
|
||||
expect(without_contact.key?("contactLink")).to be(false)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -13,18 +13,10 @@
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
-->
|
||||
<section class="federation-page">
|
||||
<header class="federation-page__intro">
|
||||
<h2>Federation Network</h2>
|
||||
<p>
|
||||
This page shows other PotatoMesh instances in the federation network.
|
||||
Each marker on the map represents a federated instance.
|
||||
<a href="/" class="federation-page__back-link">← Back to Dashboard</a>
|
||||
</p>
|
||||
</header>
|
||||
<section class="federation-page federation-page--full-width">
|
||||
<div class="federation-page__content">
|
||||
<div class="federation-page__map-row">
|
||||
<%= erb :"shared/_map_panel", locals: { full_screen: false } %>
|
||||
<%= erb :"shared/_map_panel", locals: { full_screen: true } %>
|
||||
</div>
|
||||
<%= erb :"shared/_instances_table" %>
|
||||
</div>
|
||||
|
||||
@@ -77,12 +77,14 @@
|
||||
main_classes << "page-main--full-screen" if full_screen_view
|
||||
show_header = !full_screen_view
|
||||
show_meta_info = true
|
||||
show_auto_refresh_controls = true
|
||||
show_auto_refresh_controls = view_mode != :federation
|
||||
show_auto_fit_toggle = %i[dashboard map].include?(view_mode)
|
||||
map_zoom_override = defined?(map_zoom) ? map_zoom : nil
|
||||
show_info_button = !full_screen_view
|
||||
show_footer = !full_screen_view
|
||||
show_filter_input = !%i[node_detail charts].include?(view_mode)
|
||||
show_filter_input = !%i[node_detail charts federation].include?(view_mode)
|
||||
show_auto_refresh_toggle = show_auto_refresh_controls
|
||||
show_refresh_actions = show_auto_refresh_controls || view_mode == :federation
|
||||
controls_classes = ["controls"]
|
||||
controls_classes << "controls--full-screen" if full_screen_view
|
||||
refresh_row_classes = ["refresh-row"]
|
||||
@@ -111,7 +113,10 @@
|
||||
<option value=""><%= Rack::Utils.escape_html("Select region ...") %></option>
|
||||
</select>
|
||||
</div>
|
||||
<a href="/federation" class="federation-link" aria-label="View federation network">🌐</a>
|
||||
<a href="/federation" class="federation-link" aria-label="View federation network" target="_blank" rel="noopener noreferrer">🔗</a>
|
||||
<a href="/map" class="federation-link" aria-label="Open map in new tab" target="_blank" rel="noopener noreferrer">🌍</a>
|
||||
<a href="/chat" class="federation-link" aria-label="Open chat in new tab" target="_blank" rel="noopener noreferrer">💬</a>
|
||||
<a href="/nodes" class="federation-link" aria-label="Open nodes in new tab" target="_blank" rel="noopener noreferrer">🪢</a>
|
||||
</div>
|
||||
<% end %>
|
||||
</header>
|
||||
@@ -126,9 +131,11 @@
|
||||
<% else %>
|
||||
<p id="refreshInfo" class="<%= refresh_info_classes.join(" ") %>" aria-live="polite"></p>
|
||||
<% end %>
|
||||
<% if show_auto_refresh_controls %>
|
||||
<% if show_refresh_actions %>
|
||||
<div class="refresh-actions">
|
||||
<label class="auto-refresh-toggle"><input type="checkbox" id="autoRefresh" checked /> Auto-refresh every <%= refresh_interval_seconds %> seconds</label>
|
||||
<% if show_auto_refresh_toggle %>
|
||||
<label class="auto-refresh-toggle"><input type="checkbox" id="autoRefresh" checked /> Auto-refresh every <%= refresh_interval_seconds %> seconds</label>
|
||||
<% end %>
|
||||
<button id="refreshBtn" type="button">Refresh now</button>
|
||||
<span id="status" class="pill">loading…</span>
|
||||
</div>
|
||||
|
||||
@@ -19,6 +19,7 @@
|
||||
<tr>
|
||||
<th class="instances-col instances-col--name">Name</th>
|
||||
<th class="instances-col instances-col--domain">Domain</th>
|
||||
<th class="instances-col instances-col--contact">Contact</th>
|
||||
<th class="instances-col instances-col--version">Version</th>
|
||||
<th class="instances-col instances-col--channel">Channel</th>
|
||||
<th class="instances-col instances-col--frequency">Frequency</th>
|
||||
|
||||
Reference in New Issue
Block a user