diff --git a/web/public/assets/js/app/__tests__/dom-environment.js b/web/public/assets/js/app/__tests__/dom-environment.js index 4daa46b..a82e4fb 100644 --- a/web/public/assets/js/app/__tests__/dom-environment.js +++ b/web/public/assets/js/app/__tests__/dom-environment.js @@ -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}`; + } + 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} 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') { diff --git a/web/public/assets/js/app/__tests__/dom-environment.test.js b/web/public/assets/js/app/__tests__/dom-environment.test.js new file mode 100644 index 0000000..91ac3b5 --- /dev/null +++ b/web/public/assets/js/app/__tests__/dom-environment.test.js @@ -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 = 'hello'; + 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(' { + 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>/); // 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(); +}); diff --git a/web/public/assets/js/app/federation-page.js b/web/public/assets/js/app/federation-page.js index cef7183..7b6f4b4 100644 --- a/web/public/assets/js/app/federation-page.js +++ b/web/public/assets/js/app/federation-page.js @@ -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} */ -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)}` : ''}` : `${escapeHtml(name)}`; - 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 ? `${escapeHtml(instance.domain || '')}` : escapeHtml(instance.domain || ''); + const contact = instance.contactLink ? escapeHtml(instance.contactLink) : ''; + const contactHtml = contact ? `${contact}` : ''; tr.innerHTML = ` ${nameHtml} ${domainHtml} + ${contactHtml} ${escapeHtml(instance.version || '')} ${escapeHtml(instance.channel || '')} ${escapeHtml(instance.frequency || '')} diff --git a/web/public/assets/js/app/main.js b/web/public/assets/js/app/main.js index dd3df1e..8ba17ff 100644 --- a/web/public/assets/js/app/main.js +++ b/web/public/assets/js/app/main.js @@ -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.'); } diff --git a/web/public/assets/styles/base.css b/web/public/assets/styles/base.css index b14c972..629724c 100644 --- a/web/public/assets/styles/base.css +++ b/web/public/assets/styles/base.css @@ -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 { diff --git a/web/spec/app_spec.rb b/web/spec/app_spec.rb index ae10394..414148b 100644 --- a/web/spec/app_spec.rb +++ b/web/spec/app_spec.rb @@ -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" diff --git a/web/spec/database_spec.rb b/web/spec/database_spec.rb index 6be4f77..316dd44 100644 --- a/web/spec/database_spec.rb +++ b/web/spec/database_spec.rb @@ -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 diff --git a/web/spec/federation_spec.rb b/web/spec/federation_spec.rb index 4a87df9..cf27bf2 100644 --- a/web/spec/federation_spec.rb +++ b/web/spec/federation_spec.rb @@ -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 diff --git a/web/spec/instances_spec.rb b/web/spec/instances_spec.rb index 12ef7e8..5713474 100644 --- a/web/spec/instances_spec.rb +++ b/web/spec/instances_spec.rb @@ -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 diff --git a/web/views/federation.erb b/web/views/federation.erb index e3cc1e6..13dc7bf 100644 --- a/web/views/federation.erb +++ b/web/views/federation.erb @@ -13,18 +13,10 @@ See the License for the specific language governing permissions and limitations under the License. --> -
-
-

Federation Network

-

- This page shows other PotatoMesh instances in the federation network. - Each marker on the map represents a federated instance. - ← Back to Dashboard -

-
+
- <%= erb :"shared/_map_panel", locals: { full_screen: false } %> + <%= erb :"shared/_map_panel", locals: { full_screen: true } %>
<%= erb :"shared/_instances_table" %>
diff --git a/web/views/layouts/app.erb b/web/views/layouts/app.erb index 36962bf..8ee6b53 100644 --- a/web/views/layouts/app.erb +++ b/web/views/layouts/app.erb @@ -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 @@ - 🌐 + 🔗 + 🌍 + 💬 + 🪢 <% end %> @@ -126,9 +131,11 @@ <% else %>

" aria-live="polite">

<% end %> - <% if show_auto_refresh_controls %> + <% if show_refresh_actions %>
- + <% if show_auto_refresh_toggle %> + + <% end %> loading…
diff --git a/web/views/shared/_instances_table.erb b/web/views/shared/_instances_table.erb index b3cf968..aa5539b 100644 --- a/web/views/shared/_instances_table.erb +++ b/web/views/shared/_instances_table.erb @@ -19,6 +19,7 @@ Name Domain + Contact Version Channel Frequency