web: improve instances map and table view (#546)

* web: improve instances map and table view

* web: address review comments

* run rufo
This commit is contained in:
l5y
2025-12-14 14:35:55 +01:00
committed by GitHub
parent 135de0863c
commit baf6ffff0b
10 changed files with 758 additions and 60 deletions
+1
View File
@@ -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
);
@@ -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 =
+49 -3
View File
@@ -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,
]
+2 -1
View File
@@ -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) != ''
@@ -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</);
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, />2</);
assert.match(secondRowHtml, /d ago/);
assert.deepEqual(mapFitBoundsCalls[0][0], [[10.12345, -20.98765]]);
assert.equal(circleMarkerCalls[0].options.fillColor, roleColors.CLIENT_HIDDEN);
} catch (error) {
console.error('federation sorting test error', error);
throw error;
} finally {
cleanup();
}
});
test('federation table sorting, contact rendering, and legend creation', 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);
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</);
assert.match(rows[0], /https:\/\/charlie\.example/);
assert.match(rows[0], /matrix:#c:mesh/);
assert.match(rows[1], /a\.mesh/);
assert.match(rows[2], /b\.mesh/);
const nameHandlers = headerHandlers.get(headerName);
nameHandlers.click();
const afterNameSort = tbodyEl.childNodes.map(node => 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();
}
+330 -44
View File
@@ -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(`<a href="${safeUrl}" target="_blank" rel="noopener noreferrer">${safeUrl}</a>`);
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, '<br>');
}
/**
* 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<Object>} data Instance rows.
* @returns {Array<Object>} 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<Object>} 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) : '<em>—</em>';
const domainHtml = url
? `<a href="${escapeHtml(url)}" target="_blank" rel="noopener">${escapeHtml(instance.domain || '')}</a>`
: escapeHtml(instance.domain || '');
const contactHtml = renderContactHtml(instance.contactLink);
const nodesCountValue = toFiniteNumber(instance.nodesCount ?? instance.nodes_count);
const nodesCountText = nodesCountValue == null ? '<em>—</em>' : escapeHtml(String(nodesCountValue));
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 || '<em>—</em>'}</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>
<td class="instances-col instances-col--nodes mono">${nodesCountText}</td>
<td class="instances-col instances-col--latitude mono">${fmtCoords(instance.latitude)}</td>
<td class="instances-col instances-col--longitude mono">${fmtCoords(instance.longitude)}</td>
<td class="instances-col instances-col--last-update mono">${timeAgo(instance.lastUpdateTime, nowSec)}</td>
`;
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
? `<strong><a href="${escapeHtml(url)}" target="_blank" rel="noopener">${escapeHtml(name)}</a></strong><br>
<span class="mono">${escapeHtml(instance.domain || '')}</span><br>
${instance.channel ? `Channel: ${escapeHtml(instance.channel)}<br>` : ''}
${instance.frequency ? `Frequency: ${escapeHtml(instance.frequency)}<br>` : ''}
${instance.version ? `Version: ${escapeHtml(instance.version)}` : ''}`
: `<strong>${escapeHtml(name)}</strong>`;
const nodeCountValue = toFiniteNumber(instance.nodesCount ?? instance.nodes_count);
const popupLines = [
url
? `<strong><a href="${escapeHtml(url)}" target="_blank" rel="noopener">${escapeHtml(name)}</a></strong>`
: `<strong>${escapeHtml(name)}</strong>`,
`<span class="mono">${escapeHtml(instance.domain || '')}</span>`,
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('<br>'));
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)
: '<em>—</em>';
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>
<td class="instances-col instances-col--latitude mono">${fmtCoords(instance.latitude)}</td>
<td class="instances-col instances-col--longitude mono">${fmtCoords(instance.longitude)}</td>
<td class="instances-col instances-col--last-update mono">${timeAgo(instance.lastUpdateTime, nowSec)}</td>
`;
frag.appendChild(tr);
}
tableBody.replaceChildren(frag);
attachSortHandlers(() => renderTableRows(instances, nowSec));
renderTableRows(instances, nowSec);
}
}
+27
View File
@@ -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;
+28
View File
@@ -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
+44
View File
@@ -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
+10 -9
View File
@@ -17,15 +17,16 @@
<table id="instances">
<thead>
<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>
<th class="instances-col instances-col--latitude">Latitude</th>
<th class="instances-col instances-col--longitude">Longitude</th>
<th class="instances-col instances-col--last-update">Last Update</th>
<th class="instances-col instances-col--name" data-sort-key="name"><span class="sort-header" role="button" tabindex="0" data-sort-key="name" data-sort-label="Name">Name <span class="sort-indicator" aria-hidden="true"></span></span></th>
<th class="instances-col instances-col--domain" data-sort-key="domain"><span class="sort-header" role="button" tabindex="0" data-sort-key="domain" data-sort-label="Domain">Domain <span class="sort-indicator" aria-hidden="true"></span></span></th>
<th class="instances-col instances-col--contact" data-sort-key="contact"><span class="sort-header" role="button" tabindex="0" data-sort-key="contact" data-sort-label="Contact">Contact <span class="sort-indicator" aria-hidden="true"></span></span></th>
<th class="instances-col instances-col--version" data-sort-key="version"><span class="sort-header" role="button" tabindex="0" data-sort-key="version" data-sort-label="Version">Version <span class="sort-indicator" aria-hidden="true"></span></span></th>
<th class="instances-col instances-col--channel" data-sort-key="channel"><span class="sort-header" role="button" tabindex="0" data-sort-key="channel" data-sort-label="Channel">Channel <span class="sort-indicator" aria-hidden="true"></span></span></th>
<th class="instances-col instances-col--frequency" data-sort-key="frequency"><span class="sort-header" role="button" tabindex="0" data-sort-key="frequency" data-sort-label="Frequency">Frequency <span class="sort-indicator" aria-hidden="true"></span></span></th>
<th class="instances-col instances-col--nodes" data-sort-key="nodesCount"><span class="sort-header" role="button" tabindex="0" data-sort-key="nodesCount" data-sort-label="Active Nodes (24h)">Active Nodes (24h) <span class="sort-indicator" aria-hidden="true"></span></span></th>
<th class="instances-col instances-col--latitude" data-sort-key="latitude"><span class="sort-header" role="button" tabindex="0" data-sort-key="latitude" data-sort-label="Latitude">Latitude <span class="sort-indicator" aria-hidden="true"></span></span></th>
<th class="instances-col instances-col--longitude" data-sort-key="longitude"><span class="sort-header" role="button" tabindex="0" data-sort-key="longitude" data-sort-label="Longitude">Longitude <span class="sort-indicator" aria-hidden="true"></span></span></th>
<th class="instances-col instances-col--last-update" data-sort-key="lastUpdateTime"><span class="sort-header" role="button" tabindex="0" data-sort-key="lastUpdateTime" data-sort-label="Last Update">Last Update <span class="sort-indicator" aria-hidden="true"></span></span></th>
</tr>
</thead>
<tbody></tbody>