mirror of
https://github.com/l5yth/potato-mesh.git
synced 2026-07-02 16:01:42 +02:00
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:
@@ -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 =
|
||||
|
||||
@@ -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,
|
||||
]
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user