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:
@@ -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