fix(map): revert to Leaflet + proxied OSM tiles, add CPE signal to popups
Reverted from MapLibre/PMTiles to Leaflet with nginx-proxied OSM raster tiles — the MapLibre approach had unresolvable CSP and theme compat issues. The proxy keeps all browser requests local (no third-party). Also: - Add CPE signal strength and parent AP name to fleet summary SQL and map popup cards (e.g. "Signal: -62 dBm to ap-shady-north") - Add .dockerignore to exclude 8GB PMTiles and node_modules from Docker build context (was causing 10+ minute builds) - Configure mailpit SMTP in dev compose Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
10
.dockerignore
Normal file
10
.dockerignore
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
frontend/public/tiles/
|
||||||
|
frontend/node_modules/
|
||||||
|
docker-data/
|
||||||
|
.worktrees/
|
||||||
|
.git/
|
||||||
|
.planning/
|
||||||
|
.superpowers/
|
||||||
|
docs/superpowers/
|
||||||
|
tools/dev/
|
||||||
|
*.pmtiles
|
||||||
@@ -361,7 +361,8 @@ _FLEET_SUMMARY_SQL = """
|
|||||||
d.uptime_seconds, d.last_cpu_load, d.last_memory_used_pct,
|
d.uptime_seconds, d.last_cpu_load, d.last_memory_used_pct,
|
||||||
d.latitude, d.longitude,
|
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
|
wl.client_count, wl.avg_signal,
|
||||||
|
cpe.cpe_signal, cpe.ap_hostname
|
||||||
FROM devices d
|
FROM devices d
|
||||||
JOIN tenants t ON d.tenant_id = t.id
|
JOIN tenants t ON d.tenant_id = t.id
|
||||||
LEFT JOIN LATERAL (
|
LEFT JOIN LATERAL (
|
||||||
@@ -370,6 +371,14 @@ _FLEET_SUMMARY_SQL = """
|
|||||||
FROM wireless_links
|
FROM wireless_links
|
||||||
WHERE ap_device_id = d.id AND state IN ('active', 'discovered')
|
WHERE ap_device_id = d.id AND state IN ('active', 'discovered')
|
||||||
) wl ON true
|
) wl ON true
|
||||||
|
LEFT JOIN LATERAL (
|
||||||
|
SELECT wl2.signal_strength AS cpe_signal,
|
||||||
|
ap.hostname AS ap_hostname
|
||||||
|
FROM wireless_links wl2
|
||||||
|
JOIN devices ap ON ap.id = wl2.ap_device_id
|
||||||
|
WHERE wl2.cpe_device_id = d.id AND wl2.state IN ('active', 'discovered')
|
||||||
|
LIMIT 1
|
||||||
|
) cpe ON true
|
||||||
ORDER BY d.hostname
|
ORDER BY d.hostname
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
|||||||
@@ -27,6 +27,9 @@ services:
|
|||||||
JWT_SECRET_KEY: ${JWT_SECRET_KEY:?Set JWT_SECRET_KEY in .env}
|
JWT_SECRET_KEY: ${JWT_SECRET_KEY:?Set JWT_SECRET_KEY in .env}
|
||||||
OPENBAO_ADDR: http://openbao:8200
|
OPENBAO_ADDR: http://openbao:8200
|
||||||
OPENBAO_TOKEN: dev-openbao-token
|
OPENBAO_TOKEN: dev-openbao-token
|
||||||
|
SMTP_HOST: mailpit
|
||||||
|
SMTP_PORT: "1025"
|
||||||
|
SMTP_USE_TLS: "false"
|
||||||
GIT_STORE_PATH: /data/git-store
|
GIT_STORE_PATH: /data/git-store
|
||||||
WIREGUARD_CONFIG_PATH: /data/wireguard
|
WIREGUARD_CONFIG_PATH: /data/wireguard
|
||||||
WIREGUARD_GATEWAY: wireguard
|
WIREGUARD_GATEWAY: wireguard
|
||||||
|
|||||||
274
frontend/package-lock.json
generated
274
frontend/package-lock.json
generated
@@ -13,7 +13,6 @@
|
|||||||
"@fontsource/ibm-plex-mono": "^5.2.7",
|
"@fontsource/ibm-plex-mono": "^5.2.7",
|
||||||
"@git-diff-view/lowlight": "^0.0.39",
|
"@git-diff-view/lowlight": "^0.0.39",
|
||||||
"@git-diff-view/react": "^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-avatar": "^1.1.11",
|
||||||
"@radix-ui/react-checkbox": "^1.3.3",
|
"@radix-ui/react-checkbox": "^1.3.3",
|
||||||
"@radix-ui/react-dialog": "^1.1.15",
|
"@radix-ui/react-dialog": "^1.1.15",
|
||||||
@@ -44,8 +43,6 @@
|
|||||||
"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",
|
||||||
"maplibre-gl": "^5.20.2",
|
|
||||||
"pmtiles": "^4.4.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",
|
||||||
@@ -1419,111 +1416,6 @@
|
|||||||
"@jridgewell/sourcemap-codec": "^1.4.14"
|
"@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",
|
|
||||||
"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/@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": {
|
"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",
|
||||||
@@ -1575,15 +1467,6 @@
|
|||||||
"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",
|
||||||
@@ -4784,15 +4667,6 @@
|
|||||||
"@types/react": "^19.2.0"
|
"@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": {
|
"node_modules/@types/unist": {
|
||||||
"version": "3.0.3",
|
"version": "3.0.3",
|
||||||
"resolved": "https://registry.npmjs.org/@types/unist/-/unist-3.0.3.tgz",
|
"resolved": "https://registry.npmjs.org/@types/unist/-/unist-3.0.3.tgz",
|
||||||
@@ -6241,12 +6115,6 @@
|
|||||||
"node": ">= 0.4"
|
"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": {
|
"node_modules/electron-to-chromium": {
|
||||||
"version": "1.5.302",
|
"version": "1.5.302",
|
||||||
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.302.tgz",
|
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.302.tgz",
|
||||||
@@ -6704,12 +6572,6 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"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",
|
||||||
@@ -6940,12 +6802,6 @@
|
|||||||
"url": "https://github.com/privatenumber/get-tsconfig?sponsor=1"
|
"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": {
|
"node_modules/glob-parent": {
|
||||||
"version": "6.0.2",
|
"version": "6.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz",
|
||||||
@@ -7347,12 +7203,6 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/json5": {
|
||||||
"version": "2.2.3",
|
"version": "2.2.3",
|
||||||
"resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz",
|
"resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz",
|
||||||
@@ -7365,12 +7215,6 @@
|
|||||||
"node": ">=6"
|
"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": {
|
"node_modules/keyv": {
|
||||||
"version": "4.5.4",
|
"version": "4.5.4",
|
||||||
"resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz",
|
"resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz",
|
||||||
@@ -7505,40 +7349,6 @@
|
|||||||
"@jridgewell/sourcemap-codec": "^1.5.5"
|
"@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": {
|
"node_modules/math-intrinsics": {
|
||||||
"version": "1.1.0",
|
"version": "1.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
|
||||||
@@ -7633,15 +7443,6 @@
|
|||||||
"node": "*"
|
"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": {
|
"node_modules/motion-dom": {
|
||||||
"version": "12.34.3",
|
"version": "12.34.3",
|
||||||
"resolved": "https://registry.npmjs.org/motion-dom/-/motion-dom-12.34.3.tgz",
|
"resolved": "https://registry.npmjs.org/motion-dom/-/motion-dom-12.34.3.tgz",
|
||||||
@@ -7663,12 +7464,6 @@
|
|||||||
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
|
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/mz": {
|
||||||
"version": "2.7.0",
|
"version": "2.7.0",
|
||||||
"resolved": "https://registry.npmjs.org/mz/-/mz-2.7.0.tgz",
|
"resolved": "https://registry.npmjs.org/mz/-/mz-2.7.0.tgz",
|
||||||
@@ -7857,18 +7652,6 @@
|
|||||||
"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",
|
||||||
@@ -7952,15 +7735,6 @@
|
|||||||
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
|
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/pmtiles": {
|
|
||||||
"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": {
|
|
||||||
"fflate": "^0.8.2"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"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",
|
||||||
@@ -8117,12 +7891,6 @@
|
|||||||
"integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==",
|
"integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/potpack": {
|
|
||||||
"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": {
|
"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",
|
||||||
@@ -8186,12 +7954,6 @@
|
|||||||
"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/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",
|
||||||
@@ -8228,12 +7990,6 @@
|
|||||||
],
|
],
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/quickselect": {
|
|
||||||
"version": "3.0.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/quickselect/-/quickselect-3.0.0.tgz",
|
|
||||||
"integrity": "sha512-XdjUArbK4Bm5fLLvlm5KpTFOiOThgfWWI4axAZDWg4E/0mKdZyI9tNEfds27qCi1ze/vwTR16kvmmGhRra3c2g==",
|
|
||||||
"license": "ISC"
|
|
||||||
},
|
|
||||||
"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",
|
||||||
@@ -8598,15 +8354,6 @@
|
|||||||
"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",
|
||||||
@@ -8685,12 +8432,6 @@
|
|||||||
"queue-microtask": "^1.2.2"
|
"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": {
|
"node_modules/saxes": {
|
||||||
"version": "6.0.0",
|
"version": "6.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz",
|
||||||
@@ -8860,15 +8601,6 @@
|
|||||||
"node": ">=16 || 14 >=14.17"
|
"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": {
|
"node_modules/supports-color": {
|
||||||
"version": "7.2.0",
|
"version": "7.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz",
|
||||||
@@ -9023,12 +8755,6 @@
|
|||||||
"url": "https://github.com/sponsors/SuperchupuDev"
|
"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": {
|
"node_modules/tinyrainbow": {
|
||||||
"version": "3.0.3",
|
"version": "3.0.3",
|
||||||
"resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.0.3.tgz",
|
"resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.0.3.tgz",
|
||||||
|
|||||||
@@ -20,7 +20,6 @@
|
|||||||
"@fontsource/ibm-plex-mono": "^5.2.7",
|
"@fontsource/ibm-plex-mono": "^5.2.7",
|
||||||
"@git-diff-view/lowlight": "^0.0.39",
|
"@git-diff-view/lowlight": "^0.0.39",
|
||||||
"@git-diff-view/react": "^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-avatar": "^1.1.11",
|
||||||
"@radix-ui/react-checkbox": "^1.3.3",
|
"@radix-ui/react-checkbox": "^1.3.3",
|
||||||
"@radix-ui/react-dialog": "^1.1.15",
|
"@radix-ui/react-dialog": "^1.1.15",
|
||||||
@@ -51,8 +50,6 @@
|
|||||||
"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",
|
||||||
"maplibre-gl": "^5.20.2",
|
|
||||||
"pmtiles": "^4.4.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",
|
||||||
|
|||||||
@@ -70,6 +70,9 @@ export function DeviceMarker({ device, tenantId }: DeviceMarkerProps) {
|
|||||||
{device.client_count != null && device.client_count > 0 && (
|
{device.client_count != null && device.client_count > 0 && (
|
||||||
<div>Clients: {device.client_count}{device.avg_signal != null && ` (avg ${device.avg_signal} dBm)`}</div>
|
<div>Clients: {device.client_count}{device.avg_signal != null && ` (avg ${device.avg_signal} dBm)`}</div>
|
||||||
)}
|
)}
|
||||||
|
{device.cpe_signal != null && (
|
||||||
|
<div>Signal: {device.cpe_signal} dBm{device.ap_hostname && ` to ${device.ap_hostname}`}</div>
|
||||||
|
)}
|
||||||
<div className="flex items-center gap-1.5 mt-1">
|
<div className="flex items-center gap-1.5 mt-1">
|
||||||
Status:
|
Status:
|
||||||
<span
|
<span
|
||||||
|
|||||||
@@ -1,297 +1,121 @@
|
|||||||
import { useEffect, useRef, useMemo } from 'react'
|
import { useEffect, useMemo } from 'react'
|
||||||
import maplibregl from 'maplibre-gl'
|
import { MapContainer, TileLayer, useMap } from 'react-leaflet'
|
||||||
import { Protocol } from 'pmtiles'
|
import MarkerClusterGroup from 'react-leaflet-cluster'
|
||||||
import { layers, namedFlavor } from '@protomaps/basemaps'
|
import L from 'leaflet'
|
||||||
import type { FleetDevice } from '@/lib/api'
|
import type { FleetDevice } from '@/lib/api'
|
||||||
import { formatUptime } from '@/lib/utils'
|
import { DeviceMarker } from './DeviceMarker'
|
||||||
import { useUIStore } from '@/lib/store'
|
|
||||||
|
|
||||||
import 'maplibre-gl/dist/maplibre-gl.css'
|
import 'leaflet/dist/leaflet.css'
|
||||||
|
|
||||||
// Register PMTiles protocol once
|
|
||||||
const protocol = new Protocol()
|
|
||||||
maplibregl.addProtocol('pmtiles', protocol.tile)
|
|
||||||
|
|
||||||
interface FleetMapProps {
|
interface FleetMapProps {
|
||||||
devices: FleetDevice[]
|
devices: FleetDevice[]
|
||||||
tenantId: string
|
tenantId: string
|
||||||
}
|
}
|
||||||
|
|
||||||
const DEFAULT_CENTER: [number, number] = [-89.6, 39.8]
|
const DEFAULT_CENTER: [number, number] = [39.8, -89.6]
|
||||||
const DEFAULT_ZOOM = 4
|
const DEFAULT_ZOOM = 5
|
||||||
|
|
||||||
const STATUS_COLORS: Record<string, string> = {
|
function AutoFitBounds({ devices }: { devices: FleetDevice[] }) {
|
||||||
online: '#22c55e',
|
const map = useMap()
|
||||||
offline: '#ef4444',
|
|
||||||
unknown: '#eab308',
|
useEffect(() => {
|
||||||
|
if (devices.length === 0) {
|
||||||
|
map.setView(DEFAULT_CENTER, DEFAULT_ZOOM)
|
||||||
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildMapStyle(theme: 'dark' | 'light') {
|
const bounds = L.latLngBounds(
|
||||||
return {
|
devices.map((d) => [d.latitude!, d.longitude!] as [number, number]),
|
||||||
version: 8 as const,
|
)
|
||||||
glyphs: '/map-fonts/{fontstack}/{range}.pbf',
|
|
||||||
sprite: `/map-assets/sprites/${theme}`,
|
map.fitBounds(bounds, { padding: [40, 40], maxZoom: 15 })
|
||||||
sources: {
|
}, [devices, map])
|
||||||
protomaps: {
|
|
||||||
type: 'vector' as const,
|
return null
|
||||||
url: 'pmtiles:///tiles/us.pmtiles',
|
}
|
||||||
attribution: '© <a href="https://openstreetmap.org/copyright">OpenStreetMap</a>',
|
|
||||||
},
|
function createClusterIcon(cluster: L.MarkerCluster): L.DivIcon {
|
||||||
},
|
const childMarkers = cluster.getAllChildMarkers()
|
||||||
layers: layers('protomaps', namedFlavor(theme), { lang: 'en' }),
|
const count = childMarkers.length
|
||||||
|
|
||||||
|
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 {
|
let bgColor: string
|
||||||
return {
|
if (hasOffline && hasOnline) {
|
||||||
type: 'FeatureCollection',
|
bgColor = '#f59e0b'
|
||||||
features: devices
|
} else if (hasOffline) {
|
||||||
.filter((d) => d.latitude != null && d.longitude != null)
|
bgColor = '#ef4444'
|
||||||
.map((d) => ({
|
} else {
|
||||||
type: 'Feature' as const,
|
bgColor = '#22c55e'
|
||||||
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,
|
|
||||||
},
|
|
||||||
})),
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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) {
|
export function FleetMap({ devices, tenantId }: FleetMapProps) {
|
||||||
const containerRef = useRef<HTMLDivElement>(null)
|
const mappedDevices = useMemo(
|
||||||
const mapRef = useRef<maplibregl.Map | null>(null)
|
() => devices.filter((d) => d.latitude != null && d.longitude != null),
|
||||||
const theme = useUIStore((s) => s.theme)
|
[devices],
|
||||||
|
)
|
||||||
|
|
||||||
const geojson = useMemo(() => deviceToGeoJSON(devices), [devices])
|
return (
|
||||||
|
<MapContainer
|
||||||
// Initialize map
|
center={DEFAULT_CENTER}
|
||||||
useEffect(() => {
|
zoom={DEFAULT_ZOOM}
|
||||||
if (!containerRef.current) return
|
className="h-full w-full"
|
||||||
|
scrollWheelZoom
|
||||||
const map = new maplibregl.Map({
|
maxZoom={19}
|
||||||
container: containerRef.current,
|
style={{ background: 'hsl(var(--background))' }}
|
||||||
style: buildMapStyle(theme),
|
>
|
||||||
center: DEFAULT_CENTER,
|
<TileLayer
|
||||||
zoom: DEFAULT_ZOOM,
|
attribution='© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a>'
|
||||||
maxZoom: 17,
|
url="/osm-tiles/{z}/{x}/{y}.png"
|
||||||
})
|
maxZoom={19}
|
||||||
|
/>
|
||||||
map.addControl(new maplibregl.NavigationControl(), 'top-right')
|
<AutoFitBounds devices={mappedDevices} />
|
||||||
|
<MarkerClusterGroup
|
||||||
map.on('load', () => {
|
chunkedLoading
|
||||||
// Device markers source with clustering
|
iconCreateFunction={createClusterIcon}
|
||||||
map.addSource('devices', {
|
maxClusterRadius={50}
|
||||||
type: 'geojson',
|
spiderfyOnMaxZoom
|
||||||
data: geojson,
|
showCoverageOnHover={false}
|
||||||
cluster: true,
|
>
|
||||||
clusterMaxZoom: 14,
|
{mappedDevices.map((device) => (
|
||||||
clusterRadius: 50,
|
<DeviceMarker key={device.id} device={device} tenantId={tenantId} />
|
||||||
})
|
))}
|
||||||
|
</MarkerClusterGroup>
|
||||||
// Cluster circles
|
</MapContainer>
|
||||||
map.addLayer({
|
)
|
||||||
id: 'clusters',
|
|
||||||
type: 'circle',
|
|
||||||
source: 'devices',
|
|
||||||
filter: ['has', 'point_count'],
|
|
||||||
paint: {
|
|
||||||
'circle-color': [
|
|
||||||
'step', ['get', 'point_count'],
|
|
||||||
'#22c55e', 10,
|
|
||||||
'#f59e0b', 50,
|
|
||||||
'#ef4444',
|
|
||||||
],
|
|
||||||
'circle-radius': [
|
|
||||||
'step', ['get', 'point_count'],
|
|
||||||
18, 10,
|
|
||||||
24, 50,
|
|
||||||
32,
|
|
||||||
],
|
|
||||||
'circle-stroke-width': 2,
|
|
||||||
'circle-stroke-color': '#ffffff',
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
// Cluster count labels
|
|
||||||
map.addLayer({
|
|
||||||
id: 'cluster-count',
|
|
||||||
type: 'symbol',
|
|
||||||
source: 'devices',
|
|
||||||
filter: ['has', 'point_count'],
|
|
||||||
layout: {
|
|
||||||
'text-field': '{point_count_abbreviated}',
|
|
||||||
'text-font': ['Noto Sans Medium'],
|
|
||||||
'text-size': 13,
|
|
||||||
},
|
|
||||||
paint: {
|
|
||||||
'text-color': '#ffffff',
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
// Individual device dots
|
|
||||||
map.addLayer({
|
|
||||||
id: 'device-points',
|
|
||||||
type: 'circle',
|
|
||||||
source: 'devices',
|
|
||||||
filter: ['!', ['has', 'point_count']],
|
|
||||||
paint: {
|
|
||||||
'circle-color': ['get', 'color'],
|
|
||||||
'circle-radius': 7,
|
|
||||||
'circle-stroke-width': 2,
|
|
||||||
'circle-stroke-color': '#ffffff',
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
// Click on cluster to zoom in
|
|
||||||
map.on('click', 'clusters', (e) => {
|
|
||||||
const features = map.queryRenderedFeatures(e.point, { layers: ['clusters'] })
|
|
||||||
if (!features.length) return
|
|
||||||
const clusterId = features[0].properties.cluster_id
|
|
||||||
const source = map.getSource('devices') as maplibregl.GeoJSONSource
|
|
||||||
source.getClusterExpansionZoom(clusterId).then((zoom) => {
|
|
||||||
const coords = (features[0].geometry as GeoJSON.Point).coordinates as [number, number]
|
|
||||||
map.easeTo({ center: coords, zoom })
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
// Click on device to show popup
|
|
||||||
map.on('click', 'device-points', (e) => {
|
|
||||||
if (!e.features?.length) return
|
|
||||||
const f = e.features[0]
|
|
||||||
const coords = (f.geometry as GeoJSON.Point).coordinates.slice() as [number, number]
|
|
||||||
const p = f.properties
|
|
||||||
|
|
||||||
const resolvedTenantId = tenantId || p.tenant_id
|
|
||||||
|
|
||||||
let html = `<div style="font-family:system-ui,sans-serif;font-size:13px;min-width:200px;">
|
|
||||||
<a href="/tenants/${resolvedTenantId}/devices/${p.id}" style="font-weight:600;font-size:14px;color:#7dd3fc;text-decoration:none;">${p.hostname}</a>
|
|
||||||
<div style="color:#94a3b8;margin-top:4px;">
|
|
||||||
<div>IP: ${p.ip_address}</div>`
|
|
||||||
if (p.model) html += `<div>Model: ${p.model}</div>`
|
|
||||||
if (p.uptime_seconds) html += `<div>Uptime: ${formatUptime(p.uptime_seconds)}</div>`
|
|
||||||
if (p.last_cpu_load != null) html += `<div>CPU: ${p.last_cpu_load}%</div>`
|
|
||||||
if (p.last_memory_used_pct != null) html += `<div>Memory: ${p.last_memory_used_pct}%</div>`
|
|
||||||
if (p.client_count != null && p.client_count > 0) {
|
|
||||||
html += `<div>Clients: ${p.client_count}`
|
|
||||||
if (p.avg_signal != null) html += ` (avg ${p.avg_signal} dBm)`
|
|
||||||
html += `</div>`
|
|
||||||
}
|
|
||||||
html += `<div style="margin-top:4px;display:flex;align-items:center;gap:6px;">
|
|
||||||
Status:
|
|
||||||
<span style="display:inline-block;width:8px;height:8px;border-radius:50%;background:${p.color}"></span>
|
|
||||||
${p.status}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>`
|
|
||||||
|
|
||||||
new maplibregl.Popup({ offset: 10, maxWidth: '300px' })
|
|
||||||
.setLngLat(coords)
|
|
||||||
.setHTML(html)
|
|
||||||
.addTo(map)
|
|
||||||
})
|
|
||||||
|
|
||||||
// Cursor changes
|
|
||||||
map.on('mouseenter', 'clusters', () => { map.getCanvas().style.cursor = 'pointer' })
|
|
||||||
map.on('mouseleave', 'clusters', () => { map.getCanvas().style.cursor = '' })
|
|
||||||
map.on('mouseenter', 'device-points', () => { map.getCanvas().style.cursor = 'pointer' })
|
|
||||||
map.on('mouseleave', 'device-points', () => { map.getCanvas().style.cursor = '' })
|
|
||||||
|
|
||||||
// Fit bounds to all devices
|
|
||||||
if (geojson.features.length > 0) {
|
|
||||||
const bounds = new maplibregl.LngLatBounds()
|
|
||||||
geojson.features.forEach((f) => {
|
|
||||||
const [lng, lat] = (f.geometry as GeoJSON.Point).coordinates
|
|
||||||
bounds.extend([lng, lat])
|
|
||||||
})
|
|
||||||
map.fitBounds(bounds, { padding: 60, maxZoom: 14 })
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
mapRef.current = map
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
map.remove()
|
|
||||||
mapRef.current = null
|
|
||||||
}
|
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
// Re-add device layers after style change (setStyle wipes all sources/layers)
|
|
||||||
const addDeviceLayers = (map: maplibregl.Map) => {
|
|
||||||
if (map.getSource('devices')) return
|
|
||||||
map.addSource('devices', {
|
|
||||||
type: 'geojson',
|
|
||||||
data: geojson,
|
|
||||||
cluster: true,
|
|
||||||
clusterMaxZoom: 14,
|
|
||||||
clusterRadius: 50,
|
|
||||||
})
|
|
||||||
map.addLayer({
|
|
||||||
id: 'clusters',
|
|
||||||
type: 'circle',
|
|
||||||
source: 'devices',
|
|
||||||
filter: ['has', 'point_count'],
|
|
||||||
paint: {
|
|
||||||
'circle-color': ['step', ['get', 'point_count'], '#22c55e', 10, '#f59e0b', 50, '#ef4444'],
|
|
||||||
'circle-radius': ['step', ['get', 'point_count'], 18, 10, 24, 50, 32],
|
|
||||||
'circle-stroke-width': 2,
|
|
||||||
'circle-stroke-color': '#ffffff',
|
|
||||||
},
|
|
||||||
})
|
|
||||||
map.addLayer({
|
|
||||||
id: 'cluster-count',
|
|
||||||
type: 'symbol',
|
|
||||||
source: 'devices',
|
|
||||||
filter: ['has', 'point_count'],
|
|
||||||
layout: { 'text-field': '{point_count_abbreviated}', 'text-font': ['Noto Sans Medium'], 'text-size': 13 },
|
|
||||||
paint: { 'text-color': '#ffffff' },
|
|
||||||
})
|
|
||||||
map.addLayer({
|
|
||||||
id: 'device-points',
|
|
||||||
type: 'circle',
|
|
||||||
source: 'devices',
|
|
||||||
filter: ['!', ['has', 'point_count']],
|
|
||||||
paint: {
|
|
||||||
'circle-color': ['get', 'color'],
|
|
||||||
'circle-radius': 7,
|
|
||||||
'circle-stroke-width': 2,
|
|
||||||
'circle-stroke-color': '#ffffff',
|
|
||||||
},
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// Switch map theme when app theme changes
|
|
||||||
useEffect(() => {
|
|
||||||
const map = mapRef.current
|
|
||||||
if (!map) return
|
|
||||||
map.setStyle(buildMapStyle(theme))
|
|
||||||
map.once('styledata', () => addDeviceLayers(map))
|
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
||||||
}, [theme])
|
|
||||||
|
|
||||||
// Update device data when it changes
|
|
||||||
useEffect(() => {
|
|
||||||
const map = mapRef.current
|
|
||||||
if (!map || !map.isStyleLoaded()) return
|
|
||||||
const source = map.getSource('devices') as maplibregl.GeoJSONSource | undefined
|
|
||||||
if (source) {
|
|
||||||
source.setData(geojson)
|
|
||||||
}
|
|
||||||
}, [geojson])
|
|
||||||
|
|
||||||
return <div ref={containerRef} className="h-full w-full" />
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -809,6 +809,8 @@ export interface FleetDevice {
|
|||||||
tenant_name: string
|
tenant_name: string
|
||||||
client_count: number | null
|
client_count: number | null
|
||||||
avg_signal: number | null
|
avg_signal: number | null
|
||||||
|
cpe_signal: number | null
|
||||||
|
ap_hostname: string | null
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SparklinePoint {
|
export interface SparklinePoint {
|
||||||
|
|||||||
@@ -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' '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;
|
add_header Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-eval'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; 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,19 @@ server {
|
|||||||
add_header X-Content-Type-Options "nosniff" always;
|
add_header X-Content-Type-Options "nosniff" always;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# Proxy OSM raster tiles so the browser never contacts third parties directly
|
||||||
|
location ~ ^/osm-tiles/(.+)$ {
|
||||||
|
resolver 127.0.0.11 8.8.8.8 valid=300s ipv6=off;
|
||||||
|
set $osm_tile_path $1;
|
||||||
|
proxy_pass https://tile.openstreetmap.org/$osm_tile_path;
|
||||||
|
proxy_ssl_server_name on;
|
||||||
|
proxy_set_header Host tile.openstreetmap.org;
|
||||||
|
proxy_set_header User-Agent "TOD Fleet Manager (tile proxy)";
|
||||||
|
proxy_set_header Accept-Encoding "";
|
||||||
|
add_header X-Content-Type-Options "nosniff" always;
|
||||||
|
expires 7d;
|
||||||
|
}
|
||||||
|
|
||||||
# Serve PMTiles with byte range support
|
# Serve PMTiles with byte range support
|
||||||
location /tiles/ {
|
location /tiles/ {
|
||||||
add_header Access-Control-Allow-Origin "*" always;
|
add_header Access-Control-Allow-Origin "*" always;
|
||||||
|
|||||||
Reference in New Issue
Block a user