From 452ee4f6f1cce1eef80ff5d58fe3f25e33d3ddfa Mon Sep 17 00:00:00 2001 From: Denozordec Date: Mon, 30 Mar 2026 15:26:14 +0700 Subject: [PATCH] Add geographic coordinates to IP data and enhance map visualization - Introduced latitude and longitude fields in the IP data structure to support geographic information. - Updated the API documentation to reflect the inclusion of geographic coordinates in the unique IPs endpoint. - Enhanced the UI to display a map of connections using Leaflet, providing a visual representation of IP locations based on the new geographic data. - Improved the handling of GeoIP data to ensure accurate mapping and user experience when GeoIP is enabled. --- docs/AGGREGATE.md | 2 +- docs/AGGREGATE_OPENAPI.yaml | 2 + internal/aggregate/geo_enrich.go | 6 + internal/aggregate/types.go | 2 + internal/geoip/geoip.go | 9 ++ web/package-lock.json | 23 +++ web/package.json | 2 + web/src/lib/api/aggregate.gen.ts | 4 + web/src/routes/+page.svelte | 2 +- web/src/routes/ips/+page.svelte | 146 ++++++++++++++++++- web/src/routes/users/+page.svelte | 2 +- web/src/routes/users/[username]/+page.svelte | 2 +- 12 files changed, 197 insertions(+), 5 deletions(-) diff --git a/docs/AGGREGATE.md b/docs/AGGREGATE.md index f715192..02d9877 100644 --- a/docs/AGGREGATE.md +++ b/docs/AGGREGATE.md @@ -29,7 +29,7 @@ | --- | --- | --- | | GET | `/api/agg/summary` | Сводка по флоту, список опросов upstream, `fleet_total_megabytes` / `fleet_total_connections`. Два топа (размер задаётся `top_n`): **`top_users`** — самые «прожорливые» по суммарному трафику (MiB) по всем серверам; **`top_users_by_unique_ips`** — по максимальному `active_unique_ips` среди серверов для пользователя (как в Telemt, снимок). | | GET | `/api/agg/traffic` | Трафик по каждому пользователю в разрезе серверов: `servers..total_megabytes`. | -| GET | `/api/agg/unique-ips` | Уникальные IP по пользователю: на каких серверах IP есть в active/recent списках снимка. При **`geoip.enabled`** в конфиге — из City: `country_code`, `country_name`, `city_name`; при наличии ASN-БД — `asn`, `as_organization` (см. [GEOIP.md](GEOIP.md)); отключить гео для запроса: `?geo=false`. | +| GET | `/api/agg/unique-ips` | Уникальные IP по пользователю: на каких серверах IP есть в active/recent списках снимка. При **`geoip.enabled`** в конфиге — из City: `country_code`, `country_name`, `city_name`, а также `latitude`, `longitude` (если доступны); при наличии ASN-БД — `asn`, `as_organization` (см. [GEOIP.md](GEOIP.md)); отключить гео для запроса: `?geo=false`. | | GET | `/api/agg/users` | Объединённый список пользователей с `by_server`, суммарным `total_megabytes` и **смерженными лимитами** (см. ниже). | | GET | `/api/agg/user/{username}` | Один пользователь в том же формате, что элементы `/api/agg/users` (без списка всех). Имя в пути: `[A-Za-z0-9_.-]+`. Ответ **`404`**, если пользователь не найден ни на одном успешном upstream. | | GET | `/api/agg/fleet-status` | По каждому алиасу: параллельно health + system/info; в `data.servers[]` — статусы подзапросов и тела `health` / `system_info` при успехе. См. [AGGREGATE_OPENAPI.yaml](AGGREGATE_OPENAPI.yaml). | diff --git a/docs/AGGREGATE_OPENAPI.yaml b/docs/AGGREGATE_OPENAPI.yaml index bc0db75..cf1ad02 100644 --- a/docs/AGGREGATE_OPENAPI.yaml +++ b/docs/AGGREGATE_OPENAPI.yaml @@ -211,6 +211,8 @@ components: country_code: { type: string, nullable: true } country_name: { type: string, nullable: true } city_name: { type: string, nullable: true } + latitude: { type: number, format: float, nullable: true } + longitude: { type: number, format: float, nullable: true } asn: { type: integer, format: int64, nullable: true } as_organization: { type: string, nullable: true } diff --git a/internal/aggregate/geo_enrich.go b/internal/aggregate/geo_enrich.go index e82b3ce..2d8d84d 100644 --- a/internal/aggregate/geo_enrich.go +++ b/internal/aggregate/geo_enrich.go @@ -28,6 +28,12 @@ func EnrichUniqueIPsGeo(rows []UniqueIPsRow, g *geoip.Service) { s := res.CityName rows[i].IPs[j].CityName = &s } + if res.Latitude != 0 || res.Longitude != 0 { + lat := res.Latitude + lon := res.Longitude + rows[i].IPs[j].Latitude = &lat + rows[i].IPs[j].Longitude = &lon + } if res.ASN != 0 { a := res.ASN rows[i].IPs[j].ASN = &a diff --git a/internal/aggregate/types.go b/internal/aggregate/types.go index 41cb5b6..70fe85b 100644 --- a/internal/aggregate/types.go +++ b/internal/aggregate/types.go @@ -115,6 +115,8 @@ type IPAssignments struct { CountryCode *string `json:"country_code,omitempty"` CountryName *string `json:"country_name,omitempty"` CityName *string `json:"city_name,omitempty"` + Latitude *float64 `json:"latitude,omitempty"` + Longitude *float64 `json:"longitude,omitempty"` ASN *uint64 `json:"asn,omitempty"` // GeoLite2-ASN ASOrganization *string `json:"as_organization,omitempty"` // ASN org name } diff --git a/internal/geoip/geoip.go b/internal/geoip/geoip.go index 1a0ba90..06a55dd 100644 --- a/internal/geoip/geoip.go +++ b/internal/geoip/geoip.go @@ -75,6 +75,8 @@ type Result struct { CountryCode string CountryName string CityName string + Latitude float64 + Longitude float64 ASN uint64 ASOrg string } @@ -99,6 +101,13 @@ func (s *Service) Lookup(ipStr string) (Result, bool) { if rec.City.Names != nil { out.CityName = rec.City.Names["en"] } + // Coordinates are used by the UI map. + // Note: MaxMind might still return 0/0 for unknown locations. + if rec.Location.Latitude != 0 || rec.Location.Longitude != 0 { + out.Latitude = rec.Location.Latitude + out.Longitude = rec.Location.Longitude + ok = true + } if out.CountryCode != "" || out.CountryName != "" || out.CityName != "" { ok = true } diff --git a/web/package-lock.json b/web/package-lock.json index ef752f8..7c3cf71 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -8,8 +8,10 @@ "name": "web", "version": "0.0.1", "dependencies": { + "@types/leaflet": "^1.9.21", "bits-ui": "^2.16.4", "clsx": "^2.1.1", + "leaflet": "^1.9.4", "tailwind-merge": "^3.5.0", "tailwind-variants": "^3.2.2" }, @@ -1338,6 +1340,21 @@ "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", "license": "MIT" }, + "node_modules/@types/geojson": { + "version": "7946.0.16", + "resolved": "https://registry.npmjs.org/@types/geojson/-/geojson-7946.0.16.tgz", + "integrity": "sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==", + "license": "MIT" + }, + "node_modules/@types/leaflet": { + "version": "1.9.21", + "resolved": "https://registry.npmjs.org/@types/leaflet/-/leaflet-1.9.21.tgz", + "integrity": "sha512-TbAd9DaPGSnzp6QvtYngntMZgcRk+igFELwR2N99XZn7RXUdKgsXMR+28bUO0rPsWp8MIu/f47luLIQuSLYv/w==", + "license": "MIT", + "dependencies": { + "@types/geojson": "*" + } + }, "node_modules/@types/trusted-types": { "version": "2.0.7", "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz", @@ -1774,6 +1791,12 @@ "node": ">=6" } }, + "node_modules/leaflet": { + "version": "1.9.4", + "resolved": "https://registry.npmjs.org/leaflet/-/leaflet-1.9.4.tgz", + "integrity": "sha512-nxS1ynzJOmOlHp+iL3FyWqK89GtNL8U8rvlMOsQdTTssxZwCXh8N2NB3GDQOL+YR3XnWyZAxwQixURb+FA74PA==", + "license": "BSD-2-Clause" + }, "node_modules/lightningcss": { "version": "1.32.0", "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", diff --git a/web/package.json b/web/package.json index c99807a..2276bda 100644 --- a/web/package.json +++ b/web/package.json @@ -28,8 +28,10 @@ "vite": "^7.3.1" }, "dependencies": { + "@types/leaflet": "^1.9.21", "bits-ui": "^2.16.4", "clsx": "^2.1.1", + "leaflet": "^1.9.4", "tailwind-merge": "^3.5.0", "tailwind-variants": "^3.2.2" } diff --git a/web/src/lib/api/aggregate.gen.ts b/web/src/lib/api/aggregate.gen.ts index efcef76..cefc446 100644 --- a/web/src/lib/api/aggregate.gen.ts +++ b/web/src/lib/api/aggregate.gen.ts @@ -310,6 +310,10 @@ export interface components { country_code?: string | null; country_name?: string | null; city_name?: string | null; + /** Format: float */ + latitude?: number | null; + /** Format: float */ + longitude?: number | null; /** Format: int64 */ asn?: number | null; as_organization?: string | null; diff --git a/web/src/routes/+page.svelte b/web/src/routes/+page.svelte index 780f7a6..120c738 100644 --- a/web/src/routes/+page.svelte +++ b/web/src/routes/+page.svelte @@ -461,7 +461,7 @@ {#if (activeIpServerRows?.length ?? 0) === 0} - + Нет активных подключений diff --git a/web/src/routes/ips/+page.svelte b/web/src/routes/ips/+page.svelte index fdbd4a0..cec3cc6 100644 --- a/web/src/routes/ips/+page.svelte +++ b/web/src/routes/ips/+page.svelte @@ -2,6 +2,8 @@ import { onMount } from 'svelte'; import { fetchAggUniqueIps, ApiError } from '$lib/api/client.js'; import type { components } from '$lib/api/aggregate.gen.js'; + import type * as Leaflet from 'leaflet'; + import 'leaflet/dist/leaflet.css'; import { flagEmoji } from '$lib/format.js'; import * as Card from '$lib/components/ui/card/index.js'; import { Alert, AlertDescription, AlertTitle } from '$lib/components/ui/alert/index.js'; @@ -16,10 +18,126 @@ let partial = $state(false); let geo = $state(true); + let mapEl = $state(null); + let leaflet: typeof import('leaflet') | null = null; + let map: Leaflet.Map | null = null; + let markersLayer: Leaflet.LayerGroup | null = null; + + async function ensureLeaflet() { + if (leaflet) return; + leaflet = await import('leaflet'); + } + + function initMap() { + if (!leaflet || !mapEl) return; + if (map) return; + + map = leaflet.map(mapEl, { zoomControl: true }); + map.setView([20, 0], 2); + leaflet + .tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', { + maxZoom: 19, + attribution: '© OpenStreetMap contributors' + }) + .addTo(map); + markersLayer = leaflet.layerGroup().addTo(map); + } + + function clearMap() { + markersLayer?.clearLayers(); + } + + function renderMap() { + initMap(); + if (!leaflet || !map || !markersLayer) return; + markersLayer.clearLayers(); + + // GeoIP для City сейчас идёт в координатах уровня city, + // поэтому группируем по округлённым координатам. + const groups = new Map< + string, + { + lat: number; + lon: number; + count: number; + country_code?: string | null; + city_name?: string | null; + asn?: number | null; + } + >(); + + for (const ur of rows ?? []) { + for (const ip of ur.ips ?? []) { + const lat = ip.latitude ?? null; + const lon = ip.longitude ?? null; + if (lat == null || lon == null) continue; + + // Округление: ~11 км по широте на 0.1 градуса. + const latG = Math.round(lat * 10) / 10; + const lonG = Math.round(lon * 10) / 10; + const country_code = ip.country_code ?? null; + const city_name = ip.city_name ?? null; + + // Ключ включает country/city, чтобы не смешивать разные локации + // при одинаковых координатах округления. + const key = `${latG}|${lonG}|${country_code ?? ''}|${city_name ?? ''}`; + const item = groups.get(key); + if (item) { + item.count++; + continue; + } + + groups.set(key, { + lat: latG, + lon: lonG, + count: 1, + country_code, + city_name, + asn: ip.asn ?? null + }); + } + } + + const points = Array.from(groups.values()); + if (points.length === 0) { + map.invalidateSize(); + return; + } + + for (const p of points) { + const r = Math.min(28, 5 + Math.log10(p.count + 1) * 9); + const color = + p.count >= 100 ? '#ef4444' : p.count >= 25 ? '#f97316' : p.count >= 5 ? '#22c55e' : '#60a5fa'; + + const tooltipParts = [ + `${p.count} IP`, + p.country_code ? p.country_code : '—', + p.city_name ? p.city_name : '' + ].filter(Boolean); + + const marker = leaflet.circleMarker([p.lat, p.lon], { + radius: r, + color, + fillColor: color, + fillOpacity: 0.35, + weight: 1 + }); + + marker.bindTooltip(tooltipParts.join(' · '), { direction: 'top', offset: [0, -6] }); + markersLayer.addLayer(marker); + } + + map.invalidateSize(); + const bounds = leaflet.latLngBounds(points.map((p) => [p.lat, p.lon] as [number, number])); + map.fitBounds(bounds.pad(0.2)); + } + async function load() { loading = true; err = null; try { + await ensureLeaflet(); + initMap(); const env = await fetchAggUniqueIps({ geo }); partial = !!env.partial; rows = env.data ?? []; @@ -27,10 +145,19 @@ err = e instanceof ApiError ? e.message : String(e); } finally { loading = false; + if (geo) { + renderMap(); + } else { + clearMap(); + } } } - onMount(load); + onMount(() => { + // Инициализация Leaflet и загрузка данных происходит в `load()`. + // Это избегает проблем с SSR (если он включён). + void load(); + });
@@ -65,6 +192,23 @@ {:else if loading && rows.length === 0}

Загрузка…

{:else} + {#if geo} + + + Карта подключений + GeoIP координаты из снимка `unique-ips` + + +
+
+
+ {:else} + + GeoIP выключен + Включите опцию GeoIP, чтобы увидеть карту с координатами. + + {/if} + diff --git a/web/src/routes/users/+page.svelte b/web/src/routes/users/+page.svelte index ce3e1b1..01bb779 100644 --- a/web/src/routes/users/+page.svelte +++ b/web/src/routes/users/+page.svelte @@ -118,7 +118,7 @@ {#each rows as row (row.username ?? '')} openUser(row.username)} diff --git a/web/src/routes/users/[username]/+page.svelte b/web/src/routes/users/[username]/+page.svelte index fcf6f25..c2ed6d3 100644 --- a/web/src/routes/users/[username]/+page.svelte +++ b/web/src/routes/users/[username]/+page.svelte @@ -204,7 +204,7 @@ {#if ipsForUser.length === 0} Нет активных IP