Compare commits

..

12 Commits

Author SHA1 Message Date
JingleManSweep 7de6520ae7 Merge pull request #83 from ipnet-mesh/feat/js-filter-submit
Add auto-submit for filter controls on list pages
2026-02-08 22:11:10 +00:00
Louis King 5b8b2eda10 Fix mixed content blocking for static assets behind reverse proxy
Add ProxyHeadersMiddleware to trust X-Forwarded-Proto headers from
reverse proxies. This ensures url_for() generates HTTPS URLs when
the app is accessed via HTTPS through nginx or similar proxies.

Without this, static assets (CSS, JS) were blocked by browsers as
mixed content when the site was served over HTTPS.
2026-02-08 22:08:04 +00:00
Louis King 042a1b04fa Add auto-submit for filter controls on list pages
Filter forms now auto-submit when select dropdowns change or when
Enter is pressed in text inputs. Uses a data-auto-submit attribute
pattern for consistency with existing data attribute conventions.
2026-02-08 21:53:35 +00:00
JingleManSweep 5832cbf53a Merge pull request #82 from ipnet-mesh/chore/tidy-html-output
Refactored inline styles/SVG/scripts, improved SEO
2026-02-08 21:45:30 +00:00
Louis King c540e15432 Improve HTML output and SEO title tags
- Add Jinja2 whitespace control (trim_blocks, lstrip_blocks) to
  eliminate excessive newlines in rendered HTML output
- Reverse title tag order to "Page - Brand" for better SEO (specific
  content first, brand name second to avoid truncation)
- Add dynamic titles for node detail pages using node name
- Standardize UI text: Dashboard, Advertisements, Map, Members
- Remove refresh button from dashboard page

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-08 21:40:19 +00:00
Louis King 6b1b277c6c Refactor HTML output: extract inline CSS, JS, and SVGs
Extract inline styles, JavaScript, and SVG icons from templates into
reusable external resources for improved maintainability and caching.

New static files:
- static/css/app.css: Custom CSS (scrollbar, prose, animations, Leaflet)
- static/js/charts.js: Chart.js helpers with shared colors/options
- static/js/map-main.js: Full map page functionality
- static/js/map-node.js: Node detail page map
- static/js/qrcode-init.js: QR code generation

New icon macros in macros/icons.html:
- icon_info, icon_alert, icon_chart, icon_refresh, icon_menu
- icon_github, icon_globe, icon_error, icon_channel
- icon_success, icon_lock, icon_user, icon_email, icon_tag, icon_users

