feat(map): self-hosted MapLibre GL + PMTiles vector map

Replace Leaflet + OSM raster tiles with MapLibre GL JS + PMTiles:
- Full continental US vector tiles (8GB PMTiles, zoom 0-14 with overzoom)
- Dark theme via @protomaps/basemaps (official supported path)
- Clustered device markers with status colors (green/yellow/red)
- Popup cards show CPU, memory, wireless client count + avg signal
- Font glyphs proxied through nginx, sprites served locally
- Zero third-party requests from the browser
- Fleet summary SQL now includes wireless client count and avg signal
  via LEFT JOIN LATERAL on wireless_links

Also removes alert toast spam and fixes map container height.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Jason Staack
2026-03-19 20:16:07 -05:00
parent f0ddd98b93
commit 79899840ca
18 changed files with 462 additions and 198 deletions

View File

@@ -360,9 +360,16 @@ _FLEET_SUMMARY_SQL = """
d.id, d.hostname, d.ip_address, d.status, d.model, d.last_seen,
d.uptime_seconds, d.last_cpu_load, d.last_memory_used_pct,
d.latitude, d.longitude,
d.tenant_id, t.name AS tenant_name
d.tenant_id, t.name AS tenant_name,
wl.client_count, wl.avg_signal
FROM devices d
JOIN tenants t ON d.tenant_id = t.id
LEFT JOIN LATERAL (
SELECT count(*)::int AS client_count,
avg(signal_strength)::int AS avg_signal
FROM wireless_links
WHERE ap_device_id = d.id AND state IN ('active', 'discovered')
) wl ON true
ORDER BY d.hostname
"""

View File

