From c54e4a6a21130cf397700bf2364bdd2a1ee2bbca Mon Sep 17 00:00:00 2001 From: Daniel Pupius Date: Mon, 16 Mar 2026 03:20:27 +0000 Subject: [PATCH] Improve map and node detail UI Map: - Fix __publicField error by setting esbuild target to esnext - Replace react-map-gl with direct maplibre-gl imperative API across all map components - Add dark theme styling for MapLibre popup, attribution, and navigation controls - Add NavigationControl to NetworkMap and NodeLocationMap - Fix auto-zoom race condition using mapLoaded state - Fix node click popup being immediately dismissed - Add MQTT links as separate dashed purple layer - Reduce node/dot sizes on NetworkMap - Switch glyph CDN to fonts.openmaptiles.org - Extend map legend with SNR line color key and MQTT indicator Node detail: - Extract shared ConnectionRow and ConnectionList components - Compact connections table: single row per entry with color-coded SNR, badges, short time - Apply same compact style to NeighborInfoPacket neighbor list - Move Connections section into right column below Last Activity - Split Gateway Node out of Device Information into its own card - Fold observed node count into "Recently observed nodes (N)" subtext - Add formatLastSeenShort for compact time display Co-Authored-By: Claude Sonnet 4.6 --- web/src/components/dashboard/NetworkMap.tsx | 47 ++-- web/src/components/dashboard/NodeDetail.tsx | 244 +++++++----------- .../components/packets/NeighborInfoPacket.tsx | 55 +--- web/src/components/ui/ConnectionRow.tsx | 89 +++++++ web/src/components/ui/index.ts | 4 +- web/src/lib/activity.ts | 10 + web/src/lib/mapStyle.ts | 2 +- web/src/routes/map.tsx | 18 +- web/src/styles/index.css | 24 ++ 9 files changed, 287 insertions(+), 206 deletions(-) create mode 100644 web/src/components/ui/ConnectionRow.tsx diff --git a/web/src/components/dashboard/NetworkMap.tsx b/web/src/components/dashboard/NetworkMap.tsx index ca33fc2..e6b4888 100644 --- a/web/src/components/dashboard/NetworkMap.tsx +++ b/web/src/components/dashboard/NetworkMap.tsx @@ -41,6 +41,7 @@ export const NetworkMap = React.forwardRef<{ resetAutoZoom: () => void }, Networ const popupRef = useRef(null); const nodesWithPositionRef = useRef([]); const [autoZoomEnabled, setAutoZoomEnabled] = useState(true); + const [mapLoaded, setMapLoaded] = useState(false); const { nodes, gateways } = useAppSelector((state) => state.aggregator); const topologyLinks = useAppSelector((state) => state.topology.links); @@ -68,7 +69,7 @@ export const NetworkMap = React.forwardRef<{ resetAutoZoom: () => void }, Networ name: node.shortName || node.longName || `!${node.id.toString(16)}`, fillColor: colors.fill, strokeColor: colors.stroke, - radius: node.isGateway ? 12 : 8, + radius: node.isGateway ? 8 : 5, }, }; }), @@ -89,7 +90,7 @@ export const NetworkMap = React.forwardRef<{ resetAutoZoom: () => void }, Networ return { type: "Feature" as const, geometry: { type: "LineString" as const, coordinates: [posMap.get(link.nodeA)!, posMap.get(link.nodeB)!] }, - properties: { color, opacity: link.viaMqtt ? 0.4 : 0.7 }, + properties: { color, opacity: 0.7, viaMqtt: link.viaMqtt ? 1 : 0 }, }; }), }; @@ -117,14 +118,24 @@ export const NetworkMap = React.forwardRef<{ resetAutoZoom: () => void }, Networ map.addControl(new maplibregl.NavigationControl(), 'top-left'); map.on("load", () => { + setMapLoaded(true); map.addSource("links", { type: "geojson", data: { type: "FeatureCollection", features: [] } }); map.addLayer({ id: "links-line", type: "line", source: "links", + filter: ["==", ["get", "viaMqtt"], 0], layout: { "line-join": "round", "line-cap": "round", visibility: "visible" }, paint: { "line-color": ["get", "color"], "line-width": 2, "line-opacity": ["get", "opacity"] }, }); + map.addLayer({ + id: "links-mqtt", + type: "line", + source: "links", + filter: ["==", ["get", "viaMqtt"], 1], + layout: { "line-join": "round", "line-cap": "butt", visibility: "visible" }, + paint: { "line-color": "#a855f7", "line-width": 2, "line-opacity": 0.6, "line-dasharray": [2, 3] }, + }); map.addSource("nodes", { type: "geojson", data: { type: "FeatureCollection", features: [] } }); map.addLayer({ @@ -144,7 +155,7 @@ export const NetworkMap = React.forwardRef<{ resetAutoZoom: () => void }, Networ id: "nodes-labels", type: "symbol", source: "nodes", - layout: { "text-field": ["get", "name"], "text-size": 11, "text-offset": [0, 1.5], "text-anchor": "top", "text-optional": true }, + layout: { "text-field": ["get", "name"], "text-font": ["Open Sans Regular"], "text-size": 11, "text-offset": [0, 1.5], "text-anchor": "top", "text-optional": true }, paint: { "text-color": "#e5e7eb", "text-halo-color": "#111827", "text-halo-width": 1.5 }, }); }); @@ -155,7 +166,7 @@ export const NetworkMap = React.forwardRef<{ resetAutoZoom: () => void }, Networ map.on("mouseenter", "nodes-circles", () => { map.getCanvas().style.cursor = "pointer"; }); map.on("mouseleave", "nodes-circles", () => { map.getCanvas().style.cursor = "grab"; }); - map.on("click", "nodes-circles", (e: MapMouseEvent & { features?: maplibregl.MapGeoJSONFeature[] }) => { + map.on("click", "nodes-circles", (e) => { const feature = e.features?.[0]; if (!feature) return; const props = feature.properties as { nodeId: number; name: string; fillColor: string }; @@ -174,16 +185,16 @@ export const NetworkMap = React.forwardRef<{ resetAutoZoom: () => void }, Networ popupRef.current?.remove(); const el = document.createElement("div"); - el.style.cssText = "font-family:sans-serif;max-width:220px;padding:4px"; + el.style.cssText = "font-family:sans-serif;max-width:220px;padding:2px 0"; el.innerHTML = `
${nodeName}
-
${node.isGateway ? "Gateway" : "Node"} · !${node.id.toString(16)}
-
+
${node.isGateway ? "Gateway" : "Node"} · !${node.id.toString(16)}
+
${statusText} · ${lastSeenText}
-
Packets: ${node.messageCount} · Text: ${node.textMessageCount}
- +
Packets: ${node.messageCount} · Text: ${node.textMessageCount}
+ `; el.querySelector("#nav-btn")?.addEventListener("click", () => { popup.remove(); @@ -197,8 +208,9 @@ export const NetworkMap = React.forwardRef<{ resetAutoZoom: () => void }, Networ popupRef.current = popup; }); - map.on("click", (e: MapMouseEvent & { features?: maplibregl.MapGeoJSONFeature[] }) => { - if (!e.features?.length) popupRef.current?.remove(); + map.on("click", (e) => { + const features = map.queryRenderedFeatures(e.point, { layers: ["nodes-circles"] }); + if (!features.length) popupRef.current?.remove(); }); mapRef.current = map; @@ -209,22 +221,23 @@ export const NetworkMap = React.forwardRef<{ resetAutoZoom: () => void }, Networ // Update node/link data useEffect(() => { const map = mapRef.current; - if (!map || !map.isStyleLoaded()) return; + if (!map || !mapLoaded) return; (map.getSource("nodes") as GeoJSONSource)?.setData(nodesGeoJSON); (map.getSource("links") as GeoJSONSource)?.setData(linksGeoJSON); - }, [nodesGeoJSON, linksGeoJSON]); + }, [nodesGeoJSON, linksGeoJSON, mapLoaded]); // Toggle links visibility useEffect(() => { const map = mapRef.current; - if (!map || !map.isStyleLoaded()) return; + if (!map || !mapLoaded) return; map.setLayoutProperty("links-line", "visibility", showLinks ? "visible" : "none"); - }, [showLinks]); + map.setLayoutProperty("links-mqtt", "visibility", showLinks ? "visible" : "none"); + }, [showLinks, mapLoaded]); // Auto-zoom to fit nodes useEffect(() => { const map = mapRef.current; - if (!autoZoomRef.current || nodesWithPosition.length === 0 || !map) return; + if (!autoZoomRef.current || nodesWithPosition.length === 0 || !map || !mapLoaded) return; let minLng = Infinity, maxLng = -Infinity, minLat = Infinity, maxLat = -Infinity; for (const n of nodesWithPosition) { const lng = n.position.longitudeI / 10000000; @@ -235,7 +248,7 @@ export const NetworkMap = React.forwardRef<{ resetAutoZoom: () => void }, Networ if (lat > maxLat) maxLat = lat; } map.fitBounds([[minLng, minLat], [maxLng, maxLat]], { padding: 60, maxZoom: 15, duration: 500 }); - }, [nodesWithPosition]); + }, [nodesWithPosition, mapLoaded]); // Notify parent of auto-zoom state useEffect(() => { diff --git a/web/src/components/dashboard/NodeDetail.tsx b/web/src/components/dashboard/NodeDetail.tsx index 5f5267b..395d6d8 100644 --- a/web/src/components/dashboard/NodeDetail.tsx +++ b/web/src/components/dashboard/NodeDetail.tsx @@ -8,6 +8,7 @@ import { getNodeColors, getStatusText, formatLastSeen, + formatLastSeenShort, } from "../../lib/activity"; import { cn } from "../../lib/cn"; import { @@ -36,6 +37,8 @@ import { import { Separator } from "../Separator"; import { KeyValuePair } from "../ui/KeyValuePair"; import { Section } from "../ui/Section"; +import { ConnectionRow, ConnectionList } from "../ui/ConnectionRow"; +import type { ConnectionBadge, SnrValue } from "../ui/ConnectionRow"; import { BatteryLevel } from "./BatteryLevel"; import { NodeLocationMap } from "./GoogleMap"; import { NodePositionData } from "./NodePositionData"; @@ -343,95 +346,76 @@ export const NodeDetail: React.FC = ({ nodeId }) => {
- {/* Show MapReport-specific information for gateways */} - {node.isGateway && ( -
-
-
- - - Gateway Node - - {node.observedNodeCount !== undefined && ( - - - {node.observedNodeCount}{" "} - {node.observedNodeCount === 1 ? "node" : "nodes"} - - )} - {node.mapReport?.numOnlineLocalNodes !== undefined && ( - - {node.mapReport.numOnlineLocalNodes} online local nodes - - )} -
- {/* Observed Nodes Grid - integrated into Gateway Node section */} - {gateway?.observedNodes && - gateway.observedNodes.length > 0 && ( -
-
- Recently observed nodes: -
-
- {gateway.observedNodes.map((observedNodeId) => { - const observedNode = nodes[observedNodeId]; - const displayName = getNodeDisplayName( - observedNodeId, - observedNode - ); - return ( - - - - {displayName} - - - ); - })} -
-
- )} -
- {node.mapReport?.region !== undefined && ( - } - inset={true} - /> - )} - - {node.mapReport?.modemPreset !== undefined && ( - } - inset={true} - /> - )} - - {node.mapReport?.firmwareVersion && ( - } - inset={true} - /> - )} -
- )} + {node.isGateway && ( +
} + className="mt-4" + > + {node.mapReport?.numOnlineLocalNodes !== undefined && ( + } + inset={true} + /> + )} + + {node.mapReport?.region !== undefined && ( + } + inset={true} + /> + )} + + {node.mapReport?.modemPreset !== undefined && ( + } + inset={true} + /> + )} + + {node.mapReport?.firmwareVersion && ( + } + inset={true} + /> + )} + + {gateway?.observedNodes && gateway.observedNodes.length > 0 && ( +
+
Recently observed nodes ({gateway.observedNodes.length})
+
+ {gateway.observedNodes.map((observedNodeId) => { + const observedNode = nodes[observedNodeId]; + const displayName = getNodeDisplayName(observedNodeId, observedNode); + return ( + + + {displayName} + + ); + })} +
+
+ )} +
+ )} + {/* Device Status - Moved from the right column to here */} {(node.deviceMetrics || node.batteryLevel !== undefined || @@ -574,6 +558,15 @@ export const NodeDetail: React.FC = ({ nodeId }) => { /> )} + + {/* Connections */} +
} + className="mt-4" + > + +
@@ -604,15 +597,6 @@ export const NodeDetail: React.FC = ({ nodeId }) => { )} - {/* Connections - Full Width */} -
} - className="mt-6" - > - -
- {/* Recent Packets - Full Width */}
= ({ nodeId, links, nodes } return ( -
+ {edges.map((link) => { const neighborId = link.nodeA === nodeId ? link.nodeB : link.nodeA; const neighborNode = nodes[neighborId]; @@ -684,60 +668,32 @@ const NodeConnections: React.FC = ({ nodeId, links, nodes neighborNode?.longName ?? `!${neighborId.toString(16)}`; - // Determine SNR direction relative to nodeId - // snrAtoB = SNR at nodeB receiving from nodeA - // snrBtoA = SNR at nodeA receiving from nodeB - // "outgoing SNR" = SNR that the neighbor measured (i.e., neighbor receiving from nodeId) - // "incoming SNR" = SNR that nodeId measured (i.e., nodeId receiving from neighbor) - const outgoingSnr = - nodeId === link.nodeA ? link.snrAtoB : link.snrBtoA; - const incomingSnr = - nodeId === link.nodeA ? link.snrBtoA : link.snrAtoB; + const outgoingSnr = nodeId === link.nodeA ? link.snrAtoB : link.snrBtoA; + const incomingSnr = nodeId === link.nodeA ? link.snrBtoA : link.snrAtoB; const source = bestSource(link.sourceAtoB, link.sourceBtoA); const secondsAgo = Math.floor(Date.now() / 1000) - link.lastSeen; + const snrValues: SnrValue[] = [ + ...(outgoingSnr !== undefined ? [{ label: "↑", value: outgoingSnr }] : []), + ...(incomingSnr !== undefined ? [{ label: "↓", value: incomingSnr }] : []), + ]; + const badges: ConnectionBadge[] = [ + { label: SOURCE_LABELS[source], className: SOURCE_COLORS[source] }, + ...(link.viaMqtt ? [{ label: "MQTT", className: "bg-purple-900 text-purple-300" }] : []), + ]; + return ( -
-
-
- - {neighborName} - - - {SOURCE_LABELS[source]} - - {link.viaMqtt && ( - - MQTT - - )} -
-
- {outgoingSnr !== undefined && ( - → {outgoingSnr.toFixed(1)} dB - )} - {incomingSnr !== undefined && ( - ← {incomingSnr.toFixed(1)} dB - )} - {link.rssiAtoB !== undefined && nodeId === link.nodeB && ( - - RSSI {link.rssiAtoB} dBm - - )} -
-
-
- {formatLastSeen(secondsAgo)} -
-
+ name={neighborName} + nameHref={`/node/${neighborId.toString(16)}`} + snrValues={snrValues} + badges={badges} + time={formatLastSeenShort(secondsAgo)} + /> ); })} -
+ ); }; diff --git a/web/src/components/packets/NeighborInfoPacket.tsx b/web/src/components/packets/NeighborInfoPacket.tsx index d453c5b..954c5e8 100644 --- a/web/src/components/packets/NeighborInfoPacket.tsx +++ b/web/src/components/packets/NeighborInfoPacket.tsx @@ -1,9 +1,10 @@ import React from "react"; import { Link } from "@tanstack/react-router"; import { Packet } from "../../lib/types"; -import { Network, ExternalLink } from "lucide-react"; +import { Network } from "lucide-react"; import { PacketCard } from "./PacketCard"; import { useAppSelector } from "../../hooks"; +import { ConnectionRow, ConnectionList } from "../ui/ConnectionRow"; interface NeighborInfoPacketProps { packet: Packet; @@ -66,47 +67,17 @@ export const NeighborInfoPacket: React.FC = ({ packet } {/* Neighbors list */} {neighborInfo.neighbors && neighborInfo.neighbors.length > 0 ? ( -
-
- {neighborInfo.neighbors.length} neighbor{neighborInfo.neighbors.length !== 1 ? 's' : ''}: -
-
- {neighborInfo.neighbors.map((neighbor, index) => ( -
-
- - {getNodeName(neighbor.nodeId)} - - -
-
-
- SNR: - 0 ? "text-green-400" : neighbor.snr > -10 ? "text-yellow-400" : "text-red-400"}> - {neighbor.snr.toFixed(1)}dB - -
- {neighbor.lastRxTime && ( -
- Last: - {formatTime(neighbor.lastRxTime)} -
- )} - {neighbor.nodeBroadcastIntervalSecs && ( -
- Interval: - {formatInterval(neighbor.nodeBroadcastIntervalSecs)} -
- )} -
-
- ))} -
-
+ + {neighborInfo.neighbors.map((neighbor, index) => ( + + ))} + ) : (
No neighbors reported
)} diff --git a/web/src/components/ui/ConnectionRow.tsx b/web/src/components/ui/ConnectionRow.tsx new file mode 100644 index 0000000..a758866 --- /dev/null +++ b/web/src/components/ui/ConnectionRow.tsx @@ -0,0 +1,89 @@ +import React from "react"; +import { Link } from "@tanstack/react-router"; + +interface ConnectionListProps { + count: number; + label?: string; + children: React.ReactNode; +} + +export const ConnectionList: React.FC = ({ count, label = "connection", children }) => ( +
+
{children}
+

+ {count} {label}{count !== 1 ? "s" : ""} +

+
+); + +export interface SnrValue { + label: string; // e.g. "↑" or "↓" or "" + value: number; +} + +export interface ConnectionBadge { + label: string; + className: string; +} + +interface ConnectionRowProps { + name: string; + /** If provided, the name becomes a link */ + nameHref?: string; + snrValues?: SnrValue[]; + badges?: ConnectionBadge[]; + time?: string; +} + +function snrColor(snr: number): string { + if (snr >= 5) return "text-green-400"; + if (snr >= 0) return "text-yellow-400"; + return "text-red-400"; +} + +export const ConnectionRow: React.FC = ({ + name, + nameHref, + snrValues, + badges, + time, +}) => { + return ( +
+ {/* Name */} + + {nameHref ? ( + + {name} + + ) : ( + name + )} + + + {/* SNR values */} + + {snrValues?.map((s, i) => ( + + {i > 0 && ·} + + {s.label}{s.value.toFixed(1)} + + + ))} + + + {/* Badges */} +
+ {badges?.map((b, i) => ( + + {b.label} + + ))} +
+ + {/* Time */} + {time && {time}} +
+ ); +}; diff --git a/web/src/components/ui/index.ts b/web/src/components/ui/index.ts index 7a0eecb..e84f9bb 100644 --- a/web/src/components/ui/index.ts +++ b/web/src/components/ui/index.ts @@ -1,3 +1,5 @@ export { Button } from './Button'; export { Section } from './Section'; -export { KeyValuePair } from './KeyValuePair'; \ No newline at end of file +export { KeyValuePair } from './KeyValuePair'; +export { ConnectionRow, ConnectionList } from './ConnectionRow'; +export type { SnrValue, ConnectionBadge } from './ConnectionRow'; \ No newline at end of file diff --git a/web/src/lib/activity.ts b/web/src/lib/activity.ts index 7bfd8a9..abf6e5e 100644 --- a/web/src/lib/activity.ts +++ b/web/src/lib/activity.ts @@ -197,6 +197,16 @@ export function formatLastSeen(secondsAgo: number): string { } } +/** + * Formats a "last seen" time in compact form: "45s", "5m", "2h", "3d" + */ +export function formatLastSeenShort(secondsAgo: number): string { + if (secondsAgo < 60) return `${secondsAgo}s`; + if (secondsAgo < 3600) return `${Math.floor(secondsAgo / 60)}m`; + if (secondsAgo < 86400) return `${Math.floor(secondsAgo / 3600)}h`; + return `${Math.floor(secondsAgo / 86400)}d`; +} + /** * Gets style classes based on the activity level * diff --git a/web/src/lib/mapStyle.ts b/web/src/lib/mapStyle.ts index dd8ba93..3531dc2 100644 --- a/web/src/lib/mapStyle.ts +++ b/web/src/lib/mapStyle.ts @@ -25,7 +25,7 @@ export const CARTO_DARK_STYLE: StyleSpecification = { /** CartoDB Dark Matter style with glyph support for GL text labels */ export const CARTO_DARK_STYLE_LABELLED: StyleSpecification = { version: 8, - glyphs: "https://protomaps.github.io/basemaps-assets/fonts/{fontstack}/{range}.pbf", + glyphs: "https://fonts.openmaptiles.org/{fontstack}/{range}.pbf", sources: { carto: CARTO_SOURCE }, layers: [{ id: "carto-dark", type: "raster", source: "carto" }], }; diff --git a/web/src/routes/map.tsx b/web/src/routes/map.tsx index ee00c3e..28e1469 100644 --- a/web/src/routes/map.tsx +++ b/web/src/routes/map.tsx @@ -36,7 +36,7 @@ function MapPage() {
-
+
Nodes @@ -45,6 +45,22 @@ function MapPage() { Gateways + · + + SNR ≥ 5 + + + SNR 0–4 + + + SNR < 0 + + + Unknown + + + MQTT +
diff --git a/web/src/styles/index.css b/web/src/styles/index.css index 7a9b10a..2926464 100644 --- a/web/src/styles/index.css +++ b/web/src/styles/index.css @@ -66,6 +66,30 @@ filter: invert(1) opacity(1); } +/* MapLibre popup — dark theme */ +.maplibregl-popup-content { + background: rgba(18, 18, 18, 0.95) !important; + border: 1px solid rgba(255, 255, 255, 0.12) !important; + border-radius: 8px !important; + box-shadow: 0 4px 16px rgba(0, 0, 0, 0.5) !important; + backdrop-filter: blur(8px); + padding: 12px 14px !important; + color: rgba(255, 255, 255, 0.85); +} +.maplibregl-popup-tip { + border-top-color: rgba(18, 18, 18, 0.95) !important; +} +.maplibregl-popup-close-button { + color: rgba(255, 255, 255, 0.4) !important; + font-size: 16px !important; + line-height: 1 !important; + padding: 6px 8px !important; +} +.maplibregl-popup-close-button:hover { + color: rgba(255, 255, 255, 0.8) !important; + background: transparent !important; +} + /* Prevent auto-expand on wide maps */ .maplibregl-ctrl-attrib.maplibregl-compact-show { display: flex !important;