Updated templates to use external resources and icon macros:
- base.html, home.html, dashboard.html, map.html, node_detail.html
- nodes.html, messages.html, advertisements.html, members.html
- errors/404.html, admin/*.html

Net reduction: ~700 lines of inline code removed from templates.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-08 21:23:06 +00:00
Louis King 470c374f11 Remove redundant Show Chat Nodes checkbox from map
The Node Type dropdown already provides chat node filtering,
making the separate checkbox unnecessary.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-07 21:06:25 +00:00
Louis King 71859b2168 Adjust map zoom levels for mobile devices
- Mobile portrait (< 480px): padding [50, 50] for wider view
- Mobile landscape (< 768px): padding [75, 75]
- Desktop: padding [100, 100]

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-07 21:01:29 +00:00
Louis King 3d7ed53df3 Improve map UI and add QR code to node detail page
Map improvements:
- Change non-infra nodes from emojis to subtle blue circles
- Add "Show Chat Nodes" checkbox (hidden by default)
- Fix z-index for hovered marker labels
- Increase zoom on mobile devices
- Simplify legend to show Infrastructure and Node icons

Node detail page:
- Add QR code for meshcore:// contact protocol
- Move activity (first/last seen) to title row
- QR code positioned under public key with white background
- Protocol: meshcore://contact/add?name=<name>&public_key=<key>&type=<n>
- Type mapping: chat=1, repeater=2, room=3, sensor=4

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-07 20:51:25 +00:00
Louis King ceaef9178a Fixed map z-order 2026-02-07 20:13:24 +00:00
JingleManSweep 5ccb077188 Merge pull request #81 from ipnet-mesh/feat/public-node-map
Enhance map page with GPS fallback, infrastructure filter, and UI improvements
2026-02-07 20:09:13 +00:00
Louis King 8f660d6b94 Enhance map page with GPS fallback, infrastructure filter, and UI improvements
- Add GPS coordinate fallback: use tag coords, fall back to model coords
- Filter out nodes at (0, 0) coordinates (likely unset defaults)
- Add "Show" filter to toggle between All Nodes and Infrastructure Only
- Add "Show Labels" checkbox (labels hidden by default, appear on hover)
- Infrastructure nodes display network logo instead of emoji
- Add radius-based bounds filtering (20km) to prevent outlier zoom issues
- Position labels underneath pins, centered with transparent background
- Calculate and return infra_center for infrastructure node focus
- Initial map view focuses on infrastructure nodes when available
- Update popup button to outline style
- Add comprehensive tests for new functionality

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-07 20:05:56 +00:00
24 changed files with 1698 additions and 877 deletions
+8 -1
View File
@@ -11,6 +11,7 @@ from fastapi.responses import HTMLResponse, PlainTextResponse, Response
from fastapi.staticfiles import StaticFiles
from fastapi.templating import Jinja2Templates
from starlette.exceptions import HTTPException as StarletteHTTPException
from uvicorn.middleware.proxy_headers import ProxyHeadersMiddleware
from meshcore_hub import __version__
from meshcore_hub.common.schemas import RadioConfig
@@ -98,6 +99,10 @@ def create_app(
redoc_url=None,
)
# Trust proxy headers (X-Forwarded-Proto, X-Forwarded-For) for HTTPS detection
# This ensures url_for() generates correct HTTPS URLs behind a reverse proxy
app.add_middleware(ProxyHeadersMiddleware, trusted_hosts="*")
# Store configuration in app state (use args if provided, else settings)
app.state.api_url = api_url or settings.api_base_url
app.state.api_key = api_key or settings.api_key
@@ -123,8 +128,10 @@ def create_app(
network_welcome_text or settings.network_welcome_text
)
# Set up templates
# Set up templates with whitespace control
templates = Jinja2Templates(directory=str(TEMPLATES_DIR))
templates.env.trim_blocks = True # Remove first newline after block tags
templates.env.lstrip_blocks = True # Remove leading whitespace before block tags
app.state.templates = templates
# Initialize page loader for custom markdown pages
+55 -34
View File
@@ -65,11 +65,11 @@ async def map_data(request: Request) -> JSONResponse:
nodes = data.get("items", [])
total_nodes = len(nodes)
# Filter nodes with location tags
# Filter nodes with location (from tags or model)
for node in nodes:
tags = node.get("tags", [])
lat = None
lon = None
tag_lat = None
tag_lon = None
friendly_name = None
role = None
node_member_id = None
@@ -78,12 +78,12 @@ async def map_data(request: Request) -> JSONResponse:
key = tag.get("key")
if key == "lat":
try:
lat = float(tag.get("value"))
tag_lat = float(tag.get("value"))
except (ValueError, TypeError):
pass
elif key == "lon":
try:
lon = float(tag.get("value"))
tag_lon = float(tag.get("value"))
except (ValueError, TypeError):
pass
elif key == "friendly_name":
@@ -93,35 +93,40 @@ async def map_data(request: Request) -> JSONResponse:
elif key == "member_id":
node_member_id = tag.get("value")
if lat is not None and lon is not None:
nodes_with_coords += 1
# Use friendly_name, then node name, then public key prefix
display_name = (
friendly_name
or node.get("name")
or node.get("public_key", "")[:12]
)
public_key = node.get("public_key")
# Use tag coordinates if set, otherwise fall back to model coordinates
lat = tag_lat if tag_lat is not None else node.get("lat")
lon = tag_lon if tag_lon is not None else node.get("lon")
# Find owner member by member_id tag
owner = (
members_by_id.get(node_member_id) if node_member_id else None
)
# Skip nodes without coordinates or with (0, 0) which is likely unset
if lat is None or lon is None:
continue
if lat == 0.0 and lon == 0.0:
continue
nodes_with_location.append(
{
"public_key": public_key,
"name": display_name,
"adv_type": node.get("adv_type"),
"lat": lat,
"lon": lon,
"last_seen": node.get("last_seen"),
"role": role,
"is_infra": role == "infra",
"member_id": node_member_id,
"owner": owner,
}
)
nodes_with_coords += 1
# Use friendly_name, then node name, then public key prefix
display_name = (
friendly_name or node.get("name") or node.get("public_key", "")[:12]
)
public_key = node.get("public_key")
# Find owner member by member_id tag
owner = members_by_id.get(node_member_id) if node_member_id else None
nodes_with_location.append(
{
"public_key": public_key,
"name": display_name,
"adv_type": node.get("adv_type"),
"lat": lat,
"lon": lon,
"last_seen": node.get("last_seen"),
"role": role,
"is_infra": role == "infra",
"member_id": node_member_id,
"owner": owner,
}
)
else:
error = f"API returned status {response.status_code}"
logger.warning(f"Failed to fetch nodes: {error}")
@@ -130,11 +135,17 @@ async def map_data(request: Request) -> JSONResponse:
error = str(e)
logger.warning(f"Failed to fetch nodes for map: {e}")
# Calculate infrastructure node stats
infra_nodes = [n for n in nodes_with_location if n.get("is_infra")]
infra_count = len(infra_nodes)
logger.info(
f"Map data: {total_nodes} total nodes, " f"{nodes_with_coords} with coordinates"
f"Map data: {total_nodes} total nodes, "
f"{nodes_with_coords} with coordinates, "
f"{infra_count} infrastructure"
)
# Calculate center from nodes, or use default (0, 0)
# Calculate center from all nodes, or use default (0, 0)
center_lat = 0.0
center_lon = 0.0
if nodes_with_location:
@@ -145,6 +156,14 @@ async def map_data(request: Request) -> JSONResponse:
nodes_with_location
)
# Calculate separate center for infrastructure nodes
infra_center: dict[str, float] | None = None
if infra_nodes:
infra_center = {
"lat": sum(n["lat"] for n in infra_nodes) / len(infra_nodes),
"lon": sum(n["lon"] for n in infra_nodes) / len(infra_nodes),
}
return JSONResponse(
{
"nodes": nodes_with_location,
@@ -153,9 +172,11 @@ async def map_data(request: Request) -> JSONResponse:
"lat": center_lat,
"lon": center_lon,
},
"infra_center": infra_center,
"debug": {
"total_nodes": total_nodes,
"nodes_with_coords": nodes_with_coords,
"infra_nodes": infra_count,
"error": error,
},
}
+349
View File
@@ -0,0 +1,349 @@
/**
* MeshCore Hub - Custom Application Styles
*
* This file contains all custom CSS that extends the Tailwind/DaisyUI framework.
* Organized in sections:
* - Scrollbar styling
* - Table styling
* - Text utilities
* - Prose (markdown content) styling
* - View Transitions API
* - Card animations
* - Leaflet map theming
*/
/* ==========================================================================
Scrollbar Styling
========================================================================== */
::-webkit-scrollbar {
width: 8px;
}
::-webkit-scrollbar-track {
background: oklch(var(--b2));
}
::-webkit-scrollbar-thumb {
background: oklch(var(--bc) / 0.3);
border-radius: 4px;
}
::-webkit-scrollbar-thumb:hover {
background: oklch(var(--bc) / 0.5);
}
/* ==========================================================================
Table Styling
========================================================================== */
.table-compact td,
.table-compact th {
padding: 0.5rem 0.75rem;
}
/* ==========================================================================
Text Utilities
========================================================================== */
.truncate-cell {
max-width: 200px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
/* ==========================================================================
Prose Styling (Custom Markdown Pages)
========================================================================== */
.prose h1 {
font-size: 2.25rem;
font-weight: 700;
margin-top: 1.5rem;
margin-bottom: 1rem;
}
.prose h2 {
font-size: 1.875rem;
font-weight: 600;
margin-top: 1.25rem;
margin-bottom: 0.75rem;
}
.prose h3 {
font-size: 1.5rem;
font-weight: 600;
margin-top: 1rem;
margin-bottom: 0.5rem;
}
.prose h4 {
font-size: 1.25rem;
font-weight: 600;
margin-top: 1rem;
margin-bottom: 0.5rem;
}
.prose p {
margin-bottom: 1rem;
line-height: 1.75;
}
.prose ul,
.prose ol {
margin-bottom: 1rem;
padding-left: 1.5rem;
}
.prose ul {
list-style-type: disc;
}
.prose ol {
list-style-type: decimal;
}
.prose li {
margin-bottom: 0.25rem;
}
.prose a {
color: oklch(var(--p));
text-decoration: underline;
}
.prose a:hover {
color: oklch(var(--pf));
}
.prose code {
background: oklch(var(--b2));
padding: 0.125rem 0.25rem;
border-radius: 0.25rem;
font-size: 0.875em;
}
.prose pre {
background: oklch(var(--b2));
padding: 1rem;
border-radius: 0.5rem;
overflow-x: auto;
margin-bottom: 1rem;
}
.prose pre code {
background: none;
padding: 0;
}
.prose blockquote {
border-left: 4px solid oklch(var(--bc) / 0.3);
padding-left: 1rem;
margin: 1rem 0;
font-style: italic;
}
.prose table {
width: 100%;
margin-bottom: 1rem;
border-collapse: collapse;
}
.prose th,
.prose td {
border: 1px solid oklch(var(--bc) / 0.2);
padding: 0.5rem;
text-align: left;
}
.prose th {
background: oklch(var(--b2));
font-weight: 600;
}
.prose hr {
border: none;
border-top: 1px solid oklch(var(--bc) / 0.2);
margin: 2rem 0;
}
.prose img {
max-width: 100%;
height: auto;
border-radius: 0.5rem;
margin: 1rem 0;
}
/* ==========================================================================
View Transitions API
========================================================================== */
/* Named transition elements */
.navbar {
view-transition-name: navbar;
position: relative;
z-index: 50;
}
main {
view-transition-name: main-content;
position: relative;
z-index: 10;
}
footer {
view-transition-name: footer;
position: relative;
z-index: 10;
}
/* Subtle slide + fade for main content */
::view-transition-old(main-content) {
animation: vt-fade-out 200ms ease-out forwards;
}
::view-transition-new(main-content) {
animation: vt-slide-up 250ms ease-out forwards;
}
/* Keep navbar and footer stable */
::view-transition-old(navbar),
::view-transition-new(navbar),
::view-transition-old(footer),
::view-transition-new(footer) {
animation: none;
}
/* Subtle crossfade for background */
::view-transition-old(root),
::view-transition-new(root) {
animation-duration: 150ms;
}
@keyframes vt-fade-out {
from {
opacity: 1;
}
to {
opacity: 0;
}
}
@keyframes vt-slide-up {
from {
opacity: 0;
transform: translateY(10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
/* ==========================================================================
Card Entrance Animations
========================================================================== */
/* Only for stat cards with .animate-stagger class */
.animate-stagger > .card,
.animate-stagger > .stat {
animation: card-fade-in 300ms ease-out backwards;
}
.animate-stagger > :nth-child(1) {
animation-delay: 0ms;
}
.animate-stagger > :nth-child(2) {
animation-delay: 50ms;
}
.animate-stagger > :nth-child(3) {
animation-delay: 100ms;
}
.animate-stagger > :nth-child(4) {
animation-delay: 150ms;
}
.animate-stagger > :nth-child(5) {
animation-delay: 200ms;
}
.animate-stagger > :nth-child(6) {
animation-delay: 250ms;
}
@keyframes card-fade-in {
from {
opacity: 0;
transform: translateY(8px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
/* ==========================================================================
Reduced Motion Preferences
========================================================================== */
@media (prefers-reduced-motion: reduce) {
.card,
::view-transition-old(main-content),
::view-transition-new(main-content),
::view-transition-old(root),
::view-transition-new(root) {
animation: none !important;
}
}
/* ==========================================================================
Leaflet Map Theming (Dark Mode)
========================================================================== */
/* Popup styling */
.leaflet-popup-content-wrapper {
background: oklch(var(--b1));
color: oklch(var(--bc));
}
.leaflet-popup-tip {
background: oklch(var(--b1));
}
/* Map container defaults */
#map,
#node-map {
border-radius: var(--rounded-box);
}
#map {
height: calc(100vh - 350px);
min-height: 400px;
}
#node-map {
height: 300px;
}
/* Map label visibility */
.map-label {
opacity: 0;
transition: opacity 0.15s ease-in-out;
}
.map-marker:hover .map-label {
opacity: 1;
}
.show-labels .map-label {
opacity: 1;
}
/* Bring hovered marker to front */
.leaflet-marker-icon:hover {
z-index: 10000 !important;
}
+216
View File
@@ -0,0 +1,216 @@
/**
* MeshCore Hub - Chart.js Helpers
*
* Provides common chart configuration and initialization helpers
* for activity charts used on home and dashboard pages.
*/
/**
* OKLCH color values for consistent theming
*/
const ChartColors = {
// Primary (purple/blue)
primary: 'oklch(0.65 0.24 265)',
primaryFill: 'oklch(0.65 0.24 265 / 0.1)',
// Secondary (pink/magenta)
secondary: 'oklch(0.7 0.17 330)',
secondaryFill: 'oklch(0.7 0.17 330 / 0.1)',
// Accent (teal/cyan)
accent: 'oklch(0.75 0.18 180)',
accentFill: 'oklch(0.75 0.18 180 / 0.1)',
// Neutral grays
grid: 'oklch(0.4 0 0 / 0.2)',
text: 'oklch(0.7 0 0)',
tooltipBg: 'oklch(0.25 0 0)',
tooltipText: 'oklch(0.9 0 0)',
tooltipBorder: 'oklch(0.4 0 0)'
};
/**
* Create common chart options with optional legend
* @param {boolean} showLegend - Whether to show the legend
* @returns {Object} Chart.js options object
*/
function createChartOptions(showLegend) {
return {
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: {
display: showLegend,
position: 'bottom',
labels: {
color: ChartColors.text,
boxWidth: 12,
padding: 8
}
},
tooltip: {
mode: 'index',
intersect: false,
backgroundColor: ChartColors.tooltipBg,
titleColor: ChartColors.tooltipText,
bodyColor: ChartColors.tooltipText,
borderColor: ChartColors.tooltipBorder,
borderWidth: 1
}
},
scales: {
x: {
grid: { color: ChartColors.grid },
ticks: {
color: ChartColors.text,
maxRotation: 45,
minRotation: 45,
maxTicksLimit: 10
}
},
y: {
beginAtZero: true,
grid: { color: ChartColors.grid },
ticks: {
color: ChartColors.text,
precision: 0
}
}
},
interaction: {
mode: 'nearest',
axis: 'x',
intersect: false
}
};
}
/**
* Format date labels for chart display (e.g., "8 Feb")
* @param {Array} data - Array of objects with 'date' property
* @returns {Array} Formatted date strings
*/
function formatDateLabels(data) {
return data.map(function(d) {
var date = new Date(d.date);
return date.toLocaleDateString('en-GB', { day: 'numeric', month: 'short' });
});
}
/**
* Create a single-dataset line chart
* @param {string} canvasId - ID of the canvas element
* @param {Object} data - Data object with 'data' array containing {date, count} objects
* @param {string} label - Dataset label
* @param {string} borderColor - Line color
* @param {string} backgroundColor - Fill color
* @param {boolean} fill - Whether to fill under the line
*/
function createLineChart(canvasId, data, label, borderColor, backgroundColor, fill) {
var ctx = document.getElementById(canvasId);
if (!ctx || !data || !data.data || data.data.length === 0) {
return null;
}
return new Chart(ctx, {
type: 'line',
data: {
labels: formatDateLabels(data.data),
datasets: [{
label: label,
data: data.data.map(function(d) { return d.count; }),
borderColor: borderColor,
backgroundColor: backgroundColor,
fill: fill,
tension: 0.3,
pointRadius: 2,
pointHoverRadius: 5
}]
},
options: createChartOptions(false)
});
}
/**
* Create a multi-dataset activity chart (for home page)
* @param {string} canvasId - ID of the canvas element
* @param {Object} advertData - Advertisement data with 'data' array
* @param {Object} messageData - Message data with 'data' array
*/
function createActivityChart(canvasId, advertData, messageData) {
var ctx = document.getElementById(canvasId);
if (!ctx || !advertData || !advertData.data || advertData.data.length === 0) {
return null;
}
var labels = formatDateLabels(advertData.data);
var advertCounts = advertData.data.map(function(d) { return d.count; });
var messageCounts = messageData && messageData.data
? messageData.data.map(function(d) { return d.count; })
: [];
return new Chart(ctx, {
type: 'line',
data: {
labels: labels,
datasets: [{
label: 'Advertisements',
data: advertCounts,
borderColor: ChartColors.secondary,
backgroundColor: ChartColors.secondaryFill,
fill: false,
tension: 0.3,
pointRadius: 2,
pointHoverRadius: 5
}, {
label: 'Messages',
data: messageCounts,
borderColor: ChartColors.accent,
backgroundColor: ChartColors.accentFill,
fill: false,
tension: 0.3,
pointRadius: 2,
pointHoverRadius: 5
}]
},
options: createChartOptions(true)
});
}
/**
* Initialize dashboard charts (nodes, advertisements, messages)
* @param {Object} nodeData - Node count data
* @param {Object} advertData - Advertisement data
* @param {Object} messageData - Message data
*/
function initDashboardCharts(nodeData, advertData, messageData) {
// Node count chart (primary color)
createLineChart(
'nodeChart',
nodeData,
'Total Nodes',
ChartColors.primary,
ChartColors.primaryFill,
true
);
// Advertisements chart (secondary color)
createLineChart(
'advertChart',
advertData,
'Advertisements',
ChartColors.secondary,
ChartColors.secondaryFill,
true
);
// Messages chart (accent color)
createLineChart(
'messageChart',
messageData,
'Messages',
ChartColors.accent,
ChartColors.accentFill,
true
);
}
+372
View File
@@ -0,0 +1,372 @@
/**
* MeshCore Hub - Main Map Page
*
* Full map functionality with filters, markers, and clustering.
* Requires Leaflet.js to be loaded before this script.
*
* Configuration:
* - Set window.mapConfig.logoUrl before loading this script
* - Set window.mapConfig.dataUrl for the data endpoint (default: '/map/data')
*/
(function() {
'use strict';
// Configuration (can be set before script loads)
var config = window.mapConfig || {};
var logoUrl = config.logoUrl || '/static/img/logo.svg';
var dataUrl = config.dataUrl || '/map/data';
// Initialize map with world view (will be centered on nodes once loaded)
var map = L.map('map').setView([0, 0], 2);
// Add tile layer
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
attribution: '&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors'
}).addTo(map);
// Store all nodes and markers
var allNodes = [];
var allMembers = [];
var markers = [];
var mapCenter = { lat: 0, lon: 0 };
var infraCenter = null;
// Maximum radius (km) from anchor point for bounds calculation
var MAX_BOUNDS_RADIUS_KM = 20;
// Padding for fitBounds - more padding on mobile for tighter zoom
var isMobilePortrait = window.innerWidth < 480;
var isMobile = window.innerWidth < 768;
var BOUNDS_PADDING = isMobilePortrait ? [50, 50] : (isMobile ? [75, 75] : [100, 100]);
/**
* Calculate distance between two points in km (Haversine formula)
*/
function getDistanceKm(lat1, lon1, lat2, lon2) {
var R = 6371; // Earth's radius in km
var dLat = (lat2 - lat1) * Math.PI / 180;
var dLon = (lon2 - lon1) * Math.PI / 180;
var a = Math.sin(dLat/2) * Math.sin(dLat/2) +
Math.cos(lat1 * Math.PI / 180) * Math.cos(lat2 * Math.PI / 180) *
Math.sin(dLon/2) * Math.sin(dLon/2);
var c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1-a));
return R * c;
}
/**
* Filter nodes within radius of anchor point for bounds calculation
*/
function getNodesWithinRadius(nodes, anchorLat, anchorLon, radiusKm) {
return nodes.filter(function(n) {
return getDistanceKm(anchorLat, anchorLon, n.lat, n.lon) <= radiusKm;
});
}
/**
* Get anchor point for bounds calculation (infra center or nodes center)
*/
function getAnchorPoint(nodes) {
if (infraCenter) {
return infraCenter;
}
// Fall back to center of provided nodes
if (nodes.length === 0) return { lat: 0, lon: 0 };
return {
lat: nodes.reduce(function(sum, n) { return sum + n.lat; }, 0) / nodes.length,
lon: nodes.reduce(function(sum, n) { return sum + n.lon; }, 0) / nodes.length
};
}
/**
* Normalize adv_type to lowercase for consistent comparison
*/
function normalizeType(type) {
return type ? type.toLowerCase() : null;
}
/**
* Get display name for node type
*/
function getTypeDisplay(node) {
var type = normalizeType(node.adv_type);
if (type === 'chat') return 'Chat';
if (type === 'repeater') return 'Repeater';
if (type === 'room') return 'Room';
return type ? type.charAt(0).toUpperCase() + type.slice(1) : 'Unknown';
}
/**
* Create marker icon for a node
*/
function createNodeIcon(node) {
var displayName = node.name || '';
var relativeTime = typeof formatRelativeTime === 'function' ? formatRelativeTime(node.last_seen) : '';
var timeDisplay = relativeTime ? ' (' + relativeTime + ')' : '';
// Use logo for infrastructure nodes, blue circle for others
var iconHtml;
if (node.is_infra) {
iconHtml = '<img src="' + logoUrl + '" alt="Infra" style="width: 24px; height: 24px; filter: drop-shadow(0 0 2px #1a237e) drop-shadow(0 0 4px #1a237e) drop-shadow(0 1px 2px rgba(0,0,0,0.7));">';
} else {
iconHtml = '<div style="width: 12px; height: 12px; background: #3b82f6; border: 2px solid #1e40af; border-radius: 50%; box-shadow: 0 0 4px rgba(59,130,246,0.6), 0 1px 2px rgba(0,0,0,0.5);"></div>';
}
return L.divIcon({
className: 'custom-div-icon',
html: '<div class="map-marker" style="display: flex; flex-direction: column; align-items: center; gap: 2px;">' +
iconHtml +
'<span class="map-label" style="font-size: 10px; font-weight: bold; color: #fff; background: rgba(0,0,0,0.5); padding: 1px 4px; border-radius: 3px; white-space: nowrap; text-align: center;">' + displayName + timeDisplay + '</span>' +
'</div>',
iconSize: [120, 50],
iconAnchor: [60, 12]
});
}
/**
* Create popup content for a node
*/
function createPopupContent(node) {
var ownerHtml = '';
if (node.owner) {
var ownerDisplay = node.owner.callsign
? node.owner.name + ' (' + node.owner.callsign + ')'
: node.owner.name;
ownerHtml = '<p><span class="opacity-70">Owner:</span> ' + ownerDisplay + '</p>';
}
var roleHtml = '';
if (node.role) {
roleHtml = '<p><span class="opacity-70">Role:</span> <span class="badge badge-xs badge-ghost">' + node.role + '</span></p>';
}
var typeDisplay = getTypeDisplay(node);
// Use logo for infrastructure nodes, blue circle for others
var iconHtml = node.is_infra
? '<img src="' + logoUrl + '" alt="Infra" style="width: 20px; height: 20px; display: inline-block; vertical-align: middle;">'
: '<span style="display: inline-block; width: 12px; height: 12px; background: #3b82f6; border: 2px solid #1e40af; border-radius: 50%; vertical-align: middle;"></span>';
var lastSeenHtml = node.last_seen
? '<p><span class="opacity-70">Last seen:</span> ' + node.last_seen.substring(0, 19).replace('T', ' ') + '</p>'
: '';
return '<div class="p-2">' +
'<h3 class="font-bold text-lg mb-2">' + iconHtml + ' ' + node.name + '</h3>' +
'<div class="space-y-1 text-sm">' +
'<p><span class="opacity-70">Type:</span> ' + typeDisplay + '</p>' +
roleHtml +
ownerHtml +
'<p><span class="opacity-70">Key:</span> <code class="text-xs">' + node.public_key.substring(0, 16) + '...</code></p>' +
'<p><span class="opacity-70">Location:</span> ' + node.lat.toFixed(4) + ', ' + node.lon.toFixed(4) + '</p>' +
lastSeenHtml +
'</div>' +
'<a href="/nodes/' + node.public_key + '" class="btn btn-outline btn-xs mt-3">View Details</a>' +
'</div>';
}
/**
* Clear all markers from map
*/
function clearMarkers() {
markers.forEach(function(marker) {
map.removeLayer(marker);
});
markers = [];
}
/**
* Core filter logic - returns filtered nodes and updates markers
*/
function applyFiltersCore() {
var categoryFilter = document.getElementById('filter-category').value;
var typeFilter = document.getElementById('filter-type').value;
var memberFilter = document.getElementById('filter-member').value;
// Filter nodes
var filteredNodes = allNodes.filter(function(node) {
// Category filter (infrastructure only)
if (categoryFilter === 'infra' && !node.is_infra) return false;
// Type filter (case-insensitive)
var nodeType = normalizeType(node.adv_type);
if (typeFilter && nodeType !== typeFilter) return false;
// Member filter - match node's member_id tag to selected member_id
if (memberFilter) {
if (node.member_id !== memberFilter) return false;
}
return true;
});
// Clear existing markers
clearMarkers();
// Add filtered markers
filteredNodes.forEach(function(node) {
var marker = L.marker([node.lat, node.lon], { icon: createNodeIcon(node) }).addTo(map);
marker.bindPopup(createPopupContent(node));
markers.push(marker);
});
// Update counts
var countEl = document.getElementById('node-count');
var filteredEl = document.getElementById('filtered-count');
if (filteredNodes.length === allNodes.length) {
countEl.textContent = allNodes.length + ' nodes on map';
filteredEl.classList.add('hidden');
} else {
countEl.textContent = allNodes.length + ' total';
filteredEl.textContent = filteredNodes.length + ' shown';
filteredEl.classList.remove('hidden');
}
return filteredNodes;
}
/**
* Apply filters and recenter map on filtered nodes
*/
function applyFilters() {
var filteredNodes = applyFiltersCore();
var categoryFilter = document.getElementById('filter-category').value;
// Fit bounds if we have filtered nodes
if (filteredNodes.length > 0) {
var nodesToFit = filteredNodes;
// Apply radius filter when showing all nodes (not infra-only)
if (categoryFilter !== 'infra') {
var anchor = getAnchorPoint(filteredNodes);
var nearbyNodes = getNodesWithinRadius(filteredNodes, anchor.lat, anchor.lon, MAX_BOUNDS_RADIUS_KM);
if (nearbyNodes.length > 0) {
nodesToFit = nearbyNodes;
}
}
var bounds = L.latLngBounds(nodesToFit.map(function(n) { return [n.lat, n.lon]; }));
map.fitBounds(bounds, { padding: BOUNDS_PADDING });
} else if (mapCenter.lat !== 0 || mapCenter.lon !== 0) {
map.setView([mapCenter.lat, mapCenter.lon], 10);
}
}
/**
* Apply filters without recentering (for initial load after manual center)
*/
function applyFiltersNoRecenter() {
applyFiltersCore();
}
/**
* Populate member filter dropdown
*/
function populateMemberFilter() {
var select = document.getElementById('filter-member');
// Sort members by name
var sortedMembers = allMembers.slice().sort(function(a, b) {
return a.name.localeCompare(b.name);
});
// Add options for all members
sortedMembers.forEach(function(member) {
if (member.member_id) {
var option = document.createElement('option');
option.value = member.member_id;
option.textContent = member.callsign
? member.name + ' (' + member.callsign + ')'
: member.name;
select.appendChild(option);
}
});
}
/**
* Clear all filters
*/
function clearFilters() {
document.getElementById('filter-category').value = '';
document.getElementById('filter-type').value = '';
document.getElementById('filter-member').value = '';
document.getElementById('show-labels').checked = false;
updateLabelVisibility();
applyFilters();
}
/**
* Toggle label visibility
*/
function updateLabelVisibility() {
var showLabels = document.getElementById('show-labels').checked;
var mapEl = document.getElementById('map');
if (showLabels) {
mapEl.classList.add('show-labels');
} else {
mapEl.classList.remove('show-labels');
}
}
// Event listeners for filters
document.getElementById('filter-category').addEventListener('change', applyFilters);
document.getElementById('filter-type').addEventListener('change', applyFilters);
document.getElementById('filter-member').addEventListener('change', applyFilters);
document.getElementById('show-labels').addEventListener('change', updateLabelVisibility);
document.getElementById('clear-filters').addEventListener('click', clearFilters);
// Fetch and display nodes
fetch(dataUrl)
.then(function(response) { return response.json(); })
.then(function(data) {
allNodes = data.nodes;
allMembers = data.members || [];
mapCenter = data.center;
infraCenter = data.infra_center;
// Log debug info
var debug = data.debug || {};
console.log('Map data loaded:', debug);
console.log('Sample node data:', allNodes.length > 0 ? allNodes[0] : 'No nodes');
if (debug.error) {
document.getElementById('node-count').textContent = 'Error: ' + debug.error;
return;
}
if (debug.total_nodes === 0) {
document.getElementById('node-count').textContent = 'No nodes in database';
return;
}
if (debug.nodes_with_coords === 0) {
document.getElementById('node-count').textContent = debug.total_nodes + ' nodes (none have coordinates)';
return;
}
// Populate member filter
populateMemberFilter();
// Initial display - center map on infrastructure nodes if available, else nodes within radius
var infraNodes = allNodes.filter(function(n) { return n.is_infra; });
if (infraNodes.length > 0) {
var bounds = L.latLngBounds(infraNodes.map(function(n) { return [n.lat, n.lon]; }));
map.fitBounds(bounds, { padding: BOUNDS_PADDING });
} else if (allNodes.length > 0) {
// Use radius filter to exclude outliers
var anchor = getAnchorPoint(allNodes);
var nearbyNodes = getNodesWithinRadius(allNodes, anchor.lat, anchor.lon, MAX_BOUNDS_RADIUS_KM);
var nodesToFit = nearbyNodes.length > 0 ? nearbyNodes : allNodes;
var bounds = L.latLngBounds(nodesToFit.map(function(n) { return [n.lat, n.lon]; }));
map.fitBounds(bounds, { padding: BOUNDS_PADDING });
}
// Apply filters (won't re-center since we just did above)
applyFiltersNoRecenter();
})
.catch(function(error) {
console.error('Error loading map data:', error);
document.getElementById('node-count').textContent = 'Error loading data';
});
})();
@@ -0,0 +1,79 @@
/**
* MeshCore Hub - Node Detail Map
*
* Simple map for displaying a single node's location.
* Requires Leaflet.js to be loaded before this script.
*
* Configuration via window.nodeMapConfig:
* - lat: Node latitude (required)
* - lon: Node longitude (required)
* - name: Node display name (required)
* - type: Node adv_type (optional)
* - publicKey: Node public key (optional, for linking)
*/
(function() {
'use strict';
// Get configuration
var config = window.nodeMapConfig;
if (!config || typeof config.lat === 'undefined' || typeof config.lon === 'undefined') {
console.warn('Node map config missing or invalid');
return;
}
var nodeLat = config.lat;
var nodeLon = config.lon;
var nodeName = config.name || 'Unnamed Node';
var nodeType = config.type || '';
// Check if map container exists
var mapContainer = document.getElementById('node-map');
if (!mapContainer) {
return;
}
// Initialize map centered on the node's location
var map = L.map('node-map').setView([nodeLat, nodeLon], 15);
// Add tile layer
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
attribution: '&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors'
}).addTo(map);
/**
* Get emoji marker based on node type
*/
function getNodeEmoji(type) {
var normalizedType = type ? type.toLowerCase() : null;
if (normalizedType === 'chat') return '💬';
if (normalizedType === 'repeater') return '📡';
if (normalizedType === 'room') return '🪧';
return '📍';
}
// Create marker icon (just the emoji, no label)
var emoji = getNodeEmoji(nodeType);
var icon = L.divIcon({
className: 'custom-div-icon',
html: '<span style="font-size: 32px; text-shadow: 0 0 3px #1a237e, 0 0 6px #1a237e, 0 1px 2px rgba(0,0,0,0.7);">' + emoji + '</span>',
iconSize: [32, 32],
iconAnchor: [16, 16]
});
// Add marker
var marker = L.marker([nodeLat, nodeLon], { icon: icon }).addTo(map);
// Build popup content
var typeHtml = nodeType ? '<p><span class="opacity-70">Type:</span> ' + nodeType + '</p>' : '';
var popupContent = '<div class="p-2">' +
'<h3 class="font-bold text-lg mb-2">' + emoji + ' ' + nodeName + '</h3>' +
'<div class="space-y-1 text-sm">' +
typeHtml +
'<p><span class="opacity-70">Coordinates:</span> ' + nodeLat.toFixed(4) + ', ' + nodeLon.toFixed(4) + '</p>' +
'</div>' +
'</div>';
// Add popup (shown on click, not by default)
marker.bindPopup(popupContent);
})();
@@ -0,0 +1,60 @@
/**
* MeshCore Hub - QR Code Generation
*
* Generates QR codes for adding MeshCore contacts.
* Requires qrcodejs library to be loaded before this script.
*
* Configuration via window.qrCodeConfig:
* - name: Contact name (required)
* - publicKey: 64-char hex public key (required)
* - advType: Node advertisement type (optional)
* - containerId: ID of container element (default: 'qr-code')
*/
(function() {
'use strict';
// Get configuration
var config = window.qrCodeConfig;
if (!config || !config.publicKey) {
console.warn('QR code config missing or invalid');
return;
}
var nodeName = config.name || 'Node';
var publicKey = config.publicKey;
var advType = config.advType || '';
var containerId = config.containerId || 'qr-code';
// Map adv_type to numeric type for meshcore:// protocol
var typeMap = {
'chat': 1,
'repeater': 2,
'room': 3,
'sensor': 4
};
var typeNum = typeMap[advType.toLowerCase()] || 1;
// Build meshcore:// URL
var meshcoreUrl = 'meshcore://contact/add?name=' + encodeURIComponent(nodeName) +
'&public_key=' + publicKey +
'&type=' + typeNum;
// Generate QR code
var qrContainer = document.getElementById(containerId);
if (qrContainer && typeof QRCode !== 'undefined') {
try {
new QRCode(qrContainer, {
text: meshcoreUrl,
width: 256,
height: 256,
colorDark: '#000000',
colorLight: '#ffffff',
correctLevel: QRCode.CorrectLevel.L
});
} catch (error) {
console.error('QR code generation failed:', error);
qrContainer.innerHTML = '<p class="text-sm opacity-50">QR code unavailable</p>';
}
}
})();
+26
View File
@@ -70,9 +70,35 @@ function populateRelativeTimeElements() {
});
}
/**
* Initialize auto-submit behavior for filter forms
* Forms with data-auto-submit attribute will auto-submit on:
* - Change events on select and checkbox inputs
* - Enter key on text inputs
*/
function initAutoSubmitForms() {
document.querySelectorAll('form[data-auto-submit]').forEach(form => {
// Auto-submit on select/checkbox change
form.querySelectorAll('select, input[type="checkbox"]').forEach(el => {
el.addEventListener('change', () => form.submit());
});
// Submit on Enter key for text inputs
form.querySelectorAll('input[type="text"]').forEach(el => {
el.addEventListener('keydown', (e) => {
if (e.key === 'Enter') {
e.preventDefault();
form.submit();
}
});
});
});
}
// Auto-populate when DOM is ready
document.addEventListener('DOMContentLoaded', () => {
populateRelativeTimestamps();
populateReceiverTooltips();
populateRelativeTimeElements();
initAutoSubmitForms();
});
@@ -1,13 +1,12 @@
{% extends "base.html" %}
{% from "macros/icons.html" import icon_lock %}
{% block title %}{{ network_name }} - Access Denied{% endblock %}
{% block title %}Access Denied - {{ network_name }}{% endblock %}
{% block content %}
<div class="flex flex-col items-center justify-center min-h-[50vh]">
<div class="text-center">
<svg xmlns="http://www.w3.org/2000/svg" class="h-24 w-24 mx-auto text-error opacity-50 mb-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z" />
</svg>
{{ icon_lock("h-24 w-24 mx-auto text-error opacity-50 mb-6") }}
<h1 class="text-3xl font-bold mb-2">Access Denied</h1>
<p class="text-lg opacity-70 mb-6">You don't have permission to access the admin area.</p>
<p class="text-sm opacity-50 mb-8">Please contact the network administrator if you believe this is an error.</p>
@@ -1,6 +1,7 @@
{% extends "base.html" %}
{% from "macros/icons.html" import icon_user, icon_email, icon_users, icon_tag %}
{% block title %}{{ network_name }} - Admin{% endblock %}
{% block title %}Admin - {{ network_name }}{% endblock %}
{% block content %}
<div class="flex items-center justify-between mb-4">
@@ -20,19 +21,13 @@
<div class="flex flex-wrap items-center gap-4 text-sm opacity-70 mb-6">
{% if auth_username or auth_user %}
<span class="flex items-center gap-1.5">
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" />
</svg>
{{ icon_user("h-4 w-4") }}
{{ auth_username or auth_user }}
</span>
{% endif %}
{% if auth_email %}
<span class="flex items-center gap-1.5">
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M3 8l7.89 5.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z" />
</svg>
{{ icon_email("h-4 w-4") }}
{{ auth_email }}
</span>
{% endif %}
@@ -43,11 +38,7 @@
<a href="/a/members" class="card bg-base-100 shadow-xl hover:shadow-2xl transition-shadow">
<div class="card-body">
<h2 class="card-title">
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" fill="none" viewBox="0 0 24 24"
stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0zm6 3a2 2 0 11-4 0 2 2 0 014 0zM7 10a2 2 0 11-4 0 2 2 0 014 0z" />
</svg>
{{ icon_users("h-6 w-6") }}
Members
</h2>
<p>Manage network members and operators.</p>
@@ -56,11 +47,7 @@
<a href="/a/node-tags" class="card bg-base-100 shadow-xl hover:shadow-2xl transition-shadow">
<div class="card-body">
<h2 class="card-title">
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" fill="none" viewBox="0 0 24 24"
stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M7 7h.01M7 3h5c.512 0 1.024.195 1.414.586l7 7a2 2 0 010 2.828l-7 7a2 2 0 01-2.828 0l-7-7A2 2 0 013 12V7a4 4 0 014-4z" />
</svg>
{{ icon_tag("h-6 w-6") }}
Node Tags
</h2>
<p>Manage custom tags and metadata for network nodes.</p>
@@ -1,6 +1,7 @@
{% extends "base.html" %}
{% from "macros/icons.html" import icon_success, icon_error, icon_alert %}
{% block title %}{{ network_name }} - Members Admin{% endblock %}
{% block title %}Admin: Members - {{ network_name }}{% endblock %}
{% block content %}
<div class="flex items-center justify-between mb-6">
@@ -20,18 +21,14 @@
<!-- Flash Messages -->
{% if message %}
<div class="alert alert-success mb-4">
<svg xmlns="http://www.w3.org/2000/svg" class="stroke-current shrink-0 h-6 w-6" fill="none" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
{{ icon_success("stroke-current shrink-0 h-6 w-6") }}
<span>{{ message }}</span>
</div>
{% endif %}
{% if error %}
<div class="alert alert-error mb-4">
<svg xmlns="http://www.w3.org/2000/svg" class="stroke-current shrink-0 h-6 w-6" fill="none" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
{{ icon_error("stroke-current shrink-0 h-6 w-6") }}
<span>{{ error }}</span>
</div>
{% endif %}
@@ -232,9 +229,7 @@
<p class="py-4">Are you sure you want to delete member <strong id="delete_member_name"></strong>?</p>
<div class="alert alert-error mb-4">
<svg xmlns="http://www.w3.org/2000/svg" class="stroke-current shrink-0 h-6 w-6" fill="none" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
</svg>
{{ icon_alert("stroke-current shrink-0 h-6 w-6") }}
<span>This action cannot be undone.</span>
</div>
@@ -1,6 +1,7 @@
{% extends "base.html" %}
{% from "macros/icons.html" import icon_success, icon_error, icon_alert, icon_info, icon_tag %}
{% block title %}{{ network_name }} - Node Tags Admin{% endblock %}
{% block title %}Admin: Node Tags - {{ network_name }}{% endblock %}
{% block content %}
<div class="flex items-center justify-between mb-6">
@@ -20,18 +21,14 @@
<!-- Flash Messages -->
{% if message %}
<div class="alert alert-success mb-4">
<svg xmlns="http://www.w3.org/2000/svg" class="stroke-current shrink-0 h-6 w-6" fill="none" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
{{ icon_success("stroke-current shrink-0 h-6 w-6") }}
<span>{{ message }}</span>
</div>
{% endif %}
{% if error %}
<div class="alert alert-error mb-4">
<svg xmlns="http://www.w3.org/2000/svg" class="stroke-current shrink-0 h-6 w-6" fill="none" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
{{ icon_error("stroke-current shrink-0 h-6 w-6") }}
<span>{{ error }}</span>
</div>
{% endif %}
@@ -253,9 +250,7 @@
</div>
<div class="alert alert-warning mb-4">
<svg xmlns="http://www.w3.org/2000/svg" class="stroke-current shrink-0 h-6 w-6" fill="none" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
</svg>
{{ icon_alert("stroke-current shrink-0 h-6 w-6") }}
<span>This will move the tag from the current node to the destination node.</span>
</div>
@@ -281,9 +276,7 @@
<p class="py-4">Are you sure you want to delete the tag "<span id="deleteKeyDisplay" class="font-mono font-semibold"></span>"?</p>
<div class="alert alert-error mb-4">
<svg xmlns="http://www.w3.org/2000/svg" class="stroke-current shrink-0 h-6 w-6" fill="none" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
</svg>
{{ icon_alert("stroke-current shrink-0 h-6 w-6") }}
<span>This action cannot be undone.</span>
</div>
@@ -324,9 +317,7 @@
</div>
<div class="alert alert-info mb-4">
<svg xmlns="http://www.w3.org/2000/svg" class="stroke-current shrink-0 h-6 w-6" fill="none" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
{{ icon_info("stroke-current shrink-0 h-6 w-6") }}
<span>Tags that already exist on the destination node will be skipped. Original tags remain on this node.</span>
</div>
@@ -351,9 +342,7 @@
<p class="mb-4">Are you sure you want to delete all {{ tags|length }} tag(s) from <strong>{{ selected_node.name or 'Unnamed' }}</strong>?</p>
<div class="alert alert-error mb-4">
<svg xmlns="http://www.w3.org/2000/svg" class="stroke-current shrink-0 h-6 w-6" fill="none" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
</svg>
{{ icon_alert("stroke-current shrink-0 h-6 w-6") }}
<span>This action cannot be undone. All tags will be permanently deleted.</span>
</div>
@@ -370,17 +359,13 @@
{% elif selected_public_key and not selected_node %}
<div class="alert alert-warning">
<svg xmlns="http://www.w3.org/2000/svg" class="stroke-current shrink-0 h-6 w-6" fill="none" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
</svg>
{{ icon_alert("stroke-current shrink-0 h-6 w-6") }}
<span>Node not found: {{ selected_public_key }}</span>
</div>
{% else %}
<div class="card bg-base-100 shadow-xl">
<div class="card-body text-center py-12">
<svg xmlns="http://www.w3.org/2000/svg" class="h-16 w-16 mx-auto mb-4 opacity-30" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 7h.01M7 3h5c.512 0 1.024.195 1.414.586l7 7a2 2 0 010 2.828l-7 7a2 2 0 01-2.828 0l-7-7A2 2 0 013 12V7a4 4 0 014-4z" />
</svg>
{{ icon_tag("h-16 w-16 mx-auto mb-4 opacity-30") }}
<h2 class="text-xl font-semibold mb-2">Select a Node</h2>
<p class="opacity-70">Choose a node from the dropdown above to view and manage its tags.</p>
</div>
@@ -1,7 +1,8 @@
{% extends "base.html" %}
{% from "_macros.html" import pagination %}
{% from "macros/icons.html" import icon_alert %}
{% block title %}{{ network_name }} - Advertisements{% endblock %}
{% block title %}Advertisements - {{ network_name }}{% endblock %}
{% block content %}
<div class="flex items-center justify-between mb-6">
@@ -11,9 +12,7 @@
{% if api_error %}
<div class="alert alert-warning mb-6">
<svg xmlns="http://www.w3.org/2000/svg" class="stroke-current shrink-0 h-6 w-6" fill="none" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
</svg>
{{ icon_alert("stroke-current shrink-0 h-6 w-6") }}
<span>Could not fetch data from API: {{ api_error }}</span>
</div>
{% endif %}
@@ -21,7 +20,7 @@
<!-- Filters -->
<div class="card bg-base-100 shadow mb-6">
<div class="card-body py-4">
<form method="GET" action="/advertisements" class="flex gap-4 flex-wrap items-end">
<form method="GET" action="/advertisements" class="flex gap-4 flex-wrap items-end" data-auto-submit>
<div class="form-control">
<label class="label py-1">
<span class="label-text">Search</span>
+6 -121
View File
@@ -1,4 +1,4 @@
{% from "macros/icons.html" import icon_home, icon_dashboard, icon_nodes, icon_advertisements, icon_messages, icon_map, icon_members, icon_page %}
{% from "macros/icons.html" import icon_home, icon_dashboard, icon_nodes, icon_advertisements, icon_messages, icon_map, icon_members, icon_page, icon_menu %}
<!DOCTYPE html>
<html lang="en" data-theme="dark">
<head>
@@ -37,121 +37,8 @@
<!-- Leaflet CSS for maps -->
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" />
<style>
/* Custom scrollbar */
::-webkit-scrollbar {
width: 8px;
}
::-webkit-scrollbar-track {
background: oklch(var(--b2));
}
::-webkit-scrollbar-thumb {
background: oklch(var(--bc) / 0.3);
border-radius: 4px;
}
::-webkit-scrollbar-thumb:hover {
background: oklch(var(--bc) / 0.5);
}
/* Table styling */
.table-compact td, .table-compact th {
padding: 0.5rem 0.75rem;
}
/* Truncate text in table cells */
.truncate-cell {
max-width: 200px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
/* Prose styling for custom markdown pages */
.prose h1 { font-size: 2.25rem; font-weight: 700; margin-top: 1.5rem; margin-bottom: 1rem; }
.prose h2 { font-size: 1.875rem; font-weight: 600; margin-top: 1.25rem; margin-bottom: 0.75rem; }
.prose h3 { font-size: 1.5rem; font-weight: 600; margin-top: 1rem; margin-bottom: 0.5rem; }
.prose h4 { font-size: 1.25rem; font-weight: 600; margin-top: 1rem; margin-bottom: 0.5rem; }
.prose p { margin-bottom: 1rem; line-height: 1.75; }
.prose ul, .prose ol { margin-bottom: 1rem; padding-left: 1.5rem; }
.prose ul { list-style-type: disc; }
.prose ol { list-style-type: decimal; }
.prose li { margin-bottom: 0.25rem; }
.prose a { color: oklch(var(--p)); text-decoration: underline; }
.prose a:hover { color: oklch(var(--pf)); }
.prose code { background: oklch(var(--b2)); padding: 0.125rem 0.25rem; border-radius: 0.25rem; font-size: 0.875em; }
.prose pre { background: oklch(var(--b2)); padding: 1rem; border-radius: 0.5rem; overflow-x: auto; margin-bottom: 1rem; }
.prose pre code { background: none; padding: 0; }
.prose blockquote { border-left: 4px solid oklch(var(--bc) / 0.3); padding-left: 1rem; margin: 1rem 0; font-style: italic; }
.prose table { width: 100%; margin-bottom: 1rem; border-collapse: collapse; }
.prose th, .prose td { border: 1px solid oklch(var(--bc) / 0.2); padding: 0.5rem; text-align: left; }
.prose th { background: oklch(var(--b2)); font-weight: 600; }
.prose hr { border: none; border-top: 1px solid oklch(var(--bc) / 0.2); margin: 2rem 0; }
.prose img { max-width: 100%; height: auto; border-radius: 0.5rem; margin: 1rem 0; }
/* View Transitions API - Cross-document page transitions */
.navbar { view-transition-name: navbar; position: relative; z-index: 50; }
main { view-transition-name: main-content; position: relative; z-index: 10; }
footer { view-transition-name: footer; position: relative; z-index: 10; }
/* Subtle slide + fade for main content */
::view-transition-old(main-content) {
animation: vt-fade-out 200ms ease-out forwards;
}
::view-transition-new(main-content) {
animation: vt-slide-up 250ms ease-out forwards;
}
/* Keep navbar and footer stable */
::view-transition-old(navbar),
::view-transition-new(navbar),
::view-transition-old(footer),
::view-transition-new(footer) {
animation: none;
}
/* Subtle crossfade for background */
::view-transition-old(root),
::view-transition-new(root) {
animation-duration: 150ms;
}
@keyframes vt-fade-out {
from { opacity: 1; }
to { opacity: 0; }
}
@keyframes vt-slide-up {
from { opacity: 0; transform: translateY(10px); }
to { opacity: 1; transform: translateY(0); }
}
/* Card entrance animations - only for stat cards with .animate-stagger class */
.animate-stagger > .card,
.animate-stagger > .stat {
animation: card-fade-in 300ms ease-out backwards;
}
.animate-stagger > :nth-child(1) { animation-delay: 0ms; }
.animate-stagger > :nth-child(2) { animation-delay: 50ms; }
.animate-stagger > :nth-child(3) { animation-delay: 100ms; }
.animate-stagger > :nth-child(4) { animation-delay: 150ms; }
.animate-stagger > :nth-child(5) { animation-delay: 200ms; }
.animate-stagger > :nth-child(6) { animation-delay: 250ms; }
@keyframes card-fade-in {
from { opacity: 0; transform: translateY(8px); }
to { opacity: 1; transform: translateY(0); }
}
/* Respect reduced motion preferences */
@media (prefers-reduced-motion: reduce) {
.card,
::view-transition-old(main-content),
::view-transition-new(main-content),
::view-transition-old(root),
::view-transition-new(root) {
animation: none !important;
}
}
</style>
<!-- Custom application styles -->
<link rel="stylesheet" href="{{ url_for('static', path='css/app.css') }}">
{% block extra_head %}{% endblock %}
</head>
@@ -161,15 +48,13 @@
<div class="navbar-start">
<div class="dropdown">
<div tabindex="0" role="button" class="btn btn-ghost lg:hidden">
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h8m-8 6h16" />
</svg>
{{ icon_menu("h-5 w-5") }}
</div>
<ul tabindex="0" class="menu menu-sm dropdown-content mt-3 z-[1] p-2 shadow bg-base-100 rounded-box w-52">
<li><a href="/" class="{% if request.url.path == '/' %}active{% endif %}">{{ icon_home("h-4 w-4") }} Home</a></li>
<li><a href="/dashboard" class="{% if request.url.path == '/dashboard' %}active{% endif %}">{{ icon_dashboard("h-4 w-4") }} Dashboard</a></li>
<li><a href="/nodes" class="{% if '/nodes' in request.url.path %}active{% endif %}">{{ icon_nodes("h-4 w-4") }} Nodes</a></li>
<li><a href="/advertisements" class="{% if request.url.path == '/advertisements' %}active{% endif %}">{{ icon_advertisements("h-4 w-4") }} Adverts</a></li>
<li><a href="/advertisements" class="{% if request.url.path == '/advertisements' %}active{% endif %}">{{ icon_advertisements("h-4 w-4") }} Advertisements</a></li>
<li><a href="/messages" class="{% if request.url.path == '/messages' %}active{% endif %}">{{ icon_messages("h-4 w-4") }} Messages</a></li>
<li><a href="/map" class="{% if request.url.path == '/map' %}active{% endif %}">{{ icon_map("h-4 w-4") }} Map</a></li>
<li><a href="/members" class="{% if request.url.path == '/members' %}active{% endif %}">{{ icon_members("h-4 w-4") }} Members</a></li>
@@ -188,7 +73,7 @@
<li><a href="/" class="{% if request.url.path == '/' %}active{% endif %}">{{ icon_home("h-4 w-4") }} Home</a></li>
<li><a href="/dashboard" class="{% if request.url.path == '/dashboard' %}active{% endif %}">{{ icon_dashboard("h-4 w-4") }} Dashboard</a></li>
<li><a href="/nodes" class="{% if '/nodes' in request.url.path %}active{% endif %}">{{ icon_nodes("h-4 w-4") }} Nodes</a></li>
<li><a href="/advertisements" class="{% if request.url.path == '/advertisements' %}active{% endif %}">{{ icon_advertisements("h-4 w-4") }} Adverts</a></li>
<li><a href="/advertisements" class="{% if request.url.path == '/advertisements' %}active{% endif %}">{{ icon_advertisements("h-4 w-4") }} Advertisements</a></li>
<li><a href="/messages" class="{% if request.url.path == '/messages' %}active{% endif %}">{{ icon_messages("h-4 w-4") }} Messages</a></li>
<li><a href="/map" class="{% if request.url.path == '/map' %}active{% endif %}">{{ icon_map("h-4 w-4") }} Map</a></li>
<li><a href="/members" class="{% if request.url.path == '/members' %}active{% endif %}">{{ icon_members("h-4 w-4") }} Members</a></li>
+17 -142
View File
@@ -1,23 +1,16 @@
{% extends "base.html" %}
{% from "macros/icons.html" import icon_nodes, icon_advertisements, icon_messages, icon_alert, icon_channel %}
{% block title %}{{ network_name }} - Network Overview{% endblock %}
{% block title %}Dashboard - {{ network_name }}{% endblock %}
{% block content %}
<div class="flex items-center justify-between mb-6">
<h1 class="text-3xl font-bold">Network Overview</h1>
<button onclick="location.reload()" class="btn btn-ghost btn-sm">
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
</svg>
Refresh
</button>
<h1 class="text-3xl font-bold">Dashboard</h1>
</div>
{% if api_error %}
<div class="alert alert-warning mb-6">
<svg xmlns="http://www.w3.org/2000/svg" class="stroke-current shrink-0 h-6 w-6" fill="none" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
</svg>
{{ icon_alert("stroke-current shrink-0 h-6 w-6") }}
<span>Could not fetch data from API: {{ api_error }}</span>
</div>
{% endif %}
@@ -27,9 +20,7 @@
<!-- Total Nodes -->
<div class="stat bg-base-100 rounded-box shadow">
<div class="stat-figure text-primary">
<svg xmlns="http://www.w3.org/2000/svg" class="h-8 w-8" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10" />
</svg>
{{ icon_nodes("h-8 w-8") }}
</div>
<div class="stat-title">Total Nodes</div>
<div class="stat-value text-primary">{{ stats.total_nodes }}</div>
@@ -39,9 +30,7 @@
<!-- Advertisements (7 days) -->
<div class="stat bg-base-100 rounded-box shadow">
<div class="stat-figure text-secondary">
<svg xmlns="http://www.w3.org/2000/svg" class="h-8 w-8" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 5.882V19.24a1.76 1.76 0 01-3.417.592l-2.147-6.15M18 13a3 3 0 100-6M5.436 13.683A4.001 4.001 0 017 6h1.832c4.1 0 7.625-1.234 9.168-3v14c-1.543-1.766-5.067-3-9.168-3H7a3.988 3.988 0 01-1.564-.317z" />
</svg>
{{ icon_advertisements("h-8 w-8") }}
</div>
<div class="stat-title">Advertisements</div>
<div class="stat-value text-secondary">{{ stats.advertisements_7d }}</div>
@@ -51,9 +40,7 @@
<!-- Messages (7 days) -->
<div class="stat bg-base-100 rounded-box shadow">
<div class="stat-figure text-accent">
<svg xmlns="http://www.w3.org/2000/svg" class="h-8 w-8" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 10h.01M12 10h.01M16 10h.01M9 16H5a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v8a2 2 0 01-2 2h-5l-5 5v-5z" />
</svg>
{{ icon_messages("h-8 w-8") }}
</div>
<div class="stat-title">Messages</div>
<div class="stat-value text-accent">{{ stats.messages_7d }}</div>
@@ -67,9 +54,7 @@
<div class="card bg-base-100 shadow-xl">
<div class="card-body">
<h2 class="card-title text-base">
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10" />
</svg>
{{ icon_nodes("h-5 w-5") }}
Total Nodes
</h2>
<p class="text-xs opacity-70">Over time (last 7 days)</p>
@@ -83,9 +68,7 @@
<div class="card bg-base-100 shadow-xl">
<div class="card-body">
<h2 class="card-title text-base">
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 5.882V19.24a1.76 1.76 0 01-3.417.592l-2.147-6.15M18 13a3 3 0 100-6M5.436 13.683A4.001 4.001 0 017 6h1.832c4.1 0 7.625-1.234 9.168-3v14c-1.543-1.766-5.067-3-9.168-3H7a3.988 3.988 0 01-1.564-.317z" />
</svg>
{{ icon_advertisements("h-5 w-5") }}
Advertisements
</h2>
<p class="text-xs opacity-70">Per day (last 7 days)</p>
@@ -99,9 +82,7 @@
<div class="card bg-base-100 shadow-xl">
<div class="card-body">
<h2 class="card-title text-base">
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 10h.01M12 10h.01M16 10h.01M9 16H5a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v8a2 2 0 01-2 2h-5l-5 5v-5z" />
</svg>
{{ icon_messages("h-5 w-5") }}
Messages
</h2>
<p class="text-xs opacity-70">Per day (last 7 days)</p>
@@ -118,9 +99,7 @@
<div class="card bg-base-100 shadow-xl">
<div class="card-body">
<h2 class="card-title">
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 5.882V19.24a1.76 1.76 0 01-3.417.592l-2.147-6.15M18 13a3 3 0 100-6M5.436 13.683A4.001 4.001 0 017 6h1.832c4.1 0 7.625-1.234 9.168-3v14c-1.543-1.766-5.067-3-9.168-3H7a3.988 3.988 0 01-1.564-.317z" />
</svg>
{{ icon_advertisements("h-6 w-6") }}
Recent Advertisements
</h2>
{% if stats.recent_advertisements %}
@@ -174,9 +153,7 @@
<div class="card bg-base-100 shadow-xl">
<div class="card-body">
<h2 class="card-title">
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 20l4-16m2 16l4-16M6 9h14M4 15h14" />
</svg>
{{ icon_channel("h-6 w-6") }}
Recent Channel Messages
</h2>
<div class="space-y-4">
@@ -206,115 +183,13 @@
{% block extra_scripts %}
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
<script src="{{ url_for('static', path='js/charts.js') }}"></script>
<script>
(function() {
const advertData = {{ advert_activity_json | safe }};
const messageData = {{ message_activity_json | safe }};
const nodeData = {{ node_count_json | safe }};
// Common chart options
const commonOptions = {
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: { display: false },
tooltip: {
mode: 'index',
intersect: false,
backgroundColor: 'oklch(0.25 0 0)',
titleColor: 'oklch(0.9 0 0)',
bodyColor: 'oklch(0.9 0 0)',
borderColor: 'oklch(0.4 0 0)',
borderWidth: 1
}
},
scales: {
x: {
grid: { color: 'oklch(0.4 0 0 / 0.2)' },
ticks: { color: 'oklch(0.7 0 0)', maxRotation: 45, minRotation: 45 }
},
y: {
beginAtZero: true,
grid: { color: 'oklch(0.4 0 0 / 0.2)' },
ticks: { color: 'oklch(0.7 0 0)', precision: 0 }
}
},
interaction: { mode: 'nearest', axis: 'x', intersect: false }
};
// Helper to format dates
function formatLabels(data) {
return data.map(d => {
const date = new Date(d.date);
return date.toLocaleDateString('en-GB', { day: 'numeric', month: 'short' });
});
}
// Advertisements chart (secondary color - pink/magenta)
const advertCtx = document.getElementById('advertChart');
if (advertCtx && advertData.data && advertData.data.length > 0) {
new Chart(advertCtx, {
type: 'line',
data: {
labels: formatLabels(advertData.data),
datasets: [{
label: 'Advertisements',
data: advertData.data.map(d => d.count),
borderColor: 'oklch(0.7 0.17 330)',
backgroundColor: 'oklch(0.7 0.17 330 / 0.1)',
fill: true,
tension: 0.3,
pointRadius: 2,
pointHoverRadius: 5
}]
},
options: commonOptions
});
}
// Messages chart (accent color - teal/cyan)
const messageCtx = document.getElementById('messageChart');
if (messageCtx && messageData.data && messageData.data.length > 0) {
new Chart(messageCtx, {
type: 'line',
data: {
labels: formatLabels(messageData.data),
datasets: [{
label: 'Messages',
data: messageData.data.map(d => d.count),
borderColor: 'oklch(0.75 0.18 180)',
backgroundColor: 'oklch(0.75 0.18 180 / 0.1)',
fill: true,
tension: 0.3,
pointRadius: 2,
pointHoverRadius: 5
}]
},
options: commonOptions
});
}
// Node count chart (primary color - purple/blue)
const nodeCtx = document.getElementById('nodeChart');
if (nodeCtx && nodeData.data && nodeData.data.length > 0) {
new Chart(nodeCtx, {
type: 'line',
data: {
labels: formatLabels(nodeData.data),
datasets: [{
label: 'Total Nodes',
data: nodeData.data.map(d => d.count),
borderColor: 'oklch(0.65 0.24 265)',
backgroundColor: 'oklch(0.65 0.24 265 / 0.1)',
fill: true,
tension: 0.3,
pointRadius: 2,
pointHoverRadius: 5
}]
},
options: commonOptions
});
}
var nodeData = {{ node_count_json | safe }};
var advertData = {{ advert_activity_json | safe }};
var messageData = {{ message_activity_json | safe }};
initDashboardCharts(nodeData, advertData, messageData);
})();
</script>
{% endblock %}
@@ -1,4 +1,5 @@
{% extends "base.html" %}
{% from "macros/icons.html" import icon_home, icon_nodes %}
{% block title %}Page Not Found - {{ network_name }}{% endblock %}
@@ -17,15 +18,11 @@
</p>
<div class="flex gap-4 justify-center">
<a href="/" class="btn btn-primary">
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 mr-2" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-6 0a1 1 0 001-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 001 1m-6 0h6" />
</svg>
{{ icon_home("h-5 w-5 mr-2") }}
Go Home
</a>
<a href="/nodes" class="btn btn-outline">
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 mr-2" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10" />
</svg>
{{ icon_nodes("h-5 w-5 mr-2") }}
Browse Nodes
</a>
</div>
+10 -105
View File
@@ -1,7 +1,7 @@
{% extends "base.html" %}
{% from "macros/icons.html" import icon_dashboard, icon_map, icon_nodes, icon_advertisements, icon_messages, icon_page %}
{% from "macros/icons.html" import icon_dashboard, icon_map, icon_nodes, icon_advertisements, icon_messages, icon_page, icon_info, icon_chart, icon_globe, icon_github %}
{% block title %}{{ network_name }} - Home{% endblock %}
{% block title %}{{ network_name }}{% endblock %}
{% block content %}
<!-- Hero Section with Stats -->
@@ -96,9 +96,7 @@
<div class="card bg-base-100 shadow-xl">
<div class="card-body">
<h2 class="card-title">
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
{{ icon_info("h-6 w-6") }}
Network Info
</h2>
<div class="space-y-2">
@@ -160,15 +158,11 @@
<p class="text-xs opacity-50 mt-4 text-center">Connecting people and things, without using the internet</p>
<div class="flex gap-2 mt-4">
<a href="https://meshcore.co.uk/" target="_blank" rel="noopener noreferrer" class="btn btn-outline btn-sm">
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4 mr-1" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 12a9 9 0 01-9 9m9-9a9 9 0 00-9-9m9 9H3m9 9a9 9 0 01-9-9m9 9c1.657 0 3-4.03 3-9s-1.343-9-3-9m0 18c-1.657 0-3-4.03-3-9s1.343-9 3-9m-9 9a9 9 0 019-9" />
</svg>
{{ icon_globe("h-4 w-4 mr-1") }}
Website
</a>
<a href="https://github.com/meshcore-dev/MeshCore" target="_blank" rel="noopener noreferrer" class="btn btn-outline btn-sm">
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4 mr-1" viewBox="0 0 24 24" fill="currentColor">
<path d="M12 0c-6.626 0-12 5.373-12 12 0 5.302 3.438 9.8 8.207 11.387.599.111.793-.261.793-.577v-2.234c-3.338.726-4.033-1.416-4.033-1.416-.546-1.387-1.333-1.756-1.333-1.756-1.089-.745.083-.729.083-.729 1.205.084 1.839 1.237 1.839 1.237 1.07 1.834 2.807 1.304 3.492.997.107-.775.418-1.305.762-1.604-2.665-.305-5.467-1.334-5.467-5.931 0-1.311.469-2.381 1.236-3.221-.124-.303-.535-1.524.117-3.176 0 0 1.008-.322 3.301 1.23.957-.266 1.983-.399 3.003-.404 1.02.005 2.047.138 3.006.404 2.291-1.552 3.297-1.23 3.297-1.23.653 1.653.242 2.874.118 3.176.77.84 1.235 1.911 1.235 3.221 0 4.609-2.807 5.624-5.479 5.921.43.372.823 1.102.823 2.222v3.293c0 .319.192.694.801.576 4.765-1.589 8.199-6.086 8.199-11.386 0-6.627-5.373-12-12-12z"/>
</svg>
{{ icon_github("h-4 w-4 mr-1") }}
GitHub
</a>
</div>
@@ -179,9 +173,7 @@
<div class="card bg-base-100 shadow-xl">
<div class="card-body">
<h2 class="card-title">
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 12l3-3 3 3 4-4M8 21l4-4 4 4M3 4h18M4 4h16v12a1 1 0 01-1 1H5a1 1 0 01-1-1V4z" />
</svg>
{{ icon_chart("h-6 w-6") }}
Network Activity
</h2>
<p class="text-sm opacity-70 mb-2">Activity per day (last 7 days)</p>
@@ -195,99 +187,12 @@
{% block extra_scripts %}
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
<script src="{{ url_for('static', path='js/charts.js') }}"></script>
<script>
(function() {
const advertData = {{ advert_activity_json | safe }};
const messageData = {{ message_activity_json | safe }};
const ctx = document.getElementById('activityChart');
if (ctx && advertData.data && advertData.data.length > 0) {
// Format dates for display (show only day/month)
const labels = advertData.data.map(d => {
const date = new Date(d.date);
return date.toLocaleDateString('en-GB', { day: 'numeric', month: 'short' });
});
const advertCounts = advertData.data.map(d => d.count);
const messageCounts = messageData.data ? messageData.data.map(d => d.count) : [];
new Chart(ctx, {
type: 'line',
data: {
labels: labels,
datasets: [{
label: 'Advertisements',
data: advertCounts,
borderColor: 'oklch(0.7 0.17 330)',
backgroundColor: 'oklch(0.7 0.17 330 / 0.1)',
fill: false,
tension: 0.3,
pointRadius: 2,
pointHoverRadius: 5
}, {
label: 'Messages',
data: messageCounts,
borderColor: 'oklch(0.7 0.15 200)',
backgroundColor: 'oklch(0.7 0.15 200 / 0.1)',
fill: false,
tension: 0.3,
pointRadius: 2,
pointHoverRadius: 5
}]
},
options: {
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: {
display: true,
position: 'bottom',
labels: {
color: 'oklch(0.7 0 0)',
boxWidth: 12,
padding: 8
}
},
tooltip: {
mode: 'index',
intersect: false,
backgroundColor: 'oklch(0.25 0 0)',
titleColor: 'oklch(0.9 0 0)',
bodyColor: 'oklch(0.9 0 0)',
borderColor: 'oklch(0.4 0 0)',
borderWidth: 1
}
},
scales: {
x: {
grid: {
color: 'oklch(0.4 0 0 / 0.2)'
},
ticks: {
color: 'oklch(0.7 0 0)',
maxRotation: 45,
minRotation: 45,
maxTicksLimit: 10
}
},
y: {
beginAtZero: true,
grid: {
color: 'oklch(0.4 0 0 / 0.2)'
},
ticks: {
color: 'oklch(0.7 0 0)',
precision: 0
}
}
},
interaction: {
mode: 'nearest',
axis: 'x',
intersect: false
}
}
});
}
var advertData = {{ advert_activity_json | safe }};
var messageData = {{ message_activity_json | safe }};
createActivityChart('activityChart', advertData, messageData);
})();
</script>
{% endblock %}
@@ -45,3 +45,99 @@
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
</svg>
{% endmacro %}
{% macro icon_info(class="h-5 w-5") %}
<svg xmlns="http://www.w3.org/2000/svg" class="{{ class }}" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
{% endmacro %}
{% macro icon_alert(class="h-5 w-5") %}
<svg xmlns="http://www.w3.org/2000/svg" class="{{ class }}" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
</svg>
{% endmacro %}
{% macro icon_chart(class="h-5 w-5") %}
<svg xmlns="http://www.w3.org/2000/svg" class="{{ class }}" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 12l3-3 3 3 4-4M8 21l4-4 4 4M3 4h18M4 4h16v12a1 1 0 01-1 1H5a1 1 0 01-1-1V4z" />
</svg>
{% endmacro %}
{% macro icon_refresh(class="h-5 w-5") %}
<svg xmlns="http://www.w3.org/2000/svg" class="{{ class }}" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
</svg>
{% endmacro %}
{% macro icon_menu(class="h-5 w-5") %}
<svg xmlns="http://www.w3.org/2000/svg" class="{{ class }}" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h8m-8 6h16" />
</svg>
{% endmacro %}
{% macro icon_github(class="h-5 w-5") %}
<svg xmlns="http://www.w3.org/2000/svg" class="{{ class }}" viewBox="0 0 24 24" fill="currentColor">
<path d="M12 0c-6.626 0-12 5.373-12 12 0 5.302 3.438 9.8 8.207 11.387.599.111.793-.261.793-.577v-2.234c-3.338.726-4.033-1.416-4.033-1.416-.546-1.387-1.333-1.756-1.333-1.756-1.089-.745.083-.729.083-.729 1.205.084 1.839 1.237 1.839 1.237 1.07 1.834 2.807 1.304 3.492.997.107-.775.418-1.305.762-1.604-2.665-.305-5.467-1.334-5.467-5.931 0-1.311.469-2.381 1.236-3.221-.124-.303-.535-1.524.117-3.176 0 0 1.008-.322 3.301 1.23.957-.266 1.983-.399 3.003-.404 1.02.005 2.047.138 3.006.404 2.291-1.552 3.297-1.23 3.297-1.23.653 1.653.242 2.874.118 3.176.77.84 1.235 1.911 1.235 3.221 0 4.609-2.807 5.624-5.479 5.921.43.372.823 1.102.823 2.222v3.293c0 .319.192.694.801.576 4.765-1.589 8.199-6.086 8.199-11.386 0-6.627-5.373-12-12-12z"/>
</svg>
{% endmacro %}
{% macro icon_external_link(class="h-5 w-5") %}
<svg xmlns="http://www.w3.org/2000/svg" class="{{ class }}" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" />
</svg>
{% endmacro %}
{% macro icon_globe(class="h-5 w-5") %}
<svg xmlns="http://www.w3.org/2000/svg" class="{{ class }}" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 12a9 9 0 01-9 9m9-9a9 9 0 00-9-9m9 9H3m9 9a9 9 0 01-9-9m9 9c1.657 0 3-4.03 3-9s-1.343-9-3-9m0 18c-1.657 0-3-4.03-3-9s1.343-9 3-9m-9 9a9 9 0 019-9" />
</svg>
{% endmacro %}
{% macro icon_error(class="h-5 w-5") %}
<svg xmlns="http://www.w3.org/2000/svg" class="{{ class }}" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
{% endmacro %}
{% macro icon_channel(class="h-5 w-5") %}
<svg xmlns="http://www.w3.org/2000/svg" class="{{ class }}" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 20l4-16m2 16l4-16M6 9h14M4 15h14" />
</svg>
{% endmacro %}
{% macro icon_success(class="h-5 w-5") %}
<svg xmlns="http://www.w3.org/2000/svg" class="{{ class }}" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
{% endmacro %}
{% macro icon_lock(class="h-5 w-5") %}
<svg xmlns="http://www.w3.org/2000/svg" class="{{ class }}" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z" />
</svg>
{% endmacro %}
{% macro icon_user(class="h-5 w-5") %}
<svg xmlns="http://www.w3.org/2000/svg" class="{{ class }}" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" />
</svg>
{% endmacro %}
{% macro icon_email(class="h-5 w-5") %}
<svg xmlns="http://www.w3.org/2000/svg" class="{{ class }}" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 8l7.89 5.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z" />
</svg>
{% endmacro %}
{% macro icon_tag(class="h-5 w-5") %}
<svg xmlns="http://www.w3.org/2000/svg" class="{{ class }}" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 7h.01M7 3h5c.512 0 1.024.195 1.414.586l7 7a2 2 0 010 2.828l-7 7a2 2 0 01-2.828 0l-7-7A2 2 0 013 12V7a4 4 0 014-4z" />
</svg>
{% endmacro %}
{% macro icon_users(class="h-5 w-5") %}
<svg xmlns="http://www.w3.org/2000/svg" class="{{ class }}" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0zm6 3a2 2 0 11-4 0 2 2 0 014 0zM7 10a2 2 0 11-4 0 2 2 0 014 0z" />
</svg>
{% endmacro %}
+27 -266
View File
@@ -1,27 +1,10 @@
{% extends "base.html" %}
{% block title %}{{ network_name }} - Node Map{% endblock %}
{% block extra_head %}
<style>
#map {
height: calc(100vh - 350px);
min-height: 400px;
border-radius: var(--rounded-box);
}
.leaflet-popup-content-wrapper {
background: oklch(var(--b1));
color: oklch(var(--bc));
}
.leaflet-popup-tip {
background: oklch(var(--b1));
}
</style>
{% endblock %}
{% block title %}Map - {{ network_name }}{% endblock %}
{% block content %}
<div class="flex items-center justify-between mb-6">
<h1 class="text-3xl font-bold">Node Map</h1>
<h1 class="text-3xl font-bold">Map</h1>
<div class="flex items-center gap-2">
<span id="node-count" class="badge badge-lg">Loading...</span>
<span id="filtered-count" class="badge badge-lg badge-ghost hidden"></span>
@@ -32,6 +15,15 @@
<div class="card bg-base-100 shadow mb-6">
<div class="card-body py-4">
<div class="flex gap-4 flex-wrap items-end">
<div class="form-control">
<label class="label py-1">
<span class="label-text">Show</span>
</label>
<select id="filter-category" class="select select-bordered select-sm">
<option value="">All Nodes</option>
<option value="infra">Infrastructure Only</option>
</select>
</div>
<div class="form-control">
<label class="label py-1">
<span class="label-text">Node Type</span>
@@ -52,6 +44,12 @@
<!-- Populated dynamically -->
</select>
</div>
<div class="form-control">
<label class="label cursor-pointer gap-2 py-1">
<span class="label-text">Show Labels</span>
<input type="checkbox" id="show-labels" class="checkbox checkbox-sm">
</label>
</div>
<button id="clear-filters" class="btn btn-ghost btn-sm">Clear Filters</button>
</div>
</div>
@@ -67,263 +65,26 @@
<div class="mt-4 flex flex-wrap gap-4 items-center text-sm">
<span class="opacity-70">Legend:</span>
<div class="flex items-center gap-1">
<span class="text-lg">💬</span>
<span>Chat</span>
<img src="{{ logo_url }}" alt="Infrastructure" class="h-5 w-5">
<span>Infrastructure</span>
</div>
<div class="flex items-center gap-1">
<span class="text-lg">📡</span>
<span>Repeater</span>
</div>
<div class="flex items-center gap-1">
<span class="text-lg">🪧</span>
<span>Room</span>
</div>
<div class="flex items-center gap-1">
<span class="text-lg">📍</span>
<span>Other</span>
<div style="width: 10px; height: 10px; background: #3b82f6; border: 2px solid #1e40af; border-radius: 50%;"></div>
<span>Node</span>
</div>
</div>
<div class="mt-2 text-sm opacity-70">
<p>Nodes are placed on the map based on their <code>lat</code> and <code>lon</code> tags.</p>
<p>Nodes are placed on the map based on GPS coordinates from node reports or manual tags.</p>
</div>
{% endblock %}
{% block extra_scripts %}
<script>
// Initialize map with world view (will be centered on nodes once loaded)
const map = L.map('map').setView([0, 0], 2);
// Add tile layer
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
attribution: '&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors'
}).addTo(map);
// Store all nodes and markers
let allNodes = [];
let allMembers = [];
let markers = [];
let mapCenter = { lat: 0, lon: 0 };
// Normalize adv_type to lowercase for consistent comparison
function normalizeType(type) {
return type ? type.toLowerCase() : null;
}
// formatRelativeTime is provided by /static/js/utils.js
// Get emoji marker based on node type
function getNodeEmoji(node) {
const type = normalizeType(node.adv_type);
if (type === 'chat') return '💬';
if (type === 'repeater') return '📡';
if (type === 'room') return '🪧';
return '📍';
}
// Get display name for node type
function getTypeDisplay(node) {
const type = normalizeType(node.adv_type);
if (type === 'chat') return 'Chat';
if (type === 'repeater') return 'Repeater';
if (type === 'room') return 'Room';
return type ? type.charAt(0).toUpperCase() + type.slice(1) : 'Unknown';
}
// Create marker icon for a node
function createNodeIcon(node) {
const emoji = getNodeEmoji(node);
const displayName = node.name || '';
const relativeTime = formatRelativeTime(node.last_seen);
const timeDisplay = relativeTime ? ` (${relativeTime})` : '';
return L.divIcon({
className: 'custom-div-icon',
html: `<div style="display: flex; align-items: center; gap: 2px;">
<span style="font-size: 24px; text-shadow: 0 0 3px #1a237e, 0 0 6px #1a237e, 0 1px 2px rgba(0,0,0,0.7);">${emoji}</span>
<span style="font-size: 10px; font-weight: bold; color: #000; background: rgba(255,255,255,0.9); padding: 1px 4px; border-radius: 3px; box-shadow: 0 1px 3px rgba(0,0,0,0.3);">${displayName}${timeDisplay}</span>
</div>`,
iconSize: [82, 28],
iconAnchor: [14, 14]
});
}
// Create popup content for a node
function createPopupContent(node) {
let ownerHtml = '';
if (node.owner) {
const ownerDisplay = node.owner.callsign
? `${node.owner.name} (${node.owner.callsign})`
: node.owner.name;
ownerHtml = `<p><span class="opacity-70">Owner:</span> ${ownerDisplay}</p>`;
}
let roleHtml = '';
if (node.role) {
roleHtml = `<p><span class="opacity-70">Role:</span> <span class="badge badge-xs badge-ghost">${node.role}</span></p>`;
}
const emoji = getNodeEmoji(node);
const typeDisplay = getTypeDisplay(node);
return `
<div class="p-2">
<h3 class="font-bold text-lg mb-2">${emoji} ${node.name}</h3>
<div class="space-y-1 text-sm">
<p><span class="opacity-70">Type:</span> ${typeDisplay}</p>
${roleHtml}
${ownerHtml}
<p><span class="opacity-70">Key:</span> <code class="text-xs">${node.public_key.substring(0, 16)}...</code></p>
<p><span class="opacity-70">Location:</span> ${node.lat.toFixed(4)}, ${node.lon.toFixed(4)}</p>
${node.last_seen ? `<p><span class="opacity-70">Last seen:</span> ${node.last_seen.substring(0, 19).replace('T', ' ')}</p>` : ''}
</div>
<a href="/nodes/${node.public_key}" class="btn btn-primary btn-xs mt-3">View Details</a>
</div>
`;
}
// Clear all markers from map
function clearMarkers() {
markers.forEach(marker => map.removeLayer(marker));
markers = [];
}
// Core filter logic - returns filtered nodes and updates markers
function applyFiltersCore() {
const typeFilter = document.getElementById('filter-type').value;
const memberFilter = document.getElementById('filter-member').value;
// Filter nodes
const filteredNodes = allNodes.filter(node => {
// Type filter (case-insensitive)
if (typeFilter && normalizeType(node.adv_type) !== typeFilter) return false;
// Member filter - match node's member_id tag to selected member_id
if (memberFilter) {
if (node.member_id !== memberFilter) return false;
}
return true;
});
// Clear existing markers
clearMarkers();
// Add filtered markers
filteredNodes.forEach(node => {
const marker = L.marker([node.lat, node.lon], { icon: createNodeIcon(node) }).addTo(map);
marker.bindPopup(createPopupContent(node));
markers.push(marker);
});
// Update counts
const countEl = document.getElementById('node-count');
const filteredEl = document.getElementById('filtered-count');
if (filteredNodes.length === allNodes.length) {
countEl.textContent = `${allNodes.length} nodes on map`;
filteredEl.classList.add('hidden');
} else {
countEl.textContent = `${allNodes.length} total`;
filteredEl.textContent = `${filteredNodes.length} shown`;
filteredEl.classList.remove('hidden');
}
return filteredNodes;
}
// Apply filters and recenter map on filtered nodes
function applyFilters() {
const filteredNodes = applyFiltersCore();
// Fit bounds if we have filtered nodes
if (filteredNodes.length > 0) {
const bounds = L.latLngBounds(filteredNodes.map(n => [n.lat, n.lon]));
map.fitBounds(bounds, { padding: [50, 50] });
} else if (mapCenter.lat !== 0 || mapCenter.lon !== 0) {
map.setView([mapCenter.lat, mapCenter.lon], 10);
}
}
// Apply filters without recentering (for initial load after manual center)
function applyFiltersNoRecenter() {
applyFiltersCore();
}
// Populate member filter dropdown
function populateMemberFilter() {
const select = document.getElementById('filter-member');
// Sort members by name
const sortedMembers = [...allMembers].sort((a, b) => a.name.localeCompare(b.name));
// Add options for all members
sortedMembers.forEach(member => {
if (member.member_id) {
const option = document.createElement('option');
option.value = member.member_id;
option.textContent = member.callsign
? `${member.name} (${member.callsign})`
: member.name;
select.appendChild(option);
}
});
}
// Clear all filters
function clearFilters() {
document.getElementById('filter-type').value = '';
document.getElementById('filter-member').value = '';
applyFilters();
}
// Event listeners for filters
document.getElementById('filter-type').addEventListener('change', applyFilters);
document.getElementById('filter-member').addEventListener('change', applyFilters);
document.getElementById('clear-filters').addEventListener('click', clearFilters);
// Fetch and display nodes
fetch('/map/data')
.then(response => response.json())
.then(data => {
allNodes = data.nodes;
allMembers = data.members || [];
mapCenter = data.center;
// Log debug info
const debug = data.debug || {};
console.log('Map data loaded:', debug);
console.log('Sample node data:', allNodes.length > 0 ? allNodes[0] : 'No nodes');
if (debug.error) {
document.getElementById('node-count').textContent = `Error: ${debug.error}`;
return;
}
if (debug.total_nodes === 0) {
document.getElementById('node-count').textContent = 'No nodes in database';
return;
}
if (debug.nodes_with_coords === 0) {
document.getElementById('node-count').textContent = `${debug.total_nodes} nodes (none have coordinates)`;
return;
}
// Populate member filter
populateMemberFilter();
// Initial display - center map on nodes if available
if (allNodes.length > 0) {
const bounds = L.latLngBounds(allNodes.map(n => [n.lat, n.lon]));
map.fitBounds(bounds, { padding: [50, 50] });
}
// Apply filters (won't re-center since we just did above)
applyFiltersNoRecenter();
})
.catch(error => {
console.error('Error loading map data:', error);
document.getElementById('node-count').textContent = 'Error loading data';
});
window.mapConfig = {
logoUrl: "{{ logo_url }}",
dataUrl: "/map/data"
};
</script>
<script src="{{ url_for('static', path='js/map-main.js') }}"></script>
{% endblock %}
+4 -5
View File
@@ -1,10 +1,11 @@
{% extends "base.html" %}
{% from "macros/icons.html" import icon_info %}
{% block title %}{{ network_name }} - Members{% endblock %}
{% block title %}Members - {{ network_name }}{% endblock %}
{% block content %}
<div class="flex items-center justify-between mb-6">
<h1 class="text-3xl font-bold">Network Members</h1>
<h1 class="text-3xl font-bold">Members</h1>
<span class="badge badge-lg">{{ members|length }} members</span>
</div>
@@ -71,9 +72,7 @@
</div>
{% else %}
<div class="alert alert-info">
<svg xmlns="http://www.w3.org/2000/svg" class="stroke-current shrink-0 h-6 w-6" fill="none" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
{{ icon_info("stroke-current shrink-0 h-6 w-6") }}
<div>
<h3 class="font-bold">No members configured</h3>
<p class="text-sm">To display network members, create a members.yaml file in your seed directory.</p>
+4 -5
View File
@@ -1,7 +1,8 @@
{% extends "base.html" %}
{% from "_macros.html" import pagination %}
{% from "macros/icons.html" import icon_alert %}
{% block title %}{{ network_name }} - Messages{% endblock %}
{% block title %}Messages - {{ network_name }}{% endblock %}
{% block content %}
<div class="flex items-center justify-between mb-6">
@@ -11,9 +12,7 @@
{% if api_error %}
<div class="alert alert-warning mb-6">
<svg xmlns="http://www.w3.org/2000/svg" class="stroke-current shrink-0 h-6 w-6" fill="none" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
</svg>
{{ icon_alert("stroke-current shrink-0 h-6 w-6") }}
<span>Could not fetch data from API: {{ api_error }}</span>
</div>
{% endif %}
@@ -21,7 +20,7 @@
<!-- Filters -->
<div class="card bg-base-100 shadow mb-6">
<div class="card-body py-4">
<form method="GET" action="/messages" class="flex gap-4 flex-wrap items-end">
<form method="GET" action="/messages" class="flex gap-4 flex-wrap items-end" data-auto-submit>
<div class="form-control">
<label class="label py-1">
<span class="label-text">Type</span>
+89 -124
View File
@@ -1,21 +1,10 @@
{% extends "base.html" %}
{% from "macros/icons.html" import icon_alert, icon_error %}
{% block title %}{{ network_name }} - Node Details{% endblock %}
{% block title %}{% if node %}{{ node.name or ('Node ' ~ public_key[:8] ~ '...') }} - {{ network_name }}{% else %}Node Not Found - {{ network_name }}{% endif %}{% endblock %}
{% block extra_head %}
<style>
#node-map {
height: 300px;
border-radius: var(--rounded-box);
}
.leaflet-popup-content-wrapper {
background: oklch(var(--b1));
color: oklch(var(--bc));
}
.leaflet-popup-tip {
background: oklch(var(--b1));
}
</style>
<script src="https://cdnjs.cloudflare.com/ajax/libs/qrcodejs/1.0.0/qrcode.min.js"></script>
{% endblock %}
{% block content %}
@@ -39,9 +28,7 @@
{% if api_error %}
<div class="alert alert-warning mb-6">
<svg xmlns="http://www.w3.org/2000/svg" class="stroke-current shrink-0 h-6 w-6" fill="none" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
</svg>
{{ icon_alert("stroke-current shrink-0 h-6 w-6") }}
<span>Could not fetch data from API: {{ api_error }}</span>
</div>
{% endif %}
@@ -56,36 +43,28 @@
<!-- Node Info Card -->
<div class="card bg-base-100 shadow-xl mb-6">
<div class="card-body">
<h1 class="card-title text-2xl">
{% if node.adv_type %}
{% if node.adv_type|lower == 'chat' %}
<span title="Chat">💬</span>
{% elif node.adv_type|lower == 'repeater' %}
<span title="Repeater">📡</span>
{% elif node.adv_type|lower == 'room' %}
<span title="Room">🪧</span>
{% else %}
<span title="{{ node.adv_type }}">📍</span>
<!-- Title Row with Activity -->
<div class="flex flex-col md:flex-row md:items-start md:justify-between gap-4">
<h1 class="card-title text-2xl">
{% if node.adv_type %}
{% if node.adv_type|lower == 'chat' %}
<span title="Chat">💬</span>
{% elif node.adv_type|lower == 'repeater' %}
<span title="Repeater">📡</span>
{% elif node.adv_type|lower == 'room' %}
<span title="Room">🪧</span>
{% else %}
<span title="{{ node.adv_type }}">📍</span>
{% endif %}
{% endif %}
{% endif %}
{{ ns.tag_name or node.name or 'Unnamed Node' }}
</h1>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4 mt-4">
<div>
<h3 class="font-semibold opacity-70 mb-2">Public Key</h3>
<code class="text-sm bg-base-200 p-2 rounded block break-all">{{ node.public_key }}</code>
</div>
<div>
<h3 class="font-semibold opacity-70 mb-2">Activity</h3>
<div class="space-y-1 text-sm">
<p><span class="opacity-70">First seen:</span> {{ node.first_seen[:19].replace('T', ' ') if node.first_seen else '-' }}</p>
<p><span class="opacity-70">Last seen:</span> {{ node.last_seen[:19].replace('T', ' ') if node.last_seen else '-' }}</p>
</div>
{{ ns.tag_name or node.name or 'Unnamed Node' }}
</h1>
<div class="text-sm text-right">
<p><span class="opacity-70">First seen:</span> {{ node.first_seen[:19].replace('T', ' ') if node.first_seen else '-' }}</p>
<p><span class="opacity-70">Last seen:</span> {{ node.last_seen[:19].replace('T', ' ') if node.last_seen else '-' }}</p>
</div>
</div>
<!-- Tags and Map Grid -->
{% set ns_map = namespace(lat=none, lon=none) %}
{% for tag in node.tags or [] %}
{% if tag.key == 'lat' %}
@@ -95,42 +74,17 @@
{% endif %}
{% endfor %}
<div class="grid grid-cols-1 {% if ns_map.lat and ns_map.lon %}lg:grid-cols-2{% endif %} gap-6 mt-6">
<!-- Tags -->
{% if node.tags or (admin_enabled and is_authenticated) %}
<!-- Public Key + QR Code and Map Row -->
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6 mt-6">
<!-- Public Key and QR Code -->
<div>
<h3 class="font-semibold opacity-70 mb-2">Tags</h3>
{% if node.tags %}
<div class="overflow-x-auto">
<table class="table table-compact w-full">
<thead>
<tr>
<th>Key</th>
<th>Value</th>
<th>Type</th>
</tr>
</thead>
<tbody>
{% for tag in node.tags %}
<tr>
<td class="font-mono">{{ tag.key }}</td>
<td>{{ tag.value }}</td>
<td class="opacity-70">{{ tag.value_type or 'string' }}</td>
</tr>
{% endfor %}
</tbody>
</table>
<h3 class="font-semibold opacity-70 mb-2">Public Key</h3>
<code class="text-sm bg-base-200 p-2 rounded block break-all">{{ node.public_key }}</code>
<div class="mt-4">
<div id="qr-code" class="inline-block bg-white p-3 rounded"></div>
<p class="text-xs opacity-50 mt-2">Scan to add as contact</p>
</div>
{% else %}
<p class="text-sm opacity-70 mb-2">No tags defined.</p>
{% endif %}
{% if admin_enabled and is_authenticated %}
<div class="mt-3">
<a href="/a/node-tags?public_key={{ node.public_key }}" class="btn btn-sm btn-outline">{% if node.tags %}Edit Tags{% else %}Add Tags{% endif %}</a>
</div>
{% endif %}
</div>
{% endif %}
<!-- Location Map -->
{% if ns_map.lat and ns_map.lon %}
@@ -143,6 +97,42 @@
</div>
{% endif %}
</div>
<!-- Tags Section -->
{% if node.tags or (admin_enabled and is_authenticated) %}
<div class="mt-6">
<h3 class="font-semibold opacity-70 mb-2">Tags</h3>
{% if node.tags %}
<div class="overflow-x-auto">
<table class="table table-compact w-full">
<thead>
<tr>
<th>Key</th>
<th>Value</th>
<th>Type</th>
</tr>
</thead>
<tbody>
{% for tag in node.tags %}
<tr>
<td class="font-mono">{{ tag.key }}</td>
<td>{{ tag.value }}</td>
<td class="opacity-70">{{ tag.value_type or 'string' }}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<p class="text-sm opacity-70 mb-2">No tags defined.</p>
{% endif %}
{% if admin_enabled and is_authenticated %}
<div class="mt-3">
<a href="/a/node-tags?public_key={{ node.public_key }}" class="btn btn-sm btn-outline">{% if node.tags %}Edit Tags{% else %}Add Tags{% endif %}</a>
</div>
{% endif %}
</div>
{% endif %}
</div>
</div>
@@ -256,9 +246,7 @@
{% else %}
<div class="alert alert-error">
<svg xmlns="http://www.w3.org/2000/svg" class="stroke-current shrink-0 h-6 w-6" fill="none" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
{{ icon_error("stroke-current shrink-0 h-6 w-6") }}
<span>Node not found: {{ public_key }}</span>
</div>
<a href="/nodes" class="btn btn-primary mt-4">Back to Nodes</a>
@@ -267,6 +255,21 @@
{% block extra_scripts %}
{% if node %}
{% set ns_qr = namespace(tag_name=none) %}
{% for tag in node.tags or [] %}
{% if tag.key == 'name' %}
{% set ns_qr.tag_name = tag.value %}
{% endif %}
{% endfor %}
<script>
window.qrCodeConfig = {
name: {{ (ns_qr.tag_name or node.name or 'Node') | tojson }},
publicKey: {{ node.public_key | tojson }},
advType: {{ (node.adv_type or '') | tojson }}
};
</script>
<script src="{{ url_for('static', path='js/qrcode-init.js') }}"></script>
{% set ns_map = namespace(lat=none, lon=none, name=none) %}
{% for tag in node.tags or [] %}
{% if tag.key == 'lat' %}
@@ -279,52 +282,14 @@
{% endfor %}
{% if ns_map.lat and ns_map.lon %}
<script>
// Initialize map centered on the node's location
const nodeLat = {{ ns_map.lat }};
const nodeLon = {{ ns_map.lon }};
const nodeName = {{ (ns_map.name or node.name or 'Unnamed Node') | tojson }};
const nodeType = {{ (node.adv_type or '') | tojson }};
const publicKey = {{ node.public_key | tojson }};
const map = L.map('node-map').setView([nodeLat, nodeLon], 15);
// Add tile layer
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
attribution: '&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors'
}).addTo(map);
// Get emoji marker based on node type
function getNodeEmoji(type) {
const normalizedType = type ? type.toLowerCase() : null;
if (normalizedType === 'chat') return '💬';
if (normalizedType === 'repeater') return '📡';
if (normalizedType === 'room') return '🪧';
return '📍';
}
// Create marker icon (just the emoji, no label)
const emoji = getNodeEmoji(nodeType);
const icon = L.divIcon({
className: 'custom-div-icon',
html: `<span style="font-size: 32px; text-shadow: 0 0 3px #1a237e, 0 0 6px #1a237e, 0 1px 2px rgba(0,0,0,0.7);">${emoji}</span>`,
iconSize: [32, 32],
iconAnchor: [16, 16]
});
// Add marker
const marker = L.marker([nodeLat, nodeLon], { icon: icon }).addTo(map);
// Add popup (shown on click, not by default)
marker.bindPopup(`
<div class="p-2">
<h3 class="font-bold text-lg mb-2">${emoji} ${nodeName}</h3>
<div class="space-y-1 text-sm">
${nodeType ? `<p><span class="opacity-70">Type:</span> ${nodeType}</p>` : ''}
<p><span class="opacity-70">Coordinates:</span> ${nodeLat.toFixed(4)}, ${nodeLon.toFixed(4)}</p>
</div>
</div>
`);
window.nodeMapConfig = {
lat: {{ ns_map.lat }},
lon: {{ ns_map.lon }},
name: {{ (ns_map.name or node.name or 'Unnamed Node') | tojson }},
type: {{ (node.adv_type or '') | tojson }}
};
</script>
<script src="{{ url_for('static', path='js/map-node.js') }}"></script>
{% endif %}
{% endif %}
{% endblock %}
+4 -5
View File
@@ -1,7 +1,8 @@
{% extends "base.html" %}
{% from "_macros.html" import pagination %}
{% from "macros/icons.html" import icon_alert %}
{% block title %}{{ network_name }} - Nodes{% endblock %}
{% block title %}Nodes - {{ network_name }}{% endblock %}
{% block content %}
<div class="flex items-center justify-between mb-6">
@@ -11,9 +12,7 @@
{% if api_error %}
<div class="alert alert-warning mb-6">
<svg xmlns="http://www.w3.org/2000/svg" class="stroke-current shrink-0 h-6 w-6" fill="none" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
</svg>
{{ icon_alert("stroke-current shrink-0 h-6 w-6") }}
<span>Could not fetch data from API: {{ api_error }}</span>
</div>
{% endif %}
@@ -21,7 +20,7 @@
<!-- Filters -->
<div class="card bg-base-100 shadow mb-6">
<div class="card-body py-4">
<form method="GET" action="/nodes" class="flex gap-4 flex-wrap items-end">
<form method="GET" action="/nodes" class="flex gap-4 flex-wrap items-end" data-auto-submit>
<div class="form-control">
<label class="label py-1">
<span class="label-text">Search</span>
+245
View File
@@ -173,3 +173,248 @@ class TestMapDataFiltering:
# Node with only lat should be excluded
assert len(data["nodes"]) == 0
def test_map_data_filters_zero_coordinates(
self, web_app: Any, mock_http_client: MockHttpClient
) -> None:
"""Test that map data filters nodes with (0, 0) coordinates."""
mock_http_client.set_response(
"GET",
"/api/v1/nodes",
status_code=200,
json_data={
"items": [
{
"id": "node-1",
"public_key": "abc123",
"name": "Zero Coord Node",
"lat": 0.0,
"lon": 0.0,
"tags": [],
},
],
"total": 1,
},
)
web_app.state.http_client = mock_http_client
client = TestClient(web_app, raise_server_exceptions=True)
response = client.get("/map/data")
data = response.json()
# Node at (0, 0) should be excluded
assert len(data["nodes"]) == 0
def test_map_data_uses_model_coordinates_as_fallback(
self, web_app: Any, mock_http_client: MockHttpClient
) -> None:
"""Test that map data uses model lat/lon when tags are not present."""
mock_http_client.set_response(
"GET",
"/api/v1/nodes",
status_code=200,
json_data={
"items": [
{
"id": "node-1",
"public_key": "abc123",
"name": "Model Coords Node",
"lat": 51.5074,
"lon": -0.1278,
"tags": [],
},
],
"total": 1,
},
)
web_app.state.http_client = mock_http_client
client = TestClient(web_app, raise_server_exceptions=True)
response = client.get("/map/data")
data = response.json()
# Node should use model coordinates
assert len(data["nodes"]) == 1
assert data["nodes"][0]["lat"] == 51.5074
assert data["nodes"][0]["lon"] == -0.1278
def test_map_data_prefers_tag_coordinates_over_model(
self, web_app: Any, mock_http_client: MockHttpClient
) -> None:
"""Test that tag coordinates take priority over model coordinates."""
mock_http_client.set_response(
"GET",
"/api/v1/nodes",
status_code=200,
json_data={
"items": [
{
"id": "node-1",
"public_key": "abc123",
"name": "Both Coords Node",
"lat": 51.5074,
"lon": -0.1278,
"tags": [
{"key": "lat", "value": "40.7128"},
{"key": "lon", "value": "-74.0060"},
],
},
],
"total": 1,
},
)
web_app.state.http_client = mock_http_client
client = TestClient(web_app, raise_server_exceptions=True)
response = client.get("/map/data")
data = response.json()
# Node should use tag coordinates, not model
assert len(data["nodes"]) == 1
assert data["nodes"][0]["lat"] == 40.7128
assert data["nodes"][0]["lon"] == -74.0060
class TestMapDataInfrastructure:
"""Tests for infrastructure node handling in map data."""
def test_map_data_includes_infra_center(
self, web_app: Any, mock_http_client: MockHttpClient
) -> None:
"""Test that map data includes infrastructure center when infra nodes exist."""
mock_http_client.set_response(
"GET",
"/api/v1/nodes",
status_code=200,
json_data={
"items": [
{
"id": "node-1",
"public_key": "abc123",
"name": "Infra Node",
"lat": 40.0,
"lon": -74.0,
"tags": [{"key": "role", "value": "infra"}],
},
{
"id": "node-2",
"public_key": "def456",
"name": "Regular Node",
"lat": 41.0,
"lon": -75.0,
"tags": [],
},
],
"total": 2,
},
)
web_app.state.http_client = mock_http_client
client = TestClient(web_app, raise_server_exceptions=True)
response = client.get("/map/data")
data = response.json()
# Should have infra_center based on infra node only
assert data["infra_center"] is not None
assert data["infra_center"]["lat"] == 40.0
assert data["infra_center"]["lon"] == -74.0
def test_map_data_infra_center_null_when_no_infra(
self, web_app: Any, mock_http_client: MockHttpClient
) -> None:
"""Test that infra_center is null when no infrastructure nodes exist."""
mock_http_client.set_response(
"GET",
"/api/v1/nodes",
status_code=200,
json_data={
"items": [
{
"id": "node-1",
"public_key": "abc123",
"name": "Regular Node",
"lat": 40.0,
"lon": -74.0,
"tags": [],
},
],
"total": 1,
},
)
web_app.state.http_client = mock_http_client
client = TestClient(web_app, raise_server_exceptions=True)
response = client.get("/map/data")
data = response.json()
assert data["infra_center"] is None
def test_map_data_sets_is_infra_flag(
self, web_app: Any, mock_http_client: MockHttpClient
) -> None:
"""Test that nodes have correct is_infra flag based on role tag."""
mock_http_client.set_response(
"GET",
"/api/v1/nodes",
status_code=200,
json_data={
"items": [
{
"id": "node-1",
"public_key": "abc123",
"name": "Infra Node",
"lat": 40.0,
"lon": -74.0,
"tags": [{"key": "role", "value": "infra"}],
},
{
"id": "node-2",
"public_key": "def456",
"name": "Regular Node",
"lat": 41.0,
"lon": -75.0,
"tags": [{"key": "role", "value": "other"}],
},
],
"total": 2,
},
)
web_app.state.http_client = mock_http_client
client = TestClient(web_app, raise_server_exceptions=True)
response = client.get("/map/data")
data = response.json()
nodes_by_name = {n["name"]: n for n in data["nodes"]}
assert nodes_by_name["Infra Node"]["is_infra"] is True
assert nodes_by_name["Regular Node"]["is_infra"] is False
def test_map_data_debug_includes_infra_count(
self, web_app: Any, mock_http_client: MockHttpClient
) -> None:
"""Test that debug info includes infrastructure node count."""
mock_http_client.set_response(
"GET",
"/api/v1/nodes",
status_code=200,
json_data={
"items": [
{
"id": "node-1",
"public_key": "abc123",
"name": "Infra Node",
"lat": 40.0,
"lon": -74.0,
"tags": [{"key": "role", "value": "infra"}],
},
],
"total": 1,
},
)
web_app.state.http_client = mock_http_client
client = TestClient(web_app, raise_server_exceptions=True)
response = client.get("/map/data")
data = response.json()
assert data["debug"]["infra_nodes"] == 1