fix(map): revert to Leaflet + proxied OSM tiles, add CPE signal to popups

Reverted from MapLibre/PMTiles to Leaflet with nginx-proxied OSM raster
tiles — the MapLibre approach had unresolvable CSP and theme compat
issues. The proxy keeps all browser requests local (no third-party).

Also:
- Add CPE signal strength and parent AP name to fleet summary SQL
  and map popup cards (e.g. "Signal: -62 dBm to ap-shady-north")
- Add .dockerignore to exclude 8GB PMTiles and node_modules from
  Docker build context (was causing 10+ minute builds)
- Configure mailpit SMTP in dev compose

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Jason Staack
2026-03-19 21:47:15 -05:00
parent 877cb1a55c
commit 21f2934906
9 changed files with 145 additions and 558 deletions

View File

@@ -70,6 +70,9 @@ export function DeviceMarker({ device, tenantId }: DeviceMarkerProps) {
{device.client_count != null && device.client_count > 0 && (
<div>Clients: {device.client_count}{device.avg_signal != null && ` (avg ${device.avg_signal} dBm)`}</div>
)}
{device.cpe_signal != null && (
<div>Signal: {device.cpe_signal} dBm{device.ap_hostname && ` to ${device.ap_hostname}`}</div>
)}
<div className="flex items-center gap-1.5 mt-1">
Status:
<span

View File

@@ -1,297 +1,121 @@
import { useEffect, useRef, useMemo } from 'react'
import maplibregl from 'maplibre-gl'
import { Protocol } from 'pmtiles'
import { layers, namedFlavor } from '@protomaps/basemaps'
import { useEffect, useMemo } from 'react'
import { MapContainer, TileLayer, useMap } from 'react-leaflet'
import MarkerClusterGroup from 'react-leaflet-cluster'
import L from 'leaflet'
import type { FleetDevice } from '@/lib/api'
import { formatUptime } from '@/lib/utils'
import { useUIStore } from '@/lib/store'
import { DeviceMarker } from './DeviceMarker'
import 'maplibre-gl/dist/maplibre-gl.css'
// Register PMTiles protocol once
const protocol = new Protocol()
maplibregl.addProtocol('pmtiles', protocol.tile)
import 'leaflet/dist/leaflet.css'
interface FleetMapProps {
devices: FleetDevice[]
tenantId: string
}
const DEFAULT_CENTER: [number, number] = [-89.6, 39.8]
const DEFAULT_ZOOM = 4
const DEFAULT_CENTER: [number, number] = [39.8, -89.6]
const DEFAULT_ZOOM = 5
const STATUS_COLORS: Record<string, string> = {
online: '#22c55e',
offline: '#ef4444',
unknown: '#eab308',
function AutoFitBounds({ devices }: { devices: FleetDevice[] }) {
const map = useMap()
useEffect(() => {
if (devices.length === 0) {
map.setView(DEFAULT_CENTER, DEFAULT_ZOOM)
return
}
const bounds = L.latLngBounds(
devices.map((d) => [d.latitude!, d.longitude!] as [number, number]),
)
map.fitBounds(bounds, { padding: [40, 40], maxZoom: 15 })
}, [devices, map])
return null
}
function buildMapStyle(theme: 'dark' | 'light') {
return {
version: 8 as const,
glyphs: '/map-fonts/{fontstack}/{range}.pbf',
sprite: `/map-assets/sprites/${theme}`,
sources: {
protomaps: {
type: 'vector' as const,
url: 'pmtiles:///tiles/us.pmtiles',
attribution: '&copy; <a href="https://openstreetmap.org/copyright">OpenStreetMap</a>',
},
},
layers: layers('protomaps', namedFlavor(theme), { lang: 'en' }),
}
}
function createClusterIcon(cluster: L.MarkerCluster): L.DivIcon {
const childMarkers = cluster.getAllChildMarkers()
const count = childMarkers.length
function deviceToGeoJSON(devices: FleetDevice[]): GeoJSON.FeatureCollection {
return {
type: 'FeatureCollection',
features: devices
.filter((d) => d.latitude != null && d.longitude != null)
.map((d) => ({
type: 'Feature' as const,
geometry: {
type: 'Point' as const,
coordinates: [d.longitude!, d.latitude!],
},
properties: {
id: d.id,
hostname: d.hostname,
ip_address: d.ip_address,
status: d.status,
model: d.model || '',
uptime_seconds: d.uptime_seconds,
last_cpu_load: d.last_cpu_load,
last_memory_used_pct: d.last_memory_used_pct,
client_count: d.client_count,
avg_signal: d.avg_signal,
tenant_id: d.tenant_id,
color: STATUS_COLORS[d.status] || STATUS_COLORS.unknown,
},
})),
let hasOffline = false
let hasOnline = false
for (const marker of childMarkers) {
const icon = marker.getIcon() as L.DivIcon
const html = (icon.options.html as string) ?? ''
if (html.includes('#ef4444')) {
hasOffline = true
} else if (html.includes('#22c55e')) {
hasOnline = true
}
}
let bgColor: string
if (hasOffline && hasOnline) {
bgColor = '#f59e0b'
} else if (hasOffline) {
bgColor = '#ef4444'
} else {
bgColor = '#22c55e'
}
const size = count < 10 ? 34 : count < 100 ? 40 : 48
return L.divIcon({
html: `<div style="
width: ${size}px;
height: ${size}px;
border-radius: 50%;
background: ${bgColor};
border: 3px solid white;
box-shadow: 0 2px 6px rgba(0,0,0,0.35);
display: flex;
align-items: center;
justify-content: center;
color: white;
font-weight: 700;
font-size: ${count < 100 ? 13 : 11}px;
font-family: system-ui, sans-serif;
">${count}</div>`,
className: '',
iconSize: L.point(size, size),
iconAnchor: L.point(size / 2, size / 2),
})
}
export function FleetMap({ devices, tenantId }: FleetMapProps) {
const containerRef = useRef<HTMLDivElement>(null)
const mapRef = useRef<maplibregl.Map | null>(null)
const theme = useUIStore((s) => s.theme)
const mappedDevices = useMemo(
() => devices.filter((d) => d.latitude != null && d.longitude != null),
[devices],
)
const geojson = useMemo(() => deviceToGeoJSON(devices), [devices])
// Initialize map
useEffect(() => {
if (!containerRef.current) return
const map = new maplibregl.Map({
container: containerRef.current,
style: buildMapStyle(theme),
center: DEFAULT_CENTER,
zoom: DEFAULT_ZOOM,
maxZoom: 17,
})
map.addControl(new maplibregl.NavigationControl(), 'top-right')
map.on('load', () => {
// Device markers source with clustering
map.addSource('devices', {
type: 'geojson',
data: geojson,
cluster: true,
clusterMaxZoom: 14,
clusterRadius: 50,
})
// Cluster circles
map.addLayer({
id: 'clusters',
type: 'circle',
source: 'devices',
filter: ['has', 'point_count'],
paint: {
'circle-color': [
'step', ['get', 'point_count'],
'#22c55e', 10,
'#f59e0b', 50,
'#ef4444',
],
'circle-radius': [
'step', ['get', 'point_count'],
18, 10,
24, 50,
32,
],
'circle-stroke-width': 2,
'circle-stroke-color': '#ffffff',
},
})
// Cluster count labels
map.addLayer({
id: 'cluster-count',
type: 'symbol',
source: 'devices',
filter: ['has', 'point_count'],
layout: {
'text-field': '{point_count_abbreviated}',
'text-font': ['Noto Sans Medium'],
'text-size': 13,
},
paint: {
'text-color': '#ffffff',
},
})
// Individual device dots
map.addLayer({
id: 'device-points',
type: 'circle',
source: 'devices',
filter: ['!', ['has', 'point_count']],
paint: {
'circle-color': ['get', 'color'],
'circle-radius': 7,
'circle-stroke-width': 2,
'circle-stroke-color': '#ffffff',
},
})
// Click on cluster to zoom in
map.on('click', 'clusters', (e) => {
const features = map.queryRenderedFeatures(e.point, { layers: ['clusters'] })
if (!features.length) return
const clusterId = features[0].properties.cluster_id
const source = map.getSource('devices') as maplibregl.GeoJSONSource
source.getClusterExpansionZoom(clusterId).then((zoom) => {
const coords = (features[0].geometry as GeoJSON.Point).coordinates as [number, number]
map.easeTo({ center: coords, zoom })
})
})
// Click on device to show popup
map.on('click', 'device-points', (e) => {
if (!e.features?.length) return
const f = e.features[0]
const coords = (f.geometry as GeoJSON.Point).coordinates.slice() as [number, number]
const p = f.properties
const resolvedTenantId = tenantId || p.tenant_id
let html = `<div style="font-family:system-ui,sans-serif;font-size:13px;min-width:200px;">
<a href="/tenants/${resolvedTenantId}/devices/${p.id}" style="font-weight:600;font-size:14px;color:#7dd3fc;text-decoration:none;">${p.hostname}</a>
<div style="color:#94a3b8;margin-top:4px;">
<div>IP: ${p.ip_address}</div>`
if (p.model) html += `<div>Model: ${p.model}</div>`
if (p.uptime_seconds) html += `<div>Uptime: ${formatUptime(p.uptime_seconds)}</div>`
if (p.last_cpu_load != null) html += `<div>CPU: ${p.last_cpu_load}%</div>`
if (p.last_memory_used_pct != null) html += `<div>Memory: ${p.last_memory_used_pct}%</div>`
if (p.client_count != null && p.client_count > 0) {
html += `<div>Clients: ${p.client_count}`
if (p.avg_signal != null) html += ` (avg ${p.avg_signal} dBm)`
html += `</div>`
}
html += `<div style="margin-top:4px;display:flex;align-items:center;gap:6px;">
Status:
<span style="display:inline-block;width:8px;height:8px;border-radius:50%;background:${p.color}"></span>
${p.status}
</div>
</div>
</div>`
new maplibregl.Popup({ offset: 10, maxWidth: '300px' })
.setLngLat(coords)
.setHTML(html)
.addTo(map)
})
// Cursor changes
map.on('mouseenter', 'clusters', () => { map.getCanvas().style.cursor = 'pointer' })
map.on('mouseleave', 'clusters', () => { map.getCanvas().style.cursor = '' })
map.on('mouseenter', 'device-points', () => { map.getCanvas().style.cursor = 'pointer' })
map.on('mouseleave', 'device-points', () => { map.getCanvas().style.cursor = '' })
// Fit bounds to all devices
if (geojson.features.length > 0) {
const bounds = new maplibregl.LngLatBounds()
geojson.features.forEach((f) => {
const [lng, lat] = (f.geometry as GeoJSON.Point).coordinates
bounds.extend([lng, lat])
})
map.fitBounds(bounds, { padding: 60, maxZoom: 14 })
}
})
mapRef.current = map
return () => {
map.remove()
mapRef.current = null
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
// Re-add device layers after style change (setStyle wipes all sources/layers)
const addDeviceLayers = (map: maplibregl.Map) => {
if (map.getSource('devices')) return
map.addSource('devices', {
type: 'geojson',
data: geojson,
cluster: true,
clusterMaxZoom: 14,
clusterRadius: 50,
})
map.addLayer({
id: 'clusters',
type: 'circle',
source: 'devices',
filter: ['has', 'point_count'],
paint: {
'circle-color': ['step', ['get', 'point_count'], '#22c55e', 10, '#f59e0b', 50, '#ef4444'],
'circle-radius': ['step', ['get', 'point_count'], 18, 10, 24, 50, 32],
'circle-stroke-width': 2,
'circle-stroke-color': '#ffffff',
},
})
map.addLayer({
id: 'cluster-count',
type: 'symbol',
source: 'devices',
filter: ['has', 'point_count'],
layout: { 'text-field': '{point_count_abbreviated}', 'text-font': ['Noto Sans Medium'], 'text-size': 13 },
paint: { 'text-color': '#ffffff' },
})
map.addLayer({
id: 'device-points',
type: 'circle',
source: 'devices',
filter: ['!', ['has', 'point_count']],
paint: {
'circle-color': ['get', 'color'],
'circle-radius': 7,
'circle-stroke-width': 2,
'circle-stroke-color': '#ffffff',
},
})
}
// Switch map theme when app theme changes
useEffect(() => {
const map = mapRef.current
if (!map) return
map.setStyle(buildMapStyle(theme))
map.once('styledata', () => addDeviceLayers(map))
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [theme])
// Update device data when it changes
useEffect(() => {
const map = mapRef.current
if (!map || !map.isStyleLoaded()) return
const source = map.getSource('devices') as maplibregl.GeoJSONSource | undefined
if (source) {
source.setData(geojson)
}
}, [geojson])
return <div ref={containerRef} className="h-full w-full" />
return (
<MapContainer
center={DEFAULT_CENTER}
zoom={DEFAULT_ZOOM}
className="h-full w-full"
scrollWheelZoom
maxZoom={19}
style={{ background: 'hsl(var(--background))' }}
>
<TileLayer
attribution='&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a>'
url="/osm-tiles/{z}/{x}/{y}.png"
maxZoom={19}
/>
<AutoFitBounds devices={mappedDevices} />
<MarkerClusterGroup
chunkedLoading
iconCreateFunction={createClusterIcon}
maxClusterRadius={50}
spiderfyOnMaxZoom
showCoverageOnHover={false}
>
{mappedDevices.map((device) => (
<DeviceMarker key={device.id} device={device} tenantId={tenantId} />
))}
</MarkerClusterGroup>
</MapContainer>
)
}

View File

@@ -809,6 +809,8 @@ export interface FleetDevice {
tenant_name: string
client_count: number | null
avg_signal: number | null
cpe_signal: number | null
ap_hostname: string | null
}
export interface SparklinePoint {