Add Federation Map (#532)

* Add Federation Map

* Apply suggestions from code review

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

---------

Co-authored-by: l5y <220195275+l5yth@users.noreply.github.com>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
This commit is contained in:
apo-mak
2025-12-04 13:24:54 +02:00
committed by GitHub
parent 6acb1c833c
commit 5be2ac417a
6 changed files with 474 additions and 5 deletions
@@ -186,6 +186,11 @@ module PotatoMesh
render_root_view(:charts, view_mode: :charts)
end
app.get %r{/federation/?} do
halt 404 unless federation_enabled?
render_root_view(:federation, view_mode: :federation)
end
app.get "/nodes/:id" do
node_ref = params.fetch("id", nil)
reference_payload = build_node_detail_reference(node_ref)
+233
View File
@@ -0,0 +1,233 @@
/*
* Copyright © 2025-26 l5yth & contributors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { readAppConfig } from './config.js';
import { mergeConfig } from './settings.js';
/**
* Escape HTML special characters to prevent XSS.
*
* @param {string} str Raw string to escape.
* @returns {string} Escaped string safe for HTML insertion.
*/
function escapeHtml(str) {
if (typeof str !== 'string') return '';
return str
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#039;');
}
/**
* Format a coordinate value to fixed decimal places.
*
* @param {number|null|undefined} v Coordinate value.
* @param {number} d Decimal places (default 5).
* @returns {string} Formatted coordinate or empty string.
*/
function fmtCoords(v, d = 5) {
if (v == null || v === '') return '';
const n = Number(v);
if (!Number.isFinite(n)) return '';
return n.toFixed(d);
}
/**
* Convert a Unix timestamp to a human-readable relative time string.
*
* @param {number|null|undefined} unixSec Unix timestamp in seconds.
* @param {number} nowSec Current timestamp in seconds.
* @returns {string} Relative time string or empty string.
*/
function timeAgo(unixSec, nowSec = Date.now() / 1000) {
if (unixSec == null || unixSec === '') return '';
const ts = Number(unixSec);
if (!Number.isFinite(ts) || ts <= 0) return '';
const diff = Math.max(0, Math.floor(nowSec - ts));
if (diff < 60) return `${diff}s ago`;
if (diff < 3600) return `${Math.floor(diff / 60)}m ago`;
if (diff < 86400) return `${Math.floor(diff / 3600)}h ago`;
return `${Math.floor(diff / 86400)}d ago`;
}
/**
* Build a navigable URL for an instance domain.
*
* @param {string} domain Instance domain.
* @returns {string|null} Navigable URL or null.
*/
function buildInstanceUrl(domain) {
if (typeof domain !== 'string' || !domain.trim()) return null;
const trimmed = domain.trim();
if (/^https?:\/\//i.test(trimmed)) return trimmed;
return `https://${trimmed}`;
}
/**
* Leaflet map instance for the federation page.
*
* @type {L.Map|null}
*/
let map = null;
/**
* Leaflet layer group for instance markers.
*
* @type {L.LayerGroup|null}
*/
let markersLayer = null;
/**
* Initialize the federation page by fetching instances, rendering the map,
* and populating the table.
*
* @returns {Promise<void>}
*/
export async function initializeFederationPage() {
const rawConfig = readAppConfig();
const config = mergeConfig(rawConfig);
const mapContainer = document.getElementById('map');
const tableBody = document.querySelector('#instances tbody');
const statusEl = document.getElementById('status');
const hasLeaflet =
typeof window !== 'undefined' &&
typeof window.L === 'object' &&
window.L &&
typeof window.L.map === 'function';
// Initialize the map if Leaflet is available
if (hasLeaflet && mapContainer) {
map = L.map(mapContainer, { worldCopyJump: true, attributionControl: false });
map.setView([config.mapCenter.lat, config.mapCenter.lon], 3);
// Determine theme and apply appropriate tile filter
const currentTheme = document.documentElement.getAttribute('data-theme') || 'dark';
const tileFilter =
currentTheme === 'dark' ? config.tileFilters.dark : config.tileFilters.light;
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
maxZoom: 19,
className: 'map-tiles'
}).addTo(map);
// Apply CSS filter to tiles
const style = document.createElement('style');
style.textContent = `.map-tiles { filter: ${tileFilter}; }`;
document.head.appendChild(style);
markersLayer = L.layerGroup().addTo(map);
}
// Fetch instances data
let instances = [];
try {
const response = await fetch('/api/instances', {
headers: { Accept: 'application/json' },
credentials: 'omit'
});
if (response.ok) {
instances = await response.json();
}
} catch (err) {
console.warn('Failed to fetch federation instances', err);
}
if (statusEl) {
statusEl.textContent = `${instances.length} instances`;
statusEl.classList.remove('pill--loading');
}
const nowSec = Date.now() / 1000;
// Render map markers
if (map && markersLayer && hasLeaflet && Array.isArray(instances)) {
const bounds = [];
for (const instance of instances) {
const lat = Number(instance.latitude);
const lon = Number(instance.longitude);
if (!Number.isFinite(lat) || !Number.isFinite(lon)) continue;
bounds.push([lat, lon]);
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 marker = L.circleMarker([lat, lon], {
radius: 8,
fillColor: '#4CAF50',
color: '#2E7D32',
weight: 2,
opacity: 1,
fillOpacity: 0.8
});
marker.bindPopup(popupContent);
markersLayer.addLayer(marker);
}
// Fit bounds if we have markers
if (bounds.length > 0) {
try {
map.fitBounds(bounds, { padding: [50, 50], maxZoom: 10 });
} catch (err) {
console.warn('Failed to fit map bounds', err);
}
}
}
// 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 || '');
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--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);
}
}
+161
View File
@@ -1948,3 +1948,164 @@ body.dark #map .leaflet-tile.map-tiles {
filter: var(--map-tiles-filter, var(--map-tile-filter-dark));
-webkit-filter: var(--map-tiles-filter, var(--map-tile-filter-dark));
}
/* ===========================
Federation Page Styles
=========================== */
.header-federation {
display: flex;
align-items: center;
gap: 12px;
}
.federation-link {
display: inline-flex;
align-items: center;
justify-content: center;
font-size: 1.5rem;
text-decoration: none;
padding: 4px 8px;
border-radius: 8px;
transition: background-color 160ms ease, transform 160ms ease;
}
.federation-link:hover {
background: var(--hover-bg, rgba(255, 255, 255, 0.1));
transform: scale(1.1);
}
.federation-page {
padding: 24px var(--pad) 48px;
max-width: 1600px;
margin: 0 auto;
width: 100%;
}
.federation-page__intro {
padding: 0 4px 16px;
}
.federation-page__intro h2 {
margin: 0 0 6px;
font-size: 1.6rem;
}
.federation-page__intro p {
margin: 0;
color: var(--muted);
}
.federation-page__back-link {
color: var(--accent);
text-decoration: none;
margin-left: 12px;
}
.federation-page__back-link:hover {
text-decoration: underline;
}
.federation-page__content {
display: flex;
flex-direction: column;
gap: 24px;
}
.federation-page__map-row {
width: 100%;
height: 400px;
border-radius: 8px;
overflow: hidden;
border: 1px solid var(--border);
}
.federation-page__map-row .map-panel {
height: 100%;
}
.federation-page__map-row #map {
height: 100%;
width: 100%;
}
.instances-table-wrapper {
width: 100%;
overflow-x: auto;
}
#instances {
width: 100%;
border-collapse: collapse;
font-size: 0.95rem;
}
#instances th,
#instances td {
padding: 10px 12px;
text-align: left;
border-bottom: 1px solid var(--border);
}
#instances th {
font-weight: 600;
background: var(--table-header-bg, var(--surface));
position: sticky;
top: 0;
z-index: 1;
}
#instances tbody tr:hover {
background: var(--hover-bg, rgba(255, 255, 255, 0.05));
}
#instances a {
color: var(--accent);
text-decoration: none;
}
#instances a:hover {
text-decoration: underline;
}
.instances-col--name {
min-width: 140px;
}
.instances-col--domain {
min-width: 180px;
}
.instances-col--version {
min-width: 80px;
}
.instances-col--channel,
.instances-col--frequency {
min-width: 100px;
}
.instances-col--latitude,
.instances-col--longitude {
min-width: 100px;
}
.instances-col--last-update {
min-width: 90px;
}
@media (max-width: 900px) {
.federation-page__map-row {
height: 300px;
}
.header-federation {
flex-direction: column;
align-items: flex-start;
gap: 8px;
}
.federation-link {
align-self: flex-end;
}
}
+35
View File
@@ -0,0 +1,35 @@
<!--
Copyright © 2025-26 l5yth & contributors
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
-->
<section class="federation-page">
<header class="federation-page__intro">
<h2>Federation Network</h2>
<p>
This page shows other PotatoMesh instances in the federation network.
Each marker on the map represents a federated instance.
<a href="/" class="federation-page__back-link">← Back to Dashboard</a>
</p>
</header>
<div class="federation-page__content">
<div class="federation-page__map-row">
<%= erb :"shared/_map_panel", locals: { full_screen: false } %>
</div>
<%= erb :"shared/_instances_table" %>
</div>
</section>
<script type="module">
import { initializeFederationPage } from '/assets/js/app/federation-page.js';
initializeFederationPage();
</script>
+8 -5
View File
@@ -104,11 +104,14 @@
<span class="site-title-text"><%= site_name %></span>
</h1>
<% if !private_mode && federation_enabled %>
<div class="instance-selector">
<label class="visually-hidden" for="instanceSelect">Select a region</label>
<select id="instanceSelect" class="instance-select" aria-label="Select instance region">
<option value=""><%= Rack::Utils.escape_html("Select region ...") %></option>
</select>
<div class="header-federation">
<div class="instance-selector">
<label class="visually-hidden" for="instanceSelect">Select a region</label>
<select id="instanceSelect" class="instance-select" aria-label="Select instance region">
<option value=""><%= Rack::Utils.escape_html("Select region ...") %></option>
</select>
</div>
<a href="/federation" class="federation-link" aria-label="View federation network">🌐</a>
</div>
<% end %>
</header>
+32
View File
@@ -0,0 +1,32 @@
<!--
Copyright © 2025-26 l5yth & contributors
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
-->
<div class="instances-table-wrapper">
<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--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>
</tr>
</thead>
<tbody></tbody>
</table>
</div>