@@ -13,6 +13,7 @@
"@fontsource/ibm-plex-mono": "^5.2.7",
"@git-diff-view/lowlight": "^0.0.39",
"@git-diff-view/react": "^0.0.39",
"@protomaps/basemaps": "^5.7.2",
"@radix-ui/react-avatar": "^1.1.11",
"@radix-ui/react-checkbox": "^1.3.3",
"@radix-ui/react-dialog": "^1.1.15",
@@ -43,7 +44,8 @@
"framer-motion": "^12.34.3",
"leaflet": "^1.9.4",
"lucide-react": "^0.575.0",
"protomaps-leaflet": "^5.1.0",
"maplibre-gl": "^5.20.2",
"pmtiles": "^4.4.0",
"react": "^19.2.0",
"react-dom": "^19.2.0",
"react-leaflet": "^5.0.0",
@@ -1417,12 +1419,32 @@
"@jridgewell/sourcemap-codec": "^1.4.14"
}
},
"node_modules/@mapbox/jsonlint-lines-primitives": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/@mapbox/jsonlint-lines-primitives/-/jsonlint-lines-primitives-2.0.2.tgz",
"integrity": "sha512-rY0o9A5ECsTQRVhv7tL/OyDpGAoUB4tTvLiW1DSzQGq4bvTPhNw1VpSNjDJc5GFZ2XuyOtSWSVN05qOtcD71qQ==",
"engines": {
"node": ">= 0.6"
}
},
"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/tiny-sdf": {
"version": "2.0.7",
"resolved": "https://registry.npmjs.org/@mapbox/tiny-sdf/-/tiny-sdf-2.0.7.tgz",
"integrity": "sha512-25gQLQMcpivjOSA40g3gO6qgiFPDpWRoMfd+G/GoppPIeP6JDaMMkMrEJnMZhKyyS6iKwVt5YKu02vCUyJM3Ug==",
"license": "BSD-2-Clause"
},
"node_modules/@mapbox/unitbezier": {
"version": "0.0.1",
"resolved": "https://registry.npmjs.org/@mapbox/unitbezier/-/unitbezier-0.0.1.tgz",
"integrity": "sha512-nMkuDXFv60aBr9soUG5q+GvZYL+2KZHVvsqFCzqnkGEf46U2fvmytHaEVc1/YZbiLn8X+eR3QzX1+dwDO1lxlw==",
"license": "BSD-2-Clause"
},
"node_modules/@mapbox/vector-tile": {
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/@mapbox/vector-tile/-/vector-tile-2.0.4.tgz",
@@ -1434,6 +1456,74 @@
"pbf": "^4.0.1"
}
},
"node_modules/@mapbox/whoots-js": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/@mapbox/whoots-js/-/whoots-js-3.1.0.tgz",
"integrity": "sha512-Es6WcD0nO5l+2BOQS4uLfNPYQaNDfbot3X1XUoloz+x0mPDS3eeORZJl06HXjwBG1fOGwCRnzK88LMdxKRrd6Q==",
"license": "ISC",
"engines": {
"node": ">=6.0.0"
}
},
"node_modules/@maplibre/geojson-vt": {
"version": "6.0.4",
"resolved": "https://registry.npmjs.org/@maplibre/geojson-vt/-/geojson-vt-6.0.4.tgz",
"integrity": "sha512-HYv3POhMRCdhP3UPPATM/hfcy6/WuVIf5FKboH8u/ZuFMTnAIcSVlq5nfOqroLokd925w2QtE7YwquFOIacwVQ==",
"license": "ISC",
"dependencies": {
"kdbush": "^4.0.2"
}
},
"node_modules/@maplibre/maplibre-gl-style-spec": {
"version": "24.7.0",
"resolved": "https://registry.npmjs.org/@maplibre/maplibre-gl-style-spec/-/maplibre-gl-style-spec-24.7.0.tgz",
"integrity": "sha512-Ed7rcKYU5iELfablg9Mj+TVCsXsPBgdMyXPRAxb2v7oWg9YJnpQdZ5msDs1LESu/mtXy3Z48Vdppv2t/x5kAhw==",
"license": "ISC",
"dependencies": {
"@mapbox/jsonlint-lines-primitives": "~2.0.2",
"@mapbox/unitbezier": "^0.0.1",
"json-stringify-pretty-compact": "^4.0.0",
"minimist": "^1.2.8",
"quickselect": "^3.0.0",
"rw": "^1.3.3",
"tinyqueue": "^3.0.0"
},
"bin": {
"gl-style-format": "dist/gl-style-format.mjs",
"gl-style-migrate": "dist/gl-style-migrate.mjs",
"gl-style-validate": "dist/gl-style-validate.mjs"
}
},
"node_modules/@maplibre/mlt": {
"version": "1.1.8",
"resolved": "https://registry.npmjs.org/@maplibre/mlt/-/mlt-1.1.8.tgz",
"integrity": "sha512-8vtfYGidr1rNkv5IwIoU2lfe3Oy+Wa8HluzQYcQi9cveU9K3pweAal/poQj4GJ0K/EW4bTQp2wVAs09g2yDRZg==",
"license": "(MIT OR Apache-2.0)",
"dependencies": {
"@mapbox/point-geometry": "^1.1.0"
}
},
"node_modules/@maplibre/vt-pbf": {
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/@maplibre/vt-pbf/-/vt-pbf-4.3.0.tgz",
"integrity": "sha512-jIvp8F5hQCcreqOOpEt42TJMUlsrEcpf/kI1T2v85YrQRV6PPXUcEXUg5karKtH6oh47XJZ4kHu56pUkOuqA7w==",
"license": "MIT",
"dependencies": {
"@mapbox/point-geometry": "^1.1.0",
"@mapbox/vector-tile": "^2.0.4",
"@maplibre/geojson-vt": "^5.0.4",
"@types/geojson": "^7946.0.16",
"@types/supercluster": "^7.1.3",
"pbf": "^4.0.1",
"supercluster": "^8.0.1"
}
},
"node_modules/@maplibre/vt-pbf/node_modules/@maplibre/geojson-vt": {
"version": "5.0.4",
"resolved": "https://registry.npmjs.org/@maplibre/geojson-vt/-/geojson-vt-5.0.4.tgz",
"integrity": "sha512-KGg9sma45S+stfH9vPCJk1J0lSDLWZgCT9Y8u8qWZJyjFlP8MNP1WGTxIMYJZjDvVT3PDn05kN1C95Sut1HpgQ==",
"license": "ISC"
},
"node_modules/@nodelib/fs.scandir": {
"version": "2.1.5",
"resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz",
@@ -4358,12 +4448,6 @@
"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",
@@ -4664,6 +4748,7 @@
"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": "*"
@@ -4679,12 +4764,6 @@
"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",
@@ -4705,6 +4784,15 @@
"@types/react": "^19.2.0"
}
},
"node_modules/@types/supercluster": {
"version": "7.1.3",
"resolved": "https://registry.npmjs.org/@types/supercluster/-/supercluster-7.1.3.tgz",
"integrity": "sha512-Z0pOY34GDFl3Q6hUFYf3HkTwKEE02e7QgtJppBt+beEAxnyOpJua+voGFvxINBHa06GwLFFym7gRPY2SiKIfIA==",
"license": "MIT",
"dependencies": {
"@types/geojson": "*"
}
},
"node_modules/@types/unist": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/@types/unist/-/unist-3.0.3.tgz",
@@ -5715,12 +5803,6 @@
"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",
@@ -6159,6 +6241,12 @@
"node": ">= 0.4"
}
},
"node_modules/earcut": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/earcut/-/earcut-3.0.2.tgz",
"integrity": "sha512-X7hshQbLyMJ/3RPhyObLARM2sNxxmRALLKx1+NVFFnQ9gKzmCrxm9+uLIAdBcvc8FNLpctqlQ2V6AE92Ol9UDQ==",
"license": "ISC"
},
"node_modules/electron-to-chromium": {
"version": "1.5.302",
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.302.tgz",
@@ -6852,6 +6940,12 @@
"url": "https://github.com/privatenumber/get-tsconfig?sponsor=1"
}
},
"node_modules/gl-matrix": {
"version": "3.4.4",
"resolved": "https://registry.npmjs.org/gl-matrix/-/gl-matrix-3.4.4.tgz",
"integrity": "sha512-latSnyDNt/8zYUB6VIJ6PCh2jBjJX6gnDsoCZ7LyW7GkqrD51EWwa9qCoGixj8YqBtETQK/xY7OmpTF8xz1DdQ==",
"license": "MIT"
},
"node_modules/glob-parent": {
"version": "6.0.2",
"resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz",
@@ -7253,6 +7347,12 @@
"dev": true,
"license": "MIT"
},
"node_modules/json-stringify-pretty-compact": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/json-stringify-pretty-compact/-/json-stringify-pretty-compact-4.0.0.tgz",
"integrity": "sha512-3CNZ2DnrpByG9Nqj6Xo8vqbjT4F6N+tb4Gb28ESAZjYZ5yqvmc56J+/kuIwkaAMOyblTQhUW7PxMkUb8Q36N3Q==",
"license": "MIT"
},
"node_modules/json5": {
"version": "2.2.3",
"resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz",
@@ -7265,6 +7365,12 @@
"node": ">=6"
}
},
"node_modules/kdbush": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/kdbush/-/kdbush-4.0.2.tgz",
"integrity": "sha512-WbCVYJ27Sz8zi9Q7Q0xHC+05iwkm3Znipc2XTlrnJbsHMYktW4hPhXUE8Ys1engBrvffoSCqbil1JQAa7clRpA==",
"license": "ISC"
},
"node_modules/keyv": {
"version": "4.5.4",
"resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz",
@@ -7399,6 +7505,40 @@
"@jridgewell/sourcemap-codec": "^1.5.5"
}
},
"node_modules/maplibre-gl": {
"version": "5.20.2",
"resolved": "https://registry.npmjs.org/maplibre-gl/-/maplibre-gl-5.20.2.tgz",
"integrity": "sha512-0UzMWOe+GZmIUmOA99yTI1vRh15YcGnHxADVB2s+JF3etpjj2/MBCqbPEuu4BP9mLsJWJcpHH0Nzr9uuimmbuQ==",
"license": "BSD-3-Clause",
"dependencies": {
"@mapbox/jsonlint-lines-primitives": "^2.0.2",
"@mapbox/point-geometry": "^1.1.0",
"@mapbox/tiny-sdf": "^2.0.7",
"@mapbox/unitbezier": "^0.0.1",
"@mapbox/vector-tile": "^2.0.4",
"@mapbox/whoots-js": "^3.1.0",
"@maplibre/geojson-vt": "^6.0.3",
"@maplibre/maplibre-gl-style-spec": "^24.7.0",
"@maplibre/mlt": "^1.1.7",
"@maplibre/vt-pbf": "^4.3.0",
"@types/geojson": "^7946.0.16",
"earcut": "^3.0.2",
"gl-matrix": "^3.4.4",
"kdbush": "^4.0.2",
"murmurhash-js": "^1.0.0",
"pbf": "^4.0.1",
"potpack": "^2.1.0",
"quickselect": "^3.0.0",
"tinyqueue": "^3.0.0"
},
"engines": {
"node": ">=16.14.0",
"npm": ">=8.1.0"
},
"funding": {
"url": "https://github.com/maplibre/maplibre-gl-js?sponsor=1"
}
},
"node_modules/math-intrinsics": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
@@ -7493,6 +7633,15 @@
"node": "*"
}
},
"node_modules/minimist": {
"version": "1.2.8",
"resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz",
"integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/motion-dom": {
"version": "12.34.3",
"resolved": "https://registry.npmjs.org/motion-dom/-/motion-dom-12.34.3.tgz",
@@ -7514,6 +7663,12 @@
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
"license": "MIT"
},
"node_modules/murmurhash-js": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/murmurhash-js/-/murmurhash-js-1.0.0.tgz",
"integrity": "sha512-TvmkNhkv8yct0SVBSy+o8wYzXjE4Zz3PCesbfs8HiCXXdcTuocApFv11UWlNFWKYsP2okqrhb7JNlSm9InBhIw==",
"license": "MIT"
},
"node_modules/mz": {
"version": "2.7.0",
"resolved": "https://registry.npmjs.org/mz/-/mz-2.7.0.tgz",
@@ -7798,13 +7953,12 @@
}
},
"node_modules/pmtiles": {
"version": "3.2.1",
"resolved": "https://registry.npmjs.org/pmtiles/-/pmtiles-3.2.1.tgz",
"integrity": "sha512-3R4fBwwoli5mw7a6t1IGwOtfmcSAODq6Okz0zkXhS1zi9sz1ssjjIfslwPvcWw5TNhdjNBUg9fgfPLeqZlH6ng==",
"version": "4.4.0",
"resolved": "https://registry.npmjs.org/pmtiles/-/pmtiles-4.4.0.tgz",
"integrity": "sha512-tCLI1C5134MR54i8izUWhse0QUtO/EC33n9yWp1N5dYLLvyc197U0fkF5gAJhq1TdWO9Tvl+9hgvFvM0fR27Zg==",
"license": "BSD-3-Clause",
"dependencies": {
"@types/leaflet": "^1.9.8",
"fflate": "^0.8.0"
"fflate": "^0.8.2"
}
},
"node_modules/postcss": {
@@ -7964,9 +8118,9 @@
"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==",
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/potpack/-/potpack-2.1.0.tgz",
"integrity": "sha512-pcaShQc1Shq0y+E7GqJqvZj8DTthWV1KeHGdi0Z6IAin2Oi3JnLCOfwnCo84qc+HAp52wT9nK9H7FAJp5a44GQ==",
"license": "ISC"
},
"node_modules/prelude-ls": {
@@ -8038,24 +8192,6 @@
"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",
@@ -8093,20 +8229,11 @@
"license": "MIT"
},
"node_modules/quickselect": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/quickselect/-/quickselect-2.0.0.tgz",
"integrity": "sha512-RKJ22hX8mHe3Y6wH/N3wCM6BWtjaxIyyUIkpHOvfFnxdI4yD4tBXEBKSbriGujF6jnSVkJrffuo6vxACiSSxIw==",
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/quickselect/-/quickselect-3.0.0.tgz",
"integrity": "sha512-XdjUArbK4Bm5fLLvlm5KpTFOiOThgfWWI4axAZDWg4E/0mKdZyI9tNEfds27qCi1ze/vwTR16kvmmGhRra3c2g==",
"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",
@@ -8558,6 +8685,12 @@
"queue-microtask": "^1.2.2"
}
},
"node_modules/rw": {
"version": "1.3.3",
"resolved": "https://registry.npmjs.org/rw/-/rw-1.3.3.tgz",
"integrity": "sha512-PdhdWy89SiZogBLaw42zdeqtRJ//zFd2PgQavcICDUgJT5oW10QCRKbJ6bg4r0/UY2M6BWd5tkxuGFRvCkgfHQ==",
"license": "BSD-3-Clause"
},
"node_modules/saxes": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz",
@@ -8727,6 +8860,15 @@
"node": ">=16 || 14 >=14.17"
}
},
"node_modules/supercluster": {
"version": "8.0.1",
"resolved": "https://registry.npmjs.org/supercluster/-/supercluster-8.0.1.tgz",
"integrity": "sha512-IiOea5kJ9iqzD2t7QJq/cREyLHTtSmUT6gQsweojg9WH2sYJqZK9SswTu6jrscO6D1G5v5vYZ9ru/eq85lXeZQ==",
"license": "ISC",
"dependencies": {
"kdbush": "^4.0.2"
}
},
"node_modules/supports-color": {
"version": "7.2.0",
"resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz",
@@ -8881,6 +9023,12 @@
"url": "https://github.com/sponsors/SuperchupuDev"
}
},
"node_modules/tinyqueue": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/tinyqueue/-/tinyqueue-3.0.0.tgz",
"integrity": "sha512-gRa9gwYU3ECmQYv3lslts5hxuIa90veaEcxDYuu3QGOIAEM2mOZkVHp48ANJuu1CURtRdHKUBY5Lm1tHV+sD4g==",
"license": "ISC"
},
"node_modules/tinyrainbow": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.0.3.tgz",

