feat(map): self-hosted PMTiles map tiles, remove alert toast spam
- Replace OpenStreetMap CDN with self-hosted Protomaps PMTiles (Wisconsin + Florida regional extracts, served from nginx) - Add protomaps-leaflet for vector tile rendering in dark theme - Update CSP to remove openstreetmap.org, add blob: for vector workers - Add nginx location block for /tiles/ with byte range support - Mount tiles directory as volume (not baked into image) - Remove alert_fired/alert_resolved toast notifications that spammed "undefined" at fleet scale — dashboard still updates via query invalidation - Add *.pmtiles to .gitignore (large binaries) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,7 +1,8 @@
|
||||
import { useEffect, useMemo } from 'react'
|
||||
import { MapContainer, TileLayer, useMap } from 'react-leaflet'
|
||||
import { MapContainer, useMap } from 'react-leaflet'
|
||||
import MarkerClusterGroup from 'react-leaflet-cluster'
|
||||
import L from 'leaflet'
|
||||
import * as protomapsL from 'protomaps-leaflet'
|
||||
import type { FleetDevice } from '@/lib/api'
|
||||
import { DeviceMarker } from './DeviceMarker'
|
||||
|
||||
@@ -16,6 +17,31 @@ interface FleetMapProps {
|
||||
const DEFAULT_CENTER: [number, number] = [20, 0]
|
||||
const DEFAULT_ZOOM = 2
|
||||
|
||||
/**
|
||||
* Self-hosted PMTiles basemap layer via protomaps-leaflet.
|
||||
* Loads regional PMTiles files from /tiles/ (no third-party requests).
|
||||
* Multiple region files are layered — tiles outside downloaded regions show blank.
|
||||
*/
|
||||
const PMTILES_REGIONS = ['/tiles/wisconsin.pmtiles', '/tiles/florida.pmtiles']
|
||||
|
||||
function ProtomapsLayer() {
|
||||
const map = useMap()
|
||||
|
||||
useEffect(() => {
|
||||
const layers: L.Layer[] = []
|
||||
for (const url of PMTILES_REGIONS) {
|
||||
const layer = protomapsL.leafletLayer({ url, flavor: 'dark' })
|
||||
layer.addTo(map)
|
||||
layers.push(layer)
|
||||
}
|
||||
return () => {
|
||||
for (const layer of layers) map.removeLayer(layer)
|
||||
}
|
||||
}, [map])
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* Inner component that auto-fits the map to device bounds
|
||||
* whenever the device list changes.
|
||||
@@ -111,10 +137,7 @@ export function FleetMap({ devices, tenantId }: FleetMapProps) {
|
||||
scrollWheelZoom
|
||||
style={{ background: 'hsl(var(--background))' }}
|
||||
>
|
||||
<TileLayer
|
||||
attribution='© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors'
|
||||
url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
|
||||
/>
|
||||
<ProtomapsLayer />
|
||||
<AutoFitBounds devices={mappedDevices} />
|
||||
<MarkerClusterGroup
|
||||
chunkedLoading
|
||||
|
||||
@@ -2,7 +2,7 @@ import { useCallback } from 'react'
|
||||
import { createFileRoute, Outlet, Navigate, redirect, useNavigate, useRouterState } from '@tanstack/react-router'
|
||||
import { useQueryClient } from '@tanstack/react-query'
|
||||
import { AnimatePresence } from 'framer-motion'
|
||||
import { toast } from 'sonner'
|
||||
// toast import removed — alert toasts were noisy at fleet scale
|
||||
import { useAuth } from '@/lib/auth'
|
||||
import { useUIStore } from '@/lib/store'
|
||||
import { useEventStream, type SSEEvent } from '@/hooks/useEventStream'
|
||||
@@ -72,27 +72,8 @@ function AuthenticatedLayout() {
|
||||
|
||||
// ── Alert fired (RT-03) ───────────────────────────────────────────
|
||||
case 'alert_fired': {
|
||||
const { severity, rule_name, device_name, metric, current_value, threshold } =
|
||||
event.data as {
|
||||
severity: string
|
||||
rule_name: string
|
||||
device_name?: string
|
||||
metric?: string
|
||||
current_value?: string | number
|
||||
threshold?: string | number
|
||||
}
|
||||
const toastFn =
|
||||
severity === 'critical'
|
||||
? toast.error
|
||||
: severity === 'warning'
|
||||
? toast.warning
|
||||
: toast.info
|
||||
toastFn(`Alert: ${rule_name}`, {
|
||||
description: device_name
|
||||
? `${device_name} — ${metric ?? 'unknown'}: ${current_value ?? '?'} (threshold: ${threshold ?? '?'})`
|
||||
: `${metric ?? 'unknown'}: ${current_value ?? '?'}`,
|
||||
duration: severity === 'critical' ? 10000 : 5000,
|
||||
})
|
||||
// Invalidate alert queries so dashboard/alert pages update in real-time.
|
||||
// No toast — at fleet scale these fire constantly and become noise.
|
||||
void queryClient.invalidateQueries({ queryKey: ['active-alerts'] })
|
||||
void queryClient.invalidateQueries({ queryKey: ['alert-events'] })
|
||||
void queryClient.invalidateQueries({ queryKey: ['dashboard-alerts'] })
|
||||
@@ -101,14 +82,6 @@ function AuthenticatedLayout() {
|
||||
|
||||
// ── Alert resolved ────────────────────────────────────────────────
|
||||
case 'alert_resolved': {
|
||||
const { metric } = event.data as {
|
||||
device_id?: string
|
||||
metric?: string
|
||||
}
|
||||
toast.info('Alert resolved', {
|
||||
description: `${metric ?? 'Condition'} returned to normal`,
|
||||
duration: 3000,
|
||||
})
|
||||
void queryClient.invalidateQueries({ queryKey: ['active-alerts'] })
|
||||
void queryClient.invalidateQueries({ queryKey: ['alert-events'] })
|
||||
void queryClient.invalidateQueries({ queryKey: ['dashboard-alerts'] })
|
||||
|
||||
Reference in New Issue
Block a user