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.
This commit is contained in:
Generated
+23
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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: '© 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>
|
||||
|
||||
@@ -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)}
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user