mirror of
https://github.com/l5yth/potato-mesh.git
synced 2026-06-16 08:05:07 +02:00
web: fix traces rendering (#535)
* web: fix traces rendering * web: remove icon shortcuts * web: further refine the trace routes
This commit is contained in:
@@ -584,6 +584,10 @@ module PotatoMesh
|
||||
db.results_as_hash = true
|
||||
params = []
|
||||
where_clauses = []
|
||||
now = Time.now.to_i
|
||||
min_rx_time = now - PotatoMesh::Config.week_seconds
|
||||
where_clauses << "COALESCE(rx_time, 0) >= ?"
|
||||
params << min_rx_time
|
||||
|
||||
if node_ref
|
||||
tokens = node_reference_tokens(node_ref)
|
||||
|
||||
@@ -168,6 +168,7 @@ test('buildChatTabModel includes telemetry, position, and neighbor events', () =
|
||||
telemetry: [{ node_id: nodeId, rx_time: NOW - 30 }],
|
||||
positions: [{ node_id: nodeId, rx_time: NOW - 20 }],
|
||||
neighbors: [{ node_id: nodeId, neighbor_id: neighborId, rx_time: NOW - 10 }],
|
||||
traces: [{ id: 5_000, src: nodeId, hops: [neighborId], dest: '!charlie', rx_time: NOW - 5 }],
|
||||
messages: [],
|
||||
nowSeconds: NOW,
|
||||
windowSeconds: WINDOW
|
||||
@@ -178,11 +179,35 @@ test('buildChatTabModel includes telemetry, position, and neighbor events', () =
|
||||
CHAT_LOG_ENTRY_TYPES.NODE_INFO,
|
||||
CHAT_LOG_ENTRY_TYPES.TELEMETRY,
|
||||
CHAT_LOG_ENTRY_TYPES.POSITION,
|
||||
CHAT_LOG_ENTRY_TYPES.NEIGHBOR
|
||||
CHAT_LOG_ENTRY_TYPES.NEIGHBOR,
|
||||
CHAT_LOG_ENTRY_TYPES.TRACE
|
||||
]);
|
||||
assert.equal(model.logEntries[0].nodeId, nodeId);
|
||||
const lastEntry = model.logEntries[model.logEntries.length - 1];
|
||||
assert.equal(lastEntry.neighborId, neighborId);
|
||||
const neighborEntry = model.logEntries.find(entry => entry.type === CHAT_LOG_ENTRY_TYPES.NEIGHBOR);
|
||||
assert.ok(neighborEntry);
|
||||
assert.equal(neighborEntry.neighborId, neighborId);
|
||||
const traceEntry = model.logEntries.find(entry => entry.type === CHAT_LOG_ENTRY_TYPES.TRACE);
|
||||
assert.ok(traceEntry);
|
||||
assert.deepEqual(traceEntry.traceLabels, [nodeId, neighborId, '!charlie']);
|
||||
});
|
||||
|
||||
test('buildChatTabModel normalises numeric traceroute hops into canonical IDs', () => {
|
||||
const source = 0xabcdef01;
|
||||
const hops = ['0xABCDEF02', '!abcdef03', 123];
|
||||
const dest = 0xabcdef04;
|
||||
const model = buildChatTabModel({
|
||||
nodes: [],
|
||||
traces: [{ rx_time: NOW - 5, src: source, hops, dest }],
|
||||
nowSeconds: NOW,
|
||||
windowSeconds: WINDOW
|
||||
});
|
||||
const traceEntry = model.logEntries.find(entry => entry.type === CHAT_LOG_ENTRY_TYPES.TRACE);
|
||||
assert.ok(traceEntry);
|
||||
assert.equal(traceEntry.nodeId, '!abcdef01');
|
||||
assert.deepEqual(
|
||||
traceEntry.tracePath.map(hop => hop.id),
|
||||
['!abcdef01', '!abcdef02', '!abcdef03', '!0000007b', '!abcdef04']
|
||||
);
|
||||
});
|
||||
|
||||
test('buildChatTabModel merges dedicated encrypted log feed without altering channels', () => {
|
||||
|
||||
@@ -74,6 +74,18 @@ test('chatLogEntryMatchesQuery inspects neighbor node context', () => {
|
||||
assert.equal(chatLogEntryMatchesQuery(entry, query), true);
|
||||
});
|
||||
|
||||
test('chatLogEntryMatchesQuery inspects traceroute hop labels', () => {
|
||||
const entry = {
|
||||
type: CHAT_LOG_ENTRY_TYPES.TRACE,
|
||||
traceLabels: ['!alpha', '!bravo', '!charlie'],
|
||||
tracePath: [{ id: '!alpha' }, { id: '!bravo' }, { id: '!charlie' }]
|
||||
};
|
||||
const query = normaliseChatFilterQuery('bravo');
|
||||
assert.equal(chatLogEntryMatchesQuery(entry, query), true);
|
||||
const missQuery = normaliseChatFilterQuery('delta');
|
||||
assert.equal(chatLogEntryMatchesQuery(entry, missQuery), false);
|
||||
});
|
||||
|
||||
test('filterChatModel filters both log entries and channel messages', () => {
|
||||
const model = {
|
||||
logEntries: [
|
||||
|
||||
@@ -19,8 +19,13 @@ import assert from 'node:assert/strict';
|
||||
|
||||
import { buildTraceSegments, __testUtils } from '../trace-paths.js';
|
||||
|
||||
const { coerceFiniteNumber, findNode, resolveNodeCoordinates } = __testUtils;
|
||||
const { buildNodeIndex } = __testUtils;
|
||||
const {
|
||||
coerceFiniteNumber,
|
||||
findNode,
|
||||
resolveNodeCoordinates,
|
||||
canonicalNodeIdFromNumeric,
|
||||
buildNodeIndex
|
||||
} = __testUtils;
|
||||
|
||||
test('buildTraceSegments connects source, hops, and destination when coordinates exist', () => {
|
||||
const traces = [
|
||||
@@ -43,6 +48,29 @@ test('buildTraceSegments connects source, hops, and destination when coordinates
|
||||
assert.equal(segments[0].color, 'color:ROUTER');
|
||||
assert.equal(segments[1].color, 'color:CLIENT');
|
||||
assert.equal(segments[0].rxTime, 1700);
|
||||
assert.deepEqual(
|
||||
segments[0].pathNodes.map(node => node.node_id),
|
||||
['2658361180', '19088743', '4242424242']
|
||||
);
|
||||
});
|
||||
|
||||
test('buildTraceSegments links traces to canonical node IDs when numeric references are provided', () => {
|
||||
const traces = [
|
||||
{ id: 9_010, src: 0xbead_f00d, hops: [0xcafe_babe], dest: 0xfeed_c0de, rx_time: 1900 },
|
||||
];
|
||||
const nodes = [
|
||||
{ node_id: '!beadf00d', latitude: 0, longitude: 0, role: 'ROUTER' },
|
||||
{ node_id: '!cafebabe', latitude: 1, longitude: 1, role: 'CLIENT' },
|
||||
{ node_id: '!feedc0de', latitude: 2, longitude: 2, role: 'CLIENT' },
|
||||
];
|
||||
|
||||
const segments = buildTraceSegments(traces, nodes, { colorForNode: () => '#abcdef' });
|
||||
|
||||
assert.equal(segments.length, 2);
|
||||
assert.deepEqual(segments[0].latlngs, [[0, 0], [1, 1]]);
|
||||
assert.deepEqual(segments[1].latlngs, [[1, 1], [2, 2]]);
|
||||
assert.equal(segments[0].color, '#abcdef');
|
||||
assert.equal(segments[1].color, '#abcdef');
|
||||
});
|
||||
|
||||
test('buildTraceSegments drops paths through hops without locations', () => {
|
||||
@@ -98,13 +126,24 @@ test('helper utilities coerce values and locate nodes', () => {
|
||||
assert.equal(coerceFiniteNumber(null), null);
|
||||
assert.equal(coerceFiniteNumber(' '), null);
|
||||
assert.equal(coerceFiniteNumber('7'), 7);
|
||||
assert.equal(coerceFiniteNumber('!beadf00d'), 0xbeadf00d);
|
||||
assert.equal(coerceFiniteNumber('0x10'), 16);
|
||||
|
||||
const byId = new Map([['!id', { node_id: '!id', latitude: 1, longitude: 2 }]]);
|
||||
const byNum = new Map([[99, { node_id: '!other', latitude: 0, longitude: 0 }]]);
|
||||
const byId = new Map([
|
||||
['!id', { node_id: '!id', latitude: 1, longitude: 2 }],
|
||||
['!beadf00d', { node_id: '!beadf00d', latitude: 3, longitude: 4 }]
|
||||
]);
|
||||
const byNum = new Map([
|
||||
[99, { node_id: '!other', latitude: 0, longitude: 0 }],
|
||||
[0xbeadf00d, { node_id: '!beadf00d', latitude: 3, longitude: 4 }]
|
||||
]);
|
||||
assert.equal(findNode(byId, byNum, '!id').node_id, '!id');
|
||||
assert.equal(findNode(byId, byNum, 99).node_id, '!other');
|
||||
assert.equal(findNode(byId, new Map(), 0xbeadf00d).node_id, '!beadf00d');
|
||||
assert.equal(findNode(byId, byNum, 100), null);
|
||||
|
||||
assert.equal(canonicalNodeIdFromNumeric(0xbeadf00d), '!beadf00d');
|
||||
|
||||
const coords = resolveNodeCoordinates({ latitude: 5, longitude: 6, distance_km: 10 }, { limitDistance: true, maxDistanceKm: 15 });
|
||||
assert.deepEqual(coords, [5, 6]);
|
||||
const outOfRange = resolveNodeCoordinates({ latitude: 0, longitude: 0, distance_km: 20 }, { limitDistance: true, maxDistanceKm: 15 });
|
||||
|
||||
@@ -30,7 +30,8 @@ export const MAX_CHANNEL_INDEX = 9;
|
||||
* NODE_INFO: 'node-info',
|
||||
* TELEMETRY: 'telemetry',
|
||||
* POSITION: 'position',
|
||||
* NEIGHBOR: 'neighbor'
|
||||
* NEIGHBOR: 'neighbor',
|
||||
* TRACE: 'trace'
|
||||
* }}
|
||||
*/
|
||||
export const CHAT_LOG_ENTRY_TYPES = Object.freeze({
|
||||
@@ -39,6 +40,7 @@ export const CHAT_LOG_ENTRY_TYPES = Object.freeze({
|
||||
TELEMETRY: 'telemetry',
|
||||
POSITION: 'position',
|
||||
NEIGHBOR: 'neighbor',
|
||||
TRACE: 'trace',
|
||||
MESSAGE_ENCRYPTED: 'message-encrypted'
|
||||
});
|
||||
|
||||
@@ -70,6 +72,7 @@ function resolveSnapshotList(entry) {
|
||||
* telemetry?: Array<Object>,
|
||||
* positions?: Array<Object>,
|
||||
* neighbors?: Array<Object>,
|
||||
* traces?: Array<Object>,
|
||||
* messages?: Array<Object>,
|
||||
* logOnlyMessages?: Array<Object>,
|
||||
* nowSeconds: number,
|
||||
@@ -87,6 +90,7 @@ export function buildChatTabModel({
|
||||
telemetry = [],
|
||||
positions = [],
|
||||
neighbors = [],
|
||||
traces = [],
|
||||
messages = [],
|
||||
logOnlyMessages = [],
|
||||
nowSeconds,
|
||||
@@ -156,6 +160,34 @@ export function buildChatTabModel({
|
||||
}
|
||||
}
|
||||
|
||||
for (const trace of traces || []) {
|
||||
if (!trace) continue;
|
||||
const ts = resolveTimestampSeconds(trace.rx_time ?? trace.rxTime, trace.rx_iso ?? trace.rxIso);
|
||||
if (ts == null || ts < cutoff) continue;
|
||||
const path = buildTracePath(trace);
|
||||
if (path.length < 2) continue;
|
||||
const firstHop = path[0] || {};
|
||||
const traceLabels = path
|
||||
.map(hop => {
|
||||
if (!hop || typeof hop !== 'object') return null;
|
||||
const candidates = [hop.id, hop.raw];
|
||||
if (Number.isFinite(hop.num)) {
|
||||
candidates.push(String(hop.num));
|
||||
}
|
||||
return candidates.find(val => val != null && String(val).trim().length > 0) ?? null;
|
||||
})
|
||||
.filter(value => value != null && value !== '');
|
||||
logEntries.push({
|
||||
ts,
|
||||
type: CHAT_LOG_ENTRY_TYPES.TRACE,
|
||||
trace,
|
||||
tracePath: path,
|
||||
traceLabels,
|
||||
nodeId: firstHop.id ?? null,
|
||||
nodeNum: firstHop.num ?? null
|
||||
});
|
||||
}
|
||||
|
||||
const encryptedLogEntries = [];
|
||||
const encryptedLogKeys = new Set();
|
||||
|
||||
@@ -345,10 +377,59 @@ function pickFirstPropertyValue(source, keys) {
|
||||
* @param {*} value Arbitrary payload candidate.
|
||||
* @returns {?string} Canonical node identifier.
|
||||
*/
|
||||
function coerceFiniteNumber(value) {
|
||||
if (value == null) return null;
|
||||
if (typeof value === 'number') {
|
||||
return Number.isFinite(value) ? value : null;
|
||||
}
|
||||
if (typeof value === 'string') {
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed) return null;
|
||||
if (trimmed.startsWith('!')) {
|
||||
const hex = trimmed.slice(1);
|
||||
if (!/^[0-9A-Fa-f]+$/.test(hex)) return null;
|
||||
const parsedHex = Number.parseInt(hex, 16);
|
||||
return Number.isFinite(parsedHex) ? parsedHex >>> 0 : null;
|
||||
}
|
||||
if (/^0[xX][0-9A-Fa-f]+$/.test(trimmed)) {
|
||||
const parsedHex = Number.parseInt(trimmed, 16);
|
||||
return Number.isFinite(parsedHex) ? parsedHex >>> 0 : null;
|
||||
}
|
||||
const parsed = Number(trimmed);
|
||||
return Number.isFinite(parsed) ? parsed : null;
|
||||
}
|
||||
const parsed = Number(value);
|
||||
return Number.isFinite(parsed) ? parsed : null;
|
||||
}
|
||||
|
||||
function canonicalNodeIdFromNumeric(ref) {
|
||||
if (!Number.isFinite(ref)) return null;
|
||||
const unsigned = ref >>> 0;
|
||||
const hex = unsigned.toString(16).padStart(8, '0');
|
||||
return `!${hex}`;
|
||||
}
|
||||
|
||||
function normaliseNodeId(value) {
|
||||
if (!value || typeof value !== 'object') return null;
|
||||
const raw = value.node_id ?? value.nodeId ?? null;
|
||||
return typeof raw === 'string' && raw.trim().length ? raw.trim() : null;
|
||||
if (value == null) return null;
|
||||
if (typeof value === 'number') {
|
||||
return canonicalNodeIdFromNumeric(value);
|
||||
}
|
||||
if (typeof value === 'string') {
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed) return null;
|
||||
const canonicalFromNumeric = canonicalNodeIdFromNumeric(coerceFiniteNumber(trimmed));
|
||||
return canonicalFromNumeric ?? trimmed;
|
||||
}
|
||||
if (typeof value !== 'object') return null;
|
||||
const rawId = value.node_id ?? value.nodeId ?? null;
|
||||
if (rawId != null) {
|
||||
const canonical = normaliseNodeId(rawId);
|
||||
if (canonical) return canonical;
|
||||
}
|
||||
const numericRef = value.node_num ?? value.nodeNum ?? value.num;
|
||||
const numericId = canonicalNodeIdFromNumeric(coerceFiniteNumber(numericRef));
|
||||
if (numericId) return numericId;
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -366,6 +447,29 @@ function normaliseNeighborId(value) {
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build an ordered trace path of node identifiers and numeric references.
|
||||
*
|
||||
* @param {Object} trace Trace payload.
|
||||
* @returns {Array<{id: ?string, num: ?number, raw: *}>} Ordered hop descriptors.
|
||||
*/
|
||||
function buildTracePath(trace) {
|
||||
const path = [];
|
||||
const append = value => {
|
||||
if (value == null || value === '') return;
|
||||
const id = normaliseNodeId(value);
|
||||
const num = normaliseNodeNum({ num: value });
|
||||
path.push({ id, num, raw: value });
|
||||
};
|
||||
append(trace.src ?? trace.source ?? trace.from);
|
||||
const hops = Array.isArray(trace.hops) ? trace.hops : [];
|
||||
for (const hop of hops) {
|
||||
append(hop);
|
||||
}
|
||||
append(trace.dest ?? trace.destination ?? trace.to);
|
||||
return path;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract a finite node number from a payload when available.
|
||||
*
|
||||
@@ -373,14 +477,17 @@ function normaliseNeighborId(value) {
|
||||
* @returns {?number} Canonical numeric identifier.
|
||||
*/
|
||||
function normaliseNodeNum(value) {
|
||||
if (!value || typeof value !== 'object') return null;
|
||||
const raw = value.node_num ?? value.nodeNum ?? value.num;
|
||||
if (raw == null || raw === '') return null;
|
||||
if (typeof raw === 'number') {
|
||||
return Number.isFinite(raw) ? raw : null;
|
||||
if (Number.isFinite(value)) {
|
||||
return Math.trunc(value);
|
||||
}
|
||||
const parsed = Number(raw);
|
||||
return Number.isFinite(parsed) ? parsed : null;
|
||||
const fromObject = value && typeof value === 'object'
|
||||
? coerceFiniteNumber(value.node_num ?? value.nodeNum ?? value.num)
|
||||
: null;
|
||||
if (fromObject != null) {
|
||||
return Math.trunc(fromObject);
|
||||
}
|
||||
const parsed = coerceFiniteNumber(value);
|
||||
return parsed != null ? Math.trunc(parsed) : null;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -110,6 +110,7 @@ export function chatLogEntryMatchesQuery(entry, query) {
|
||||
candidates.push(...collectSearchValues(entry.position));
|
||||
candidates.push(...collectSearchValues(entry.neighbor));
|
||||
candidates.push(...collectSearchValues(entry.neighborNode));
|
||||
candidates.push(...(Array.isArray(entry.traceLabels) ? entry.traceLabels : []));
|
||||
if (entry.nodeId) candidates.push(entry.nodeId);
|
||||
if (entry.nodeNum != null && entry.nodeNum !== '') candidates.push(entry.nodeNum);
|
||||
if (entry.neighborId) candidates.push(entry.neighborId);
|
||||
|
||||
@@ -190,6 +190,7 @@ export function initializeApp(config) {
|
||||
});
|
||||
const NODE_LIMIT = 1000;
|
||||
const TRACE_LIMIT = 200;
|
||||
const TRACE_MAX_AGE_SECONDS = 7 * 24 * 60 * 60;
|
||||
const SNAPSHOT_LIMIT = SNAPSHOT_WINDOW;
|
||||
const CHAT_LIMIT = MESSAGE_LIMIT;
|
||||
const CHAT_RECENT_WINDOW_SECONDS = 7 * 24 * 60 * 60;
|
||||
@@ -452,8 +453,11 @@ export function initializeApp(config) {
|
||||
const AUTO_FIT_PADDING_PX = 12;
|
||||
const MAX_INITIAL_ZOOM = 13;
|
||||
let neighborLinesLayer = null;
|
||||
let traceLinesLayer = null;
|
||||
let neighborLinesVisible = true;
|
||||
let traceLinesVisible = true;
|
||||
let neighborLinesToggleButton = null;
|
||||
let traceLinesToggleButton = null;
|
||||
let markersLayer = null;
|
||||
let tileDomObserver = null;
|
||||
const fullscreenChangeEvents = [
|
||||
@@ -1244,6 +1248,7 @@ export function initializeApp(config) {
|
||||
});
|
||||
|
||||
neighborLinesLayer = L.layerGroup().addTo(map);
|
||||
traceLinesLayer = L.layerGroup().addTo(map);
|
||||
markersLayer = L.layerGroup().addTo(map);
|
||||
|
||||
if (typeof navigator !== 'undefined' && navigator && navigator.onLine === false) {
|
||||
@@ -1336,6 +1341,38 @@ export function initializeApp(config) {
|
||||
updateNeighborLinesToggleState();
|
||||
}
|
||||
|
||||
/**
|
||||
* Synchronise the traceroute line toggle button with the active state.
|
||||
*
|
||||
* @returns {void}
|
||||
*/
|
||||
function updateTraceLinesToggleState() {
|
||||
if (!traceLinesToggleButton) return;
|
||||
const label = traceLinesVisible ? 'Hide trace lines' : 'Show trace lines';
|
||||
traceLinesToggleButton.textContent = label;
|
||||
traceLinesToggleButton.setAttribute('aria-pressed', traceLinesVisible ? 'true' : 'false');
|
||||
traceLinesToggleButton.setAttribute('aria-label', label);
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggle the Leaflet layer that renders traceroute connections.
|
||||
*
|
||||
* @param {boolean} visible Whether to show traceroute paths.
|
||||
* @returns {void}
|
||||
*/
|
||||
function setTraceLinesVisibility(visible) {
|
||||
traceLinesVisible = Boolean(visible);
|
||||
if (traceLinesLayer && map) {
|
||||
const hasLayer = map.hasLayer(traceLinesLayer);
|
||||
if (traceLinesVisible && !hasLayer) {
|
||||
traceLinesLayer.addTo(map);
|
||||
} else if (!traceLinesVisible && hasLayer) {
|
||||
map.removeLayer(traceLinesLayer);
|
||||
}
|
||||
}
|
||||
updateTraceLinesToggleState();
|
||||
}
|
||||
|
||||
/**
|
||||
* Refresh the legend buttons to reflect the active role filters.
|
||||
*
|
||||
@@ -1433,6 +1470,15 @@ export function initializeApp(config) {
|
||||
});
|
||||
updateNeighborLinesToggleState();
|
||||
|
||||
traceLinesToggleButton = L.DomUtil.create('button', 'legend-item legend-toggle-traces', toggle);
|
||||
traceLinesToggleButton.type = 'button';
|
||||
traceLinesToggleButton.addEventListener('click', event => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
setTraceLinesVisibility(!traceLinesVisible);
|
||||
});
|
||||
updateTraceLinesToggleState();
|
||||
|
||||
const resetButton = L.DomUtil.create('button', 'legend-item legend-reset', toggle);
|
||||
resetButton.type = 'button';
|
||||
resetButton.textContent = 'Clear filters';
|
||||
@@ -1764,6 +1810,37 @@ export function initializeApp(config) {
|
||||
return value.replace(/[^a-zA-Z0-9_-]/g, chr => `\\${chr}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a node identifier or numeric reference into a finite number.
|
||||
*
|
||||
* @param {*} ref Identifier or numeric reference.
|
||||
* @returns {number|null} Parsed number or ``null``.
|
||||
*/
|
||||
function parseNodeNumericRef(ref) {
|
||||
if (ref == null) return null;
|
||||
if (typeof ref === 'number') {
|
||||
return Number.isFinite(ref) ? ref : null;
|
||||
}
|
||||
if (typeof ref === 'string') {
|
||||
const trimmed = ref.trim();
|
||||
if (!trimmed) return null;
|
||||
if (trimmed.startsWith('!')) {
|
||||
const hex = trimmed.slice(1);
|
||||
if (!/^[0-9A-Fa-f]+$/.test(hex)) return null;
|
||||
const parsedHex = Number.parseInt(hex, 16);
|
||||
return Number.isFinite(parsedHex) ? parsedHex >>> 0 : null;
|
||||
}
|
||||
if (/^0[xX][0-9A-Fa-f]+$/.test(trimmed)) {
|
||||
const parsedHex = Number.parseInt(trimmed, 16);
|
||||
return Number.isFinite(parsedHex) ? parsedHex >>> 0 : null;
|
||||
}
|
||||
const parsed = Number(trimmed);
|
||||
return Number.isFinite(parsed) ? parsed : null;
|
||||
}
|
||||
const parsed = Number(ref);
|
||||
return Number.isFinite(parsed) ? parsed : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Populate the ``nodesById`` index for quick lookups.
|
||||
*
|
||||
@@ -1781,9 +1858,13 @@ export function initializeApp(config) {
|
||||
: (typeof node.nodeId === 'string' ? node.nodeId : null);
|
||||
if (nodeIdRaw) {
|
||||
nodesById.set(nodeIdRaw.trim(), node);
|
||||
const numericFromId = parseNodeNumericRef(nodeIdRaw);
|
||||
if (numericFromId != null && !nodesByNum.has(numericFromId)) {
|
||||
nodesByNum.set(numericFromId, node);
|
||||
}
|
||||
}
|
||||
const nodeNumRaw = node.num ?? node.node_num ?? node.nodeNum;
|
||||
const nodeNum = typeof nodeNumRaw === 'number' ? nodeNumRaw : Number(nodeNumRaw);
|
||||
const nodeNum = parseNodeNumericRef(nodeNumRaw);
|
||||
if (Number.isFinite(nodeNum)) {
|
||||
nodesByNum.set(nodeNum, node);
|
||||
}
|
||||
@@ -2400,6 +2481,8 @@ export function initializeApp(config) {
|
||||
return createPositionChatEntry(entry, context);
|
||||
case CHAT_LOG_ENTRY_TYPES.NEIGHBOR:
|
||||
return createNeighborChatEntry(entry, context);
|
||||
case CHAT_LOG_ENTRY_TYPES.TRACE:
|
||||
return createTraceChatEntry(entry, context);
|
||||
case CHAT_LOG_ENTRY_TYPES.MESSAGE_ENCRYPTED:
|
||||
return entry?.message ? createMessageChatEntry(entry.message) : null;
|
||||
default:
|
||||
@@ -2446,6 +2529,138 @@ export function initializeApp(config) {
|
||||
return div;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert a trace path into user-friendly labels using cached node metadata.
|
||||
*
|
||||
* @param {Array<{id: ?string, num: ?number, raw: *}>} tracePath Ordered hop references.
|
||||
* @returns {Array<string>} Display labels for each hop.
|
||||
*/
|
||||
function formatTracePathLabels(tracePath) {
|
||||
if (!Array.isArray(tracePath)) return [];
|
||||
const labels = [];
|
||||
for (const hop of tracePath) {
|
||||
if (!hop || typeof hop !== 'object') continue;
|
||||
const node = resolveNodeForHop(hop);
|
||||
const fallbackId = hop.id ?? (Number.isFinite(hop.num) ? String(hop.num) : (hop.raw != null ? String(hop.raw) : ''));
|
||||
const shortName = node ? normalizeNodeNameValue(node.short_name ?? node.shortName) : null;
|
||||
const label = shortName || (node ? (getNodeDisplayNameForOverlay(node) || fallbackId) : fallbackId);
|
||||
if (label) {
|
||||
labels.push(String(label));
|
||||
}
|
||||
}
|
||||
return labels;
|
||||
}
|
||||
|
||||
function createTraceChatEntry(entry, context) {
|
||||
if (!entry || !Array.isArray(entry.tracePath) || entry.tracePath.length < 2) {
|
||||
return null;
|
||||
}
|
||||
const sourceHop = entry.tracePath[0] || null;
|
||||
const sourceNode = resolveNodeForHop(sourceHop);
|
||||
const labels = formatTracePathLabels(entry.tracePath);
|
||||
const labelText = labels.length ? labels.join(', ') : 'Traceroute';
|
||||
const labelSuffix = `: ${escapeHtml(labelText)}`;
|
||||
return createAnnouncementEntry({
|
||||
timestampSeconds: entry?.ts ?? null,
|
||||
shortName: context.shortName,
|
||||
longName: context.longName || context.nodeId || labels[0] || 'Traceroute',
|
||||
role: context.role,
|
||||
metadataSource: sourceNode || context.metadataSource,
|
||||
nodeData: sourceNode || context.nodeData,
|
||||
messageHtml: `${renderEmojiHtml('👣')} ${renderAnnouncementCopy('Caught trace', labelSuffix)}`
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Build tooltip HTML showing styled short-name badges for a trace path.
|
||||
*
|
||||
* @param {Array<Object>} pathNodes Ordered node payloads along the trace.
|
||||
* @returns {string} HTML fragment or ``''`` when unavailable.
|
||||
*/
|
||||
function buildTraceTooltipHtml(pathNodes) {
|
||||
if (!Array.isArray(pathNodes) || pathNodes.length < 2) {
|
||||
return '';
|
||||
}
|
||||
const parts = pathNodes
|
||||
.map(node => {
|
||||
if (!node || typeof node !== 'object') {
|
||||
return null;
|
||||
}
|
||||
const short = normalizeNodeNameValue(node.short_name ?? node.shortName) || (typeof node.node_id === 'string' ? node.node_id : '');
|
||||
const long = normalizeNodeNameValue(node.long_name ?? node.longName) || '';
|
||||
return renderShortHtml(short, node.role, long, node);
|
||||
})
|
||||
.filter(Boolean);
|
||||
if (!parts.length) return '';
|
||||
const arrow = '<span class="trace-tooltip__arrow" aria-hidden="true">→</span>';
|
||||
return `<div class="trace-tooltip__content">${parts.join(arrow)}</div>`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build tooltip HTML for a neighbor segment showing styled short-name badges.
|
||||
*
|
||||
* @param {{sourceNode?: Object, targetNode?: Object, sourceShortName?: string, targetShortName?: string, sourceRole?: string, targetRole?: string}} segment Neighbor segment descriptor.
|
||||
* @returns {string} HTML fragment or ``''`` when unavailable.
|
||||
*/
|
||||
function buildNeighborTooltipHtml(segment) {
|
||||
if (!segment) return '';
|
||||
const sourceNode = segment.sourceNode || null;
|
||||
const targetNode = segment.targetNode || null;
|
||||
const sourceShort = normalizeNodeNameValue(
|
||||
segment.sourceShortName ||
|
||||
(sourceNode ? sourceNode.short_name ?? sourceNode.shortName : null) ||
|
||||
(sourceNode && typeof sourceNode.node_id === 'string' ? sourceNode.node_id : '')
|
||||
);
|
||||
const targetShort = normalizeNodeNameValue(
|
||||
segment.targetShortName ||
|
||||
(targetNode ? targetNode.short_name ?? targetNode.shortName : null) ||
|
||||
(targetNode && typeof targetNode.node_id === 'string' ? targetNode.node_id : '')
|
||||
);
|
||||
if (!sourceShort || !targetShort) return '';
|
||||
const sourceLong = normalizeNodeNameValue(sourceNode?.long_name ?? sourceNode?.longName) || '';
|
||||
const targetLong = normalizeNodeNameValue(targetNode?.long_name ?? targetNode?.longName) || '';
|
||||
const sourceHtml = renderShortHtml(sourceShort, segment.sourceRole, sourceLong, sourceNode || {});
|
||||
const targetHtml = renderShortHtml(targetShort, segment.targetRole, targetLong, targetNode || {});
|
||||
const arrow = '<span class="trace-tooltip__arrow" aria-hidden="true">→</span>';
|
||||
return `<div class="trace-tooltip__content">${sourceHtml}${arrow}${targetHtml}</div>`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a node reference for a trace hop using cached node indices.
|
||||
*
|
||||
* @param {{id?: string, num?: number}|null} hop Trace hop descriptor.
|
||||
* @returns {?Object} Node payload when available.
|
||||
*/
|
||||
function resolveNodeForHop(hop) {
|
||||
if (!hop || typeof hop !== 'object') {
|
||||
return null;
|
||||
}
|
||||
const id = typeof hop.id === 'string' ? hop.id.trim() : null;
|
||||
const idCandidates = [];
|
||||
if (id) {
|
||||
idCandidates.push(id);
|
||||
idCandidates.push(id.toUpperCase());
|
||||
idCandidates.push(id.toLowerCase());
|
||||
}
|
||||
for (const candidate of idCandidates) {
|
||||
if (candidate && nodesById instanceof Map && nodesById.has(candidate)) {
|
||||
return nodesById.get(candidate);
|
||||
}
|
||||
}
|
||||
const numericCandidates = [];
|
||||
if (Number.isFinite(hop.num)) numericCandidates.push(hop.num);
|
||||
const parsedFromId = parseNodeNumericRef(id);
|
||||
if (parsedFromId != null) numericCandidates.push(parsedFromId);
|
||||
const parsedFromNum = parseNodeNumericRef(hop.num);
|
||||
if (parsedFromNum != null) numericCandidates.push(parsedFromNum);
|
||||
for (const numeric of numericCandidates) {
|
||||
if (Number.isFinite(numeric) && nodesByNum instanceof Map && nodesByNum.has(numeric)) {
|
||||
return nodesByNum.get(numeric);
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Derive display context for a chat log entry by inspecting node payloads.
|
||||
*
|
||||
@@ -2809,6 +3024,7 @@ export function initializeApp(config) {
|
||||
* telemetryEntries?: Array<Object>,
|
||||
* positionEntries?: Array<Object>,
|
||||
* neighborEntries?: Array<Object>,
|
||||
* traceEntries?: Array<Object>,
|
||||
* filterQuery?: string
|
||||
* }} params Render inputs.
|
||||
* @returns {void}
|
||||
@@ -2820,6 +3036,7 @@ export function initializeApp(config) {
|
||||
telemetryEntries = [],
|
||||
positionEntries = [],
|
||||
neighborEntries = [],
|
||||
traceEntries = [],
|
||||
filterQuery = ''
|
||||
}) {
|
||||
if (!CHAT_ENABLED || !chatEl) return;
|
||||
@@ -2834,6 +3051,7 @@ export function initializeApp(config) {
|
||||
telemetry: telemetryEntries,
|
||||
positions: positionEntries,
|
||||
neighbors: neighborEntries,
|
||||
traces: traceEntries,
|
||||
messages,
|
||||
logOnlyMessages: encryptedMessages,
|
||||
nowSeconds,
|
||||
@@ -3283,7 +3501,8 @@ export function initializeApp(config) {
|
||||
const effectiveLimit = Math.min(safeLimit, NODE_LIMIT);
|
||||
const r = await fetch(`/api/traces?limit=${effectiveLimit}`, { cache: 'no-store' });
|
||||
if (!r.ok) throw new Error('HTTP ' + r.status);
|
||||
return r.json();
|
||||
const traces = await r.json();
|
||||
return filterRecentTraces(traces, TRACE_MAX_AGE_SECONDS);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -3343,6 +3562,28 @@ export function initializeApp(config) {
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Filter trace entries to discard packets older than the configured window.
|
||||
*
|
||||
* @param {Array<Object>} traces Trace payloads.
|
||||
* @param {number} [maxAgeSeconds=TRACE_MAX_AGE_SECONDS] Maximum allowed age in seconds.
|
||||
* @returns {Array<Object>} Recent trace entries.
|
||||
*/
|
||||
function filterRecentTraces(traces, maxAgeSeconds = TRACE_MAX_AGE_SECONDS) {
|
||||
if (!Array.isArray(traces)) {
|
||||
return [];
|
||||
}
|
||||
if (!Number.isFinite(maxAgeSeconds) || maxAgeSeconds <= 0) {
|
||||
return [...traces];
|
||||
}
|
||||
const nowSeconds = Math.floor(Date.now() / 1000);
|
||||
const cutoff = nowSeconds - maxAgeSeconds;
|
||||
return traces.filter(trace => {
|
||||
const rxTime = resolveTimestampSeconds(trace?.rx_time ?? trace?.rxTime, trace?.rx_iso ?? trace?.rxIso);
|
||||
return rxTime != null && rxTime >= cutoff;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Merge recent position packets into the node list.
|
||||
*
|
||||
@@ -3623,6 +3864,9 @@ export function initializeApp(config) {
|
||||
if (neighborLinesLayer) {
|
||||
neighborLinesLayer.clearLayers();
|
||||
}
|
||||
if (traceLinesLayer) {
|
||||
traceLinesLayer.clearLayers();
|
||||
}
|
||||
markersLayer.clearLayers();
|
||||
const pts = [];
|
||||
const nodesById = new Map();
|
||||
@@ -3632,7 +3876,7 @@ export function initializeApp(config) {
|
||||
if (typeof nodeId !== 'string' || nodeId.length === 0) continue;
|
||||
nodesById.set(nodeId, node);
|
||||
}
|
||||
const traceSegments = neighborLinesLayer
|
||||
const traceSegments = traceLinesLayer
|
||||
? buildTraceSegments(allTraces, nodes, {
|
||||
limitDistance: LIMIT_DISTANCE,
|
||||
maxDistanceKm: MAX_DISTANCE_KM,
|
||||
@@ -3724,6 +3968,21 @@ export function initializeApp(config) {
|
||||
opacity: 0.42,
|
||||
className: 'neighbor-connection-line'
|
||||
}).addTo(neighborLinesLayer);
|
||||
if (polyline && typeof polyline.bindTooltip === 'function') {
|
||||
const tooltipHtml = buildNeighborTooltipHtml({
|
||||
...segment,
|
||||
sourceNode: nodesById.get(segment.sourceId),
|
||||
targetNode: nodesById.get(segment.targetId)
|
||||
});
|
||||
if (tooltipHtml) {
|
||||
polyline.bindTooltip(tooltipHtml, {
|
||||
direction: 'center',
|
||||
opacity: 0.92,
|
||||
sticky: true,
|
||||
className: 'trace-tooltip'
|
||||
});
|
||||
}
|
||||
}
|
||||
if (polyline && typeof polyline.on === 'function') {
|
||||
polyline.on('click', event => {
|
||||
if (event && event.originalEvent) {
|
||||
@@ -3741,6 +4000,13 @@ export function initializeApp(config) {
|
||||
? event.originalEvent.target
|
||||
: null;
|
||||
const anchorEl = polyline.getElement() || clickTarget;
|
||||
if (polyline && typeof polyline.isTooltipOpen === 'function' && typeof polyline.openTooltip === 'function') {
|
||||
if (polyline.isTooltipOpen()) {
|
||||
polyline.closeTooltip();
|
||||
} else {
|
||||
polyline.openTooltip();
|
||||
}
|
||||
}
|
||||
if (!anchorEl) return;
|
||||
if (overlayStack.isOpen(anchorEl)) {
|
||||
overlayStack.close(anchorEl);
|
||||
@@ -3752,7 +4018,7 @@ export function initializeApp(config) {
|
||||
});
|
||||
}
|
||||
|
||||
if (neighborLinesLayer && traceSegments.length) {
|
||||
if (traceLinesLayer && traceSegments.length) {
|
||||
traceSegments
|
||||
.sort((a, b) => {
|
||||
const rxA = Number.isFinite(a.rxTime) ? a.rxTime : -Infinity;
|
||||
@@ -3761,13 +4027,43 @@ export function initializeApp(config) {
|
||||
return rxA - rxB;
|
||||
})
|
||||
.forEach(segment => {
|
||||
L.polyline(segment.latlngs, {
|
||||
const polyline = L.polyline(segment.latlngs, {
|
||||
color: segment.color,
|
||||
weight: 2,
|
||||
opacity: 0.42,
|
||||
dashArray: '6 6',
|
||||
className: 'neighbor-connection-line trace-connection-line'
|
||||
}).addTo(neighborLinesLayer);
|
||||
}).addTo(traceLinesLayer);
|
||||
if (polyline && typeof polyline.bindTooltip === 'function') {
|
||||
const tooltipHtml = buildTraceTooltipHtml(segment.pathNodes);
|
||||
if (tooltipHtml) {
|
||||
polyline.bindTooltip(tooltipHtml, {
|
||||
direction: 'center',
|
||||
opacity: 0.92,
|
||||
sticky: true,
|
||||
className: 'trace-tooltip'
|
||||
});
|
||||
}
|
||||
}
|
||||
if (polyline && typeof polyline.on === 'function') {
|
||||
polyline.on('click', event => {
|
||||
if (event && event.originalEvent) {
|
||||
if (typeof event.originalEvent.preventDefault === 'function') {
|
||||
event.originalEvent.preventDefault();
|
||||
}
|
||||
if (typeof event.originalEvent.stopPropagation === 'function') {
|
||||
event.originalEvent.stopPropagation();
|
||||
}
|
||||
}
|
||||
if (polyline && typeof polyline.isTooltipOpen === 'function' && typeof polyline.openTooltip === 'function') {
|
||||
if (polyline.isTooltipOpen()) {
|
||||
polyline.closeTooltip();
|
||||
} else {
|
||||
polyline.openTooltip();
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -3917,6 +4213,7 @@ export function initializeApp(config) {
|
||||
telemetryEntries: allTelemetryEntries,
|
||||
positionEntries: allPositionEntries,
|
||||
neighborEntries: allNeighbors,
|
||||
traceEntries: allTraces,
|
||||
filterQuery
|
||||
});
|
||||
}
|
||||
|
||||
@@ -22,8 +22,26 @@
|
||||
*/
|
||||
function coerceFiniteNumber(value) {
|
||||
if (value == null) return null;
|
||||
if (typeof value === 'string' && value.trim().length === 0) return null;
|
||||
const num = typeof value === 'number' ? value : Number(value);
|
||||
if (typeof value === 'number') {
|
||||
return Number.isFinite(value) ? value : null;
|
||||
}
|
||||
if (typeof value === 'string') {
|
||||
const trimmed = value.trim();
|
||||
if (trimmed.length === 0) return null;
|
||||
if (trimmed.startsWith('!')) {
|
||||
const hex = trimmed.slice(1);
|
||||
if (!/^[0-9A-Fa-f]+$/.test(hex)) return null;
|
||||
const parsedHex = Number.parseInt(hex, 16);
|
||||
return Number.isFinite(parsedHex) ? parsedHex >>> 0 : null;
|
||||
}
|
||||
if (/^0[xX][0-9A-Fa-f]+$/.test(trimmed)) {
|
||||
const parsedHex = Number.parseInt(trimmed, 16);
|
||||
return Number.isFinite(parsedHex) ? parsedHex >>> 0 : null;
|
||||
}
|
||||
const parsed = Number(trimmed);
|
||||
return Number.isFinite(parsed) ? parsed : null;
|
||||
}
|
||||
const num = Number(value);
|
||||
return Number.isFinite(num) ? num : null;
|
||||
}
|
||||
|
||||
@@ -64,6 +82,19 @@ function buildNodeIndex(nodes) {
|
||||
return { byId, byNum };
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert a numeric node reference into the canonical hex-prefixed identifier.
|
||||
*
|
||||
* @param {number} ref Numeric node identifier.
|
||||
* @returns {string|null} Canonical identifier or ``null`` when invalid.
|
||||
*/
|
||||
function canonicalNodeIdFromNumeric(ref) {
|
||||
if (!Number.isFinite(ref)) return null;
|
||||
const unsigned = ref >>> 0;
|
||||
const hex = unsigned.toString(16).padStart(8, '0');
|
||||
return `!${hex}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Locate a node by either string identifier or numeric reference.
|
||||
*
|
||||
@@ -80,6 +111,12 @@ function findNode(byId, byNum, ref) {
|
||||
}
|
||||
if (numeric != null) {
|
||||
if (byNum.has(numeric)) return byNum.get(numeric) || null;
|
||||
const canonicalId = canonicalNodeIdFromNumeric(numeric);
|
||||
if (canonicalId) {
|
||||
if (byId.has(canonicalId)) return byId.get(canonicalId) || null;
|
||||
const canonicalUpper = canonicalId.toUpperCase();
|
||||
if (byId.has(canonicalUpper)) return byId.get(canonicalUpper) || null;
|
||||
}
|
||||
const asString = String(numeric);
|
||||
if (byId.has(asString)) return byId.get(asString) || null;
|
||||
}
|
||||
@@ -163,6 +200,8 @@ export function buildTraceSegments(traces, nodes, { limitDistance = false, maxDi
|
||||
const path = extractTracePath(trace);
|
||||
if (path.length < 2) continue;
|
||||
const rxTime = coerceFiniteNumber(trace.rx_time ?? trace.rxTime);
|
||||
const nodesWithCoords = [];
|
||||
const segmentsForTrace = [];
|
||||
let previous = null;
|
||||
|
||||
for (const ref of path) {
|
||||
@@ -172,8 +211,9 @@ export function buildTraceSegments(traces, nodes, { limitDistance = false, maxDi
|
||||
previous = null;
|
||||
continue;
|
||||
}
|
||||
nodesWithCoords.push(node);
|
||||
if (previous) {
|
||||
segments.push({
|
||||
segmentsForTrace.push({
|
||||
latlngs: [previous.coords, coords],
|
||||
color: colorResolver(previous.node),
|
||||
traceId: trace.id ?? trace.packet_id ?? trace.trace_id,
|
||||
@@ -182,6 +222,14 @@ export function buildTraceSegments(traces, nodes, { limitDistance = false, maxDi
|
||||
}
|
||||
previous = { node, coords };
|
||||
}
|
||||
|
||||
if (segmentsForTrace.length) {
|
||||
const pathNodes = nodesWithCoords.slice();
|
||||
segmentsForTrace.forEach(segment => {
|
||||
segment.pathNodes = pathNodes;
|
||||
});
|
||||
segments.push(...segmentsForTrace);
|
||||
}
|
||||
}
|
||||
|
||||
return segments;
|
||||
@@ -193,4 +241,5 @@ export const __testUtils = {
|
||||
findNode,
|
||||
resolveNodeCoordinates,
|
||||
extractTracePath,
|
||||
canonicalNodeIdFromNumeric,
|
||||
};
|
||||
|
||||
@@ -121,6 +121,25 @@ tbody tr:nth-child(even) td {
|
||||
stroke-dasharray: 6 6;
|
||||
}
|
||||
|
||||
.leaflet-tooltip.trace-tooltip {
|
||||
background: var(--bg2);
|
||||
color: var(--fg);
|
||||
border: 1px solid var(--line);
|
||||
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.18);
|
||||
padding: 6px 8px;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.trace-tooltip__content {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.trace-tooltip__arrow {
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.neighbor-snr {
|
||||
margin-left: 4px;
|
||||
color: var(--muted);
|
||||
|
||||
@@ -4657,6 +4657,26 @@ RSpec.describe "Potato Mesh Sinatra app" do
|
||||
expect(last_response).to be_ok
|
||||
expect(JSON.parse(last_response.body)).to eq([])
|
||||
end
|
||||
|
||||
it "excludes traces older than one week" do
|
||||
clear_database
|
||||
now = Time.now.to_i
|
||||
recent_rx = now - (PotatoMesh::Config.week_seconds / 2)
|
||||
stale_rx = now - (PotatoMesh::Config.week_seconds + 60)
|
||||
payload = [
|
||||
{ "id" => 50_001, "src" => 1, "dest" => 2, "rx_time" => recent_rx, "metrics" => {} },
|
||||
{ "id" => 50_002, "src" => 3, "dest" => 4, "rx_time" => stale_rx, "metrics" => {} },
|
||||
]
|
||||
|
||||
post "/api/traces", payload.to_json, auth_headers
|
||||
expect(last_response).to be_ok
|
||||
|
||||
get "/api/traces"
|
||||
|
||||
expect(last_response).to be_ok
|
||||
ids = JSON.parse(last_response.body).map { |row| row["id"] }
|
||||
expect(ids).to eq([50_001])
|
||||
end
|
||||
end
|
||||
|
||||
describe "GET /nodes/:id" do
|
||||
|
||||
@@ -113,10 +113,6 @@
|
||||
<option value=""><%= Rack::Utils.escape_html("Select region ...") %></option>
|
||||
</select>
|
||||
</div>
|
||||
<a href="/federation" class="federation-link" aria-label="View federation network" target="_blank" rel="noopener noreferrer">🔗</a>
|
||||
<a href="/map" class="federation-link" aria-label="Open map in new tab" target="_blank" rel="noopener noreferrer">🌍</a>
|
||||
<a href="/chat" class="federation-link" aria-label="Open chat in new tab" target="_blank" rel="noopener noreferrer">💬</a>
|
||||
<a href="/nodes" class="federation-link" aria-label="Open nodes in new tab" target="_blank" rel="noopener noreferrer">🪢</a>
|
||||
</div>
|
||||
<% end %>
|
||||
</header>
|
||||
|
||||
Reference in New Issue
Block a user