diff --git a/web/lib/potato_mesh/application/routes/root.rb b/web/lib/potato_mesh/application/routes/root.rb index 140980f..01e643a 100644 --- a/web/lib/potato_mesh/application/routes/root.rb +++ b/web/lib/potato_mesh/application/routes/root.rb @@ -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) diff --git a/web/public/assets/js/app/federation-page.js b/web/public/assets/js/app/federation-page.js new file mode 100644 index 0000000..cef7183 --- /dev/null +++ b/web/public/assets/js/app/federation-page.js @@ -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, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); +} + +/** + * 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} + */ +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 + ? `${escapeHtml(name)}
+ ${escapeHtml(instance.domain || '')}
+ ${instance.channel ? `Channel: ${escapeHtml(instance.channel)}
` : ''} + ${instance.frequency ? `Frequency: ${escapeHtml(instance.frequency)}
` : ''} + ${instance.version ? `Version: ${escapeHtml(instance.version)}` : ''}` + : `${escapeHtml(name)}`; + + const 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) + : ''; + const domainHtml = url + ? `${escapeHtml(instance.domain || '')}` + : escapeHtml(instance.domain || ''); + + tr.innerHTML = ` + ${nameHtml} + ${domainHtml} + ${escapeHtml(instance.version || '')} + ${escapeHtml(instance.channel || '')} + ${escapeHtml(instance.frequency || '')} + ${fmtCoords(instance.latitude)} + ${fmtCoords(instance.longitude)} + ${timeAgo(instance.lastUpdateTime, nowSec)} + `; + + frag.appendChild(tr); + } + + tableBody.replaceChildren(frag); + } +} diff --git a/web/public/assets/styles/base.css b/web/public/assets/styles/base.css index 086bf31..b14c972 100644 --- a/web/public/assets/styles/base.css +++ b/web/public/assets/styles/base.css @@ -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; + } +} diff --git a/web/views/federation.erb b/web/views/federation.erb new file mode 100644 index 0000000..e3cc1e6 --- /dev/null +++ b/web/views/federation.erb @@ -0,0 +1,35 @@ + +
+
+

Federation Network

+

+ This page shows other PotatoMesh instances in the federation network. + Each marker on the map represents a federated instance. + ← Back to Dashboard +

+
+
+
+ <%= erb :"shared/_map_panel", locals: { full_screen: false } %> +
+ <%= erb :"shared/_instances_table" %> +
+
+ diff --git a/web/views/layouts/app.erb b/web/views/layouts/app.erb index eff2ff0..36962bf 100644 --- a/web/views/layouts/app.erb +++ b/web/views/layouts/app.erb @@ -104,11 +104,14 @@ <%= site_name %> <% if !private_mode && federation_enabled %> -
- - +
+
+ + +
+ 🌐
<% end %> diff --git a/web/views/shared/_instances_table.erb b/web/views/shared/_instances_table.erb new file mode 100644 index 0000000..b3cf968 --- /dev/null +++ b/web/views/shared/_instances_table.erb @@ -0,0 +1,32 @@ + +
+ + + + + + + + + + + + + + +
NameDomainVersionChannelFrequencyLatitudeLongitudeLast Update
+