View File

@@ -20,6 +20,7 @@
"@fontsource/ibm-plex-mono": "^5.2.7",
"@git-diff-view/lowlight": "^0.0.39",
"@git-diff-view/react": "^0.0.39",
"@protomaps/basemaps": "^5.7.2",
"@radix-ui/react-avatar": "^1.1.11",
"@radix-ui/react-checkbox": "^1.3.3",
"@radix-ui/react-dialog": "^1.1.15",
@@ -50,7 +51,8 @@
"framer-motion": "^12.34.3",
"leaflet": "^1.9.4",
"lucide-react": "^0.575.0",
"protomaps-leaflet": "^5.1.0",
"maplibre-gl": "^5.20.2",
"pmtiles": "^4.4.0",
"react": "^19.2.0",
"react-dom": "^19.2.0",
"react-leaflet": "^5.0.0",

View File

@@ -0,0 +1 @@
{"NL:S-road-1char":{"height":21,"pixelRatio":1,"width":21,"x":0,"y":36},"NL:S-road-2char":{"height":21,"pixelRatio":1,"width":22,"x":38,"y":21},"NL:S-road-3char":{"height":21,"pixelRatio":1,"width":25,"x":202,"y":18},"NL:S-road-4char":{"height":21,"pixelRatio":1,"width":29,"x":173,"y":0},"NL:S-road-5char":{"height":21,"pixelRatio":1,"width":32,"x":79,"y":0},"US:I-1char":{"height":21,"pixelRatio":1,"width":20,"x":84,"y":21},"US:I-2char":{"height":21,"pixelRatio":1,"width":23,"x":227,"y":18},"US:I-3char":{"height":21,"pixelRatio":1,"width":30,"x":143,"y":0},"US:I-4char":{"height":21,"pixelRatio":1,"width":32,"x":111,"y":0},"US:I-5char":{"height":21,"pixelRatio":1,"width":34,"x":45,"y":0},"aerodrome":{"height":19,"pixelRatio":1,"width":19,"x":60,"y":39},"animal":{"height":19,"pixelRatio":1,"width":19,"x":21,"y":42},"arrow":{"height":19,"pixelRatio":1,"width":19,"x":0,"y":57},"artwork":{"height":19,"pixelRatio":1,"width":19,"x":40,"y":42},"attraction":{"height":19,"pixelRatio":1,"width":19,"x":104,"y":21},"bar":{"height":19,"pixelRatio":1,"width":19,"x":123,"y":21},"beach":{"height":19,"pixelRatio":1,"width":19,"x":142,"y":21},"beauty":{"height":19,"pixelRatio":1,"width":19,"x":161,"y":21},"bench":{"height":19,"pixelRatio":1,"width":19,"x":180,"y":21},"books":{"height":19,"pixelRatio":1,"width":19,"x":199,"y":39},"building":{"height":17,"pixelRatio":1,"width":17,"x":193,"y":77},"bus_stop":{"height":19,"pixelRatio":1,"width":19,"x":218,"y":39},"cafe":{"height":19,"pixelRatio":1,"width":19,"x":237,"y":39},"capital":{"height":10,"pixelRatio":1,"width":10,"x":210,"y":77},"clothes":{"height":19,"pixelRatio":1,"width":19,"x":104,"y":40},"convenience":{"height":19,"pixelRatio":1,"width":19,"x":79,"y":42},"drinking_water":{"height":19,"pixelRatio":1,"width":19,"x":59,"y":58},"electronics":{"height":19,"pixelRatio":1,"width":19,"x":19,"y":61},"fast_food":{"height":19,"pixelRatio":1,"width":19,"x":0,"y":76},"ferry_terminal":{"height":19,"pixelRatio":1,"width":19,"x":38,"y":61},"forest":{"height":19,"pixelRatio":1,"width":19,"x":123,"y":40},"garden":{"height":19,"pixelRatio":1,"width":19,"x":142,"y":40},"generic_shield-1char":{"height":18,"pixelRatio":1,"width":19,"x":235,"y":0},"generic_shield-2char":{"height":18,"pixelRatio":1,"width":24,"x":60,"y":21},"generic_shield-3char":{"height":18,"pixelRatio":1,"width":33,"x":202,"y":0},"generic_shield-4char":{"height":18,"pixelRatio":1,"width":38,"x":0,"y":18},"generic_shield-5char":{"height":18,"pixelRatio":1,"width":45,"x":0,"y":0},"library":{"height":19,"pixelRatio":1,"width":19,"x":161,"y":40},"marina":{"height":19,"pixelRatio":1,"width":19,"x":180,"y":40},"museum":{"height":19,"pixelRatio":1,"width":19,"x":199,"y":58},"park":{"height":19,"pixelRatio":1,"width":19,"x":218,"y":58},"peak":{"height":19,"pixelRatio":1,"width":19,"x":237,"y":58},"post_office":{"height":19,"pixelRatio":1,"width":19,"x":98,"y":59},"restaurant":{"height":19,"pixelRatio":1,"width":19,"x":78,"y":61},"school":{"height":19,"pixelRatio":1,"width":19,"x":57,"y":77},"stadium":{"height":19,"pixelRatio":1,"width":19,"x":19,"y":80},"supermarket":{"height":19,"pixelRatio":1,"width":19,"x":0,"y":95},"theatre":{"height":19,"pixelRatio":1,"width":19,"x":38,"y":80},"toilets":{"height":19,"pixelRatio":1,"width":19,"x":117,"y":59},"townspot":{"height":10,"pixelRatio":1,"width":10,"x":220,"y":77},"train_station":{"height":19,"pixelRatio":1,"width":19,"x":136,"y":59},"university":{"height":19,"pixelRatio":1,"width":19,"x":155,"y":59},"zoo":{"height":19,"pixelRatio":1,"width":19,"x":174,"y":59}}

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

