mirror of
https://github.com/dpup/meshstream.git
synced 2026-03-28 17:42:37 +01:00
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 <noreply@anthropic.com>
This commit is contained in:
@@ -41,6 +41,7 @@ export const NetworkMap = React.forwardRef<{ resetAutoZoom: () => void }, Networ
|
||||
const popupRef = useRef<maplibregl.Popup | null>(null);
|
||||
const nodesWithPositionRef = useRef<MapNode[]>([]);
|
||||
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 = `
|
||||
<div style="font-weight:600;font-size:14px;color:${colors.fill};margin-bottom:3px">${nodeName}</div>
|
||||
<div style="font-size:11px;color:#6b7280;margin-bottom:6px">${node.isGateway ? "Gateway" : "Node"} · !${node.id.toString(16)}</div>
|
||||
<div style="font-size:11px;color:#374151;margin-bottom:4px">
|
||||
<div style="font-size:11px;color:rgba(255,255,255,0.45);margin-bottom:6px">${node.isGateway ? "Gateway" : "Node"} · !${node.id.toString(16)}</div>
|
||||
<div style="font-size:11px;color:rgba(255,255,255,0.7);margin-bottom:4px">
|
||||
<span style="display:inline-block;width:7px;height:7px;border-radius:50%;background:${colors.fill};margin-right:5px;vertical-align:middle"></span>
|
||||
${statusText} · ${lastSeenText}
|
||||
</div>
|
||||
<div style="font-size:11px;color:#6b7280;margin-bottom:8px">Packets: ${node.messageCount} · Text: ${node.textMessageCount}</div>
|
||||
<button id="nav-btn" style="font-size:12px;font-weight:500;color:#3b82f6;background:#f1f5f9;border:none;border-radius:4px;padding:4px 8px;cursor:pointer">View details →</button>
|
||||
<div style="font-size:11px;color:rgba(255,255,255,0.45);margin-bottom:10px">Packets: ${node.messageCount} · Text: ${node.textMessageCount}</div>
|
||||
<button id="nav-btn" style="font-size:12px;font-weight:500;color:rgba(147,197,253,0.9);background:none;border:none;padding:0;cursor:pointer;letter-spacing:0.01em">View details →</button>
|
||||
`;
|
||||
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(() => {
|
||||
|
||||
@@ -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<NodeDetailProps> = ({ nodeId }) => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Show MapReport-specific information for gateways */}
|
||||
{node.isGateway && (
|
||||
<div className="mt-4 pt-3 border-t border-neutral-700 space-y-3">
|
||||
<div className=" mb-2 p-2 rounded effect-inset bg-neutral-700/50 ">
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="flex items-center">
|
||||
<Network className="w-4 h-4 mr-1.5" />
|
||||
Gateway Node
|
||||
</span>
|
||||
{node.observedNodeCount !== undefined && (
|
||||
<span className="flex items-center">
|
||||
<Users className="w-4 h-4 mr-1.5" />
|
||||
{node.observedNodeCount}{" "}
|
||||
{node.observedNodeCount === 1 ? "node" : "nodes"}
|
||||
</span>
|
||||
)}
|
||||
{node.mapReport?.numOnlineLocalNodes !== undefined && (
|
||||
<span className="text-xs flex items-center font-mono opacity-80">
|
||||
{node.mapReport.numOnlineLocalNodes} online local nodes
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{/* Observed Nodes Grid - integrated into Gateway Node section */}
|
||||
{gateway?.observedNodes &&
|
||||
gateway.observedNodes.length > 0 && (
|
||||
<div>
|
||||
<div className="my-2 text-xs text-neutral-400">
|
||||
Recently observed nodes:
|
||||
</div>
|
||||
<div className="grid grid-cols-2 md:grid-cols-3 gap-2">
|
||||
{gateway.observedNodes.map((observedNodeId) => {
|
||||
const observedNode = nodes[observedNodeId];
|
||||
const displayName = getNodeDisplayName(
|
||||
observedNodeId,
|
||||
observedNode
|
||||
);
|
||||
return (
|
||||
<Link
|
||||
key={observedNodeId}
|
||||
to="/node/$nodeId"
|
||||
params={{
|
||||
nodeId: observedNodeId
|
||||
.toString(16)
|
||||
.toLowerCase(),
|
||||
}}
|
||||
className="flex items-center p-2 bg-neutral-800/50 hover:bg-neutral-700/50 rounded transition-colors text-xs border border-neutral-700/30"
|
||||
>
|
||||
<BoomBox className="w-3 h-3 mr-2 text-neutral-400 flex-shrink-0" />
|
||||
<span className="text-neutral-200 truncate">
|
||||
{displayName}
|
||||
</span>
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{node.mapReport?.region !== undefined && (
|
||||
<KeyValuePair
|
||||
label="Region"
|
||||
value={getRegionName(node.mapReport.region)}
|
||||
icon={<Earth className="w-3 h-3" />}
|
||||
inset={true}
|
||||
/>
|
||||
)}
|
||||
|
||||
{node.mapReport?.modemPreset !== undefined && (
|
||||
<KeyValuePair
|
||||
label="Modem Preset"
|
||||
value={getModemPresetName(node.mapReport.modemPreset)}
|
||||
icon={<TableConfig className="w-3 h-3" />}
|
||||
inset={true}
|
||||
/>
|
||||
)}
|
||||
|
||||
{node.mapReport?.firmwareVersion && (
|
||||
<KeyValuePair
|
||||
label="Firmware"
|
||||
value={node.mapReport.firmwareVersion}
|
||||
monospace={true}
|
||||
icon={<Save className="w-3 h-3" />}
|
||||
inset={true}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</Section>
|
||||
|
||||
{node.isGateway && (
|
||||
<Section
|
||||
title="Gateway Node"
|
||||
icon={<Network className="w-4 h-4" />}
|
||||
className="mt-4"
|
||||
>
|
||||
{node.mapReport?.numOnlineLocalNodes !== undefined && (
|
||||
<KeyValuePair
|
||||
label="Online local"
|
||||
value={String(node.mapReport.numOnlineLocalNodes)}
|
||||
icon={<Network className="w-3 h-3" />}
|
||||
inset={true}
|
||||
/>
|
||||
)}
|
||||
|
||||
{node.mapReport?.region !== undefined && (
|
||||
<KeyValuePair
|
||||
label="Region"
|
||||
value={getRegionName(node.mapReport.region)}
|
||||
icon={<Earth className="w-3 h-3" />}
|
||||
inset={true}
|
||||
/>
|
||||
)}
|
||||
|
||||
{node.mapReport?.modemPreset !== undefined && (
|
||||
<KeyValuePair
|
||||
label="Modem Preset"
|
||||
value={getModemPresetName(node.mapReport.modemPreset)}
|
||||
icon={<TableConfig className="w-3 h-3" />}
|
||||
inset={true}
|
||||
/>
|
||||
)}
|
||||
|
||||
{node.mapReport?.firmwareVersion && (
|
||||
<KeyValuePair
|
||||
label="Firmware"
|
||||
value={node.mapReport.firmwareVersion}
|
||||
monospace={true}
|
||||
icon={<Save className="w-3 h-3" />}
|
||||
inset={true}
|
||||
/>
|
||||
)}
|
||||
|
||||
{gateway?.observedNodes && gateway.observedNodes.length > 0 && (
|
||||
<div className="mt-3">
|
||||
<div className="text-xs text-neutral-500 mb-2">Recently observed nodes ({gateway.observedNodes.length})</div>
|
||||
<div className="grid grid-cols-2 md:grid-cols-3 gap-2">
|
||||
{gateway.observedNodes.map((observedNodeId) => {
|
||||
const observedNode = nodes[observedNodeId];
|
||||
const displayName = getNodeDisplayName(observedNodeId, observedNode);
|
||||
return (
|
||||
<Link
|
||||
key={observedNodeId}
|
||||
to="/node/$nodeId"
|
||||
params={{ nodeId: observedNodeId.toString(16).toLowerCase() }}
|
||||
className="flex items-center p-2 bg-neutral-800/50 hover:bg-neutral-700/50 rounded transition-colors text-xs border border-neutral-700/30"
|
||||
>
|
||||
<BoomBox className="w-3 h-3 mr-2 text-neutral-400 flex-shrink-0" />
|
||||
<span className="text-neutral-200 truncate">{displayName}</span>
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Section>
|
||||
)}
|
||||
|
||||
{/* Device Status - Moved from the right column to here */}
|
||||
{(node.deviceMetrics ||
|
||||
node.batteryLevel !== undefined ||
|
||||
@@ -574,6 +558,15 @@ export const NodeDetail: React.FC<NodeDetailProps> = ({ nodeId }) => {
|
||||
/>
|
||||
</Section>
|
||||
)}
|
||||
|
||||
{/* Connections */}
|
||||
<Section
|
||||
title="Connections"
|
||||
icon={<Network className="w-4 h-4" />}
|
||||
className="mt-4"
|
||||
>
|
||||
<NodeConnections nodeId={nodeId} links={topologyLinks} nodes={nodes} />
|
||||
</Section>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -604,15 +597,6 @@ export const NodeDetail: React.FC<NodeDetailProps> = ({ nodeId }) => {
|
||||
</Section>
|
||||
)}
|
||||
|
||||
{/* Connections - Full Width */}
|
||||
<Section
|
||||
title="Connections"
|
||||
icon={<Network className="w-4 h-4" />}
|
||||
className="mt-6"
|
||||
>
|
||||
<NodeConnections nodeId={nodeId} links={topologyLinks} nodes={nodes} />
|
||||
</Section>
|
||||
|
||||
{/* Recent Packets - Full Width */}
|
||||
<Section
|
||||
title="Recent Packets"
|
||||
@@ -675,7 +659,7 @@ const NodeConnections: React.FC<NodeConnectionsProps> = ({ nodeId, links, nodes
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<ConnectionList count={edges.length}>
|
||||
{edges.map((link) => {
|
||||
const neighborId = link.nodeA === nodeId ? link.nodeB : link.nodeA;
|
||||
const neighborNode = nodes[neighborId];
|
||||
@@ -684,60 +668,32 @@ const NodeConnections: React.FC<NodeConnectionsProps> = ({ 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 (
|
||||
<div
|
||||
<ConnectionRow
|
||||
key={link.key}
|
||||
className="flex items-start justify-between p-3 bg-neutral-800 rounded-lg border border-neutral-700"
|
||||
>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<span className="font-mono text-sm text-white font-medium truncate">
|
||||
{neighborName}
|
||||
</span>
|
||||
<span
|
||||
className={`text-xs px-1.5 py-0.5 rounded font-medium ${SOURCE_COLORS[source]}`}
|
||||
>
|
||||
{SOURCE_LABELS[source]}
|
||||
</span>
|
||||
{link.viaMqtt && (
|
||||
<span className="text-xs px-1.5 py-0.5 rounded bg-purple-900 text-purple-300 font-medium">
|
||||
MQTT
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex gap-3 text-xs text-neutral-400">
|
||||
{outgoingSnr !== undefined && (
|
||||
<span>→ {outgoingSnr.toFixed(1)} dB</span>
|
||||
)}
|
||||
{incomingSnr !== undefined && (
|
||||
<span>← {incomingSnr.toFixed(1)} dB</span>
|
||||
)}
|
||||
{link.rssiAtoB !== undefined && nodeId === link.nodeB && (
|
||||
<span className="text-neutral-500">
|
||||
RSSI {link.rssiAtoB} dBm
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-xs text-neutral-500 ml-3 shrink-0">
|
||||
{formatLastSeen(secondsAgo)}
|
||||
</div>
|
||||
</div>
|
||||
name={neighborName}
|
||||
nameHref={`/node/${neighborId.toString(16)}`}
|
||||
snrValues={snrValues}
|
||||
badges={badges}
|
||||
time={formatLastSeenShort(secondsAgo)}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</ConnectionList>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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<NeighborInfoPacketProps> = ({ packet }
|
||||
|
||||
{/* Neighbors list */}
|
||||
{neighborInfo.neighbors && neighborInfo.neighbors.length > 0 ? (
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="text-sm text-neutral-400">
|
||||
{neighborInfo.neighbors.length} neighbor{neighborInfo.neighbors.length !== 1 ? 's' : ''}:
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
{neighborInfo.neighbors.map((neighbor, index) => (
|
||||
<div key={index} className="bg-neutral-800/50 rounded p-2 flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<Link
|
||||
to="/node/$nodeId"
|
||||
params={{ nodeId: neighbor.nodeId.toString(16) }}
|
||||
className="text-blue-400 hover:text-blue-300 transition-colors font-mono flex items-center gap-1"
|
||||
>
|
||||
{getNodeName(neighbor.nodeId)}
|
||||
<ExternalLink className="w-3 h-3" />
|
||||
</Link>
|
||||
</div>
|
||||
<div className="flex items-center gap-3 text-xs text-neutral-400">
|
||||
<div>
|
||||
<span className="text-neutral-500 mr-1">SNR:</span>
|
||||
<span className={neighbor.snr > 0 ? "text-green-400" : neighbor.snr > -10 ? "text-yellow-400" : "text-red-400"}>
|
||||
{neighbor.snr.toFixed(1)}dB
|
||||
</span>
|
||||
</div>
|
||||
{neighbor.lastRxTime && (
|
||||
<div>
|
||||
<span className="text-neutral-500 mr-1">Last:</span>
|
||||
<span>{formatTime(neighbor.lastRxTime)}</span>
|
||||
</div>
|
||||
)}
|
||||
{neighbor.nodeBroadcastIntervalSecs && (
|
||||
<div>
|
||||
<span className="text-neutral-500 mr-1">Interval:</span>
|
||||
<span>{formatInterval(neighbor.nodeBroadcastIntervalSecs)}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<ConnectionList count={neighborInfo.neighbors.length} label="neighbor">
|
||||
{neighborInfo.neighbors.map((neighbor, index) => (
|
||||
<ConnectionRow
|
||||
key={index}
|
||||
name={getNodeName(neighbor.nodeId)}
|
||||
nameHref={`/node/${neighbor.nodeId.toString(16)}`}
|
||||
snrValues={[{ label: "", value: neighbor.snr }]}
|
||||
time={neighbor.lastRxTime ? formatTime(neighbor.lastRxTime) : undefined}
|
||||
/>
|
||||
))}
|
||||
</ConnectionList>
|
||||
) : (
|
||||
<div className="text-sm text-neutral-500 italic">No neighbors reported</div>
|
||||
)}
|
||||
|
||||
89
web/src/components/ui/ConnectionRow.tsx
Normal file
89
web/src/components/ui/ConnectionRow.tsx
Normal file
@@ -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<ConnectionListProps> = ({ count, label = "connection", children }) => (
|
||||
<div>
|
||||
<div className="divide-y divide-neutral-700/50">{children}</div>
|
||||
<p className="text-xs text-neutral-500 mt-2 px-1">
|
||||
{count} {label}{count !== 1 ? "s" : ""}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
|
||||
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<ConnectionRowProps> = ({
|
||||
name,
|
||||
nameHref,
|
||||
snrValues,
|
||||
badges,
|
||||
time,
|
||||
}) => {
|
||||
return (
|
||||
<div className="flex items-center gap-3 px-1 py-1.5 text-xs">
|
||||
{/* Name */}
|
||||
<span className="font-mono text-xs text-white font-medium w-24 shrink-0 truncate">
|
||||
{nameHref ? (
|
||||
<Link to={nameHref} className="cursor-pointer hover:text-blue-300 transition-colors">
|
||||
{name}
|
||||
</Link>
|
||||
) : (
|
||||
name
|
||||
)}
|
||||
</span>
|
||||
|
||||
{/* SNR values */}
|
||||
<span className="font-mono w-28 shrink-0 text-neutral-400">
|
||||
{snrValues?.map((s, i) => (
|
||||
<React.Fragment key={i}>
|
||||
{i > 0 && <span className="text-neutral-600 mx-1">·</span>}
|
||||
<span className={snrColor(s.value)}>
|
||||
{s.label}{s.value.toFixed(1)}
|
||||
</span>
|
||||
</React.Fragment>
|
||||
))}
|
||||
</span>
|
||||
|
||||
{/* Badges */}
|
||||
<div className="flex gap-1 flex-1 min-w-0">
|
||||
{badges?.map((b, i) => (
|
||||
<span key={i} className={`px-1.5 py-0.5 rounded font-medium ${b.className}`}>
|
||||
{b.label}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Time */}
|
||||
{time && <span className="text-neutral-500 shrink-0">{time}</span>}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,3 +1,5 @@
|
||||
export { Button } from './Button';
|
||||
export { Section } from './Section';
|
||||
export { KeyValuePair } from './KeyValuePair';
|
||||
export { KeyValuePair } from './KeyValuePair';
|
||||
export { ConnectionRow, ConnectionList } from './ConnectionRow';
|
||||
export type { SnrValue, ConnectionBadge } from './ConnectionRow';
|
||||
@@ -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
|
||||
*
|
||||
|
||||
@@ -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" }],
|
||||
};
|
||||
|
||||
@@ -36,7 +36,7 @@ function MapPage() {
|
||||
|
||||
<div className="mt-2 rounded-lg p-2 text-xs flex items-center justify-between effect-inset">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex items-center gap-x-3 gap-y-1 flex-wrap">
|
||||
<span className={`inline-flex items-center px-2 py-0.5 rounded ${getNodeColors(ActivityLevel.RECENT, false).textClass} ${getNodeColors(ActivityLevel.RECENT, false).background}`}>
|
||||
<span className={`w-2 h-2 ${getNodeColors(ActivityLevel.RECENT, false).statusDot} rounded-full mr-1.5`}></span>
|
||||
Nodes
|
||||
@@ -45,6 +45,22 @@ function MapPage() {
|
||||
<span className={`w-2 h-2 ${getNodeColors(ActivityLevel.RECENT, true).statusDot} rounded-full mr-1.5`}></span>
|
||||
Gateways
|
||||
</span>
|
||||
<span className="text-neutral-500">·</span>
|
||||
<span className="inline-flex items-center gap-1.5 text-neutral-300">
|
||||
<span className="inline-block w-5 h-0.5 rounded-full bg-[#22c55e]"></span>SNR ≥ 5
|
||||
</span>
|
||||
<span className="inline-flex items-center gap-1.5 text-neutral-300">
|
||||
<span className="inline-block w-5 h-0.5 rounded-full bg-[#eab308]"></span>SNR 0–4
|
||||
</span>
|
||||
<span className="inline-flex items-center gap-1.5 text-neutral-300">
|
||||
<span className="inline-block w-5 h-0.5 rounded-full bg-[#ef4444]"></span>SNR < 0
|
||||
</span>
|
||||
<span className="inline-flex items-center gap-1.5 text-neutral-300">
|
||||
<span className="inline-block w-5 h-0.5 rounded-full bg-[#6b7280]"></span>Unknown
|
||||
</span>
|
||||
<span className="inline-flex items-center gap-1.5 text-neutral-300">
|
||||
<span className="inline-block w-5 border-t-2 border-dashed border-[#a855f7] opacity-80"></span>MQTT
|
||||
</span>
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user