diff --git a/data/instances.sql b/data/instances.sql index 9b7a0c0..a355d24 100644 --- a/data/instances.sql +++ b/data/instances.sql @@ -26,6 +26,7 @@ CREATE TABLE IF NOT EXISTS instances ( longitude REAL, last_update_time INTEGER, is_private BOOLEAN NOT NULL DEFAULT 0, + nodes_count INTEGER, contact_link TEXT, signature TEXT ); diff --git a/web/lib/potato_mesh/application/database.rb b/web/lib/potato_mesh/application/database.rb index 7bee1e3..07e4685 100644 --- a/web/lib/potato_mesh/application/database.rb +++ b/web/lib/potato_mesh/application/database.rb @@ -167,6 +167,11 @@ module PotatoMesh instance_columns = db.execute("PRAGMA table_info(instances)").map { |row| row[1] } unless instance_columns.include?("contact_link") db.execute("ALTER TABLE instances ADD COLUMN contact_link TEXT") + instance_columns << "contact_link" + end + + unless instance_columns.include?("nodes_count") + db.execute("ALTER TABLE instances ADD COLUMN nodes_count INTEGER") end telemetry_tables = diff --git a/web/lib/potato_mesh/application/federation.rb b/web/lib/potato_mesh/application/federation.rb index f0f474d..1b629a8 100644 --- a/web/lib/potato_mesh/application/federation.rb +++ b/web/lib/potato_mesh/application/federation.rb @@ -61,6 +61,7 @@ module PotatoMesh def self_instance_attributes domain = self_instance_domain last_update = latest_node_update_timestamp || Time.now.to_i + nodes_count = active_node_count_since(Time.now.to_i - PotatoMesh::Config.remote_instance_max_node_age) { id: app_constant(:SELF_INSTANCE_ID), domain: domain, @@ -74,9 +75,36 @@ module PotatoMesh last_update_time: last_update, is_private: private_mode?, contact_link: sanitized_contact_link, + nodes_count: nodes_count, } end + # Count the number of nodes active since the supplied timestamp. + # + # @param cutoff [Integer] unix timestamp in seconds. + # @param db [SQLite3::Database, nil] optional open handle to reuse. + # @return [Integer, nil] node count or nil when unavailable. + def active_node_count_since(cutoff, db: nil) + return nil unless cutoff + + handle = db || open_database(readonly: true) + count = + with_busy_retry do + handle.get_first_value("SELECT COUNT(*) FROM nodes WHERE last_heard >= ?", cutoff.to_i) + end + Integer(count) + rescue SQLite3::Exception, ArgumentError => e + warn_log( + "Failed to count active nodes", + context: "instances.nodes_count", + error_class: e.class.name, + error_message: e.message, + ) + nil + ensure + handle&.close unless db + end + def sign_instance_attributes(attributes) payload = canonical_instance_payload(attributes) Base64.strict_encode64( @@ -723,6 +751,7 @@ module PotatoMesh end processed_entries = 0 + recent_cutoff = Time.now.to_i - PotatoMesh::Config.remote_instance_max_node_age payload.each do |entry| if per_response_limit && per_response_limit.positive? && processed_entries >= per_response_limit debug_log( @@ -777,13 +806,27 @@ module PotatoMesh attributes[:is_private] = false if attributes[:is_private].nil? + nodes_since_path = "/api/nodes?since=#{recent_cutoff}" + nodes_since_window, nodes_since_metadata = fetch_instance_json(attributes[:domain], nodes_since_path) + if nodes_since_window.is_a?(Array) + attributes[:nodes_count] = nodes_since_window.length + elsif nodes_since_metadata + warn_log( + "Failed to load remote node window", + context: "federation.instances", + domain: attributes[:domain], + reason: Array(nodes_since_metadata).map(&:to_s).join("; "), + ) + end + remote_nodes, node_metadata = fetch_instance_json(attributes[:domain], "/api/nodes") + remote_nodes ||= nodes_since_window if nodes_since_window.is_a?(Array) unless remote_nodes warn_log( "Failed to load remote node data", context: "federation.instances", domain: attributes[:domain], - reason: Array(node_metadata).map(&:to_s).join("; "), + reason: Array(node_metadata || nodes_since_metadata).map(&:to_s).join("; "), ) next end @@ -1059,8 +1102,8 @@ module PotatoMesh sql = <<~SQL INSERT INTO instances ( id, domain, pubkey, name, version, channel, frequency, - latitude, longitude, last_update_time, is_private, contact_link, signature - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + latitude, longitude, last_update_time, is_private, nodes_count, contact_link, signature + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ON CONFLICT(id) DO UPDATE SET domain=excluded.domain, pubkey=excluded.pubkey, @@ -1072,10 +1115,12 @@ module PotatoMesh longitude=excluded.longitude, last_update_time=excluded.last_update_time, is_private=excluded.is_private, + nodes_count=excluded.nodes_count, contact_link=excluded.contact_link, signature=excluded.signature SQL + nodes_count = coerce_integer(attributes[:nodes_count]) params = [ attributes[:id], normalized_domain, @@ -1088,6 +1133,7 @@ module PotatoMesh attributes[:longitude], attributes[:last_update_time], attributes[:is_private] ? 1 : 0, + nodes_count, attributes[:contact_link], signature, ] diff --git a/web/lib/potato_mesh/application/instances.rb b/web/lib/potato_mesh/application/instances.rb index c4b4964..f0d4fa8 100644 --- a/web/lib/potato_mesh/application/instances.rb +++ b/web/lib/potato_mesh/application/instances.rb @@ -143,6 +143,7 @@ module PotatoMesh "longitude" => coerce_float(row["longitude"]), "lastUpdateTime" => last_update_time, "isPrivate" => private_flag, + "nodesCount" => coerce_integer(row["nodes_count"]), "contactLink" => string_or_nil(row["contact_link"]), "signature" => signature, } @@ -174,7 +175,7 @@ module PotatoMesh min_last_update_time = now - PotatoMesh::Config.week_seconds sql = <<~SQL SELECT id, domain, pubkey, name, version, channel, frequency, - latitude, longitude, last_update_time, is_private, contact_link, signature + latitude, longitude, last_update_time, is_private, nodes_count, contact_link, signature FROM instances WHERE domain IS NOT NULL AND TRIM(domain) != '' AND pubkey IS NOT NULL AND TRIM(pubkey) != '' diff --git a/web/public/assets/js/app/__tests__/federation-page.test.js b/web/public/assets/js/app/__tests__/federation-page.test.js index c28f79c..eaae281 100644 --- a/web/public/assets/js/app/__tests__/federation-page.test.js +++ b/web/public/assets/js/app/__tests__/federation-page.test.js @@ -19,6 +19,7 @@ import assert from 'node:assert/strict'; import { createDomEnvironment } from './dom-environment.js'; import { initializeFederationPage } from '../federation-page.js'; +import { roleColors } from '../role-helpers.js'; test('federation map centers on configured coordinates and follows theme filters', async () => { const env = createDomEnvironment({ includeBody: true, bodyHasDarkClass: true }); @@ -54,6 +55,7 @@ test('federation map centers on configured coordinates and follows theme filters tilePane.appendChild(tileImage); const mapSetViewCalls = []; const mapFitBoundsCalls = []; + const circleMarkerCalls = []; const tileLayerStub = { addTo() { return this; @@ -94,7 +96,8 @@ test('federation map centers on configured coordinates and follows theme filters } }; }, - circleMarker() { + circleMarker(latlng, options) { + circleMarkerCalls.push({ latlng, options }); return { bindPopup() { return this; @@ -112,13 +115,15 @@ const fetchImpl = async () => ({ version: '1.0.0', latitude: 10.12345, longitude: -20.98765, - lastUpdateTime: Math.floor(Date.now() / 1000) - 90 + lastUpdateTime: Math.floor(Date.now() / 1000) - 90, + nodesCount: 12 }, { domain: 'bravo.mesh', contactLink: null, version: '2.0.0', - lastUpdateTime: Math.floor(Date.now() / 1000) - (2 * 86400) + lastUpdateTime: Math.floor(Date.now() / 1000) - (2 * 86400), + nodesCount: 2 } ] }); @@ -150,14 +155,268 @@ const fetchImpl = async () => ({ assert.match(firstRowHtml, /https:\/\/chat\.alpha/); assert.match(firstRowHtml, /10\.12345/); assert.match(firstRowHtml, /-20\.98765/); + assert.match(firstRowHtml, />12—<\/em>/); // no contact link assert.match(secondRowHtml, /2\.0\.0/); + assert.match(secondRowHtml, />2 { + 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); + tableEl.appendChild(tbodyEl); + + const headerNameTh = createElement('th'); + const headerName = createElement('span'); + headerName.classList.add('sort-header'); + headerName.dataset.sortKey = 'name'; + headerName.dataset.sortLabel = 'Name'; + headerNameTh.appendChild(headerName); + + const headerDomainTh = createElement('th'); + const headerDomain = createElement('span'); + headerDomain.classList.add('sort-header'); + headerDomain.dataset.sortKey = 'domain'; + headerDomain.dataset.sortLabel = 'Domain'; + headerDomainTh.appendChild(headerDomain); + + const ths = [headerNameTh, headerDomainTh]; + const headers = [headerName, headerDomain]; + const headerHandlers = new Map(); + headers.forEach(header => { + header.addEventListener = (event, handler) => { + const existing = headerHandlers.get(header) || {}; + existing[event] = handler; + headerHandlers.set(header, existing); + }; + header.closest = () => ths.find(th => th.childNodes.includes(header)); + header.querySelector = selector => { + if (selector === '.sort-indicator') { + const span = createElement('span'); + span.classList.add('sort-indicator'); + return span; + } + return null; + }; + }); + + tableEl.querySelectorAll = selector => { + if (selector === 'thead .sort-header[data-sort-key]') return headers; + if (selector === 'thead th') return ths; + return []; + }; + + const configPayload = { + mapCenter: { lat: 0, lon: 0 }, + mapZoom: 3, + tileFilters: { light: 'none', 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 legendContainers = []; + const mapSetViewCalls = []; + const mapFitBoundsCalls = []; + const circleMarkerCalls = []; + + const DomUtil = { + create(tag, className, parent) { + const el = { + tagName: tag, + className, + children: [], + style: {}, + textContent: '', + setAttribute() {}, + appendChild(child) { + this.children.push(child); + return child; + }, + }; + if (parent && parent.appendChild) parent.appendChild(el); + return el; + } + }; + + const controlStub = () => { + const ctrl = { + onAdd: null, + container: null, + addTo(map) { + this.container = this.onAdd ? this.onAdd(map) : null; + legendContainers.push(this.container); + return this; + }, + getContainer() { + return this.container; + } + }; + return ctrl; + }; + + const markersLayer = { + layers: [], + addLayer(marker) { + this.layers.push(marker); + return marker; + }, + addTo() { + return this; + } + }; + + const mapStub = { + addedControls: [], + setView(...args) { + mapSetViewCalls.push(args); + }, + on() {}, + fitBounds(...args) { + mapFitBoundsCalls.push(args); + }, + addLayer(layer) { + this.addedControls.push(layer); + return layer; + } + }; + + const leafletStub = { + map() { + return mapStub; + }, + tileLayer() { + return { + addTo() { + return this; + }, + getContainer() { + return null; + }, + on() {} + }; + }, + layerGroup() { + return markersLayer; + }, + circleMarker(latlng, options) { + circleMarkerCalls.push({ latlng, options }); + return { + bindPopup() { + return this; + }, + addTo() { + return this; + } + }; + }, + control: controlStub, + DomUtil + }; + + const now = Math.floor(Date.now() / 1000); + const fetchImpl = async () => ({ + ok: true, + json: async () => [ + { + domain: 'c.mesh', + name: 'Charlie', + contactLink: 'https://charlie.example\nmatrix:#c:mesh', + version: '3.0.0', + latitude: 1, + longitude: 1, + lastUpdateTime: now - 10, + nodesCount: 0 + }, + { + domain: 'b.mesh', + contactLink: '', + version: '2.0.0', + latitude: 2, + longitude: 2, + lastUpdateTime: now - 60, + nodesCount: 650 + }, + { + domain: 'a.mesh', + name: 'Alpha', + contactLink: 'mailto:alpha@mesh', + version: '1.0.0', + latitude: 3, + longitude: 3, + lastUpdateTime: now - 30, + nodesCount: 5 + } + ] + }); + + try { + await initializeFederationPage({ config: configPayload, fetchImpl, leaflet: leafletStub }); + + const rows = tbodyEl.childNodes.map(node => String(node.childNodes[0])); + assert.match(rows[0], /c\.mesh/); + assert.match(rows[0], /0 String(node.childNodes[0])); + assert.match(afterNameSort[0], /a\.mesh/); + assert.match(afterNameSort[1], /c\.mesh/); + assert.match(afterNameSort[2], /b\.mesh/); + + nameHandlers.click(); + const descSort = tbodyEl.childNodes.map(node => String(node.childNodes[0])); + assert.match(descSort[0], /c\.mesh/); + assert.match(descSort[1], /a\.mesh/); + assert.match(descSort[2], /b\.mesh/); + assert.equal(headerName.closest().attributes.get('aria-sort'), 'descending'); + + assert.equal(circleMarkerCalls[0].options.fillColor, roleColors.CLIENT_HIDDEN); + assert.equal(circleMarkerCalls[1].options.fillColor, roleColors.REPEATER); + + assert.deepEqual(mapSetViewCalls[0], [[0, 0], 3]); + assert.equal(mapFitBoundsCalls[0][0].length, 3); + + assert.equal(legendContainers.length, 1); + const legend = legendContainers[0]; + assert.ok(legend.className.includes('legend')); + const legendHeader = legend.children.find(child => child.className === 'legend-header'); + const legendTitle = legendHeader && Array.isArray(legendHeader.children) + ? legendHeader.children.find(child => child.className === 'legend-title') + : null; + assert.ok(legendTitle); + assert.equal(legendTitle.textContent, 'Active nodes'); } finally { cleanup(); } diff --git a/web/public/assets/js/app/federation-page.js b/web/public/assets/js/app/federation-page.js index 7b6f4b4..967d30c 100644 --- a/web/public/assets/js/app/federation-page.js +++ b/web/public/assets/js/app/federation-page.js @@ -16,6 +16,7 @@ import { readAppConfig } from './config.js'; import { mergeConfig } from './settings.js'; +import { roleColors } from './role-helpers.js'; /** * Escape HTML special characters to prevent XSS. @@ -78,6 +79,131 @@ function buildInstanceUrl(domain) { return `https://${trimmed}`; } +const NODE_COUNT_COLOR_STOPS = [ + { limit: 100, color: roleColors.CLIENT_HIDDEN }, + { limit: 200, color: roleColors.SENSOR }, + { limit: 300, color: roleColors.TRACKER }, + { limit: 400, color: roleColors.CLIENT_MUTE }, + { limit: 500, color: roleColors.CLIENT }, + { limit: 600, color: roleColors.CLIENT_BASE }, + { limit: 700, color: roleColors.REPEATER }, + { limit: 800, color: roleColors.ROUTER_LATE }, + { limit: 900, color: roleColors.ROUTER } +]; + +const DEFAULT_INSTANCE_COLOR = roleColors.LOST_AND_FOUND || '#3388ff'; + +/** + * Determine the marker colour for an instance based on its active node count. + * + * @param {*} count Raw node count value from the API. + * @returns {string} CSS colour string. + */ +function colorForNodeCount(count) { + const numeric = Number(count); + if (!Number.isFinite(numeric) || numeric < 0) return DEFAULT_INSTANCE_COLOR; + const stop = NODE_COUNT_COLOR_STOPS.find(entry => numeric < entry.limit); + return stop && stop.color ? stop.color : DEFAULT_INSTANCE_COLOR; +} + +/** + * Render arbitrary contact text while hyperlinking recognised URL-like segments. + * + * @param {*} contact Raw contact value from the API. + * @returns {string} HTML markup safe for insertion. + */ +function renderContactHtml(contact) { + if (typeof contact !== 'string') return ''; + const trimmed = contact.trim(); + if (!trimmed) return ''; + const urlPattern = /(https?:\/\/[^\s]+|mailto:[^\s]+|matrix:[^\s]+)/gi; + const parts = []; + let lastIndex = 0; + let match; + + while ((match = urlPattern.exec(trimmed)) !== null) { + const textBefore = trimmed.slice(lastIndex, match.index); + if (textBefore) { + parts.push(escapeHtml(textBefore)); + } + const url = match[0]; + const safeUrl = escapeHtml(url); + parts.push(`${safeUrl}`); + lastIndex = match.index + url.length; + } + + const trailing = trimmed.slice(lastIndex); + if (trailing) { + parts.push(escapeHtml(trailing)); + } + + const html = parts.join(''); + return html.replace(/\r?\n/g, '
'); +} + +/** + * Convert a value into a finite number or null when invalid. + * + * @param {*} value Raw value to convert. + * @returns {number|null} Finite number or null. + */ +function toFiniteNumber(value) { + const num = Number(value); + return Number.isFinite(num) ? num : null; +} + +/** + * Compare two string-like values ignoring case. + * + * @param {*} a Left-hand operand. + * @param {*} b Right-hand operand. + * @returns {number} Comparator result. + */ +function compareString(a, b) { + const left = typeof a === 'string' ? a.toLowerCase() : String(a ?? '').toLowerCase(); + const right = typeof b === 'string' ? b.toLowerCase() : String(b ?? '').toLowerCase(); + return left.localeCompare(right); +} + +/** + * Compare two numeric values. + * + * @param {*} a Left-hand operand. + * @param {*} b Right-hand operand. + * @returns {number} Comparator result. + */ +function compareNumber(a, b) { + const left = toFiniteNumber(a); + const right = toFiniteNumber(b); + if (left == null && right == null) return 0; + if (left == null) return 1; + if (right == null) return -1; + if (left === right) return 0; + return left < right ? -1 : 1; +} + +/** + * Determine whether a string-like value is present. + * + * @param {*} value Candidate value. + * @returns {boolean} true when present. + */ +function hasStringValue(value) { + if (value == null) return false; + if (typeof value === 'string') return value.trim() !== ''; + return String(value).trim() !== ''; +} + +/** + * Determine whether a numeric value is present. + * + * @param {*} value Candidate value. + * @returns {boolean} true when present. + */ +function hasNumberValue(value) { + return toFiniteNumber(value) != null; +} + const TILE_LAYER_URL = 'https://{s}.tile.openstreetmap.fr/hot/{z}/{x}/{y}.png'; /** @@ -97,8 +223,12 @@ export async function initializeFederationPage(options = {}) { const fetchImpl = options.fetchImpl || fetch; const leaflet = options.leaflet || (typeof window !== 'undefined' ? window.L : null); const mapContainer = document.getElementById('map'); + const tableEl = document.getElementById('instances'); const tableBody = document.querySelector('#instances tbody'); const statusEl = document.getElementById('status'); + const sortHeaders = tableEl + ? Array.from(tableEl.querySelectorAll('thead .sort-header[data-sort-key]')) + : []; const hasLeaflet = typeof leaflet === 'object' && @@ -109,6 +239,154 @@ export async function initializeFederationPage(options = {}) { let map = null; let markersLayer = null; let tileLayer = null; + const tableSorters = { + name: { getValue: inst => inst.name ?? '', compare: compareString, hasValue: hasStringValue, defaultDirection: 'asc' }, + domain: { getValue: inst => inst.domain ?? '', compare: compareString, hasValue: hasStringValue, defaultDirection: 'asc' }, + contact: { getValue: inst => inst.contactLink ?? '', compare: compareString, hasValue: hasStringValue, defaultDirection: 'asc' }, + version: { getValue: inst => inst.version ?? '', compare: compareString, hasValue: hasStringValue, defaultDirection: 'asc' }, + channel: { getValue: inst => inst.channel ?? '', compare: compareString, hasValue: hasStringValue, defaultDirection: 'asc' }, + frequency: { getValue: inst => inst.frequency ?? '', compare: compareString, hasValue: hasStringValue, defaultDirection: 'asc' }, + nodesCount: { + getValue: inst => toFiniteNumber(inst.nodesCount ?? inst.nodes_count), + compare: compareNumber, + hasValue: hasNumberValue, + defaultDirection: 'desc' + }, + latitude: { getValue: inst => toFiniteNumber(inst.latitude), compare: compareNumber, hasValue: hasNumberValue, defaultDirection: 'asc' }, + longitude: { getValue: inst => toFiniteNumber(inst.longitude), compare: compareNumber, hasValue: hasNumberValue, defaultDirection: 'asc' }, + lastUpdateTime: { + getValue: inst => toFiniteNumber(inst.lastUpdateTime), + compare: compareNumber, + hasValue: hasNumberValue, + defaultDirection: 'desc' + } + }; + let sortState = { + key: 'lastUpdateTime', + direction: tableSorters.lastUpdateTime ? tableSorters.lastUpdateTime.defaultDirection : 'desc' + }; + + /** + * Sort instances using the active sort configuration. + * + * @param {Array} data Instance rows. + * @returns {Array} sorted rows. + */ + const sortInstancesData = data => { + const sorter = tableSorters[sortState.key]; + if (!sorter) return Array.isArray(data) ? [...data] : []; + const dir = sortState.direction === 'asc' ? 1 : -1; + return [...(data || [])].sort((a, b) => { + const aVal = sorter.getValue(a); + const bVal = sorter.getValue(b); + const aHas = sorter.hasValue ? sorter.hasValue(aVal) : hasStringValue(aVal); + const bHas = sorter.hasValue ? sorter.hasValue(bVal) : hasStringValue(bVal); + if (aHas && bHas) { + return sorter.compare(aVal, bVal) * dir; + } + if (aHas) return -1; + if (bHas) return 1; + return 0; + }); + }; + + /** + * Update the visual sort indicators for the active column. + * + * @returns {void} + */ + const syncSortIndicators = () => { + if (!tableEl || !sortHeaders.length) return; + tableEl.querySelectorAll('thead th').forEach(th => th.removeAttribute('aria-sort')); + sortHeaders.forEach(header => { + header.removeAttribute('data-sort-active'); + const indicator = header.querySelector('.sort-indicator'); + if (indicator) indicator.textContent = ''; + }); + const active = sortHeaders.find(header => header.dataset.sortKey === sortState.key); + if (!active) return; + const indicator = active.querySelector('.sort-indicator'); + if (indicator) indicator.textContent = sortState.direction === 'asc' ? '▲' : '▼'; + active.setAttribute('data-sort-active', 'true'); + const th = active.closest('th'); + if (th) { + th.setAttribute('aria-sort', sortState.direction === 'asc' ? 'ascending' : 'descending'); + } + }; + + /** + * Render the instances table body with sorting applied. + * + * @param {Array} data Instance rows. + * @param {number} nowSec Reference timestamp for relative time rendering. + * @returns {void} + */ + const renderTableRows = (data, nowSec) => { + if (!tableBody) return; + const frag = document.createDocumentFragment(); + const sorted = sortInstancesData(data); + + for (const instance of sorted) { + const tr = document.createElement('tr'); + const url = buildInstanceUrl(instance.domain); + const nameHtml = instance.name ? escapeHtml(instance.name) : ''; + const domainHtml = url + ? `${escapeHtml(instance.domain || '')}` + : escapeHtml(instance.domain || ''); + const contactHtml = renderContactHtml(instance.contactLink); + const nodesCountValue = toFiniteNumber(instance.nodesCount ?? instance.nodes_count); + const nodesCountText = nodesCountValue == null ? '' : escapeHtml(String(nodesCountValue)); + + tr.innerHTML = ` + ${nameHtml} + ${domainHtml} + ${contactHtml || ''} + ${escapeHtml(instance.version || '')} + ${escapeHtml(instance.channel || '')} + ${escapeHtml(instance.frequency || '')} + ${nodesCountText} + ${fmtCoords(instance.latitude)} + ${fmtCoords(instance.longitude)} + ${timeAgo(instance.lastUpdateTime, nowSec)} + `; + + frag.appendChild(tr); + } + + tableBody.replaceChildren(frag); + syncSortIndicators(); + }; + + /** + * Wire up click and keyboard handlers for sortable headers. + * + * @param {Function} rerender Callback to refresh the table. + * @returns {void} + */ + const attachSortHandlers = rerender => { + if (!sortHeaders.length) return; + const applySortKey = key => { + if (!key) return; + if (sortState.key === key) { + sortState = { key, direction: sortState.direction === 'asc' ? 'desc' : 'asc' }; + } else { + const defaultDir = tableSorters[key]?.defaultDirection || 'asc'; + sortState = { key, direction: defaultDir }; + } + rerender(); + }; + + sortHeaders.forEach(header => { + const key = header.dataset.sortKey; + header.addEventListener('click', () => applySortKey(key)); + header.addEventListener('keydown', event => { + if (event.key === 'Enter' || event.key === ' ') { + event.preventDefault(); + applySortKey(key); + } + }); + }); + }; /** * Resolve the active theme based on the DOM state. @@ -202,6 +480,38 @@ export async function initializeFederationPage(options = {}) { // Render map markers if (map && markersLayer && hasLeaflet && Array.isArray(instances)) { const bounds = []; + const canRenderLegend = + typeof leaflet.control === 'function' && leaflet.DomUtil && typeof leaflet.DomUtil.create === 'function'; + if (canRenderLegend) { + const legendStops = NODE_COUNT_COLOR_STOPS.map((stop, index) => { + const lower = index === 0 ? 0 : NODE_COUNT_COLOR_STOPS[index - 1].limit; + const upper = stop.limit - 1; + const label = index === 0 ? `< ${stop.limit} nodes` : `${lower}-${upper} nodes`; + return { color: stop.color || DEFAULT_INSTANCE_COLOR, label }; + }); + const lastLimit = NODE_COUNT_COLOR_STOPS[NODE_COUNT_COLOR_STOPS.length - 1]?.limit || 900; + legendStops.push({ color: DEFAULT_INSTANCE_COLOR, label: `≥ ${lastLimit} nodes` }); + + const legend = leaflet.control({ position: 'bottomright' }); + legend.onAdd = function onAdd() { + const container = leaflet.DomUtil.create('div', 'legend legend--instances'); + container.setAttribute('aria-label', 'Active nodes legend'); + const header = leaflet.DomUtil.create('div', 'legend-header', container); + const title = leaflet.DomUtil.create('span', 'legend-title', header); + title.textContent = 'Active nodes'; + const items = leaflet.DomUtil.create('div', 'legend-items', container); + legendStops.forEach(stop => { + const item = leaflet.DomUtil.create('div', 'legend-item', items); + item.setAttribute('aria-hidden', 'true'); + const swatch = leaflet.DomUtil.create('span', 'legend-swatch', item); + swatch.style.background = stop.color; + const label = leaflet.DomUtil.create('span', 'legend-label', item); + label.textContent = stop.label; + }); + return container; + }; + legend.addTo(map); + } for (const instance of instances) { const lat = Number(instance.latitude); @@ -213,24 +523,28 @@ export async function initializeFederationPage(options = {}) { const name = instance.name || instance.domain || 'Unknown'; const url = buildInstanceUrl(instance.domain); - const popupContent = url - ? `${escapeHtml(name)}
- ${escapeHtml(instance.domain || '')}
- ${instance.channel ? `Channel: ${escapeHtml(instance.channel)}
` : ''} - ${instance.frequency ? `Frequency: ${escapeHtml(instance.frequency)}
` : ''} - ${instance.version ? `Version: ${escapeHtml(instance.version)}` : ''}` - : `${escapeHtml(name)}`; + const nodeCountValue = toFiniteNumber(instance.nodesCount ?? instance.nodes_count); + const popupLines = [ + url + ? `${escapeHtml(name)}` + : `${escapeHtml(name)}`, + `${escapeHtml(instance.domain || '')}`, + instance.channel ? `Channel: ${escapeHtml(instance.channel)}` : '', + instance.frequency ? `Frequency: ${escapeHtml(instance.frequency)}` : '', + instance.version ? `Version: ${escapeHtml(instance.version)}` : '', + nodeCountValue != null ? `Active nodes (24h): ${escapeHtml(String(nodeCountValue))}` : '' + ].filter(Boolean); const marker = leaflet.circleMarker([lat, lon], { - radius: 8, - fillColor: '#4CAF50', - color: '#2E7D32', - weight: 2, - opacity: 1, - fillOpacity: 0.8 + radius: 9, + fillColor: colorForNodeCount(nodeCountValue), + color: '#000', + weight: 1, + opacity: 0.8, + fillOpacity: 0.75 }); - marker.bindPopup(popupContent); + marker.bindPopup(popupLines.join('
')); markersLayer.addLayer(marker); } @@ -245,35 +559,7 @@ export async function initializeFederationPage(options = {}) { // Render table if (tableBody && Array.isArray(instances)) { - const frag = document.createDocumentFragment(); - - for (const instance of instances) { - const tr = document.createElement('tr'); - const url = buildInstanceUrl(instance.domain); - const nameHtml = instance.name - ? escapeHtml(instance.name) - : ''; - 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 || '')} - ${fmtCoords(instance.latitude)} - ${fmtCoords(instance.longitude)} - ${timeAgo(instance.lastUpdateTime, nowSec)} - `; - - frag.appendChild(tr); - } - - tableBody.replaceChildren(frag); + attachSortHandlers(() => renderTableRows(instances, nowSec)); + renderTableRows(instances, nowSec); } } diff --git a/web/public/assets/styles/base.css b/web/public/assets/styles/base.css index 6c0752c..e0d9202 100644 --- a/web/public/assets/styles/base.css +++ b/web/public/assets/styles/base.css @@ -1373,6 +1373,19 @@ button:not(.chat-tab):not(.sort-button):hover { outline-offset: 2px; } +.sort-header { + display: inline-flex; + align-items: center; + gap: 4px; + cursor: pointer; + user-select: none; +} + +.sort-header:focus-visible { + outline: 2px solid #4a90e2; + outline-offset: 2px; +} + .sort-indicator { font-size: 0.75em; opacity: 0.6; @@ -1850,6 +1863,10 @@ body.dark .sort-button { color: inherit; } +body.dark .sort-header { + color: inherit; +} + body.dark .sort-button:hover { background: none; } @@ -2075,6 +2092,12 @@ body.dark #map .leaflet-tile.map-tiles { min-width: 180px; } +.instances-col--contact { + min-width: 160px; + white-space: pre-wrap; + word-break: break-word; +} + .instances-col--version { min-width: 80px; } @@ -2084,6 +2107,10 @@ body.dark #map .leaflet-tile.map-tiles { min-width: 100px; } +.instances-col--nodes { + min-width: 110px; +} + .instances-col--latitude, .instances-col--longitude { min-width: 100px; diff --git a/web/spec/federation_spec.rb b/web/spec/federation_spec.rb index cf27bf2..63c7100 100644 --- a/web/spec/federation_spec.rb +++ b/web/spec/federation_spec.rb @@ -400,6 +400,34 @@ RSpec.describe PotatoMesh::App::Federation do expect(row[1]).to eq("sig-3") end end + + it "stores the nodes_count for new records" do + with_db do |db| + federation_helpers.send(:upsert_instance_record, db, base_attributes.merge(nodes_count: 77), "sig-1") + + stored = db.get_first_value("SELECT nodes_count FROM instances WHERE id = ?", base_attributes[:id]) + expect(stored).to eq(77) + end + end + + it "updates the nodes_count on conflict" do + with_db do |db| + federation_helpers.send(:upsert_instance_record, db, base_attributes.merge(nodes_count: 12), "sig-1") + + federation_helpers.send( + :upsert_instance_record, + db, + base_attributes.merge(nodes_count: 99, name: "Renamed Mesh"), + "sig-2", + ) + + row = + db.get_first_row("SELECT nodes_count, name, signature FROM instances WHERE id = ?", base_attributes[:id]) + expect(row[0]).to eq(99) + expect(row[1]).to eq("Renamed Mesh") + expect(row[2]).to eq("sig-2") + end + end end describe ".federation_user_agent_header" do diff --git a/web/spec/instances_spec.rb b/web/spec/instances_spec.rb index 5713474..af100b3 100644 --- a/web/spec/instances_spec.rb +++ b/web/spec/instances_spec.rb @@ -38,6 +38,7 @@ RSpec.describe PotatoMesh::App::Instances do 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 @@ -132,5 +133,48 @@ RSpec.describe PotatoMesh::App::Instances do expect(with_contact["contactLink"]).to eq("https://example.org/contact") expect(without_contact.key?("contactLink")).to be(false) end + + it "includes nodesCount values, preserving zeros" do + fixed_time = Time.utc(2025, 2, 2, 8, 0, 0) + allow(Time).to receive(:now).and_return(fixed_time) + + with_db do |db| + db.execute( + <<~SQL, + INSERT INTO instances (id, domain, pubkey, last_update_time, is_private, nodes_count) + VALUES (?, ?, ?, ?, ?, ?) + SQL + [ + "instance-with-nodes", + "gamma.mesh.test", + PotatoMesh::Application::INSTANCE_PUBLIC_KEY_PEM, + fixed_time.to_i, + 0, + 42, + ], + ) + db.execute( + <<~SQL, + INSERT INTO instances (id, domain, pubkey, last_update_time, is_private, nodes_count) + VALUES (?, ?, ?, ?, ?, ?) + SQL + [ + "instance-with-zero", + "delta.mesh.test", + PotatoMesh::Application::INSTANCE_PUBLIC_KEY_PEM, + fixed_time.to_i, + 0, + 0, + ], + ) + end + + payload = application_class.load_instances_for_api + with_nodes = payload.find { |row| row["domain"] == "gamma.mesh.test" } + zero_nodes = payload.find { |row| row["domain"] == "delta.mesh.test" } + + expect(with_nodes["nodesCount"]).to eq(42) + expect(zero_nodes["nodesCount"]).to eq(0) + end end end diff --git a/web/views/shared/_instances_table.erb b/web/views/shared/_instances_table.erb index aa5539b..716f33f 100644 --- a/web/views/shared/_instances_table.erb +++ b/web/views/shared/_instances_table.erb @@ -17,15 +17,16 @@ - - - - - - - - - + + + + + + + + + +
NameDomainContactVersionChannelFrequencyLatitudeLongitudeLast UpdateName Domain Contact Version Channel Frequency Active Nodes (24h) Latitude Longitude Last Update