View File

@@ -0,0 +1 @@
{"NL:S-road-1char":{"height":42,"pixelRatio":2,"width":42,"x":0,"y":72},"NL:S-road-2char":{"height":42,"pixelRatio":2,"width":44,"x":76,"y":42},"NL:S-road-3char":{"height":42,"pixelRatio":2,"width":50,"x":404,"y":36},"NL:S-road-4char":{"height":42,"pixelRatio":2,"width":58,"x":346,"y":0},"NL:S-road-5char":{"height":42,"pixelRatio":2,"width":64,"x":158,"y":0},"US:I-1char":{"height":42,"pixelRatio":2,"width":40,"x":168,"y":42},"US:I-2char":{"height":42,"pixelRatio":2,"width":46,"x":454,"y":36},"US:I-3char":{"height":42,"pixelRatio":2,"width":60,"x":286,"y":0},"US:I-4char":{"height":42,"pixelRatio":2,"width":64,"x":222,"y":0},"US:I-5char":{"height":42,"pixelRatio":2,"width":68,"x":90,"y":0},"aerodrome":{"height":38,"pixelRatio":2,"width":38,"x":120,"y":78},"animal":{"height":38,"pixelRatio":2,"width":38,"x":42,"y":84},"arrow":{"height":38,"pixelRatio":2,"width":38,"x":0,"y":114},"artwork":{"height":38,"pixelRatio":2,"width":38,"x":80,"y":84},"attraction":{"height":38,"pixelRatio":2,"width":38,"x":208,"y":42},"bar":{"height":38,"pixelRatio":2,"width":38,"x":246,"y":42},"beach":{"height":38,"pixelRatio":2,"width":38,"x":284,"y":42},"beauty":{"height":38,"pixelRatio":2,"width":38,"x":322,"y":42},"bench":{"height":38,"pixelRatio":2,"width":38,"x":360,"y":42},"books":{"height":38,"pixelRatio":2,"width":38,"x":398,"y":78},"building":{"height":34,"pixelRatio":2,"width":34,"x":386,"y":154},"bus_stop":{"height":38,"pixelRatio":2,"width":38,"x":436,"y":78},"cafe":{"height":38,"pixelRatio":2,"width":38,"x":474,"y":78},"capital":{"height":20,"pixelRatio":2,"width":20,"x":420,"y":154},"clothes":{"height":38,"pixelRatio":2,"width":38,"x":208,"y":80},"convenience":{"height":38,"pixelRatio":2,"width":38,"x":158,"y":84},"drinking_water":{"height":38,"pixelRatio":2,"width":38,"x":118,"y":116},"electronics":{"height":38,"pixelRatio":2,"width":38,"x":38,"y":122},"fast_food":{"height":38,"pixelRatio":2,"width":38,"x":0,"y":152},"ferry_terminal":{"height":38,"pixelRatio":2,"width":38,"x":76,"y":122},"forest":{"height":38,"pixelRatio":2,"width":38,"x":246,"y":80},"garden":{"height":38,"pixelRatio":2,"width":38,"x":284,"y":80},"generic_shield-1char":{"height":36,"pixelRatio":2,"width":38,"x":470,"y":0},"generic_shield-2char":{"height":36,"pixelRatio":2,"width":48,"x":120,"y":42},"generic_shield-3char":{"height":36,"pixelRatio":2,"width":66,"x":404,"y":0},"generic_shield-4char":{"height":36,"pixelRatio":2,"width":76,"x":0,"y":36},"generic_shield-5char":{"height":36,"pixelRatio":2,"width":90,"x":0,"y":0},"library":{"height":38,"pixelRatio":2,"width":38,"x":322,"y":80},"marina":{"height":38,"pixelRatio":2,"width":38,"x":360,"y":80},"museum":{"height":38,"pixelRatio":2,"width":38,"x":398,"y":116},"park":{"height":38,"pixelRatio":2,"width":38,"x":436,"y":116},"peak":{"height":38,"pixelRatio":2,"width":38,"x":474,"y":116},"post_office":{"height":38,"pixelRatio":2,"width":38,"x":196,"y":118},"restaurant":{"height":38,"pixelRatio":2,"width":38,"x":156,"y":122},"school":{"height":38,"pixelRatio":2,"width":38,"x":114,"y":154},"stadium":{"height":38,"pixelRatio":2,"width":38,"x":38,"y":160},"supermarket":{"height":38,"pixelRatio":2,"width":38,"x":0,"y":190},"theatre":{"height":38,"pixelRatio":2,"width":38,"x":76,"y":160},"toilets":{"height":38,"pixelRatio":2,"width":38,"x":234,"y":118},"townspot":{"height":20,"pixelRatio":2,"width":20,"x":440,"y":154},"train_station":{"height":38,"pixelRatio":2,"width":38,"x":272,"y":118},"university":{"height":38,"pixelRatio":2,"width":38,"x":310,"y":118},"zoo":{"height":38,"pixelRatio":2,"width":38,"x":348,"y":118}}

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

