Add geographic coordinates to IP data and enhance map visualization
Publish telemt-api gateway Docker image / test (push) Successful in 24s
Publish telemt-api gateway Docker image / build-and-push (push) Successful in 1m55s

- 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.
This commit is contained in:
Denozordec
2026-03-30 15:26:14 +07:00
parent b69f2dd0e4
commit 452ee4f6f1
12 changed files with 197 additions and 5 deletions
+23
View File
@@ -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",
+2
View File
@@ -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"
}
+4
View File
@@ -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;
+1 -1
View File
@@ -461,7 +461,7 @@
<Table.Body>
{#if (activeIpServerRows?.length ?? 0) === 0}
<Table.Row>
<Table.Cell colspan="3" class="p-4 text-center text-muted-foreground">
<Table.Cell colspan={3} class="p-4 text-center text-muted-foreground">
Нет активных подключений
</Table.Cell>
</Table.Row>
+145 -1
View File
@@ -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<HTMLDivElement | null>(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: '&copy; 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();
});
</script>
<div class="mb-6 flex flex-wrap items-end justify-between gap-4">
@@ -65,6 +192,23 @@
{:else if loading && rows.length === 0}
<p class="text-muted-foreground">Загрузка…</p>
{:else}
{#if geo}
<Card.Root class="mb-6">
<Card.Header>
<Card.Title>Карта подключений</Card.Title>
<Card.Description>GeoIP координаты из снимка `unique-ips`</Card.Description>
</Card.Header>
<Card.Content class="p-0">
<div class="h-[420px] w-full rounded-md" bind:this={mapEl}></div>
</Card.Content>
</Card.Root>
{:else}
<Alert class="mb-6">
<AlertTitle>GeoIP выключен</AlertTitle>
<AlertDescription>Включите опцию GeoIP, чтобы увидеть карту с координатами.</AlertDescription>
</Alert>
{/if}
<Card.Root>
<Card.Content class="p-0">
<Table.Root>
+1 -1
View File
@@ -118,7 +118,7 @@
{#each rows as row (row.username ?? '')}
<Table.Row
class="cursor-pointer focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/40"
tabindex="0"
tabindex={0}
role="button"
aria-label={`Открыть пользователя ${row.username ?? ''}`}
onclick={() => openUser(row.username)}
+1 -1
View File
@@ -204,7 +204,7 @@
{#if ipsForUser.length === 0}
<Table.Row>
<Table.Cell
colspan="3"
colspan={3}
class="p-4 text-center text-muted-foreground whitespace-normal"
>
Нет активных IP