diff --git a/docs-es/v2.0/node-manager-ui-es.md b/docs-es/v2.0/node-manager-ui-es.md index 644813df..58046c71 100644 --- a/docs-es/v2.0/node-manager-ui-es.md +++ b/docs-es/v2.0/node-manager-ui-es.md @@ -9,15 +9,15 @@ Esta página documenta las vistas clave de la WebUI (Node Manager) y su comporta - El contenido puede variar según versión y funciones habilitadas. - Executive Summary ofrece una vista operativa compacta para redes grandes, con páginas de detalle para heatmaps y rankings ejecutivos. - Bakery ofrece una pestaña dedicada para el estado de aplicación de colas, eventos recientes de Bakery paginados, resultados de seguridad/preflight de qdisc y el gráfico existente de actividad de circuitos. -- Las pestañas de Bakery y TreeGuard ahora comienzan con una fila visual de modelo del sistema para que el operador entienda el comportamiento del pipeline/control loop de un vistazo antes de bajar a tablas. -- El widget `Pipeline` de Bakery ahora incluye la barra de progreso activa dentro de la misma tarjeta, con `%`, `completado / total de comandos tc` y avance por chunk durante full reloads grandes. -- La fila superior de Bakery ahora incluye `Runtime Operations`, que muestra mutaciones en vivo de topología entre TreeGuard y Bakery, trabajo de limpieza diferida, fallos, subárboles marcados como `Dirty` y si Bakery ha congelado más cambios incrementales hasta hacer un full reload. -- `Recent Bakery Events` ahora separa cada evento en etapa, resultado y alcance para que el operador pueda ver rápidamente si Bakery está planificando, bloqueado en preflight, aplicando, limpiando trabajo de TreeGuard o si ya terminó. -- `TreeGuard Activity` ahora prioriza intención y resultado en lugar de una columna cruda de `persisted`, haciendo mucho más fácil interpretar dry-runs, cambios aplicados, acciones con limpieza pendiente, skips y fallos. -- `TreeGuard Decision Impact` ahora separa advertencias operativas de errores reales, usa ámbar en lugar de rojo para advertencias no fatales y expone el texto actual de advertencias/errores directamente en el resumen del widget y en el tooltip al pasar el cursor. -- El preflight de qdisc de Bakery ahora presenta los qdisc planificados por interfaz como barras compactas de uso con badges del tipo de qdisc, además de una tabla separada de límites/memoria para presupuesto seguro, límite del kernel, memoria estimada de qdisc, memoria disponible actual y margen sobre el piso de seguridad. +- Las pestañas de Bakery y TreeGuard incluyen un resumen de alto nivel del pipeline/control loop antes de las tablas detalladas. +- El widget `Pipeline` de Bakery muestra el progreso activo de aplicación, incluyendo porcentaje completado, conteo de comandos y avance por chunk durante full reloads grandes. +- `Runtime Operations` resume mutaciones en vivo de topología entre TreeGuard y Bakery, trabajo de limpieza diferida, fallos, subárboles marcados como `Dirty` y si los cambios incrementales están congelados hasta un full reload. +- `Recent Bakery Events` ayuda a distinguir rápidamente estados de planificación, preflight, aplicación, limpieza y finalización. +- `TreeGuard Activity` se centra en la intención y el resultado operativo, incluyendo dry-runs, cambios aplicados, skips, acciones con limpieza pendiente y fallos. +- `TreeGuard Decision Impact` separa advertencias operativas de errores reales y expone el texto actual de advertencia o error en el resumen del widget y en el tooltip. +- El preflight de qdisc de Bakery resume el uso planificado de qdisc por interfaz y el margen de presupuesto antes de aplicar cambios. - Algunos gráficos pueden tardar un poco en poblarse al abrir una pestaña por primera vez, especialmente en sistemas ocupados o inmediatamente después de reiniciar servicios. -- Durante un full reload de Bakery, las tarjetas de conteo de colas mantienen los últimos valores conocidos de HTB/CAKE/fq-codel y los marcan como `Reloading` en lugar de mostrar ceros momentáneamente. +- Durante un full reload de Bakery, las tarjetas de conteo de colas mantienen los últimos valores conocidos de HTB/CAKE/fq-codel y los marcan como `Reloading`. ### Vista de árbol de red - Vista jerárquica de nodos/circuitos desde la perspectiva del shaper. @@ -33,8 +33,10 @@ Esta página documenta las vistas clave de la WebUI (Node Manager) y su comporta - Usa QoO por defecto con un selector alternativo para RTT, mientras el tamaño del marcador refleja el throughput combinado reciente. - Usa un promedio del lado cliente de 30 segundos a partir de `NetworkTree`, sin agregar trabajo de rollup en el backend. - Los APs pueden heredar coordenadas del sitio padre solo para visualización cuando faltan coordenadas explícitas. +- Los marcadores de sitios cercanos se agrupan y se expanden al acercar el mapa o seleccionar un grupo. +- Los APs sin coordenadas explícitas se representan a través de su sitio padre y pueden desplegarse temporalmente alrededor del sitio seleccionado para inspección. - Cuando el modo de redacción del navegador está habilitado, Site Map reemplaza los nombres de sitios mostrados por `[redacted]` sin modificar los datos reales de topología. -- El encuadre inicial del mapa ahora prioriza las coordenadas de los sitios para una vista inicial más cercana, usando coordenadas de AP solo cuando todavía no hay sitios mapeados. +- El encuadre inicial del mapa prioriza las coordenadas de los sitios para la vista inicial, usando coordenadas de AP solo cuando todavía no hay sitios mapeados. - Usa un mapa base local con estilo LibreQoS con bordes de país/estado, costas, lagos principales, ríos principales, áreas marinas, superposiciones sutiles de regiones físicas y contexto de autopistas principales a mayor zoom para orientación geográfica. - Site Map utiliza una capa local de carreteras derivada de Natural Earth para ayudar con la orientación, manteniendo el resto del mapa base discreto y operativo. @@ -49,16 +51,16 @@ Esta página documenta las vistas clave de la WebUI (Node Manager) y su comporta ### ASN Analysis - Página operativa ASN en vivo que combina un ranking top-20 de ASN, gráfico de burbujas latencia-vs-tráfico, franja mínima de KPIs del ASN seleccionado, gráfico de tendencia ASN de 15 minutos y la sección integrada de Flow Evidence. - Soporta modos de ranking `Impact` y `Throughput`, manteniendo la evidencia de flujos ASN en la misma página. -- Las versiones actuales obtienen el contexto ejecutivo ASN mediante requests paginados y acotados solo a ASN, en lugar de suscribirse a un feed completo de heatmaps ejecutivos. -- La ruta heredada `ASN Explorer` ahora redirige aquí para conservar compatibilidad con marcadores antiguos. +- El contexto ejecutivo ASN se obtiene mediante requests acotados al ASN, en lugar de suscribirse a un feed completo de heatmaps ejecutivos. +- Los marcadores antiguos de `ASN Explorer` siguen funcionando mediante redirección. - Resultados vacíos suelen indicar poco dato reciente, no necesariamente falla. ### Página de circuito - Las páginas de circuito combinan comportamiento de colas, throughput en vivo, RTT, retransmisiones y troubleshooting por flujo para un suscriptor/circuito individual. - `Queue Dynamics` muestra el comportamiento del throughput y RTT del circuito a lo largo del tiempo, incluyendo un KPI de `Active Flows` basado en la misma ventana reciente usada por la tabla `Traffic Flows`. - `Queue Stats` muestra los 3 minutos más recientes del historial en vivo de la cola del circuito como muestras scatter crudas de 1 segundo, incluyendo backlog, delay, longitud de cola, tráfico, marcas ECN y drops. -- Los gráficos de Queue Stats ahora usan hover sincronizado para inspeccionar el mismo segundo en todos los gráficos de cola al mismo tiempo. -- `Queue Tree` ahora presenta la ruta ascendente de colas del circuito en tarjetas de ancestros con estilo, incluyendo un resumen de ruta y paneles de throughput, retransmisiones y latencia de igual ancho para cada nodo aguas arriba. +- Los gráficos de Queue Stats usan hover sincronizado para inspeccionar el mismo segundo en todos los gráficos de cola al mismo tiempo. +- `Queue Tree` muestra la ruta ascendente de colas del circuito, incluyendo un resumen de ruta y contexto de throughput, retransmisiones y latencia para cada nodo aguas arriba. - `Traffic Flows` es una tabla operativa de flujos recientes, no una vista de historial a largo plazo. - `Traffic Flows` incluye paginación y un filtro `Hide Small Flows` para que los circuitos grandes y ocupados sigan siendo utilizables sin intentar renderizar cada fila. - `Flow Sankey` enfatiza los flujos recientes más activos en lugar de todos los flujos retenidos más antiguos. @@ -70,7 +72,7 @@ Esta página documenta las vistas clave de la WebUI (Node Manager) y su comporta ### Editor de Shaped Devices - Editor CRUD para `ShapedDevices.csv`. -- Incluye paginación y filtros en versiones actuales. +- Incluye paginación y filtros. - En el editor dedicado, las acciones de agregar, editar y eliminar se guardan de inmediato. ### Problemas urgentes diff --git a/docs/v2.0/node-manager-ui.md b/docs/v2.0/node-manager-ui.md index 501e294b..dba5d77e 100644 --- a/docs/v2.0/node-manager-ui.md +++ b/docs/v2.0/node-manager-ui.md @@ -9,15 +9,15 @@ This page documents key WebUI (Node Manager) views and operational behavior in t - Dashboard content can vary by version and enabled features. - Executive Summary provides a compact operational view for large networks, with drilldown pages for executive heatmaps and leaderboards. - Bakery provides dedicated status for queue apply state, paginated recent Bakery events, qdisc safety/preflight results, and the existing circuit-activity graph. -- The Bakery and TreeGuard tabs now begin with a visual system-model row so operators can understand pipeline/control-loop behavior at a glance before drilling into tables. -- The Bakery `Pipeline` widget now includes the active apply progress bar directly in the pipeline card, including `%`, `completed / total tc commands`, and chunk progress during large full reloads. -- The Bakery top row now includes `Runtime Operations`, which shows live TreeGuard/Bakery topology mutations, deferred cleanup work, failures, dirty subtrees, and whether Bakery has frozen further incremental changes pending a full reload. -- `Recent Bakery Events` now splits each entry into stage, outcome, and scope so operators can quickly tell whether Bakery is planning, blocked in preflight, applying, cleaning up TreeGuard work, or has finished. -- `TreeGuard Activity` now emphasizes intent and outcome instead of a raw `persisted` flag, making dry-runs, applied changes, cleanup-pending actions, skips, and failures much easier to interpret. -- `TreeGuard Decision Impact` now splits operational warnings from true errors, uses amber instead of red for non-fatal warnings, and exposes the current warning/error text directly in the widget summary and hover tooltip. -- Bakery qdisc preflight now presents per-interface planned qdiscs as compact usage bars with qdisc-kind mix badges, plus a separate limits/memory table for safe budget, kernel limit, estimated qdisc memory, current available memory, and safety-floor headroom. +- The Bakery and TreeGuard tabs include a high-level pipeline/control-loop summary before the detailed tables. +- The Bakery `Pipeline` widget shows active apply progress, including percent complete, command counts, and chunk progress during large full reloads. +- `Runtime Operations` summarizes live TreeGuard/Bakery topology mutations, deferred cleanup work, failures, dirty subtrees, and whether incremental changes are frozen pending a full reload. +- `Recent Bakery Events` helps operators distinguish planning, preflight, apply, cleanup, and completion states. +- `TreeGuard Activity` focuses on operator intent and outcome, including dry-runs, applied changes, skips, cleanup-pending actions, and failures. +- `TreeGuard Decision Impact` separates operational warnings from true errors and exposes the current warning or error text directly in the widget summary and tooltip. +- Bakery qdisc preflight summarizes planned per-interface qdisc usage and budget headroom before apply. - Some charts may take a short time to populate after first opening a tab, especially on busy systems or immediately after a service restart. -- During a Bakery full reload, queue-count cards keep showing the last known HTB/CAKE/fq-codel values and mark them as `Reloading` instead of briefly zeroing them. +- During a Bakery full reload, queue-count cards keep showing the last known HTB/CAKE/fq-codel values and mark them as `Reloading`. ### Network Tree Overview - Hierarchical topology view of nodes/circuits from the shaper perspective. @@ -35,9 +35,11 @@ This page documents key WebUI (Node Manager) views and operational behavior in t - Defaults to QoO coloring with an RTT toggle, while marker size reflects recent combined throughput. - Uses a 30-second client-side average from `NetworkTree` data rather than adding backend rollup work. - APs can inherit parent site coordinates for display when explicit AP coordinates are missing. +- Nearby site markers cluster and expand as the operator zooms or selects a cluster. +- APs without explicit coordinates are represented through their parent site and can be expanded temporarily around the selected site for inspection. - When browser redaction mode is enabled, Site Map replaces displayed site names with `[redacted]` while leaving the underlying topology data unchanged. -- Initial map framing now prefers site coordinates for a closer first view, falling back to AP coordinates when no sites are mapped yet. -- Current builds use an Insight-hosted OpenStreetMap raster tile cache rather than a fully local basemap bundle. +- Initial map framing prefers site coordinates for the first view, falling back to AP coordinates when no sites are mapped yet. +- Site Map uses an Insight-hosted OpenStreetMap raster tile cache. - Site Map depends on outbound access to `https://insight.libreqos.com` for initial bbox/bootstrap and raster tile fetches. - When tiles are missing from the remote cache, the browser retries automatically for a short period instead of failing immediately, so initial map paint can lag briefly on cold tiles. @@ -52,16 +54,16 @@ This page documents key WebUI (Node Manager) views and operational behavior in t ### ASN Analysis - Live ASN operations page combining a top-20 ASN leaderboard, latency-vs-traffic bubble chart, selected ASN KPI strip, 15-minute ASN trend chart, and embedded Flow Evidence. - Supports `Impact` and `Throughput` ranking modes while keeping ASN flow evidence on the same page. -- Current builds populate ASN executive context from bounded ASN-only executive page requests instead of subscribing to a full executive heatmap feed. -- The legacy `ASN Explorer` route now redirects here so older bookmarks still work. +- ASN executive context is fetched through bounded ASN-focused requests rather than a full executive heatmap subscription. +- Older `ASN Explorer` bookmarks continue to work through a redirect. - Empty results usually indicate low recent data volume rather than failure. ### Circuit page - Circuit pages combine queue behavior, live throughput, RTT, retransmits, and per-flow troubleshooting for an individual subscriber/circuit. - `Queue Dynamics` shows circuit throughput and RTT behavior over time, including an `Active Flows` KPI based on the same recent flow window used by the `Traffic Flows` table. - `Queue Stats` shows the most recent 3 minutes of live queue history for the circuit as raw 1-second scatter samples, including backlog, delay, queue length, traffic, ECN marks, and drops. -- Queue Stats charts now use synchronized hover so operators can inspect the same second across all queue charts together. -- `Queue Tree` now presents the circuit's live upstream queue path in styled ancestor cards, with a path summary plus equal-width throughput, retransmit, and latency panels for each upstream node. +- Queue Stats charts use synchronized hover so operators can inspect the same second across all queue charts together. +- `Queue Tree` shows the circuit's live upstream queue path, including a path summary and per-node throughput, retransmit, and latency context. - `Traffic Flows` is a recent-flow operational table rather than a long-term history view. - `Traffic Flows` includes paging and a `Hide Small Flows` filter so large busy circuits remain usable without trying to render every row at once. - `Flow Sankey` emphasizes the hottest recent flows rather than every older retained flow. @@ -69,11 +71,11 @@ This page documents key WebUI (Node Manager) views and operational behavior in t ### CPU Tree / CPU Weights - Shows queue/circuit distribution by CPU core. - Helps evaluate binpacking and load distribution behavior. -- CPU Affinity defaults to the detected shaping CPU set, so excluded hybrid E-cores / non-shaping host cores stay hidden unless the operator explicitly opts to show them. +- CPU Affinity defaults to the detected shaping CPU set, so excluded hybrid E-cores and other non-shaping host cores stay hidden unless the operator explicitly opts to show them. ### Shaped Devices Editor - CRUD editor for `ShapedDevices.csv`. -- Supports paging and filtering in current builds. +- Supports paging and filtering. - Add, edit, and delete actions save immediately in the dedicated editor. ### Urgent Issues diff --git a/src/rust/lqosd/src/node_manager/js_build/src/site_map.js b/src/rust/lqosd/src/node_manager/js_build/src/site_map.js index 2d7b901a..a5addc16 100644 --- a/src/rust/lqosd/src/node_manager/js_build/src/site_map.js +++ b/src/rust/lqosd/src/node_manager/js_build/src/site_map.js @@ -126,8 +126,15 @@ const OSM_RASTER_LAYER_ID = "site-map-osm-tiles"; const SITE_SOURCE_ID = "site-map-sites"; const AP_SOURCE_ID = "site-map-aps"; const SITE_LINK_SOURCE_ID = "site-map-site-links"; +const FANOUT_LINE_SOURCE_ID = "site-map-fanout-lines"; +const FANOUT_POINT_SOURCE_ID = "site-map-fanout-points"; +const SITE_CLUSTER_LAYER_ID = "site-map-site-clusters"; +const SITE_CLUSTER_COUNT_LAYER_ID = "site-map-site-cluster-count"; const SITE_POINTS_LAYER_ID = "site-map-site-points"; +const SITE_LABEL_LAYER_ID = "site-map-site-labels"; const AP_POINTS_LAYER_ID = "site-map-ap-points"; +const FANOUT_LINE_LAYER_ID = "site-map-fanout-lines-layer"; +const FANOUT_POINT_LAYER_ID = "site-map-fanout-points-layer"; const SITE_LINK_LAYER_ID = "site-map-site-links-line"; const INSIGHT_TILE_MAX_PARALLEL_FETCHES = 2; @@ -636,7 +643,8 @@ class SiteMapPage { this.unmappedOpen = false; this.mapInitPromise = null; this.mapBootstrapped = false; - this.siteLabelMarkers = new Map(); + this.attachedInheritedApsBySite = new Map(); + this.fanoutSiteKey = null; this.lastBboxAttemptAt = 0; this.fixtureMode = FIXTURE_MODE; @@ -650,6 +658,7 @@ class SiteMapPage { this.detailsPanel = document.getElementById("siteMapDetails"); this.detailsTitle = document.getElementById("siteMapDetailsTitle"); this.detailsSubtitle = document.getElementById("siteMapDetailsSubtitle"); + this.attachedList = document.getElementById("siteMapAttachedList"); this.detailsGrid = document.getElementById("siteMapDetailsGrid"); this.legendGradient = document.getElementById("siteMapLegendGradient"); this.legendLow = document.getElementById("siteMapLegendLow"); @@ -693,6 +702,8 @@ class SiteMapPage { }); document.getElementById("siteMapDetailsClose")?.addEventListener("click", () => { this.selectedFeature = null; + this.fanoutSiteKey = null; + this.syncFanoutOverlays(); this.renderDetails(null); }); window.addEventListener("colorBlindModeChanged", () => { @@ -790,6 +801,18 @@ class SiteMapPage { if (this.map.getLayer(AP_POINTS_LAYER_ID)) { this.map.setPaintProperty(AP_POINTS_LAYER_ID, "circle-stroke-color", palette.apStroke); } + if (this.map.getLayer(SITE_LABEL_LAYER_ID)) { + this.map.setPaintProperty( + SITE_LABEL_LAYER_ID, + "text-halo-color", + isDarkMode() ? "rgba(11, 18, 32, 0.92)" : "rgba(255, 255, 255, 0.94)", + ); + this.map.setPaintProperty( + SITE_LABEL_LAYER_ID, + "text-color", + isDarkMode() ? "rgba(248, 250, 252, 0.94)" : "rgba(15, 23, 42, 0.92)", + ); + } } installSourcesAndLayers() { @@ -803,6 +826,9 @@ class SiteMapPage { this.map.addSource(SITE_SOURCE_ID, { type: "geojson", data: { type: "FeatureCollection", features: [] }, + cluster: true, + clusterRadius: 50, + clusterMaxZoom: 12, }); } if (!this.map.getSource(AP_SOURCE_ID)) { @@ -811,6 +837,18 @@ class SiteMapPage { data: { type: "FeatureCollection", features: [] }, }); } + if (!this.map.getSource(FANOUT_LINE_SOURCE_ID)) { + this.map.addSource(FANOUT_LINE_SOURCE_ID, { + type: "geojson", + data: { type: "FeatureCollection", features: [] }, + }); + } + if (!this.map.getSource(FANOUT_POINT_SOURCE_ID)) { + this.map.addSource(FANOUT_POINT_SOURCE_ID, { + type: "geojson", + data: { type: "FeatureCollection", features: [] }, + }); + } if (!this.map.getLayer(SITE_LINK_LAYER_ID)) { this.map.addLayer({ @@ -839,6 +877,7 @@ class SiteMapPage { id: SITE_POINTS_LAYER_ID, type: "circle", source: SITE_SOURCE_ID, + filter: ["!", ["has", "point_count"]], paint: { "circle-color": ["get", "metricColor"], "circle-radius": ["get", "markerRadius"], @@ -855,6 +894,79 @@ class SiteMapPage { }); } + if (!this.map.getLayer(SITE_CLUSTER_LAYER_ID)) { + this.map.addLayer({ + id: SITE_CLUSTER_LAYER_ID, + type: "circle", + source: SITE_SOURCE_ID, + filter: ["has", "point_count"], + paint: { + "circle-color": [ + "step", + ["get", "point_count"], + "#4ca4ff", + 8, "#f5b84f", + 20, "#d94b5b", + ], + "circle-radius": [ + "step", + ["get", "point_count"], + 18, + 8, 24, + 20, 30, + ], + "circle-opacity": 0.9, + "circle-stroke-color": markerPalette().siteStroke, + "circle-stroke-width": 1.3, + }, + }); + } + + if (!this.map.getLayer(SITE_CLUSTER_COUNT_LAYER_ID)) { + this.map.addLayer({ + id: SITE_CLUSTER_COUNT_LAYER_ID, + type: "symbol", + source: SITE_SOURCE_ID, + filter: ["has", "point_count"], + layout: { + "text-field": ["get", "point_count_abbreviated"], + "text-size": 12, + "text-font": ["Open Sans Bold"], + }, + paint: { + "text-color": "#ffffff", + }, + }); + } + + if (!this.map.getLayer(SITE_LABEL_LAYER_ID)) { + this.map.addLayer({ + id: SITE_LABEL_LAYER_ID, + type: "symbol", + source: SITE_SOURCE_ID, + filter: ["!", ["has", "point_count"]], + minzoom: 8, + layout: { + "text-field": ["get", "displayName"], + "text-size": [ + "interpolate", ["linear"], ["zoom"], + 8, 10, + 12, 12, + ], + "text-font": ["Open Sans Semibold"], + "text-variable-anchor": ["top", "bottom", "left", "right"], + "text-radial-offset": 1.15, + "text-justify": "auto", + "text-max-width": 14, + }, + paint: { + "text-color": isDarkMode() ? "rgba(248, 250, 252, 0.94)" : "rgba(15, 23, 42, 0.92)", + "text-halo-color": isDarkMode() ? "rgba(11, 18, 32, 0.92)" : "rgba(255, 255, 255, 0.94)", + "text-halo-width": 1.6, + }, + }); + } + if (!this.map.getLayer(AP_POINTS_LAYER_ID)) { this.map.addLayer({ id: AP_POINTS_LAYER_ID, @@ -876,10 +988,43 @@ class SiteMapPage { }, }); } + + if (!this.map.getLayer(FANOUT_LINE_LAYER_ID)) { + this.map.addLayer({ + id: FANOUT_LINE_LAYER_ID, + type: "line", + source: FANOUT_LINE_SOURCE_ID, + layout: { + "line-cap": "round", + "line-join": "round", + }, + paint: { + "line-color": isDarkMode() ? "rgba(248, 250, 252, 0.46)" : "rgba(15, 23, 42, 0.34)", + "line-width": 1.4, + "line-dasharray": [1.2, 1.2], + "line-opacity": 0.72, + }, + }); + } + + if (!this.map.getLayer(FANOUT_POINT_LAYER_ID)) { + this.map.addLayer({ + id: FANOUT_POINT_LAYER_ID, + type: "circle", + source: FANOUT_POINT_SOURCE_ID, + paint: { + "circle-color": ["get", "metricColor"], + "circle-radius": ["get", "markerRadius"], + "circle-opacity": 0.95, + "circle-stroke-color": markerPalette().apStroke, + "circle-stroke-width": 1.0, + }, + }); + } } installInteractions() { - const pointLayers = [SITE_POINTS_LAYER_ID, AP_POINTS_LAYER_ID]; + const pointLayers = [SITE_POINTS_LAYER_ID, SITE_LABEL_LAYER_ID, AP_POINTS_LAYER_ID, FANOUT_POINT_LAYER_ID]; pointLayers.forEach((layerId) => { this.map.on("mouseenter", layerId, () => { @@ -900,10 +1045,45 @@ class SiteMapPage { this.map.on("click", layerId, (event) => { const feature = event.features?.[0]; if (!feature) return; - this.selectedFeature = feature.properties; - this.renderDetails(feature.properties); + this.selectFeature(feature.properties); }); }); + + [SITE_CLUSTER_LAYER_ID, SITE_CLUSTER_COUNT_LAYER_ID].forEach((layerId) => { + this.map.on("mouseenter", layerId, () => { + this.map.getCanvas().style.cursor = "pointer"; + }); + this.map.on("mouseleave", layerId, () => { + this.map.getCanvas().style.cursor = ""; + }); + this.map.on("click", layerId, async (event) => { + const feature = event.features?.[0]; + const clusterId = feature?.properties?.cluster_id; + if (!Number.isFinite(clusterId)) { + return; + } + const source = this.map.getSource(SITE_SOURCE_ID); + if (!source?.getClusterExpansionZoom) { + return; + } + const expansionZoom = await source.getClusterExpansionZoom(clusterId); + this.map.easeTo({ + center: feature.geometry.coordinates, + zoom: expansionZoom, + duration: 350, + }); + }); + }); + + this.map.on("zoomend", () => { + if (this.fanoutSiteKey) { + this.fanoutSiteKey = null; + if (this.selectedFeature) { + this.renderDetails(this.selectedFeature); + } + } + this.syncFanoutOverlays(); + }); } requestInitialTree() { @@ -991,6 +1171,7 @@ class SiteMapPage { const aggregate = new Map(); let latestIndexMap = new Map(); + const attachedInheritedApsBySite = new Map(); this.history.forEach((entry) => { const snapshotIndexMap = new Map(); @@ -1132,6 +1313,19 @@ class SiteMapPage { } }); + byIndex.forEach((node) => { + if (node.type !== "ap" || !node.inheritedCoords) { + return; + } + const parent = byIndex.get(node.immediateParent); + if (!parent || parent.type !== "site") { + return; + } + const bucket = attachedInheritedApsBySite.get(parent.key) || []; + bucket.push(node); + attachedInheritedApsBySite.set(parent.key, bucket); + }); + byIndex.forEach((node) => { if (node.latitude === null || node.longitude === null) { const listTarget = node.type === "site" ? unmappedSites : unmappedAps; @@ -1139,6 +1333,10 @@ class SiteMapPage { return; } + if (node.type === "ap" && node.inheritedCoords) { + return; + } + const metricValue = this.mode === "qoo" ? node.qooWorst : node.rttWorst; const metricColor = metricColorForMode(this.mode, metricValue); const feature = { @@ -1151,10 +1349,12 @@ class SiteMapPage { key: node.key, nodeId: node.id || "", name: node.name, + displayName: displayNodeName(node.name, node.type), nodeType: node.type, parentName: node.parentName || "", parentType: node.parentType || "", inheritedCoords: node.inheritedCoords ? 1 : 0, + attachedInheritedApCount: attachedInheritedApsBySite.get(node.key)?.length || 0, throughputDown: node.throughputDown, throughputUp: node.throughputUp, throughputCombined: node.throughputCombined, @@ -1234,6 +1434,7 @@ class SiteMapPage { siteLinkFeatures, unmappedSites, unmappedAps, + attachedInheritedApsBySite, maxBitsPerSecond, maxSiteBitsPerSecond, }; @@ -1255,6 +1456,7 @@ class SiteMapPage { return; } this.latestRender = aggregate; + this.attachedInheritedApsBySite = aggregate.attachedInheritedApsBySite || new Map(); this.updateSources(aggregate); this.updateSelection(); this.renderUnmapped(aggregate.unmappedSites, aggregate.unmappedAps); @@ -1294,7 +1496,7 @@ class SiteMapPage { this.map.setPaintProperty(SITE_POINTS_LAYER_ID, "circle-opacity", siteOpacity); this.map.setPaintProperty(AP_POINTS_LAYER_ID, "circle-opacity", apOpacity); - this.syncSiteLabels(aggregate.siteFeatures); + this.syncFanoutOverlays(); this.syncMarkerSizeLegend(aggregate.maxBitsPerSecond); } @@ -1320,56 +1522,114 @@ class SiteMapPage { apply(this.sizeDotLarge, diameters[2]); } - syncSiteLabels(siteFeatures) { + attachedApsForSite(siteKey) { + const aps = this.attachedInheritedApsBySite.get(siteKey) || []; + return [...aps].sort((left, right) => left.name.localeCompare(right.name)); + } + + buildFanoutFeatures(siteProps, attachedAps) { + if (!this.map || !siteProps || !Array.isArray(attachedAps) || attachedAps.length === 0) { + return { lines: [], points: [] }; + } + + const siteCoord = this.latestRender?.siteFeatures + ?.find((feature) => feature.properties?.key === siteProps.key) + ?.geometry?.coordinates; + if (!Array.isArray(siteCoord) || siteCoord.length < 2) { + return { lines: [], points: [] }; + } + + const center = this.map.project(siteCoord); + const radiusPx = Math.max(42, Math.min(94, 34 + (attachedAps.length * 4))); + const baseAngle = -Math.PI / 2; + const lines = []; + const points = []; + + attachedAps.forEach((node, index) => { + const angle = attachedAps.length === 1 + ? baseAngle + : baseAngle + ((Math.PI * 2 * index) / attachedAps.length); + const projected = [ + center.x + (Math.cos(angle) * radiusPx), + center.y + (Math.sin(angle) * radiusPx), + ]; + const lngLat = this.map.unproject(projected); + const metricValue = this.mode === "qoo" ? node.qooWorst : node.rttWorst; + const metricColor = metricColorForMode(this.mode, metricValue); + + lines.push({ + type: "Feature", + geometry: { + type: "LineString", + coordinates: [siteCoord, [lngLat.lng, lngLat.lat]], + }, + properties: { + key: `${siteProps.key}->${node.key}`, + }, + }); + + const point = { + type: "Feature", + geometry: { + type: "Point", + coordinates: [lngLat.lng, lngLat.lat], + }, + properties: { + key: node.key, + nodeId: node.id || "", + name: node.name, + nodeType: node.type, + parentName: node.parentName || siteProps.name || "", + parentType: node.parentType || "site", + inheritedCoords: 1, + throughputDown: node.throughputDown, + throughputUp: node.throughputUp, + throughputCombined: node.throughputCombined, + qooDown: node.qooDown, + qooUp: node.qooUp, + rttDownMs: node.rttDownMs, + rttUpMs: node.rttUpMs, + markerRadius: throughputRadiusPx( + node.throughputCombined, + Math.max(this.latestRender?.maxBitsPerSecond || 1, 1), + ), + metricColor, + isFanoutAp: 1, + }, + }; + if (Number.isFinite(node.qooWorst)) { + point.properties.qooWorst = node.qooWorst; + } + if (Number.isFinite(node.rttWorst)) { + point.properties.rttWorst = node.rttWorst; + } + points.push(point); + }); + + return { lines, points }; + } + + syncFanoutOverlays() { if (!this.map) { return; } - const wanted = new Set(); - siteFeatures.forEach((feature) => { - const key = feature?.properties?.key; - const name = displayNodeName(feature?.properties?.name, feature?.properties?.nodeType); - const coordinates = feature?.geometry?.coordinates; - if (!key || !name || !Array.isArray(coordinates) || coordinates.length < 2) { - return; - } - wanted.add(key); + const lineSource = this.map.getSource(FANOUT_LINE_SOURCE_ID); + const pointSource = this.map.getSource(FANOUT_POINT_SOURCE_ID); + if (!lineSource || !pointSource) { + return; + } - const existing = this.siteLabelMarkers.get(key); - if (!existing) { - const el = document.createElement("div"); - el.className = "site-map-site-label"; - el.innerHTML = ``; - const text = el.querySelector(".site-map-site-label-text"); - if (text) { - text.textContent = name; - } + if (!this.fanoutSiteKey || !this.selectedFeature || this.selectedFeature.nodeType !== "site") { + lineSource.setData({ type: "FeatureCollection", features: [] }); + pointSource.setData({ type: "FeatureCollection", features: [] }); + return; + } - const marker = new window.maplibregl.Marker({ - element: el, - anchor: "bottom", - offset: [0, -14], - }) - .setLngLat(coordinates) - .addTo(this.map); - - this.siteLabelMarkers.set(key, { marker, text }); - return; - } - - existing.marker.setLngLat(coordinates); - if (existing.text) { - existing.text.textContent = name; - } - }); - - Array.from(this.siteLabelMarkers.entries()).forEach(([key, value]) => { - if (wanted.has(key)) { - return; - } - value.marker.remove(); - this.siteLabelMarkers.delete(key); - }); + const attachedAps = this.attachedApsForSite(this.fanoutSiteKey); + const fanout = this.buildFanoutFeatures(this.selectedFeature, attachedAps); + lineSource.setData({ type: "FeatureCollection", features: fanout.lines }); + pointSource.setData({ type: "FeatureCollection", features: fanout.points }); } fitToDataIfNeeded(siteFeatures, apFeatures) { @@ -1434,16 +1694,35 @@ class SiteMapPage { } pointPopupHtml(props) { + const attachedApCount = toNumber(props.attachedInheritedApCount, 0); const displayName = displayNodeName(props.name, props.nodeType); return `