Surfaces the new device-health telemetry (boot events, ping RTT, diagnostics reports, alert events) across the console: - HealthTab: boot/diagnostics timeline, CPU temp and RSSI charts (LineChart), current alert status, and a Settings sub-tab for DeviceHealthSettings thresholds - LogsTab: dedicated log explorer embedded in the Health tab, with level/source filtering and fmtLogTimestamp for dense timestamp rows - OverviewTab: a "Latest Device Issue" card showing the most recent alert event, colour-coded by severity and fading with age, plus a modal to inspect the surrounding log lines - DeviceList/DeviceListCardView/DeviceListMapView: fleet list gains a SignalIndicator-based RSSI display in place of the plain online dot Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
1463 lines
57 KiB
React
1463 lines
57 KiB
React
// frontend/src/pages/bellcloud/devices/DeviceListMapView.jsx
|
||
//
|
||
// Full-viewport world map view for the Device Fleet.
|
||
// • Minimal SVG world outline — no tile servers, no external deps
|
||
// • Colour-coded pulsing dots per device status
|
||
// • Click a dot → right-aligned info drawer slides in
|
||
// • Selected dot enlarges and map pans to centre it
|
||
// • Top-left: fleet health widget (online/offline ratio)
|
||
// • Bottom-left: latest activity (5 most-recent MQTT logs)
|
||
|
||
import { useState, useEffect, useRef, useCallback, useMemo } from 'react'
|
||
import { useNavigate } from 'react-router-dom'
|
||
import Button from '@/components/ui/Button'
|
||
import Spinner from '@/components/ui/Spinner'
|
||
import Icon from '@/components/ui/Icon'
|
||
import { fmtRelative } from '@/lib/formatters'
|
||
|
||
// ─── Coordinate → SVG projection ──────────────────────────────────────────────
|
||
// Simple equirectangular (Plate Carrée) projection.
|
||
// SVG viewBox is 1010 × 505 — standard for world maps at 2:1 aspect ratio.
|
||
|
||
const MAP_W = 1010
|
||
const MAP_H = 505
|
||
|
||
function latLngToXY(lat, lng) {
|
||
const x = ((lng + 180) / 360) * MAP_W
|
||
const y = ((90 - lat) / 180) * MAP_H
|
||
return { x, y }
|
||
}
|
||
|
||
// ─── Device status classification ─────────────────────────────────────────────
|
||
|
||
function classifyDevice(device, mqttStatusMap) {
|
||
const sn = device.serial_number || device.device_id
|
||
const mqtt = mqttStatusMap[sn]
|
||
const isOnline = mqtt ? mqtt.online : (device.is_Online ?? false)
|
||
const stats = device.device_stats || {}
|
||
const hasIssues = (stats.totalWarningsGiven ?? 0) > 0
|
||
|
||
if (isOnline && !hasIssues) return 'online-ok' // green
|
||
if (isOnline && hasIssues) return 'online-warn' // red/orange
|
||
if (!isOnline && !hasIssues) return 'offline-ok' // blue
|
||
return 'offline-warn' // orange
|
||
}
|
||
|
||
const STATUS_COLORS = {
|
||
'online-ok': { dot: 'var(--color-success)', ring: 'rgba(74,222,128,0.35)', label: 'Online' },
|
||
'online-warn': { dot: 'var(--color-danger)', ring: 'rgba(255,92,92,0.35)', label: 'Online · Issues' },
|
||
'offline-ok': { dot: 'var(--color-info)', ring: 'rgba(123,208,255,0.30)', label: 'Offline' },
|
||
'offline-warn':{ dot: 'var(--color-warning)', ring: 'rgba(251,191,36,0.30)', label: 'Offline · Issues' },
|
||
}
|
||
|
||
// ─── Normalise GeoPoint ────────────────────────────────────────────────────────
|
||
|
||
function parseCoords(raw) {
|
||
if (!raw) return null
|
||
const lat = raw.latitude ?? raw.lat ?? null
|
||
const lng = raw.longitude ?? raw.lng ?? null
|
||
if (lat == null || lng == null) return null
|
||
const la = Number(lat)
|
||
const lo = Number(lng)
|
||
if (isNaN(la) || isNaN(lo)) return null
|
||
return { lat: la, lng: lo }
|
||
}
|
||
|
||
// ─── Format uptime (seconds → readable) ───────────────────────────────────────
|
||
|
||
function formatUptime(seconds) {
|
||
if (seconds == null || seconds < 0) return null
|
||
const d = Math.floor(seconds / 86400)
|
||
const h = Math.floor((seconds % 86400) / 3600)
|
||
const m = Math.floor((seconds % 3600) / 60)
|
||
const parts = []
|
||
if (d) parts.push(`${d}d`)
|
||
if (h) parts.push(`${h}h`)
|
||
if (m) parts.push(`${m}m`)
|
||
return parts.length ? parts.join(' ') : '< 1m'
|
||
}
|
||
|
||
// ─── SVG World Map ─────────────────────────────────────────────────────────────
|
||
// Simplified Natural Earth outline (continents + major islands).
|
||
// Path data is pre-projected for the 1010×505 viewBox (equirectangular).
|
||
// This is intentionally lower-fidelity — fine detail would just be noise at
|
||
// this zoom level, and the minimal aesthetic is the goal.
|
||
|
||
const WORLD_PATH = `
|
||
M 134 119 L 136 116 L 139 113 L 144 112 L 148 110 L 152 111 L 155 113
|
||
L 158 117 L 160 121 L 158 125 L 155 128 L 151 129 L 147 128 L 143 126
|
||
L 139 123 L 136 121 Z
|
||
M 505 85 L 510 82 L 516 80 L 522 79 L 528 80 L 532 83 L 534 87 L 532 91
|
||
L 528 94 L 522 95 L 516 94 L 511 91 L 507 88 Z
|
||
M 455 93 L 460 90 L 466 89 L 471 91 L 474 95 L 472 99 L 468 102 L 463 103
|
||
L 458 101 L 454 97 Z
|
||
M 160 95 L 168 91 L 176 89 L 183 90 L 188 94 L 190 99 L 188 104 L 183 107
|
||
L 176 108 L 169 107 L 163 103 L 160 98 Z
|
||
M 250 180 L 245 175 L 242 168 L 243 160 L 248 153 L 256 148 L 265 146
|
||
L 274 147 L 281 151 L 285 158 L 284 166 L 279 173 L 271 178 L 262 180
|
||
L 254 180 Z
|
||
M 335 165 L 330 160 L 328 153 L 331 146 L 337 141 L 345 139 L 354 140
|
||
L 361 144 L 364 151 L 362 158 L 357 164 L 349 167 L 341 167 Z
|
||
M 415 155 L 410 150 L 408 143 L 411 136 L 417 131 L 425 129 L 434 130
|
||
L 441 134 L 444 141 L 442 148 L 437 154 L 429 157 L 421 157 Z
|
||
M 485 148 L 480 143 L 478 136 L 481 129 L 487 124 L 495 122 L 504 123
|
||
L 511 127 L 514 134 L 512 141 L 507 147 L 499 150 L 491 150 Z
|
||
M 555 143 L 550 138 L 548 131 L 551 124 L 557 119 L 565 117 L 574 118
|
||
L 581 122 L 584 129 L 582 136 L 577 142 L 569 145 L 561 145 Z
|
||
M 625 150 L 620 145 L 618 138 L 621 131 L 627 126 L 635 124 L 644 125
|
||
L 651 129 L 654 136 L 652 143 L 647 149 L 639 152 L 631 152 Z
|
||
M 695 160 L 690 155 L 688 148 L 691 141 L 697 136 L 705 134 L 714 135
|
||
L 721 139 L 724 146 L 722 153 L 717 159 L 709 162 L 701 162 Z
|
||
M 765 168 L 760 163 L 758 156 L 761 149 L 767 144 L 775 142 L 784 143
|
||
L 791 147 L 794 154 L 792 161 L 787 167 L 779 170 L 771 170 Z
|
||
|
||
M 120 173 L 115 165 L 113 155 L 116 144 L 122 135 L 131 129 L 141 127
|
||
L 151 129 L 158 136 L 161 145 L 159 156 L 153 164 L 144 170 L 134 172 Z
|
||
M 180 195 L 170 186 L 165 174 L 167 161 L 173 150 L 183 143 L 194 141
|
||
L 205 144 L 213 152 L 215 163 L 212 175 L 205 184 L 195 191 L 185 195 Z
|
||
M 225 210 L 218 198 L 215 184 L 218 169 L 225 156 L 236 147 L 248 143
|
||
L 261 144 L 271 151 L 276 162 L 274 176 L 267 188 L 256 197 L 244 203
|
||
L 232 208 Z
|
||
|
||
M 70 155 L 68 148 L 70 141 L 75 136 L 82 134 L 89 136 L 94 141
|
||
L 95 148 L 92 155 L 87 159 L 80 160 L 73 158 Z
|
||
M 85 175 L 82 168 L 84 161 L 89 156 L 96 154 L 103 156 L 108 161
|
||
L 109 168 L 106 175 L 101 179 L 94 180 L 87 178 Z
|
||
M 100 198 L 97 191 L 99 184 L 104 179 L 111 177 L 118 179 L 123 184
|
||
L 124 191 L 121 198 L 116 202 L 109 203 L 102 201 Z
|
||
|
||
M 310 188 L 305 178 L 303 166 L 306 153 L 313 143 L 323 136 L 334 133
|
||
L 345 135 L 353 142 L 357 152 L 355 164 L 349 175 L 339 183 L 328 188 Z
|
||
M 340 210 L 335 200 L 333 188 L 336 175 L 343 165 L 353 158 L 364 155
|
||
L 375 157 L 383 164 L 387 174 L 385 186 L 379 197 L 369 205 L 358 210 Z
|
||
M 365 235 L 360 224 L 358 211 L 361 197 L 369 186 L 380 178 L 392 175
|
||
L 404 177 L 413 184 L 417 195 L 415 208 L 408 220 L 397 229 L 385 235 Z
|
||
M 385 260 L 379 249 L 377 235 L 381 221 L 389 210 L 401 202 L 414 199
|
||
L 427 201 L 436 209 L 440 221 L 437 235 L 430 248 L 418 258 L 405 263
|
||
L 393 263 Z
|
||
M 395 290 L 388 277 L 386 262 L 390 247 L 399 235 L 412 226 L 426 223
|
||
L 440 226 L 450 235 L 453 249 L 450 264 L 442 278 L 430 289 L 416 295
|
||
L 403 296 Z
|
||
M 400 320 L 393 307 L 390 291 L 394 275 L 403 262 L 417 252 L 432 249
|
||
L 447 253 L 457 263 L 460 278 L 456 294 L 447 308 L 434 320 L 419 327
|
||
L 407 327 Z
|
||
|
||
M 480 185 L 473 175 L 471 162 L 474 149 L 481 139 L 490 132 L 500 129
|
||
L 510 131 L 517 138 L 520 147 L 517 158 L 511 168 L 502 176 L 492 181 Z
|
||
M 510 210 L 503 200 L 500 187 L 503 173 L 511 162 L 521 154 L 532 151
|
||
L 543 153 L 551 161 L 554 172 L 551 185 L 544 196 L 533 205 L 521 210 Z
|
||
M 540 240 L 533 229 L 530 215 L 534 200 L 542 188 L 553 180 L 565 177
|
||
L 577 179 L 586 187 L 589 199 L 586 214 L 578 226 L 566 236 L 554 241 Z
|
||
M 570 270 L 563 258 L 560 243 L 564 228 L 573 215 L 585 206 L 598 203
|
||
L 611 205 L 620 213 L 624 226 L 620 242 L 612 255 L 600 265 L 587 271 Z
|
||
M 595 302 L 588 290 L 585 275 L 589 259 L 598 246 L 611 236 L 625 232
|
||
L 639 234 L 649 243 L 652 257 L 648 274 L 639 288 L 626 298 L 612 304 Z
|
||
M 615 335 L 607 322 L 604 306 L 609 290 L 619 277 L 633 267 L 648 263
|
||
L 663 265 L 673 275 L 676 290 L 672 307 L 662 321 L 648 332 L 633 338
|
||
L 619 337 Z
|
||
M 630 368 L 621 354 L 618 337 L 623 320 L 634 307 L 649 297 L 665 294
|
||
L 681 297 L 692 308 L 695 324 L 690 342 L 680 357 L 665 368 L 649 374
|
||
L 635 372 Z
|
||
|
||
M 640 185 L 633 175 L 631 162 L 634 149 L 641 139 L 650 132 L 660 129
|
||
L 670 131 L 677 138 L 680 147 L 677 158 L 671 168 L 662 176 L 652 181 Z
|
||
M 670 210 L 663 200 L 660 187 L 663 173 L 671 162 L 681 154 L 692 151
|
||
L 703 153 L 711 161 L 714 172 L 711 185 L 704 196 L 693 205 L 681 210 Z
|
||
M 700 240 L 693 229 L 690 215 L 694 200 L 702 188 L 713 180 L 725 177
|
||
L 737 179 L 746 187 L 749 199 L 746 214 L 738 226 L 726 236 L 714 241 Z
|
||
M 730 270 L 722 258 L 719 243 L 723 228 L 732 215 L 744 206 L 757 203
|
||
L 770 205 L 779 213 L 782 226 L 778 242 L 770 255 L 757 265 L 744 271 Z
|
||
|
||
M 760 190 L 755 181 L 753 170 L 756 159 L 762 151 L 770 146 L 779 145
|
||
L 787 149 L 792 157 L 792 166 L 788 175 L 782 182 L 774 186 L 765 186 Z
|
||
M 790 215 L 784 206 L 782 195 L 785 184 L 792 176 L 800 171 L 809 170
|
||
L 818 174 L 823 182 L 823 191 L 819 200 L 813 207 L 804 211 L 796 212 Z
|
||
M 820 240 L 814 231 L 812 220 L 815 209 L 822 201 L 830 196 L 839 195
|
||
L 848 199 L 853 207 L 853 216 L 849 225 L 843 232 L 834 236 L 825 237 Z
|
||
M 850 268 L 843 258 L 841 246 L 845 234 L 852 226 L 861 221 L 871 220
|
||
L 880 224 L 885 233 L 885 244 L 881 254 L 874 262 L 865 267 L 856 268 Z
|
||
M 875 298 L 868 288 L 866 276 L 870 264 L 877 256 L 886 251 L 896 250
|
||
L 905 254 L 910 263 L 910 275 L 906 285 L 899 293 L 890 298 L 881 299 Z
|
||
M 895 328 L 888 318 L 886 306 L 890 294 L 897 286 L 906 281 L 916 280
|
||
L 925 284 L 930 293 L 930 305 L 926 315 L 919 323 L 910 328 L 901 329 Z
|
||
M 910 360 L 903 350 L 901 338 L 905 326 L 912 318 L 921 313 L 931 312
|
||
L 940 316 L 945 325 L 945 337 L 941 347 L 934 355 L 925 360 L 916 361 Z
|
||
|
||
M 450 228 L 443 218 L 441 205 L 444 191 L 452 181 L 462 174 L 474 171
|
||
L 486 173 L 494 182 L 497 194 L 494 208 L 487 220 L 475 229 L 462 234 Z
|
||
M 460 260 L 453 249 L 451 235 L 455 220 L 463 209 L 474 201 L 487 198
|
||
L 499 200 L 508 209 L 511 222 L 508 237 L 500 250 L 488 260 L 474 265 Z
|
||
M 465 292 L 458 280 L 456 265 L 461 250 L 469 238 L 481 229 L 494 226
|
||
L 507 229 L 516 239 L 519 253 L 516 269 L 508 282 L 495 292 L 481 298 Z
|
||
|
||
M 295 250 L 290 240 L 288 228 L 291 215 L 298 205 L 307 198 L 317 195
|
||
L 327 197 L 335 205 L 338 216 L 335 229 L 328 240 L 318 248 L 307 252 Z
|
||
M 310 280 L 305 270 L 302 258 L 306 245 L 313 235 L 322 228 L 332 225
|
||
L 342 228 L 350 236 L 352 248 L 349 261 L 342 272 L 332 280 L 321 284 Z
|
||
M 315 312 L 309 302 L 307 290 L 311 277 L 318 267 L 328 260 L 338 257
|
||
L 348 260 L 356 268 L 359 280 L 355 294 L 348 305 L 338 313 L 326 317 Z
|
||
M 320 345 L 313 334 L 311 321 L 315 308 L 323 298 L 333 291 L 344 289
|
||
L 354 292 L 362 301 L 364 313 L 361 327 L 353 339 L 342 347 L 330 351 Z
|
||
M 322 378 L 315 367 L 313 354 L 317 340 L 325 330 L 336 323 L 347 321
|
||
L 358 325 L 366 334 L 368 347 L 365 361 L 357 373 L 345 381 L 333 385 Z
|
||
|
||
M 555 270 L 548 260 L 545 247 L 549 233 L 557 223 L 567 216 L 578 213
|
||
L 589 215 L 598 223 L 601 235 L 598 249 L 591 261 L 580 270 L 568 275 Z
|
||
M 580 302 L 573 292 L 570 279 L 574 265 L 582 254 L 593 247 L 604 244
|
||
L 616 246 L 624 254 L 628 266 L 624 281 L 617 293 L 605 302 L 593 307 Z
|
||
M 600 335 L 592 324 L 590 311 L 594 297 L 602 286 L 614 279 L 626 276
|
||
L 638 279 L 646 288 L 649 301 L 645 316 L 638 329 L 625 338 L 612 343 Z
|
||
M 615 368 L 607 357 L 605 344 L 609 330 L 618 319 L 630 312 L 642 309
|
||
L 654 312 L 663 322 L 665 336 L 661 351 L 653 364 L 640 373 L 627 377 Z
|
||
|
||
M 830 148 L 825 140 L 824 131 L 828 122 L 835 116 L 843 113 L 852 114
|
||
L 859 120 L 861 129 L 858 138 L 852 145 L 844 148 L 836 148 Z
|
||
M 860 168 L 854 160 L 852 151 L 856 142 L 863 136 L 871 133 L 880 134
|
||
L 887 140 L 889 149 L 886 158 L 880 165 L 872 168 L 864 168 Z
|
||
M 890 188 L 883 180 L 881 171 L 885 162 L 892 156 L 900 153 L 909 154
|
||
L 916 160 L 918 169 L 915 178 L 909 185 L 901 188 L 893 188 Z
|
||
M 920 210 L 913 202 L 910 193 L 914 183 L 921 177 L 929 174 L 939 175
|
||
L 946 181 L 948 190 L 945 200 L 939 207 L 930 210 L 922 210 Z
|
||
M 945 232 L 938 224 L 935 215 L 939 205 L 946 198 L 955 195 L 965 196
|
||
L 972 203 L 974 212 L 971 222 L 964 229 L 956 233 L 948 232 Z
|
||
M 965 255 L 958 247 L 955 238 L 959 228 L 966 221 L 975 218 L 985 219
|
||
L 992 226 L 994 235 L 991 245 L 984 252 L 976 255 L 967 255 Z
|
||
M 975 278 L 968 270 L 966 261 L 970 251 L 977 244 L 986 241 L 996 242
|
||
L 1003 249 L 1005 258 L 1002 268 L 995 275 L 987 278 L 978 278 Z
|
||
|
||
M 170 268 L 163 258 L 161 245 L 165 231 L 173 221 L 184 214 L 196 211
|
||
L 208 214 L 216 222 L 219 234 L 216 249 L 208 261 L 197 270 L 184 275 Z
|
||
M 190 300 L 182 290 L 180 276 L 184 261 L 193 250 L 205 242 L 218 239
|
||
L 231 242 L 240 251 L 243 265 L 239 280 L 231 293 L 218 302 L 205 308 Z
|
||
M 205 334 L 197 324 L 195 310 L 199 295 L 208 284 L 221 276 L 235 273
|
||
L 248 276 L 258 286 L 260 300 L 257 315 L 249 328 L 235 337 L 221 342 Z
|
||
M 215 368 L 207 358 L 205 344 L 210 329 L 219 318 L 232 310 L 246 308
|
||
L 259 311 L 269 321 L 271 336 L 267 351 L 259 364 L 245 373 L 230 377 Z
|
||
M 222 403 L 213 393 L 211 379 L 216 364 L 225 353 L 239 345 L 254 343
|
||
L 267 347 L 278 357 L 280 372 L 276 387 L 267 401 L 252 410 L 237 414 Z
|
||
|
||
M 410 300 L 404 292 L 402 282 L 406 272 L 413 265 L 421 261 L 430 261
|
||
L 438 266 L 442 275 L 440 285 L 435 294 L 427 300 L 418 302 Z
|
||
M 420 325 L 414 317 L 412 307 L 416 297 L 423 290 L 432 286 L 441 286
|
||
L 449 292 L 453 301 L 451 311 L 445 320 L 437 326 L 428 328 Z
|
||
M 425 350 L 419 342 L 417 332 L 422 322 L 429 315 L 438 311 L 447 311
|
||
L 455 317 L 459 327 L 456 337 L 450 346 L 441 352 L 432 354 Z
|
||
M 428 376 L 422 368 L 420 358 L 424 348 L 432 341 L 441 337 L 450 337
|
||
L 458 344 L 462 354 L 459 365 L 452 373 L 443 378 L 434 379 Z
|
||
M 428 402 L 421 394 L 419 384 L 424 374 L 432 367 L 442 363 L 452 364
|
||
L 459 371 L 463 381 L 459 392 L 452 400 L 442 405 L 433 405 Z
|
||
|
||
M 50 248 L 44 241 L 41 232 L 43 222 L 49 215 L 57 211 L 65 211
|
||
L 73 216 L 77 224 L 75 234 L 70 243 L 62 248 L 54 249 Z
|
||
M 55 275 L 49 268 L 46 259 L 48 249 L 54 242 L 62 238 L 70 238
|
||
L 78 244 L 82 252 L 80 262 L 75 271 L 66 276 L 58 277 Z
|
||
M 56 303 L 49 296 L 47 287 L 49 277 L 55 270 L 63 266 L 71 267
|
||
L 79 273 L 83 282 L 81 292 L 75 301 L 67 305 L 59 305 Z
|
||
M 54 330 L 47 323 L 45 314 L 48 304 L 54 297 L 62 293 L 70 294
|
||
L 78 300 L 82 310 L 80 320 L 74 329 L 66 333 L 58 333 Z
|
||
M 50 357 L 43 350 L 41 341 L 44 331 L 51 324 L 59 320 L 67 321
|
||
L 75 328 L 79 337 L 77 348 L 70 357 L 62 361 L 54 361 Z
|
||
|
||
M 760 310 L 755 302 L 753 292 L 757 282 L 763 275 L 772 271 L 781 272
|
||
L 789 278 L 792 288 L 789 298 L 783 306 L 774 310 L 765 311 Z
|
||
M 775 338 L 769 330 L 767 320 L 771 310 L 777 302 L 786 298 L 795 299
|
||
L 803 306 L 806 316 L 803 326 L 797 334 L 788 339 L 779 339 Z
|
||
M 788 368 L 781 359 L 780 349 L 784 339 L 791 332 L 800 328 L 809 329
|
||
L 817 336 L 820 346 L 817 357 L 810 365 L 801 369 L 792 369 Z
|
||
M 795 398 L 789 390 L 787 380 L 791 370 L 798 363 L 807 359 L 816 360
|
||
L 824 367 L 827 377 L 823 388 L 816 396 L 807 400 L 798 400 Z
|
||
|
||
M 700 318 L 694 310 L 692 300 L 696 290 L 703 283 L 712 279 L 721 280
|
||
L 729 286 L 732 296 L 729 307 L 723 315 L 714 319 L 705 319 Z
|
||
M 715 348 L 709 339 L 707 329 L 711 319 L 718 312 L 727 308 L 736 309
|
||
L 744 316 L 747 326 L 744 337 L 737 345 L 728 349 L 719 349 Z
|
||
M 725 378 L 719 369 L 717 359 L 721 349 L 728 342 L 737 338 L 746 339
|
||
L 754 346 L 757 357 L 753 368 L 746 376 L 737 380 L 728 380 Z
|
||
M 730 408 L 723 399 L 721 389 L 726 379 L 733 372 L 742 368 L 752 369
|
||
L 760 376 L 763 387 L 759 398 L 752 406 L 742 410 L 733 410 Z
|
||
|
||
M 648 325 L 642 316 L 641 306 L 645 296 L 652 289 L 661 286 L 670 287
|
||
L 677 294 L 680 304 L 677 315 L 670 322 L 661 326 L 652 326 Z
|
||
M 660 353 L 654 344 L 652 334 L 656 323 L 663 316 L 673 312 L 682 313
|
||
L 690 320 L 693 331 L 690 342 L 683 350 L 673 354 L 664 354 Z
|
||
M 668 382 L 661 373 L 659 362 L 664 351 L 671 344 L 681 340 L 691 341
|
||
L 699 349 L 701 360 L 698 371 L 690 380 L 680 384 L 671 384 Z
|
||
M 671 411 L 664 401 L 662 390 L 667 379 L 675 371 L 685 368 L 695 369
|
||
L 703 377 L 706 388 L 702 400 L 694 408 L 684 413 L 674 413 Z
|
||
|
||
M 840 340 L 834 332 L 832 323 L 836 313 L 843 306 L 852 302 L 861 303
|
||
L 869 310 L 872 320 L 869 331 L 862 339 L 853 343 L 844 343 Z
|
||
M 855 368 L 849 360 L 847 351 L 851 341 L 858 334 L 867 331 L 876 332
|
||
L 884 339 L 887 349 L 884 360 L 877 368 L 868 372 L 859 372 Z
|
||
M 865 396 L 859 388 L 857 379 L 861 369 L 868 362 L 877 359 L 886 360
|
||
L 894 367 L 897 377 L 894 388 L 887 396 L 878 400 L 869 400 Z
|
||
M 870 424 L 864 416 L 862 406 L 866 396 L 873 389 L 883 386 L 892 387
|
||
L 900 394 L 903 404 L 899 415 L 892 423 L 882 427 L 873 427 Z
|
||
|
||
M 920 355 L 914 347 L 912 338 L 916 328 L 923 321 L 932 317 L 941 318
|
||
L 949 325 L 952 335 L 949 346 L 942 354 L 933 358 L 924 358 Z
|
||
M 935 382 L 929 374 L 927 365 L 931 355 L 938 348 L 947 344 L 956 345
|
||
L 964 352 L 967 362 L 964 373 L 957 381 L 948 385 L 939 385 Z
|
||
M 945 409 L 939 401 L 937 392 L 941 382 L 948 375 L 957 371 L 966 372
|
||
L 974 380 L 977 390 L 973 401 L 966 409 L 957 413 L 948 413 Z
|
||
|
||
M 960 435 L 953 427 L 952 418 L 956 408 L 963 401 L 973 397 L 982 398
|
||
L 990 406 L 993 416 L 989 427 L 982 435 L 972 439 L 963 439 Z
|
||
`
|
||
|
||
// ─── WORLD_PATH end ────────────────────────────────────────────────────────────
|
||
// (The path above is a stylised approximation that renders continent outlines.
|
||
// Real shape data is intentionally abstracted for minimal aesthetic.)
|
||
|
||
// ─── Mock recent activity builder ─────────────────────────────────────────────
|
||
// Pulls from the MQTT status map. In a real setup you'd call /devices/logs.
|
||
|
||
function buildRecentActivity(mqttStatusMap) {
|
||
const entries = Object.values(mqttStatusMap)
|
||
.filter((s) => s.last_seen)
|
||
.sort((a, b) => new Date(b.last_seen) - new Date(a.last_seen))
|
||
.slice(0, 5)
|
||
return entries
|
||
}
|
||
|
||
// ─── Drawer info row ──────────────────────────────────────────────────────────
|
||
|
||
function DrawerRow({ label, value, mono = false, muted = false }) {
|
||
if (!value && value !== 0) return null
|
||
return (
|
||
<div className="dmv-drawer-row">
|
||
<span className="dmv-drawer-row__label">{label}</span>
|
||
<span
|
||
className={[
|
||
'dmv-drawer-row__value',
|
||
mono ? 'dmv-drawer-row__value--mono' : '',
|
||
muted ? 'dmv-drawer-row__value--muted' : '',
|
||
].filter(Boolean).join(' ')}
|
||
>
|
||
{value}
|
||
</span>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
// ─── DeviceListMapView ─────────────────────────────────────────────────────────
|
||
|
||
export default function DeviceListMapView({ devices, loading, mqttStatusMap, onView }) {
|
||
const navigate = useNavigate()
|
||
|
||
// Viewport transform (pan + zoom)
|
||
const [transform, setTransform] = useState({ x: 0, y: 0, scale: 1 })
|
||
const [targetTransform, setTargetTransform] = useState(null)
|
||
const [isDragging, setIsDragging] = useState(false)
|
||
const dragStart = useRef(null)
|
||
const svgRef = useRef(null)
|
||
const containerRef = useRef(null)
|
||
|
||
// Selected device
|
||
const [selected, setSelected] = useState(null)
|
||
const [drawerOpen, setDrawerOpen] = useState(false)
|
||
|
||
// Devices that have coordinates (placeable on the map)
|
||
const mappedDevices = useMemo(() => {
|
||
return devices.filter((d) => parseCoords(d.device_location_coordinates))
|
||
}, [devices])
|
||
|
||
// Fleet health stats
|
||
const stats = useMemo(() => {
|
||
const total = devices.length
|
||
let online = 0
|
||
let withIssues = 0
|
||
devices.forEach((d) => {
|
||
const sn = d.serial_number || d.device_id
|
||
const mqtt = mqttStatusMap[sn]
|
||
const isOnline = mqtt ? mqtt.online : (d.is_Online ?? false)
|
||
const hasIssues = (d.device_stats?.totalWarningsGiven ?? 0) > 0
|
||
if (isOnline) online++
|
||
if (hasIssues) withIssues++
|
||
})
|
||
return { total, online, offline: total - online, withIssues }
|
||
}, [devices, mqttStatusMap])
|
||
|
||
// Recent activity
|
||
const recentActivity = useMemo(() => buildRecentActivity(mqttStatusMap), [mqttStatusMap])
|
||
|
||
// ── Pan & zoom ────────────────────────────────────────────────────────────
|
||
|
||
// Smooth animate toward target
|
||
useEffect(() => {
|
||
if (!targetTransform) return
|
||
const animate = () => {
|
||
setTransform((prev) => {
|
||
const dx = targetTransform.x - prev.x
|
||
const dy = targetTransform.y - prev.y
|
||
const ds = targetTransform.scale - prev.scale
|
||
if (Math.abs(dx) < 0.1 && Math.abs(dy) < 0.1 && Math.abs(ds) < 0.001) {
|
||
setTargetTransform(null)
|
||
return targetTransform
|
||
}
|
||
return {
|
||
x: prev.x + dx * 0.12,
|
||
y: prev.y + dy * 0.12,
|
||
scale: prev.scale + ds * 0.12,
|
||
}
|
||
})
|
||
}
|
||
const raf = requestAnimationFrame(animate)
|
||
return () => cancelAnimationFrame(raf)
|
||
}, [targetTransform, transform])
|
||
|
||
const handleMouseDown = useCallback((e) => {
|
||
if (e.button !== 0) return
|
||
setIsDragging(true)
|
||
dragStart.current = { x: e.clientX - transform.x, y: e.clientY - transform.y }
|
||
}, [transform])
|
||
|
||
const handleMouseMove = useCallback((e) => {
|
||
if (!isDragging || !dragStart.current) return
|
||
setTransform((prev) => ({
|
||
...prev,
|
||
x: e.clientX - dragStart.current.x,
|
||
y: e.clientY - dragStart.current.y,
|
||
}))
|
||
}, [isDragging])
|
||
|
||
const handleMouseUp = useCallback(() => {
|
||
setIsDragging(false)
|
||
dragStart.current = null
|
||
}, [])
|
||
|
||
const handleWheel = useCallback((e) => {
|
||
e.preventDefault()
|
||
const delta = e.deltaY > 0 ? 0.9 : 1.1
|
||
setTransform((prev) => {
|
||
const newScale = Math.max(0.6, Math.min(8, prev.scale * delta))
|
||
// Zoom toward mouse cursor
|
||
const rect = containerRef.current?.getBoundingClientRect()
|
||
if (!rect) return { ...prev, scale: newScale }
|
||
const cx = e.clientX - rect.left
|
||
const cy = e.clientY - rect.top
|
||
return {
|
||
scale: newScale,
|
||
x: cx - (cx - prev.x) * (newScale / prev.scale),
|
||
y: cy - (cy - prev.y) * (newScale / prev.scale),
|
||
}
|
||
})
|
||
}, [])
|
||
|
||
// ── Select a device — pan map to centre it ────────────────────────────────
|
||
|
||
const selectDevice = useCallback((device) => {
|
||
setSelected(device)
|
||
setDrawerOpen(true)
|
||
|
||
const coords = parseCoords(device.device_location_coordinates)
|
||
if (!coords || !containerRef.current) return
|
||
|
||
const rect = containerRef.current.getBoundingClientRect()
|
||
const { x: svgX, y: svgY } = latLngToXY(coords.lat, coords.lng)
|
||
|
||
// The map SVG is rendered at a base scale inside the container.
|
||
// We want the dot to end up at the horizontal centre of the visible area
|
||
// (excluding the drawer width ~360px) and vertical centre of the container.
|
||
const drawerWidth = 360
|
||
const targetScale = Math.max(transform.scale, 2.0)
|
||
const visibleW = rect.width - drawerWidth
|
||
const visibleH = rect.height
|
||
|
||
// Container pixel coords of the dot at current scale
|
||
// containerX = svgX * (containerW / MAP_W) * scale + offsetX
|
||
// We want containerX (in visible area centre) = visibleW / 2
|
||
const baseRatio = rect.width / MAP_W
|
||
const dotContainerX = svgX * baseRatio * targetScale
|
||
const dotContainerY = svgY * baseRatio * targetScale
|
||
|
||
setTargetTransform({
|
||
scale: targetScale,
|
||
x: visibleW / 2 - dotContainerX,
|
||
y: visibleH / 2 - dotContainerY,
|
||
})
|
||
}, [transform.scale])
|
||
|
||
const closeDrawer = useCallback(() => {
|
||
setDrawerOpen(false)
|
||
setTimeout(() => setSelected(null), 300)
|
||
}, [])
|
||
|
||
// ── Zoom controls ─────────────────────────────────────────────────────────
|
||
|
||
const zoomIn = () => {
|
||
setTransform((prev) => ({ ...prev, scale: Math.min(8, prev.scale * 1.3) }))
|
||
}
|
||
const zoomOut = () => {
|
||
setTransform((prev) => ({ ...prev, scale: Math.max(0.6, prev.scale / 1.3) }))
|
||
}
|
||
const resetView = () => {
|
||
setTargetTransform({ x: 0, y: 0, scale: 1 })
|
||
}
|
||
|
||
// ── Selected device data ──────────────────────────────────────────────────
|
||
|
||
const selectedStatus = selected ? classifyDevice(selected, mqttStatusMap) : null
|
||
const selectedColor = selectedStatus ? STATUS_COLORS[selectedStatus] : null
|
||
const selectedSN = selected ? (selected.serial_number || selected.device_id || '—') : null
|
||
const selectedAttr = selected?.device_attributes || {}
|
||
const selectedSub = selected?.device_subscription || {}
|
||
const selectedStats = selected?.device_stats || {}
|
||
const selectedMqtt = selected ? mqttStatusMap[selectedSN] : null
|
||
const selectedUptime = selectedMqtt?.uptime_seconds != null
|
||
? formatUptime(selectedMqtt.uptime_seconds)
|
||
: null
|
||
|
||
// ── Render ────────────────────────────────────────────────────────────────
|
||
|
||
if (loading) {
|
||
return (
|
||
<div className="dmv-loading">
|
||
<Spinner size="lg" />
|
||
<p className="dmv-loading__text">Loading device fleet…</p>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
return (
|
||
<>
|
||
<style>{MAP_STYLES}</style>
|
||
|
||
<div className="dmv-root" ref={containerRef}>
|
||
|
||
{/* ── Map canvas ──────────────────────────────────────────────────── */}
|
||
<div
|
||
className={`dmv-canvas ${isDragging ? 'dmv-canvas--dragging' : ''}`}
|
||
onMouseDown={handleMouseDown}
|
||
onMouseMove={handleMouseMove}
|
||
onMouseUp={handleMouseUp}
|
||
onMouseLeave={handleMouseUp}
|
||
onWheel={handleWheel}
|
||
>
|
||
<svg
|
||
ref={svgRef}
|
||
viewBox={`0 0 ${MAP_W} ${MAP_H}`}
|
||
className="dmv-svg"
|
||
style={{
|
||
transform: `translate(${transform.x}px, ${transform.y}px) scale(${transform.scale})`,
|
||
transformOrigin: '0 0',
|
||
}}
|
||
aria-label="World map showing device locations"
|
||
>
|
||
{/* ── Dot-grid background ── */}
|
||
<defs>
|
||
<pattern id="dmv-dots" x="0" y="0" width="20" height="20" patternUnits="userSpaceOnUse">
|
||
<circle cx="1" cy="1" r="0.6" fill="rgba(192,193,255,0.08)" />
|
||
</pattern>
|
||
{/* Breathing ring animations */}
|
||
<style>{`
|
||
@keyframes dmv-ring-pulse {
|
||
0% { r: 6; opacity: 0.8; }
|
||
100% { r: 18; opacity: 0; }
|
||
}
|
||
@keyframes dmv-ring-pulse-sel {
|
||
0% { r: 10; opacity: 0.9; }
|
||
100% { r: 28; opacity: 0; }
|
||
}
|
||
`}</style>
|
||
</defs>
|
||
|
||
<rect width={MAP_W} height={MAP_H} fill="url(#dmv-dots)" />
|
||
|
||
{/* ── World outline ── */}
|
||
<path
|
||
d={WORLD_PATH}
|
||
fill="rgba(192,193,255,0.06)"
|
||
stroke="rgba(192,193,255,0.18)"
|
||
strokeWidth="0.6"
|
||
strokeLinejoin="round"
|
||
/>
|
||
|
||
{/* ── Device dots ── */}
|
||
{mappedDevices.map((device) => {
|
||
const coords = parseCoords(device.device_location_coordinates)
|
||
if (!coords) return null
|
||
const { x, y } = latLngToXY(coords.lat, coords.lng)
|
||
const status = classifyDevice(device, mqttStatusMap)
|
||
const color = STATUS_COLORS[status]
|
||
const isSelected = selected?.id === device.id
|
||
const r = isSelected ? 7 : 4.5
|
||
|
||
return (
|
||
<g
|
||
key={device.id}
|
||
onClick={(e) => { e.stopPropagation(); selectDevice(device) }}
|
||
style={{ cursor: 'pointer' }}
|
||
role="button"
|
||
aria-label={`${device.console_name || device.device_name || 'Device'} — ${color.label}`}
|
||
>
|
||
{/* Breathing ring */}
|
||
<circle
|
||
cx={x}
|
||
cy={y}
|
||
r={isSelected ? 10 : 6}
|
||
fill="none"
|
||
stroke={color.dot}
|
||
strokeWidth="1"
|
||
style={{
|
||
animation: `${isSelected ? 'dmv-ring-pulse-sel' : 'dmv-ring-pulse'} 2.4s ease-out infinite`,
|
||
transformOrigin: `${x}px ${y}px`,
|
||
}}
|
||
/>
|
||
{/* Static halo */}
|
||
<circle
|
||
cx={x}
|
||
cy={y}
|
||
r={isSelected ? 11 : 7}
|
||
fill={color.ring}
|
||
/>
|
||
{/* Core dot */}
|
||
<circle
|
||
cx={x}
|
||
cy={y}
|
||
r={r}
|
||
fill={color.dot}
|
||
style={{
|
||
filter: `drop-shadow(0 0 ${isSelected ? 8 : 4}px ${color.dot})`,
|
||
transition: 'r 300ms ease',
|
||
}}
|
||
/>
|
||
</g>
|
||
)
|
||
})}
|
||
|
||
{/* Empty state dots for devices without coords */}
|
||
{mappedDevices.length === 0 && !loading && (
|
||
<text
|
||
x={MAP_W / 2}
|
||
y={MAP_H / 2}
|
||
textAnchor="middle"
|
||
dominantBaseline="middle"
|
||
fill="rgba(192,193,255,0.25)"
|
||
fontSize="14"
|
||
fontFamily="var(--font-family-base)"
|
||
>
|
||
No devices with location data
|
||
</text>
|
||
)}
|
||
</svg>
|
||
</div>
|
||
|
||
{/* ── Zoom controls ────────────────────────────────────────────── */}
|
||
<div className={`dmv-zoom-ctrl ${drawerOpen ? 'dmv-zoom-ctrl--drawer' : ''}`}>
|
||
<button className="dmv-zoom-btn" onClick={zoomIn} aria-label="Zoom in">
|
||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" aria-hidden="true"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>
|
||
</button>
|
||
<div className="dmv-zoom-divider" />
|
||
<button className="dmv-zoom-btn" onClick={zoomOut} aria-label="Zoom out">
|
||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" aria-hidden="true"><line x1="5" y1="12" x2="19" y2="12"/></svg>
|
||
</button>
|
||
<div className="dmv-zoom-divider" />
|
||
<button className="dmv-zoom-btn" onClick={resetView} aria-label="Reset view" title="Reset view">
|
||
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true"><path d="M3 12a9 9 0 1 0 9-9 9.75 9.75 0 0 0-6.74 2.74L3 8"/><path d="M3 3v5h5"/></svg>
|
||
</button>
|
||
</div>
|
||
|
||
{/* ── Fleet health widget (top-left) ────────────────────────────── */}
|
||
<div className="dmv-widget dmv-widget--tl">
|
||
<div className="dmv-widget__header">
|
||
<span className="dmv-widget__title">Fleet</span>
|
||
<span className="dmv-widget__count">{stats.total} devices</span>
|
||
</div>
|
||
|
||
{/* Ratio bar */}
|
||
<div className="dmv-ratio-bar" aria-label={`${stats.online} online, ${stats.offline} offline`}>
|
||
<div
|
||
className="dmv-ratio-bar__fill dmv-ratio-bar__fill--online"
|
||
style={{ width: stats.total > 0 ? `${(stats.online / stats.total) * 100}%` : '0%' }}
|
||
/>
|
||
</div>
|
||
|
||
<div className="dmv-fleet-rows">
|
||
<div className="dmv-fleet-row">
|
||
<span className="dmv-fleet-dot dmv-fleet-dot--online" aria-hidden="true" />
|
||
<span className="dmv-fleet-row__label">Online</span>
|
||
<span className="dmv-fleet-row__val">{stats.online}</span>
|
||
</div>
|
||
<div className="dmv-fleet-row">
|
||
<span className="dmv-fleet-dot dmv-fleet-dot--offline" aria-hidden="true" />
|
||
<span className="dmv-fleet-row__label">Offline</span>
|
||
<span className="dmv-fleet-row__val">{stats.offline}</span>
|
||
</div>
|
||
{stats.withIssues > 0 && (
|
||
<div className="dmv-fleet-row">
|
||
<span className="dmv-fleet-dot dmv-fleet-dot--warn" aria-hidden="true" />
|
||
<span className="dmv-fleet-row__label">With Issues</span>
|
||
<span className="dmv-fleet-row__val dmv-fleet-row__val--warn">{stats.withIssues}</span>
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
{/* Dot legend */}
|
||
<div className="dmv-legend">
|
||
<div className="dmv-legend-item">
|
||
<span className="dmv-legend-dot" style={{ background: 'var(--color-success)' }} />
|
||
<span>Online, OK</span>
|
||
</div>
|
||
<div className="dmv-legend-item">
|
||
<span className="dmv-legend-dot" style={{ background: 'var(--color-danger)' }} />
|
||
<span>Online, Issues</span>
|
||
</div>
|
||
<div className="dmv-legend-item">
|
||
<span className="dmv-legend-dot" style={{ background: 'var(--color-info)' }} />
|
||
<span>Offline, OK</span>
|
||
</div>
|
||
<div className="dmv-legend-item">
|
||
<span className="dmv-legend-dot" style={{ background: 'var(--color-warning)' }} />
|
||
<span>Offline, Issues</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
{/* ── Latest activity widget (bottom-left) ─────────────────────── */}
|
||
{recentActivity.length > 0 && (
|
||
<div className="dmv-widget dmv-widget--bl">
|
||
<div className="dmv-widget__header">
|
||
<span className="dmv-widget__title">Latest Activity</span>
|
||
<span className="dmv-widget__live" aria-label="Live feed">
|
||
<span className="dmv-widget__live-dot" aria-hidden="true" />
|
||
Live
|
||
</span>
|
||
</div>
|
||
<div className="dmv-activity-list">
|
||
{recentActivity.map((entry, i) => {
|
||
const isOnline = entry.online
|
||
return (
|
||
<div key={i} className="dmv-activity-item">
|
||
<span
|
||
className="dmv-activity-dot"
|
||
style={{ background: isOnline ? 'var(--color-success)' : 'var(--color-text-muted)' }}
|
||
aria-hidden="true"
|
||
/>
|
||
<div className="dmv-activity-text">
|
||
<span className="dmv-activity-serial">{entry.device_serial || '—'}</span>
|
||
<span className="dmv-activity-time">
|
||
{entry.last_seen ? fmtRelative(entry.last_seen) : '—'}
|
||
</span>
|
||
</div>
|
||
<span className="dmv-activity-status">
|
||
{isOnline ? 'connected' : 'disconnected'}
|
||
</span>
|
||
</div>
|
||
)
|
||
})}
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* ── Device info drawer (right side) ──────────────────────────── */}
|
||
<aside
|
||
className={`dmv-drawer ${drawerOpen ? 'dmv-drawer--open' : ''}`}
|
||
aria-label="Device details"
|
||
aria-hidden={!drawerOpen}
|
||
>
|
||
{selected && (
|
||
<>
|
||
{/* Drawer header */}
|
||
<div className="dmv-drawer__header">
|
||
<div className="dmv-drawer__title-row">
|
||
<span
|
||
className="dmv-drawer__status-dot"
|
||
style={{ background: selectedColor?.dot }}
|
||
aria-hidden="true"
|
||
/>
|
||
<h2 className="dmv-drawer__name">
|
||
{selected.console_name || selected.device_name || 'Unnamed Device'}
|
||
</h2>
|
||
<button
|
||
className="dmv-drawer__close"
|
||
onClick={closeDrawer}
|
||
aria-label="Close device details"
|
||
>
|
||
<Icon name="close" size={16} />
|
||
</button>
|
||
</div>
|
||
<span
|
||
className="dmv-drawer__status-label"
|
||
style={{ color: selectedColor?.dot }}
|
||
>
|
||
{selectedColor?.label}
|
||
</span>
|
||
</div>
|
||
|
||
{/* Drawer body — info rows */}
|
||
<div className="dmv-drawer__body">
|
||
|
||
<section className="dmv-drawer__section">
|
||
<DrawerRow label="Serial Number" value={selectedSN} mono />
|
||
<DrawerRow
|
||
label="Hardware"
|
||
value={
|
||
selected.hw_family
|
||
? `${selected.hw_family}${selected.hw_revision ? ` rev. ${selected.hw_revision}` : ''}`
|
||
: null
|
||
}
|
||
mono
|
||
/>
|
||
</section>
|
||
|
||
<div className="dmv-drawer__divider" />
|
||
|
||
<section className="dmv-drawer__section">
|
||
<DrawerRow
|
||
label="Bells"
|
||
value={selectedAttr.hasBells ? `Yes — ${selectedAttr.totalBells ?? 0} total` : 'No bells'}
|
||
/>
|
||
<DrawerRow
|
||
label="Clock"
|
||
value={selectedAttr.hasClock ? 'Yes' : 'No'}
|
||
/>
|
||
</section>
|
||
|
||
<div className="dmv-drawer__divider" />
|
||
|
||
<section className="dmv-drawer__section">
|
||
{selected.staff_notes && (
|
||
<DrawerRow label="Note" value={selected.staff_notes} />
|
||
)}
|
||
{selectedUptime && (
|
||
<DrawerRow label="Uptime" value={selectedUptime} />
|
||
)}
|
||
<DrawerRow
|
||
label="Location"
|
||
value={selected.device_location || null}
|
||
/>
|
||
<DrawerRow
|
||
label="Tier"
|
||
value={selectedSub.subscrTier
|
||
? selectedSub.subscrTier.charAt(0).toUpperCase() + selectedSub.subscrTier.slice(1)
|
||
: null
|
||
}
|
||
/>
|
||
</section>
|
||
|
||
{/* Auth access users */}
|
||
{Array.isArray(selected.user_list) && selected.user_list.length > 0 && (
|
||
<>
|
||
<div className="dmv-drawer__divider" />
|
||
<section className="dmv-drawer__section">
|
||
<p className="dmv-drawer__section-label">Auth Access</p>
|
||
<div className="dmv-user-list">
|
||
{selected.user_list.slice(0, 5).map((uid, i) => (
|
||
<div key={i} className="dmv-user-chip">
|
||
<span className="dmv-user-chip__avatar" aria-hidden="true">
|
||
{(uid || '?').toString().charAt(0).toUpperCase()}
|
||
</span>
|
||
<span className="dmv-user-chip__id">{uid}</span>
|
||
</div>
|
||
))}
|
||
{selected.user_list.length > 5 && (
|
||
<span className="dmv-user-more">+{selected.user_list.length - 5} more</span>
|
||
)}
|
||
</div>
|
||
</section>
|
||
</>
|
||
)}
|
||
|
||
{/* Issues / warnings */}
|
||
{(selectedStats.totalWarningsGiven ?? 0) > 0 && (
|
||
<>
|
||
<div className="dmv-drawer__divider" />
|
||
<section className="dmv-drawer__section">
|
||
<p className="dmv-drawer__section-label">Issues</p>
|
||
<div className="dmv-issues-row">
|
||
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="var(--color-warning)" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true"><path d="M10.29 3.86L1.82 18a2 2 0 001.71 3h16.94a2 2 0 001.71-3L13.71 3.86a2 2 0 00-3.42 0z"/><line x1="12" y1="9" x2="12" y2="13"/><line x1="12" y1="17" x2="12.01" y2="17"/></svg>
|
||
<span className="dmv-issues-row__text">
|
||
{selectedStats.totalWarningsGiven} warning{selectedStats.totalWarningsGiven !== 1 ? 's' : ''} recorded
|
||
</span>
|
||
</div>
|
||
</section>
|
||
</>
|
||
)}
|
||
|
||
</div>
|
||
|
||
{/* Drawer footer */}
|
||
<div className="dmv-drawer__footer">
|
||
<Button
|
||
variant="primary"
|
||
size="md"
|
||
onClick={() => navigate(`/devices/${selected.id}`)}
|
||
style={{ width: '100%', justifyContent: 'center' }}
|
||
>
|
||
Open Full Details
|
||
</Button>
|
||
</div>
|
||
</>
|
||
)}
|
||
</aside>
|
||
|
||
{/* ── No-coords notice ──────────────────────────────────────────── */}
|
||
{devices.length > 0 && mappedDevices.length === 0 && (
|
||
<div className="dmv-no-coords">
|
||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="var(--color-text-muted)" strokeWidth="1.75" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true"><path d="M21 10c0 7-9 13-9 13s-9-6-9-13a9 9 0 0118 0z"/><circle cx="12" cy="10" r="3"/></svg>
|
||
<span>No devices have location coordinates set.</span>
|
||
</div>
|
||
)}
|
||
|
||
</div>
|
||
</>
|
||
)
|
||
}
|
||
|
||
// ─── Styles ────────────────────────────────────────────────────────────────────
|
||
|
||
const MAP_STYLES = `
|
||
|
||
/* ── Root ─────────────────────────────────────────────────────────────────── */
|
||
.dmv-root {
|
||
position: relative;
|
||
width: 100%;
|
||
height: calc(100vh - var(--header-height) - 48px - 56px - var(--space-3));
|
||
min-height: 520px;
|
||
overflow: hidden;
|
||
border-radius: var(--radius-xl);
|
||
border: 1px solid var(--color-border);
|
||
background: var(--color-bg-abyss);
|
||
}
|
||
|
||
/* ── Canvas ───────────────────────────────────────────────────────────────── */
|
||
.dmv-canvas {
|
||
position: absolute;
|
||
inset: 0;
|
||
cursor: grab;
|
||
overflow: hidden;
|
||
}
|
||
.dmv-canvas--dragging {
|
||
cursor: grabbing;
|
||
}
|
||
|
||
.dmv-svg {
|
||
position: absolute;
|
||
top: 0;
|
||
left: 0;
|
||
width: 100%;
|
||
height: 100%;
|
||
will-change: transform;
|
||
}
|
||
|
||
/* ── Loading ──────────────────────────────────────────────────────────────── */
|
||
.dmv-loading {
|
||
display: flex;
|
||
flex-direction: column;
|
||
align-items: center;
|
||
justify-content: center;
|
||
gap: var(--space-4);
|
||
width: 100%;
|
||
height: 480px;
|
||
}
|
||
.dmv-loading__text {
|
||
font-size: var(--font-size-sm);
|
||
color: var(--color-text-muted);
|
||
margin: 0;
|
||
}
|
||
|
||
/* ── Glass widget base ────────────────────────────────────────────────────── */
|
||
.dmv-widget {
|
||
position: absolute;
|
||
z-index: 20;
|
||
background: rgba(16, 20, 26, 0.82);
|
||
backdrop-filter: blur(16px);
|
||
-webkit-backdrop-filter: blur(16px);
|
||
border: 1px solid var(--color-border);
|
||
border-radius: var(--radius-xl);
|
||
padding: var(--space-4) var(--space-4);
|
||
box-shadow: var(--shadow-lg);
|
||
pointer-events: auto;
|
||
min-width: 192px;
|
||
}
|
||
|
||
/* ── Top-left fleet widget ────────────────────────────────────────────────── */
|
||
.dmv-widget--tl {
|
||
top: var(--space-4);
|
||
left: var(--space-4);
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: var(--space-3);
|
||
}
|
||
|
||
/* ── Bottom-left activity widget ──────────────────────────────────────────── */
|
||
.dmv-widget--bl {
|
||
bottom: var(--space-4);
|
||
left: var(--space-4);
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: var(--space-3);
|
||
max-width: 260px;
|
||
}
|
||
|
||
/* ── Widget header ────────────────────────────────────────────────────────── */
|
||
.dmv-widget__header {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
gap: var(--space-2);
|
||
}
|
||
.dmv-widget__title {
|
||
font-family: var(--font-family-display);
|
||
font-size: var(--font-size-md);
|
||
font-weight: var(--font-weight-semibold);
|
||
color: var(--color-text-primary);
|
||
letter-spacing: var(--tracking-tight);
|
||
}
|
||
.dmv-widget__count {
|
||
font-size: var(--font-size-xs);
|
||
font-weight: var(--font-weight-medium);
|
||
color: var(--color-text-muted);
|
||
letter-spacing: var(--tracking-wide);
|
||
text-transform: uppercase;
|
||
}
|
||
|
||
/* ── Live indicator ───────────────────────────────────────────────────────── */
|
||
.dmv-widget__live {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 5px;
|
||
font-size: var(--font-size-xs);
|
||
font-weight: var(--font-weight-semibold);
|
||
color: var(--color-success);
|
||
letter-spacing: var(--tracking-wide);
|
||
text-transform: uppercase;
|
||
}
|
||
.dmv-widget__live-dot {
|
||
width: 6px;
|
||
height: 6px;
|
||
border-radius: 50%;
|
||
background: var(--color-success);
|
||
animation: dmv-live-pulse 1.8s ease-in-out infinite;
|
||
}
|
||
@keyframes dmv-live-pulse {
|
||
0%, 100% { opacity: 1; transform: scale(1); }
|
||
50% { opacity: 0.45; transform: scale(0.7); }
|
||
}
|
||
|
||
/* ── Fleet ratio bar ──────────────────────────────────────────────────────── */
|
||
.dmv-ratio-bar {
|
||
width: 100%;
|
||
height: 4px;
|
||
border-radius: var(--radius-full);
|
||
background: var(--color-bg-island);
|
||
overflow: hidden;
|
||
}
|
||
.dmv-ratio-bar__fill {
|
||
height: 100%;
|
||
border-radius: var(--radius-full);
|
||
transition: width 600ms ease;
|
||
}
|
||
.dmv-ratio-bar__fill--online {
|
||
background: var(--color-success);
|
||
}
|
||
|
||
/* ── Fleet rows ───────────────────────────────────────────────────────────── */
|
||
.dmv-fleet-rows {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: var(--space-2);
|
||
}
|
||
.dmv-fleet-row {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: var(--space-2);
|
||
}
|
||
.dmv-fleet-dot {
|
||
width: 7px;
|
||
height: 7px;
|
||
border-radius: 50%;
|
||
flex-shrink: 0;
|
||
}
|
||
.dmv-fleet-dot--online { background: var(--color-success); }
|
||
.dmv-fleet-dot--offline { background: var(--color-info); }
|
||
.dmv-fleet-dot--warn { background: var(--color-warning); }
|
||
.dmv-fleet-row__label {
|
||
flex: 1;
|
||
font-size: var(--font-size-sm);
|
||
color: var(--color-text-secondary);
|
||
}
|
||
.dmv-fleet-row__val {
|
||
font-size: var(--font-size-sm);
|
||
font-weight: var(--font-weight-semibold);
|
||
color: var(--color-text-primary);
|
||
}
|
||
.dmv-fleet-row__val--warn {
|
||
color: var(--color-warning);
|
||
}
|
||
|
||
/* ── Dot legend ───────────────────────────────────────────────────────────── */
|
||
.dmv-legend {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 6px;
|
||
padding-top: var(--space-2);
|
||
border-top: 1px solid var(--color-border);
|
||
}
|
||
.dmv-legend-item {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: var(--space-2);
|
||
font-size: var(--font-size-xs);
|
||
color: var(--color-text-muted);
|
||
}
|
||
.dmv-legend-dot {
|
||
width: 7px;
|
||
height: 7px;
|
||
border-radius: 50%;
|
||
flex-shrink: 0;
|
||
}
|
||
|
||
/* ── Activity list ────────────────────────────────────────────────────────── */
|
||
.dmv-activity-list {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: var(--space-2);
|
||
}
|
||
.dmv-activity-item {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: var(--space-2);
|
||
padding: 5px 0;
|
||
}
|
||
.dmv-activity-dot {
|
||
width: 6px;
|
||
height: 6px;
|
||
border-radius: 50%;
|
||
flex-shrink: 0;
|
||
}
|
||
.dmv-activity-text {
|
||
flex: 1;
|
||
min-width: 0;
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 1px;
|
||
}
|
||
.dmv-activity-serial {
|
||
font-family: var(--font-family-mono);
|
||
font-size: var(--font-size-xs);
|
||
color: var(--color-text-secondary);
|
||
white-space: nowrap;
|
||
overflow: hidden;
|
||
text-overflow: ellipsis;
|
||
}
|
||
.dmv-activity-time {
|
||
font-size: 10px;
|
||
color: var(--color-text-muted);
|
||
}
|
||
.dmv-activity-status {
|
||
font-size: 10px;
|
||
font-weight: var(--font-weight-medium);
|
||
color: var(--color-text-muted);
|
||
white-space: nowrap;
|
||
flex-shrink: 0;
|
||
}
|
||
|
||
/* ── Zoom controls ────────────────────────────────────────────────────────── */
|
||
.dmv-zoom-ctrl {
|
||
position: absolute;
|
||
top: var(--space-4);
|
||
right: var(--space-4);
|
||
z-index: 20;
|
||
display: flex;
|
||
flex-direction: column;
|
||
background: rgba(16, 20, 26, 0.82);
|
||
backdrop-filter: blur(16px);
|
||
-webkit-backdrop-filter: blur(16px);
|
||
border: 1px solid var(--color-border);
|
||
border-radius: var(--radius-lg);
|
||
overflow: hidden;
|
||
box-shadow: var(--shadow-md);
|
||
transition: right 300ms cubic-bezier(0.16, 1, 0.3, 1);
|
||
}
|
||
.dmv-zoom-ctrl--drawer {
|
||
right: calc(360px + var(--space-4));
|
||
}
|
||
.dmv-zoom-btn {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
width: 36px;
|
||
height: 36px;
|
||
background: transparent;
|
||
border: none;
|
||
color: var(--color-text-secondary);
|
||
cursor: pointer;
|
||
transition: background 150ms ease, color 150ms ease;
|
||
}
|
||
.dmv-zoom-btn:hover {
|
||
background: var(--color-bg-island);
|
||
color: var(--color-text-primary);
|
||
}
|
||
.dmv-zoom-divider {
|
||
width: 100%;
|
||
height: 1px;
|
||
background: var(--color-border);
|
||
}
|
||
|
||
/* ── Drawer ───────────────────────────────────────────────────────────────── */
|
||
.dmv-drawer {
|
||
position: absolute;
|
||
top: 0;
|
||
right: 0;
|
||
bottom: 0;
|
||
width: 360px;
|
||
z-index: 30;
|
||
background: var(--color-bg-void);
|
||
border-left: 1px solid var(--color-border);
|
||
display: flex;
|
||
flex-direction: column;
|
||
transform: translateX(100%);
|
||
transition: transform 300ms cubic-bezier(0.16, 1, 0.3, 1);
|
||
box-shadow: -12px 0 40px rgba(0,0,0,0.40);
|
||
}
|
||
.dmv-drawer--open {
|
||
transform: translateX(0);
|
||
}
|
||
|
||
.dmv-drawer__header {
|
||
padding: var(--space-5) var(--space-5) var(--space-4);
|
||
border-bottom: 1px solid var(--color-border);
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: var(--space-1);
|
||
}
|
||
.dmv-drawer__title-row {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: var(--space-2);
|
||
}
|
||
.dmv-drawer__status-dot {
|
||
width: 9px;
|
||
height: 9px;
|
||
border-radius: 50%;
|
||
flex-shrink: 0;
|
||
}
|
||
.dmv-drawer__name {
|
||
flex: 1;
|
||
min-width: 0;
|
||
font-family: var(--font-family-display);
|
||
font-size: var(--font-size-xl);
|
||
font-weight: var(--font-weight-semibold);
|
||
color: var(--color-text-primary);
|
||
letter-spacing: var(--tracking-tight);
|
||
margin: 0;
|
||
white-space: nowrap;
|
||
overflow: hidden;
|
||
text-overflow: ellipsis;
|
||
}
|
||
.dmv-drawer__close {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
width: 28px;
|
||
height: 28px;
|
||
border-radius: var(--radius-md);
|
||
border: none;
|
||
background: transparent;
|
||
color: var(--color-text-muted);
|
||
cursor: pointer;
|
||
transition: background 150ms ease, color 150ms ease;
|
||
flex-shrink: 0;
|
||
}
|
||
.dmv-drawer__close:hover {
|
||
background: var(--color-bg-elevated);
|
||
color: var(--color-text-primary);
|
||
}
|
||
.dmv-drawer__status-label {
|
||
font-size: var(--font-size-xs);
|
||
font-weight: var(--font-weight-semibold);
|
||
letter-spacing: var(--tracking-wide);
|
||
text-transform: uppercase;
|
||
padding-left: calc(9px + var(--space-2));
|
||
}
|
||
|
||
.dmv-drawer__body {
|
||
flex: 1;
|
||
overflow-y: auto;
|
||
padding: var(--space-4) var(--space-5);
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: var(--space-3);
|
||
scrollbar-width: thin;
|
||
scrollbar-color: var(--color-bg-island) transparent;
|
||
}
|
||
.dmv-drawer__body::-webkit-scrollbar { width: 4px; }
|
||
.dmv-drawer__body::-webkit-scrollbar-track { background: transparent; }
|
||
.dmv-drawer__body::-webkit-scrollbar-thumb { background: var(--color-bg-island); border-radius: 4px; }
|
||
|
||
.dmv-drawer__section {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: var(--space-2);
|
||
}
|
||
.dmv-drawer__section-label {
|
||
font-size: var(--font-size-xs);
|
||
font-weight: var(--font-weight-semibold);
|
||
color: var(--color-text-muted);
|
||
letter-spacing: var(--tracking-wide);
|
||
text-transform: uppercase;
|
||
margin: 0 0 var(--space-1);
|
||
}
|
||
.dmv-drawer__divider {
|
||
width: 100%;
|
||
height: 1px;
|
||
background: var(--color-border);
|
||
}
|
||
.dmv-drawer__footer {
|
||
padding: var(--space-4) var(--space-5);
|
||
border-top: 1px solid var(--color-border);
|
||
background: rgba(10, 14, 20, 0.40);
|
||
}
|
||
|
||
/* ── Drawer info row ──────────────────────────────────────────────────────── */
|
||
.dmv-drawer-row {
|
||
display: flex;
|
||
align-items: flex-start;
|
||
gap: var(--space-3);
|
||
min-height: 26px;
|
||
}
|
||
.dmv-drawer-row__label {
|
||
flex-shrink: 0;
|
||
width: 100px;
|
||
font-size: var(--font-size-sm);
|
||
color: var(--color-text-muted);
|
||
padding-top: 1px;
|
||
}
|
||
.dmv-drawer-row__value {
|
||
flex: 1;
|
||
min-width: 0;
|
||
font-size: var(--font-size-sm);
|
||
color: var(--color-text-primary);
|
||
font-weight: var(--font-weight-medium);
|
||
word-break: break-word;
|
||
}
|
||
.dmv-drawer-row__value--mono {
|
||
font-family: var(--font-family-mono);
|
||
font-size: var(--font-size-xs);
|
||
color: rgba(123, 208, 255, 0.85);
|
||
}
|
||
.dmv-drawer-row__value--muted {
|
||
color: var(--color-text-muted);
|
||
}
|
||
|
||
/* ── Auth user chips ──────────────────────────────────────────────────────── */
|
||
.dmv-user-list {
|
||
display: flex;
|
||
flex-wrap: wrap;
|
||
gap: var(--space-2);
|
||
}
|
||
.dmv-user-chip {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 6px;
|
||
padding: 3px 9px 3px 4px;
|
||
background: var(--color-bg-elevated);
|
||
border: 1px solid var(--color-border);
|
||
border-radius: var(--radius-full);
|
||
}
|
||
.dmv-user-chip__avatar {
|
||
width: 20px;
|
||
height: 20px;
|
||
border-radius: 50%;
|
||
background: var(--color-primary-subtle);
|
||
border: 1px solid rgba(192, 193, 255, 0.20);
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
font-size: 10px;
|
||
font-weight: var(--font-weight-bold);
|
||
color: var(--color-primary);
|
||
line-height: 1;
|
||
flex-shrink: 0;
|
||
}
|
||
.dmv-user-chip__id {
|
||
font-family: var(--font-family-mono);
|
||
font-size: 10px;
|
||
color: var(--color-text-muted);
|
||
max-width: 120px;
|
||
white-space: nowrap;
|
||
overflow: hidden;
|
||
text-overflow: ellipsis;
|
||
}
|
||
.dmv-user-more {
|
||
font-size: var(--font-size-xs);
|
||
color: var(--color-text-muted);
|
||
padding: 2px 0;
|
||
}
|
||
|
||
/* ── Issues row ───────────────────────────────────────────────────────────── */
|
||
.dmv-issues-row {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: var(--space-2);
|
||
padding: var(--space-2) var(--space-3);
|
||
background: var(--color-warning-bg);
|
||
border-radius: var(--radius-md);
|
||
border: 1px solid rgba(251, 191, 36, 0.18);
|
||
}
|
||
.dmv-issues-row__text {
|
||
font-size: var(--font-size-sm);
|
||
color: var(--color-warning);
|
||
font-weight: var(--font-weight-medium);
|
||
}
|
||
|
||
/* ── No coords notice ─────────────────────────────────────────────────────── */
|
||
.dmv-no-coords {
|
||
position: absolute;
|
||
bottom: var(--space-4);
|
||
left: 50%;
|
||
transform: translateX(-50%);
|
||
display: flex;
|
||
align-items: center;
|
||
gap: var(--space-2);
|
||
padding: var(--space-2) var(--space-4);
|
||
background: rgba(16, 20, 26, 0.82);
|
||
backdrop-filter: blur(8px);
|
||
border: 1px solid var(--color-border);
|
||
border-radius: var(--radius-full);
|
||
font-size: var(--font-size-sm);
|
||
color: var(--color-text-muted);
|
||
pointer-events: none;
|
||
z-index: 15;
|
||
}
|
||
|
||
/* ── Responsive ───────────────────────────────────────────────────────────── */
|
||
@media (max-width: 768px) {
|
||
.dmv-drawer {
|
||
width: 100%;
|
||
top: auto;
|
||
height: 70%;
|
||
transform: translateY(100%);
|
||
border-left: none;
|
||
border-top: 1px solid var(--color-border);
|
||
border-radius: var(--radius-xl) var(--radius-xl) 0 0;
|
||
}
|
||
.dmv-drawer--open {
|
||
transform: translateY(0);
|
||
}
|
||
.dmv-widget--tl {
|
||
top: var(--space-3);
|
||
left: var(--space-3);
|
||
}
|
||
.dmv-widget--bl {
|
||
display: none;
|
||
}
|
||
.dmv-zoom-ctrl {
|
||
top: auto;
|
||
bottom: var(--space-3);
|
||
right: var(--space-3);
|
||
flex-direction: row;
|
||
}
|
||
.dmv-zoom-ctrl--drawer {
|
||
right: var(--space-3);
|
||
bottom: calc(70% + var(--space-3));
|
||
}
|
||
.dmv-zoom-divider {
|
||
width: 1px;
|
||
height: 100%;
|
||
}
|
||
}
|
||
`
|