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:
3
.gitignore
vendored
3
.gitignore
vendored
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
132
frontend/package-lock.json
generated
132
frontend/package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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='© <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
|
||||
|
||||
@@ -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'] })
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user