View File

@@ -65,6 +65,11 @@ export function DeviceMarker({ device, tenantId }: DeviceMarkerProps) {
<div>IP: {device.ip_address}</div>
{device.model && <div>Model: {device.model}</div>}
<div>Uptime: {formatUptime(device.uptime_seconds)}</div>
{device.last_cpu_load != null && <div>CPU: {device.last_cpu_load}%</div>}
{device.last_memory_used_pct != null && <div>Memory: {device.last_memory_used_pct}%</div>}
{device.client_count != null && device.client_count > 0 && (
<div>Clients: {device.client_count}{device.avg_signal != null && ` (avg ${device.avg_signal} dBm)`}</div>
)}
<div className="flex items-center gap-1.5 mt-1">
Status:
<span

View File

@@ -1,155 +1,242 @@
import { useEffect, useMemo } from 'react'
import { MapContainer, useMap } from 'react-leaflet'
import MarkerClusterGroup from 'react-leaflet-cluster'
import L from 'leaflet'
import * as protomapsL from 'protomaps-leaflet'
import { useEffect, useRef, useMemo } from 'react'
import maplibregl from 'maplibre-gl'
import { Protocol } from 'pmtiles'
import { layers, namedFlavor } from '@protomaps/basemaps'
import type { FleetDevice } from '@/lib/api'
import { DeviceMarker } from './DeviceMarker'
import { formatUptime } from '@/lib/utils'
import 'leaflet/dist/leaflet.css'
import 'maplibre-gl/dist/maplibre-gl.css'
// Register PMTiles protocol once
const protocol = new Protocol()
maplibregl.addProtocol('pmtiles', protocol.tile)
interface FleetMapProps {
devices: FleetDevice[]
tenantId: string
}
/** Default world view when no devices have coordinates. */
const DEFAULT_CENTER: [number, number] = [20, 0]
const DEFAULT_ZOOM = 2
const DEFAULT_CENTER: [number, number] = [-89.6, 39.8]
const DEFAULT_ZOOM = 4
/**
* 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
const STATUS_COLORS: Record<string, string> = {
online: '#22c55e',
offline: '#ef4444',
unknown: '#eab308',
}
/**
* Inner component that auto-fits the map to device bounds
* whenever the device list changes.
*/
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() {
return {
version: 8 as const,
glyphs: '/map-fonts/{fontstack}/{range}.pbf',
sprite: '/map-assets/sprites/dark',
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('dark'), { lang: 'en' }),
}
}
/**
* Custom cluster icon factory.
* - All online: green cluster
* - Any offline: red cluster
* - Mixed: yellow/orange cluster
*/
function createClusterIcon(cluster: L.MarkerCluster): L.DivIcon {
const childMarkers = cluster.getAllChildMarkers()
const count = childMarkers.length
// Determine aggregate status by inspecting marker HTML color
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
}
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 bgColor: string
if (hasOffline && hasOnline) {
bgColor = '#f59e0b' // amber-500 — mixed
} else if (hasOffline) {
bgColor = '#ef4444' // red-500 — all offline
} else {
bgColor = '#22c55e' // green-500 — all online
}
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) {
// Filter to only devices that have coordinates
const mappedDevices = useMemo(
() => devices.filter((d) => d.latitude != null && d.longitude != null),
[devices],
)
const containerRef = useRef<HTMLDivElement>(null)
const mapRef = useRef<maplibregl.Map | null>(null)
return (
<MapContainer
center={DEFAULT_CENTER}
zoom={DEFAULT_ZOOM}
className="h-full w-full"
scrollWheelZoom
style={{ background: 'hsl(var(--background))' }}
>
<ProtomapsLayer />
<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>
)
const geojson = useMemo(() => deviceToGeoJSON(devices), [devices])
// Initialize map
useEffect(() => {
if (!containerRef.current) return
const map = new maplibregl.Map({
container: containerRef.current,
style: buildMapStyle(),
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
}, [])
// 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" />
}

View File

@@ -73,7 +73,7 @@ export function MapPage() {
}
return (
<div className="flex flex-col h-full">
<div className="flex flex-col" style={{ height: 'calc(100vh - 6rem)' }}>
{/* Toolbar */}
<div className="flex items-center justify-between px-4 py-2 border-b border-border bg-sidebar shrink-0">
<div className="flex items-center gap-3">

View File

@@ -807,6 +807,8 @@ export interface FleetDevice {
longitude: number | null
tenant_id: string
tenant_name: string
client_count: number | null
avg_signal: number | null
}
export interface SparklinePoint {

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: blob:; font-src 'self'; connect-src 'self' ws: wss:; worker-src 'self' blob:; frame-ancestors 'self'; base-uri 'self'; form-action 'self';" always;
add_header Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-eval'; 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,7 +91,7 @@ server {
add_header X-Content-Type-Options "nosniff" always;
}
# Serve PMTiles with byte range support (protomaps-leaflet reads via HTTP Range)
# Serve PMTiles with byte range support
location /tiles/ {
add_header Access-Control-Allow-Origin "*" always;
add_header X-Content-Type-Options "nosniff" always;
@@ -101,6 +101,17 @@ server {
try_files $uri =404;
}
# Proxy map font glyphs (MapLibre requests many glyph ranges dynamically)
location ~ ^/map-fonts/(.+)$ {
resolver 127.0.0.11 8.8.8.8 valid=300s ipv6=off;
set $font_path $1;
proxy_pass https://protomaps.github.io/basemaps-assets/fonts/$font_path;
proxy_ssl_server_name on;
proxy_set_header Host protomaps.github.io;
add_header X-Content-Type-Options "nosniff" always;
expires 30d;
}
# 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.