feat(map): self-hosted PMTiles map tiles, remove alert toast spam

- Replace OpenStreetMap CDN with self-hosted Protomaps PMTiles
  (Wisconsin + Florida regional extracts, served from nginx)
- Add protomaps-leaflet for vector tile rendering in dark theme
- Update CSP to remove openstreetmap.org, add blob: for vector workers
- Add nginx location block for /tiles/ with byte range support
- Mount tiles directory as volume (not baked into image)
- Remove alert_fired/alert_resolved toast notifications that spammed
  "undefined" at fleet scale — dashboard still updates via query invalidation
- Add *.pmtiles to .gitignore (large binaries)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Jason Staack
2026-03-19 18:30:08 -05:00
parent 222b7c2b25
commit f0ddd98b93
7 changed files with 177 additions and 39 deletions

3
.gitignore vendored
View File

@@ -50,4 +50,7 @@ docs/comparison-*.md
# Local dev tools (mock servers, screenshot automation)
tools/dev/
# PMTiles map data (large binaries, downloaded per-region)
frontend/public/tiles/*.pmtiles
fleet-dashboard.png

View File

@@ -135,6 +135,8 @@ services:
container_name: tod_frontend
ports:
- "3000:80"
volumes:
- ./frontend/public/tiles:/usr/share/nginx/html/tiles:ro
depends_on:
- api
deploy:

View File

@@ -1,12 +1,12 @@
{
"name": "frontend",
"version": "9.0.1",
"version": "9.7.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "frontend",
"version": "9.0.1",
"version": "9.7.0",
"dependencies": {
"@dagrejs/dagre": "^2.0.4",
"@fontsource-variable/manrope": "^5.2.8",
@@ -43,6 +43,7 @@
"framer-motion": "^12.34.3",
"leaflet": "^1.9.4",
"lucide-react": "^0.575.0",
"protomaps-leaflet": "^5.1.0",
"react": "^19.2.0",
"react-dom": "^19.2.0",
"react-leaflet": "^5.0.0",
@@ -1416,6 +1417,23 @@
"@jridgewell/sourcemap-codec": "^1.4.14"
}
},
"node_modules/@mapbox/point-geometry": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@mapbox/point-geometry/-/point-geometry-1.1.0.tgz",
"integrity": "sha512-YGcBz1cg4ATXDCM/71L9xveh4dynfGmcLDqufR+nQQy3fKwsAZsWd/x4621/6uJaeB9mwOHE6hPeDgXz9uViUQ==",
"license": "ISC"
},
"node_modules/@mapbox/vector-tile": {
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/@mapbox/vector-tile/-/vector-tile-2.0.4.tgz",
"integrity": "sha512-AkOLcbgGTdXScosBWwmmD7cDlvOjkg/DetGva26pIRiZPdeJYjYKarIlb4uxVzi6bwHO6EWH82eZ5Nuv4T5DUg==",
"license": "BSD-3-Clause",
"dependencies": {
"@mapbox/point-geometry": "~1.1.0",
"@types/geojson": "^7946.0.16",
"pbf": "^4.0.1"
}
},
"node_modules/@nodelib/fs.scandir": {
"version": "2.1.5",
"resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz",
@@ -1467,6 +1485,15 @@
"node": ">=18"
}
},
"node_modules/@protomaps/basemaps": {
"version": "5.7.2",
"resolved": "https://registry.npmjs.org/@protomaps/basemaps/-/basemaps-5.7.2.tgz",
"integrity": "sha512-K1Yk6bWdULulYg+R2QRVXx4NzJZan5YQhpejEG0c1/sXruJrfPIPZuakpf3jwAgVmjIRVQwAv+yRafDeN0aaUQ==",
"license": "BSD-3-Clause",
"bin": {
"generate_style": "src/cli.ts"
}
},
"node_modules/@radix-ui/number": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/@radix-ui/number/-/number-1.1.1.tgz",
@@ -4331,6 +4358,12 @@
"assertion-error": "^2.0.1"
}
},
"node_modules/@types/css-font-loading-module": {
"version": "0.0.7",
"resolved": "https://registry.npmjs.org/@types/css-font-loading-module/-/css-font-loading-module-0.0.7.tgz",
"integrity": "sha512-nl09VhutdjINdWyXxHWN/w9zlNCfr60JUqJbd24YXUuCwgeL0TpFSdElCwb6cxfB6ybE19Gjj4g0jsgkXxKv1Q==",
"license": "MIT"
},
"node_modules/@types/d3": {
"version": "7.4.3",
"resolved": "https://registry.npmjs.org/@types/d3/-/d3-7.4.3.tgz",
@@ -4631,7 +4664,6 @@
"version": "1.9.21",
"resolved": "https://registry.npmjs.org/@types/leaflet/-/leaflet-1.9.21.tgz",
"integrity": "sha512-TbAd9DaPGSnzp6QvtYngntMZgcRk+igFELwR2N99XZn7RXUdKgsXMR+28bUO0rPsWp8MIu/f47luLIQuSLYv/w==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/geojson": "*"
@@ -4647,6 +4679,12 @@
"undici-types": "~7.16.0"
}
},
"node_modules/@types/rbush": {
"version": "3.0.4",
"resolved": "https://registry.npmjs.org/@types/rbush/-/rbush-3.0.4.tgz",
"integrity": "sha512-knSt9cCW8jj1ZSFcFeBZaX++OucmfPxxHiRwTahZfJlnQsek7O0bazTJHWD2RVj9LEoejUYF2de3/stf+QXcXw==",
"license": "MIT"
},
"node_modules/@types/react": {
"version": "19.2.14",
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz",
@@ -5677,6 +5715,12 @@
"dev": true,
"license": "MIT"
},
"node_modules/color2k": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/color2k/-/color2k-2.0.3.tgz",
"integrity": "sha512-zW190nQTIoXcGCaU08DvVNFTmQhUpnJfVuAKfWqUQkflXKpaDdpaYoM0iluLS9lgJNHyBF58KKA2FBEwkD7wog==",
"license": "MIT"
},
"node_modules/combined-stream": {
"version": "1.0.8",
"resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz",
@@ -6572,6 +6616,12 @@
}
}
},
"node_modules/fflate": {
"version": "0.8.2",
"resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.2.tgz",
"integrity": "sha512-cPJU47OaAoCbg0pBvzsgpTPhmhqI5eJjh/JIu8tPj5q+T7iLvW/JAYUqmE7KOB4R1ZyEhzBaIQpQpardBF5z8A==",
"license": "MIT"
},
"node_modules/file-entry-cache": {
"version": "8.0.0",
"resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz",
@@ -7652,6 +7702,18 @@
"integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==",
"license": "MIT"
},
"node_modules/pbf": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/pbf/-/pbf-4.0.1.tgz",
"integrity": "sha512-SuLdBvS42z33m8ejRbInMapQe8n0D3vN/Xd5fmWM3tufNgRQFBpaW2YVJxQZV4iPNqb0vEFvssMEo5w9c6BTIA==",
"license": "BSD-3-Clause",
"dependencies": {
"resolve-protobuf-schema": "^2.1.0"
},
"bin": {
"pbf": "bin/pbf"
}
},
"node_modules/picocolors": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
@@ -7735,6 +7797,16 @@
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
}
},
"node_modules/pmtiles": {
"version": "3.2.1",
"resolved": "https://registry.npmjs.org/pmtiles/-/pmtiles-3.2.1.tgz",
"integrity": "sha512-3R4fBwwoli5mw7a6t1IGwOtfmcSAODq6Okz0zkXhS1zi9sz1ssjjIfslwPvcWw5TNhdjNBUg9fgfPLeqZlH6ng==",
"license": "BSD-3-Clause",
"dependencies": {
"@types/leaflet": "^1.9.8",
"fflate": "^0.8.0"
}
},
"node_modules/postcss": {
"version": "8.5.6",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz",
@@ -7891,6 +7963,12 @@
"integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==",
"license": "MIT"
},
"node_modules/potpack": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/potpack/-/potpack-1.0.2.tgz",
"integrity": "sha512-choctRBIV9EMT9WGAZHn3V7t0Z2pMQyl0EZE6pFc/6ml3ssw7Dlf/oAOvFwjm1HVsqfQN8GfeFyJ+d8tRzqueQ==",
"license": "ISC"
},
"node_modules/prelude-ls": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz",
@@ -7954,6 +8032,30 @@
"license": "MIT",
"peer": true
},
"node_modules/protocol-buffers-schema": {
"version": "3.6.0",
"resolved": "https://registry.npmjs.org/protocol-buffers-schema/-/protocol-buffers-schema-3.6.0.tgz",
"integrity": "sha512-TdDRD+/QNdrCGCE7v8340QyuXd4kIWIgapsE2+n/SaGiSSbomYl4TjHlvIoCWRpE7wFt02EpB35VVA2ImcBVqw==",
"license": "MIT"
},
"node_modules/protomaps-leaflet": {
"version": "5.1.0",
"resolved": "https://registry.npmjs.org/protomaps-leaflet/-/protomaps-leaflet-5.1.0.tgz",
"integrity": "sha512-N0YY1Gbw5nZ9n8VUOvwHKm/w3zhTTuaZEeMZl8doHemeM4ujGPp2otI6t4Sgqrz8iM4svx2rpG1Q17rHkOWSaA==",
"license": "BSD-3-Clause",
"dependencies": {
"@mapbox/point-geometry": "^1.1.0",
"@mapbox/vector-tile": "^2.0.2",
"@protomaps/basemaps": "^5.0.0",
"@types/css-font-loading-module": "^0.0.7",
"@types/rbush": "^3.0.0",
"color2k": "^2.0.3",
"pbf": "^4.0.1",
"pmtiles": "^3.1.0",
"potpack": "^1.0.2",
"rbush": "^3.0.1"
}
},
"node_modules/proxy-from-env": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz",
@@ -7990,6 +8092,21 @@
],
"license": "MIT"
},
"node_modules/quickselect": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/quickselect/-/quickselect-2.0.0.tgz",
"integrity": "sha512-RKJ22hX8mHe3Y6wH/N3wCM6BWtjaxIyyUIkpHOvfFnxdI4yD4tBXEBKSbriGujF6jnSVkJrffuo6vxACiSSxIw==",
"license": "ISC"
},
"node_modules/rbush": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/rbush/-/rbush-3.0.1.tgz",
"integrity": "sha512-XRaVO0YecOpEuIvbhbpTrZgoiI6xBlz6hnlr6EHhd+0x9ase6EmeN+hdwwUaJvLcsFFQ8iWVF1GAK1yB0BWi0w==",
"license": "MIT",
"dependencies": {
"quickselect": "^2.0.0"
}
},
"node_modules/react": {
"version": "19.2.4",
"resolved": "https://registry.npmjs.org/react/-/react-19.2.4.tgz",
@@ -8354,6 +8471,15 @@
"url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1"
}
},
"node_modules/resolve-protobuf-schema": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/resolve-protobuf-schema/-/resolve-protobuf-schema-2.1.0.tgz",
"integrity": "sha512-kI5ffTiZWmJaS/huM8wZfEMer1eRd7oJQhDuxeCLe3t7N7mX3z94CN0xPxBQxFYQTSNz9T0i+v6inKqSdK8xrQ==",
"license": "MIT",
"dependencies": {
"protocol-buffers-schema": "^3.3.1"
}
},
"node_modules/reusify": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz",

View File

@@ -50,6 +50,7 @@
"framer-motion": "^12.34.3",
"leaflet": "^1.9.4",
"lucide-react": "^0.575.0",
"protomaps-leaflet": "^5.1.0",
"react": "^19.2.0",
"react-dom": "^19.2.0",
"react-leaflet": "^5.0.0",

View File

@@ -1,7 +1,8 @@
import { useEffect, useMemo } from 'react'
import { MapContainer, TileLayer, useMap } from 'react-leaflet'
import { MapContainer, useMap } from 'react-leaflet'
import MarkerClusterGroup from 'react-leaflet-cluster'
import L from 'leaflet'
import * as protomapsL from 'protomaps-leaflet'
import type { FleetDevice } from '@/lib/api'
import { DeviceMarker } from './DeviceMarker'
@@ -16,6 +17,31 @@ interface FleetMapProps {
const DEFAULT_CENTER: [number, number] = [20, 0]
const DEFAULT_ZOOM = 2
/**
* Self-hosted PMTiles basemap layer via protomaps-leaflet.
* Loads regional PMTiles files from /tiles/ (no third-party requests).
* Multiple region files are layered — tiles outside downloaded regions show blank.
*/
const PMTILES_REGIONS = ['/tiles/wisconsin.pmtiles', '/tiles/florida.pmtiles']
function ProtomapsLayer() {
const map = useMap()
useEffect(() => {
const layers: L.Layer[] = []
for (const url of PMTILES_REGIONS) {
const layer = protomapsL.leafletLayer({ url, flavor: 'dark' })
layer.addTo(map)
layers.push(layer)
}
return () => {
for (const layer of layers) map.removeLayer(layer)
}
}, [map])
return null
}
/**
* Inner component that auto-fits the map to device bounds
* whenever the device list changes.
@@ -111,10 +137,7 @@ export function FleetMap({ devices, tenantId }: FleetMapProps) {
scrollWheelZoom
style={{ background: 'hsl(var(--background))' }}
>
<TileLayer
attribution='&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors'
url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
/>
<ProtomapsLayer />
<AutoFitBounds devices={mappedDevices} />
<MarkerClusterGroup
chunkedLoading

View File

@@ -2,7 +2,7 @@ import { useCallback } from 'react'
import { createFileRoute, Outlet, Navigate, redirect, useNavigate, useRouterState } from '@tanstack/react-router'
import { useQueryClient } from '@tanstack/react-query'
import { AnimatePresence } from 'framer-motion'
import { toast } from 'sonner'
// toast import removed — alert toasts were noisy at fleet scale
import { useAuth } from '@/lib/auth'
import { useUIStore } from '@/lib/store'
import { useEventStream, type SSEEvent } from '@/hooks/useEventStream'
@@ -72,27 +72,8 @@ function AuthenticatedLayout() {
// ── Alert fired (RT-03) ───────────────────────────────────────────
case 'alert_fired': {
const { severity, rule_name, device_name, metric, current_value, threshold } =
event.data as {
severity: string
rule_name: string
device_name?: string
metric?: string
current_value?: string | number
threshold?: string | number
}
const toastFn =
severity === 'critical'
? toast.error
: severity === 'warning'
? toast.warning
: toast.info
toastFn(`Alert: ${rule_name}`, {
description: device_name
? `${device_name}${metric ?? 'unknown'}: ${current_value ?? '?'} (threshold: ${threshold ?? '?'})`
: `${metric ?? 'unknown'}: ${current_value ?? '?'}`,
duration: severity === 'critical' ? 10000 : 5000,
})
// Invalidate alert queries so dashboard/alert pages update in real-time.
// No toast — at fleet scale these fire constantly and become noise.
void queryClient.invalidateQueries({ queryKey: ['active-alerts'] })
void queryClient.invalidateQueries({ queryKey: ['alert-events'] })
void queryClient.invalidateQueries({ queryKey: ['dashboard-alerts'] })
@@ -101,14 +82,6 @@ function AuthenticatedLayout() {
// ── Alert resolved ────────────────────────────────────────────────
case 'alert_resolved': {
const { metric } = event.data as {
device_id?: string
metric?: string
}
toast.info('Alert resolved', {
description: `${metric ?? 'Condition'} returned to normal`,
duration: 3000,
})
void queryClient.invalidateQueries({ queryKey: ['active-alerts'] })
void queryClient.invalidateQueries({ queryKey: ['alert-events'] })
void queryClient.invalidateQueries({ queryKey: ['dashboard-alerts'] })

View File

@@ -23,7 +23,7 @@ server {
# CSP for React SPA with Tailwind CSS and Leaflet maps
# worker-src required for SRP key derivation Web Worker (Safari won't fall back to script-src)
add_header Content-Security-Policy "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https://*.tile.openstreetmap.org; font-src 'self'; connect-src 'self' ws: wss:; worker-src 'self'; frame-ancestors 'self'; base-uri 'self'; form-action 'self';" always;
add_header Content-Security-Policy "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; font-src 'self'; connect-src 'self' ws: wss:; worker-src 'self' blob:; frame-ancestors 'self'; base-uri 'self'; form-action 'self';" always;
# Proxy API requests to the backend service
# The api container is reachable via Docker internal DNS as "api" on port 8000
@@ -91,6 +91,16 @@ server {
add_header X-Content-Type-Options "nosniff" always;
}
# Serve PMTiles with byte range support (protomaps-leaflet reads via HTTP Range)
location /tiles/ {
add_header Access-Control-Allow-Origin "*" always;
add_header X-Content-Type-Options "nosniff" always;
types {
application/octet-stream pmtiles;
}
try_files $uri =404;
}
# Serve static assets with long cache headers
# Note: add_header in a location block clears parent-block headers,
# so we re-add the essential security header for static assets.