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)
|
# Local dev tools (mock servers, screenshot automation)
|
||||||
tools/dev/
|
tools/dev/
|
||||||
|
|
||||||
|
# PMTiles map data (large binaries, downloaded per-region)
|
||||||
|
frontend/public/tiles/*.pmtiles
|
||||||
fleet-dashboard.png
|
fleet-dashboard.png
|
||||||
|
|||||||
@@ -135,6 +135,8 @@ services:
|
|||||||
container_name: tod_frontend
|
container_name: tod_frontend
|
||||||
ports:
|
ports:
|
||||||
- "3000:80"
|
- "3000:80"
|
||||||
|
volumes:
|
||||||
|
- ./frontend/public/tiles:/usr/share/nginx/html/tiles:ro
|
||||||
depends_on:
|
depends_on:
|
||||||
- api
|
- api
|
||||||
deploy:
|
deploy:
|
||||||
|
|||||||
132
frontend/package-lock.json
generated
132
frontend/package-lock.json
generated
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "frontend",
|
"name": "frontend",
|
||||||
"version": "9.0.1",
|
"version": "9.7.0",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "frontend",
|
"name": "frontend",
|
||||||
"version": "9.0.1",
|
"version": "9.7.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@dagrejs/dagre": "^2.0.4",
|
"@dagrejs/dagre": "^2.0.4",
|
||||||
"@fontsource-variable/manrope": "^5.2.8",
|
"@fontsource-variable/manrope": "^5.2.8",
|
||||||
@@ -43,6 +43,7 @@
|
|||||||
"framer-motion": "^12.34.3",
|
"framer-motion": "^12.34.3",
|
||||||
"leaflet": "^1.9.4",
|
"leaflet": "^1.9.4",
|
||||||
"lucide-react": "^0.575.0",
|
"lucide-react": "^0.575.0",
|
||||||
|
"protomaps-leaflet": "^5.1.0",
|
||||||
"react": "^19.2.0",
|
"react": "^19.2.0",
|
||||||
"react-dom": "^19.2.0",
|
"react-dom": "^19.2.0",
|
||||||
"react-leaflet": "^5.0.0",
|
"react-leaflet": "^5.0.0",
|
||||||
@@ -1416,6 +1417,23 @@
|
|||||||
"@jridgewell/sourcemap-codec": "^1.4.14"
|
"@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": {
|
"node_modules/@nodelib/fs.scandir": {
|
||||||
"version": "2.1.5",
|
"version": "2.1.5",
|
||||||
"resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz",
|
"resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz",
|
||||||
@@ -1467,6 +1485,15 @@
|
|||||||
"node": ">=18"
|
"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": {
|
"node_modules/@radix-ui/number": {
|
||||||
"version": "1.1.1",
|
"version": "1.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/@radix-ui/number/-/number-1.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/@radix-ui/number/-/number-1.1.1.tgz",
|
||||||
@@ -4331,6 +4358,12 @@
|
|||||||
"assertion-error": "^2.0.1"
|
"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": {
|
"node_modules/@types/d3": {
|
||||||
"version": "7.4.3",
|
"version": "7.4.3",
|
||||||
"resolved": "https://registry.npmjs.org/@types/d3/-/d3-7.4.3.tgz",
|
"resolved": "https://registry.npmjs.org/@types/d3/-/d3-7.4.3.tgz",
|
||||||
@@ -4631,7 +4664,6 @@
|
|||||||
"version": "1.9.21",
|
"version": "1.9.21",
|
||||||
"resolved": "https://registry.npmjs.org/@types/leaflet/-/leaflet-1.9.21.tgz",
|
"resolved": "https://registry.npmjs.org/@types/leaflet/-/leaflet-1.9.21.tgz",
|
||||||
"integrity": "sha512-TbAd9DaPGSnzp6QvtYngntMZgcRk+igFELwR2N99XZn7RXUdKgsXMR+28bUO0rPsWp8MIu/f47luLIQuSLYv/w==",
|
"integrity": "sha512-TbAd9DaPGSnzp6QvtYngntMZgcRk+igFELwR2N99XZn7RXUdKgsXMR+28bUO0rPsWp8MIu/f47luLIQuSLYv/w==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@types/geojson": "*"
|
"@types/geojson": "*"
|
||||||
@@ -4647,6 +4679,12 @@
|
|||||||
"undici-types": "~7.16.0"
|
"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": {
|
"node_modules/@types/react": {
|
||||||
"version": "19.2.14",
|
"version": "19.2.14",
|
||||||
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz",
|
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz",
|
||||||
@@ -5677,6 +5715,12 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/combined-stream": {
|
||||||
"version": "1.0.8",
|
"version": "1.0.8",
|
||||||
"resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz",
|
"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": {
|
"node_modules/file-entry-cache": {
|
||||||
"version": "8.0.0",
|
"version": "8.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz",
|
"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==",
|
"integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==",
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/picocolors": {
|
||||||
"version": "1.1.1",
|
"version": "1.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
|
"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": "^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": {
|
"node_modules/postcss": {
|
||||||
"version": "8.5.6",
|
"version": "8.5.6",
|
||||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz",
|
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz",
|
||||||
@@ -7891,6 +7963,12 @@
|
|||||||
"integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==",
|
"integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==",
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/prelude-ls": {
|
||||||
"version": "1.2.1",
|
"version": "1.2.1",
|
||||||
"resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz",
|
"resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz",
|
||||||
@@ -7954,6 +8032,30 @@
|
|||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true
|
"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": {
|
"node_modules/proxy-from-env": {
|
||||||
"version": "1.1.0",
|
"version": "1.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz",
|
||||||
@@ -7990,6 +8092,21 @@
|
|||||||
],
|
],
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/react": {
|
||||||
"version": "19.2.4",
|
"version": "19.2.4",
|
||||||
"resolved": "https://registry.npmjs.org/react/-/react-19.2.4.tgz",
|
"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"
|
"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": {
|
"node_modules/reusify": {
|
||||||
"version": "1.1.0",
|
"version": "1.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz",
|
||||||
|
|||||||
@@ -50,6 +50,7 @@
|
|||||||
"framer-motion": "^12.34.3",
|
"framer-motion": "^12.34.3",
|
||||||
"leaflet": "^1.9.4",
|
"leaflet": "^1.9.4",
|
||||||
"lucide-react": "^0.575.0",
|
"lucide-react": "^0.575.0",
|
||||||
|
"protomaps-leaflet": "^5.1.0",
|
||||||
"react": "^19.2.0",
|
"react": "^19.2.0",
|
||||||
"react-dom": "^19.2.0",
|
"react-dom": "^19.2.0",
|
||||||
"react-leaflet": "^5.0.0",
|
"react-leaflet": "^5.0.0",
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
import { useEffect, useMemo } from 'react'
|
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 MarkerClusterGroup from 'react-leaflet-cluster'
|
||||||
import L from 'leaflet'
|
import L from 'leaflet'
|
||||||
|
import * as protomapsL from 'protomaps-leaflet'
|
||||||
import type { FleetDevice } from '@/lib/api'
|
import type { FleetDevice } from '@/lib/api'
|
||||||
import { DeviceMarker } from './DeviceMarker'
|
import { DeviceMarker } from './DeviceMarker'
|
||||||
|
|
||||||
@@ -16,6 +17,31 @@ interface FleetMapProps {
|
|||||||
const DEFAULT_CENTER: [number, number] = [20, 0]
|
const DEFAULT_CENTER: [number, number] = [20, 0]
|
||||||
const DEFAULT_ZOOM = 2
|
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
|
* Inner component that auto-fits the map to device bounds
|
||||||
* whenever the device list changes.
|
* whenever the device list changes.
|
||||||
@@ -111,10 +137,7 @@ export function FleetMap({ devices, tenantId }: FleetMapProps) {
|
|||||||
scrollWheelZoom
|
scrollWheelZoom
|
||||||
style={{ background: 'hsl(var(--background))' }}
|
style={{ background: 'hsl(var(--background))' }}
|
||||||
>
|
>
|
||||||
<TileLayer
|
<ProtomapsLayer />
|
||||||
attribution='© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors'
|
|
||||||
url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
|
|
||||||
/>
|
|
||||||
<AutoFitBounds devices={mappedDevices} />
|
<AutoFitBounds devices={mappedDevices} />
|
||||||
<MarkerClusterGroup
|
<MarkerClusterGroup
|
||||||
chunkedLoading
|
chunkedLoading
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { useCallback } from 'react'
|
|||||||
import { createFileRoute, Outlet, Navigate, redirect, useNavigate, useRouterState } from '@tanstack/react-router'
|
import { createFileRoute, Outlet, Navigate, redirect, useNavigate, useRouterState } from '@tanstack/react-router'
|
||||||
import { useQueryClient } from '@tanstack/react-query'
|
import { useQueryClient } from '@tanstack/react-query'
|
||||||
import { AnimatePresence } from 'framer-motion'
|
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 { useAuth } from '@/lib/auth'
|
||||||
import { useUIStore } from '@/lib/store'
|
import { useUIStore } from '@/lib/store'
|
||||||
import { useEventStream, type SSEEvent } from '@/hooks/useEventStream'
|
import { useEventStream, type SSEEvent } from '@/hooks/useEventStream'
|
||||||
@@ -72,27 +72,8 @@ function AuthenticatedLayout() {
|
|||||||
|
|
||||||
// ── Alert fired (RT-03) ───────────────────────────────────────────
|
// ── Alert fired (RT-03) ───────────────────────────────────────────
|
||||||
case 'alert_fired': {
|
case 'alert_fired': {
|
||||||
const { severity, rule_name, device_name, metric, current_value, threshold } =
|
// Invalidate alert queries so dashboard/alert pages update in real-time.
|
||||||
event.data as {
|
// No toast — at fleet scale these fire constantly and become noise.
|
||||||
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,
|
|
||||||
})
|
|
||||||
void queryClient.invalidateQueries({ queryKey: ['active-alerts'] })
|
void queryClient.invalidateQueries({ queryKey: ['active-alerts'] })
|
||||||
void queryClient.invalidateQueries({ queryKey: ['alert-events'] })
|
void queryClient.invalidateQueries({ queryKey: ['alert-events'] })
|
||||||
void queryClient.invalidateQueries({ queryKey: ['dashboard-alerts'] })
|
void queryClient.invalidateQueries({ queryKey: ['dashboard-alerts'] })
|
||||||
@@ -101,14 +82,6 @@ function AuthenticatedLayout() {
|
|||||||
|
|
||||||
// ── Alert resolved ────────────────────────────────────────────────
|
// ── Alert resolved ────────────────────────────────────────────────
|
||||||
case '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: ['active-alerts'] })
|
||||||
void queryClient.invalidateQueries({ queryKey: ['alert-events'] })
|
void queryClient.invalidateQueries({ queryKey: ['alert-events'] })
|
||||||
void queryClient.invalidateQueries({ queryKey: ['dashboard-alerts'] })
|
void queryClient.invalidateQueries({ queryKey: ['dashboard-alerts'] })
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ server {
|
|||||||
|
|
||||||
# CSP for React SPA with Tailwind CSS and Leaflet maps
|
# 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)
|
# 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
|
# Proxy API requests to the backend service
|
||||||
# The api container is reachable via Docker internal DNS as "api" on port 8000
|
# 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;
|
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
|
# Serve static assets with long cache headers
|
||||||
# Note: add_header in a location block clears parent-block headers,
|
# Note: add_header in a location block clears parent-block headers,
|
||||||
# so we re-add the essential security header for static assets.
|
# so we re-add the essential security header for static assets.
|
||||||
|
|||||||
Reference in New Issue
Block a user