diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..9dbe3d6 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,10 @@ +frontend/public/tiles/ +frontend/node_modules/ +docker-data/ +.worktrees/ +.git/ +.planning/ +.superpowers/ +docs/superpowers/ +tools/dev/ +*.pmtiles diff --git a/backend/app/routers/metrics.py b/backend/app/routers/metrics.py index 8d25d61..6640876 100644 --- a/backend/app/routers/metrics.py +++ b/backend/app/routers/metrics.py @@ -361,7 +361,8 @@ _FLEET_SUMMARY_SQL = """ d.uptime_seconds, d.last_cpu_load, d.last_memory_used_pct, d.latitude, d.longitude, 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 JOIN tenants t ON d.tenant_id = t.id LEFT JOIN LATERAL ( @@ -370,6 +371,14 @@ _FLEET_SUMMARY_SQL = """ FROM wireless_links WHERE ap_device_id = d.id AND state IN ('active', 'discovered') ) 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 """ diff --git a/docker-compose.override.yml b/docker-compose.override.yml index 6e3cb6e..aa06244 100644 --- a/docker-compose.override.yml +++ b/docker-compose.override.yml @@ -27,6 +27,9 @@ services: JWT_SECRET_KEY: ${JWT_SECRET_KEY:?Set JWT_SECRET_KEY in .env} OPENBAO_ADDR: http://openbao:8200 OPENBAO_TOKEN: dev-openbao-token + SMTP_HOST: mailpit + SMTP_PORT: "1025" + SMTP_USE_TLS: "false" GIT_STORE_PATH: /data/git-store WIREGUARD_CONFIG_PATH: /data/wireguard WIREGUARD_GATEWAY: wireguard diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 03ff592..53b292e 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -13,7 +13,6 @@ "@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", @@ -44,8 +43,6 @@ "framer-motion": "^12.34.3", "leaflet": "^1.9.4", "lucide-react": "^0.575.0", - "maplibre-gl": "^5.20.2", - "pmtiles": "^4.4.0", "react": "^19.2.0", "react-dom": "^19.2.0", "react-leaflet": "^5.0.0", @@ -1419,111 +1416,6 @@ "@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": { "version": "2.1.5", "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", @@ -1575,15 +1467,6 @@ "node": ">=18" } }, - "node_modules/@protomaps/basemaps": { - "version": "5.7.2", - "resolved": "https://registry.npmjs.org/@protomaps/basemaps/-/basemaps-5.7.2.tgz", - "integrity": "sha512-K1Yk6bWdULulYg+R2QRVXx4NzJZan5YQhpejEG0c1/sXruJrfPIPZuakpf3jwAgVmjIRVQwAv+yRafDeN0aaUQ==", - "license": "BSD-3-Clause", - "bin": { - "generate_style": "src/cli.ts" - } - }, "node_modules/@radix-ui/number": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/@radix-ui/number/-/number-1.1.1.tgz", @@ -4784,15 +4667,6 @@ "@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", @@ -6241,12 +6115,6 @@ "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", @@ -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": { "version": "8.0.0", "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" } }, - "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", @@ -7347,12 +7203,6 @@ "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", @@ -7365,12 +7215,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": { "version": "4.5.4", "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", @@ -7505,40 +7349,6 @@ "@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", @@ -7633,15 +7443,6 @@ "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", @@ -7663,12 +7464,6 @@ "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", @@ -7857,18 +7652,6 @@ "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", "license": "MIT" }, - "node_modules/pbf": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/pbf/-/pbf-4.0.1.tgz", - "integrity": "sha512-SuLdBvS42z33m8ejRbInMapQe8n0D3vN/Xd5fmWM3tufNgRQFBpaW2YVJxQZV4iPNqb0vEFvssMEo5w9c6BTIA==", - "license": "BSD-3-Clause", - "dependencies": { - "resolve-protobuf-schema": "^2.1.0" - }, - "bin": { - "pbf": "bin/pbf" - } - }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", @@ -7952,15 +7735,6 @@ "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": { "version": "8.5.6", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz", @@ -8117,12 +7891,6 @@ "integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==", "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": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", @@ -8186,12 +7954,6 @@ "license": "MIT", "peer": true }, - "node_modules/protocol-buffers-schema": { - "version": "3.6.0", - "resolved": "https://registry.npmjs.org/protocol-buffers-schema/-/protocol-buffers-schema-3.6.0.tgz", - "integrity": "sha512-TdDRD+/QNdrCGCE7v8340QyuXd4kIWIgapsE2+n/SaGiSSbomYl4TjHlvIoCWRpE7wFt02EpB35VVA2ImcBVqw==", - "license": "MIT" - }, "node_modules/proxy-from-env": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", @@ -8228,12 +7990,6 @@ ], "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": { "version": "19.2.4", "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" } }, - "node_modules/resolve-protobuf-schema": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/resolve-protobuf-schema/-/resolve-protobuf-schema-2.1.0.tgz", - "integrity": "sha512-kI5ffTiZWmJaS/huM8wZfEMer1eRd7oJQhDuxeCLe3t7N7mX3z94CN0xPxBQxFYQTSNz9T0i+v6inKqSdK8xrQ==", - "license": "MIT", - "dependencies": { - "protocol-buffers-schema": "^3.3.1" - } - }, "node_modules/reusify": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", @@ -8685,12 +8432,6 @@ "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", @@ -8860,15 +8601,6 @@ "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", @@ -9023,12 +8755,6 @@ "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", diff --git a/frontend/package.json b/frontend/package.json index 3622cbc..a44d25a 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -20,7 +20,6 @@ "@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", @@ -51,8 +50,6 @@ "framer-motion": "^12.34.3", "leaflet": "^1.9.4", "lucide-react": "^0.575.0", - "maplibre-gl": "^5.20.2", - "pmtiles": "^4.4.0", "react": "^19.2.0", "react-dom": "^19.2.0", "react-leaflet": "^5.0.0", diff --git a/frontend/src/components/map/DeviceMarker.tsx b/frontend/src/components/map/DeviceMarker.tsx index 17f4d43..a8ee550 100644 --- a/frontend/src/components/map/DeviceMarker.tsx +++ b/frontend/src/components/map/DeviceMarker.tsx @@ -70,6 +70,9 @@ export function DeviceMarker({ device, tenantId }: DeviceMarkerProps) { {device.client_count != null && device.client_count > 0 && (
Clients: {device.client_count}{device.avg_signal != null && ` (avg ${device.avg_signal} dBm)`}
)} + {device.cpe_signal != null && ( +
Signal: {device.cpe_signal} dBm{device.ap_hostname && ` to ${device.ap_hostname}`}
+ )}
Status: = { - online: '#22c55e', - offline: '#ef4444', - unknown: '#eab308', +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(theme: 'dark' | 'light') { - return { - version: 8 as const, - glyphs: '/map-fonts/{fontstack}/{range}.pbf', - sprite: `/map-assets/sprites/${theme}`, - sources: { - protomaps: { - type: 'vector' as const, - url: 'pmtiles:///tiles/us.pmtiles', - attribution: '© OpenStreetMap', - }, - }, - layers: layers('protomaps', namedFlavor(theme), { lang: 'en' }), - } -} +function createClusterIcon(cluster: L.MarkerCluster): L.DivIcon { + const childMarkers = cluster.getAllChildMarkers() + const count = childMarkers.length -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 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 + } } + + let bgColor: string + if (hasOffline && hasOnline) { + bgColor = '#f59e0b' + } else if (hasOffline) { + bgColor = '#ef4444' + } else { + bgColor = '#22c55e' + } + + const size = count < 10 ? 34 : count < 100 ? 40 : 48 + + return L.divIcon({ + html: `
${count}
`, + className: '', + iconSize: L.point(size, size), + iconAnchor: L.point(size / 2, size / 2), + }) } export function FleetMap({ devices, tenantId }: FleetMapProps) { - const containerRef = useRef(null) - const mapRef = useRef(null) - const theme = useUIStore((s) => s.theme) + const mappedDevices = useMemo( + () => devices.filter((d) => d.latitude != null && d.longitude != null), + [devices], + ) - const geojson = useMemo(() => deviceToGeoJSON(devices), [devices]) - - // Initialize map - useEffect(() => { - if (!containerRef.current) return - - const map = new maplibregl.Map({ - container: containerRef.current, - style: buildMapStyle(theme), - 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 = `
- ${p.hostname} -
-
IP: ${p.ip_address}
` - if (p.model) html += `
Model: ${p.model}
` - if (p.uptime_seconds) html += `
Uptime: ${formatUptime(p.uptime_seconds)}
` - if (p.last_cpu_load != null) html += `
CPU: ${p.last_cpu_load}%
` - if (p.last_memory_used_pct != null) html += `
Memory: ${p.last_memory_used_pct}%
` - if (p.client_count != null && p.client_count > 0) { - html += `
Clients: ${p.client_count}` - if (p.avg_signal != null) html += ` (avg ${p.avg_signal} dBm)` - html += `
` - } - html += `
- Status: - - ${p.status} -
-
-
` - - 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
+ return ( + + + + + {mappedDevices.map((device) => ( + + ))} + + + ) } diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index 494d68d..7f39110 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -809,6 +809,8 @@ export interface FleetDevice { tenant_name: string client_count: number | null avg_signal: number | null + cpe_signal: number | null + ap_hostname: string | null } export interface SparklinePoint { diff --git a/infrastructure/docker/nginx-spa.conf b/infrastructure/docker/nginx-spa.conf index 744d267..4d1e58b 100644 --- a/infrastructure/docker/nginx-spa.conf +++ b/infrastructure/docker/nginx-spa.conf @@ -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' '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 # 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; } + # 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 location /tiles/ { add_header Access-Control-Allow-Origin "*" always;