Enhance IP display and server presence in Svelte components
Publish telemt-api gateway Docker image / test (push) Successful in 27s
Publish telemt-api gateway Docker image / build-and-push (push) Successful in 2m36s

- Added `IpServerPresence` component to streamline the display of active and recent servers for IPs, improving code readability and maintainability.
- Introduced tooltips for the "Servers" column in tables to provide additional context on active and recent server lists, enhancing user experience.
- Updated text descriptions for clarity and consistency across the IP and user pages, ensuring better understanding of the displayed data.
This commit is contained in:
Denozordec
2026-03-30 23:58:05 +07:00
parent 141d900594
commit 46d080e70a
3 changed files with 182 additions and 46 deletions
@@ -0,0 +1,120 @@
<script lang="ts">
import { Badge } from '$lib/components/ui/badge/index.js';
import { Separator } from '$lib/components/ui/separator/index.js';
import * as Tooltip from '$lib/components/ui/tooltip/index.js';
import { cn } from '$lib/utils.js';
import InfoIcon from '@lucide/svelte/icons/info';
import StarIcon from '@lucide/svelte/icons/star';
let {
active_on_servers,
recent_on_servers,
primary_server,
linkToServer = true,
class: className
}: {
active_on_servers?: string[] | null;
recent_on_servers?: string[] | null;
primary_server?: string | null;
/** Ссылка на `/servers/{alias}` у бейджей */
linkToServer?: boolean;
class?: string;
} = $props();
const activeServers = $derived(((active_on_servers ?? []) as string[]).filter(Boolean));
const recentServers = $derived(((recent_on_servers ?? []) as string[]).filter(Boolean));
const recentOnly = $derived(recentServers.filter((s) => !activeServers.includes(s)));
const soleActiveTip =
'Единственный сервер в активных на этом снимке (не приоритет, а однозначность).';
function serverHref(alias: string): string | undefined {
if (!linkToServer) return undefined;
return `/servers/${encodeURIComponent(alias)}`;
}
</script>
<div class={cn('space-y-2 text-xs', className)}>
{#if activeServers.length > 0}
<div class="space-y-1">
<div class="flex flex-wrap items-center gap-1">
<span class="text-muted-foreground">Активные</span>
<Tooltip.Root>
<Tooltip.Trigger
class="inline-flex size-3.5 shrink-0 items-center justify-center rounded-sm text-muted-foreground hover:text-foreground"
aria-label="Пояснение: активные"
>
<InfoIcon class="size-3.5" />
</Tooltip.Trigger>
<Tooltip.Content side="top" class="max-w-xs">
<p class="text-xs">
Серверы, где этот IP есть в списке активных уникальных адресов на момент снимка
шлюза.
</p>
</Tooltip.Content>
</Tooltip.Root>
</div>
<div class="flex flex-wrap items-center gap-1">
{#each activeServers as srv (srv)}
{#if primary_server && srv === primary_server && activeServers.length === 1}
<Tooltip.Root>
<Tooltip.Trigger class="inline-flex">
<Badge
variant="secondary"
class="font-normal px-2 py-0.5 gap-1"
href={serverHref(srv)}
>
<StarIcon class="size-3 shrink-0 opacity-90" aria-hidden="true" />
{srv}
</Badge>
</Tooltip.Trigger>
<Tooltip.Content side="bottom" class="max-w-xs">
<p class="text-xs">{soleActiveTip}</p>
</Tooltip.Content>
</Tooltip.Root>
{:else}
<Badge variant="secondary" class="font-normal px-2 py-0.5" href={serverHref(srv)}>
{srv}
</Badge>
{/if}
{/each}
</div>
</div>
{:else}
<span class="text-muted-foreground">
{recentOnly.length > 0 ? 'Нет в активных' : '—'}
</span>
{/if}
{#if recentOnly.length > 0}
{#if activeServers.length > 0}
<Separator class="my-1" />
{/if}
<div class="space-y-1">
<div class="flex flex-wrap items-center gap-1">
<span class="text-muted-foreground">Только недавние</span>
<Tooltip.Root>
<Tooltip.Trigger
class="inline-flex size-3.5 shrink-0 items-center justify-center rounded-sm text-muted-foreground hover:text-foreground"
aria-label="Пояснение: только недавние"
>
<InfoIcon class="size-3.5" />
</Tooltip.Trigger>
<Tooltip.Content side="top" class="max-w-xs">
<p class="text-xs">
Серверы, где IP есть в недавнем списке, но на этом снимке не в активных — например
недавнее подключение с другого шлюза.
</p>
</Tooltip.Content>
</Tooltip.Root>
</div>
<div class="flex flex-wrap items-center gap-1">
{#each recentOnly as srv (srv)}
<Badge variant="outline" class="font-normal px-2 py-0.5" href={serverHref(srv)}>
{srv}
</Badge>
{/each}
</div>
</div>
{/if}
</div>
+31 -34
View File
@@ -12,10 +12,13 @@
import { Button } from '$lib/components/ui/button/index.js';
import { Badge } from '$lib/components/ui/badge/index.js';
import * as Table from '$lib/components/ui/table/index.js';
import * as Tooltip from '$lib/components/ui/tooltip/index.js';
import IpServerPresence from '$lib/components/ip-server-presence.svelte';
import { Skeleton } from '$lib/components/ui/skeleton/index.js';
import { Switch } from '$lib/components/ui/switch/index.js';
import { Label } from '$lib/components/ui/label/index.js';
import RefreshCwIcon from '@lucide/svelte/icons/refresh-cw';
import InfoIcon from '@lucide/svelte/icons/info';
import AliasFilterSelect from '$lib/components/alias-filter-select.svelte';
import DataQueryState from '$lib/components/fleet/data-query-state.svelte';
import FleetLiveStaleBadge from '$lib/components/fleet/fleet-live-stale-badge.svelte';
@@ -428,7 +431,9 @@
<div class="mb-6 flex flex-wrap items-end justify-between gap-4">
<div>
<h1 class="text-2xl font-semibold tracking-tight">Уникальные IP</h1>
<p class="text-sm text-muted-foreground">Снимок active/recent с шлюза; GeoIP из конфига шлюза.</p>
<p class="text-sm text-muted-foreground">
Снимок активных и недавних списков с шлюза; GeoIP из конфига шлюза.
</p>
</div>
<div class="flex items-center gap-2">
<FleetLiveStaleBadge {isStale} {staleSeconds} refreshSeconds={refreshSeconds} />
@@ -500,7 +505,7 @@
<Card.Root class="mb-6">
<Card.Header>
<Card.Title>Карта подключений</Card.Title>
<Card.Description>GeoIP координаты из снимка `unique-ips`</Card.Description>
<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>
@@ -531,7 +536,24 @@
<Table.Head>Пользователь</Table.Head>
<Table.Head>IP</Table.Head>
<Table.Head>Geo</Table.Head>
<Table.Head>Серверы</Table.Head>
<Table.Head>
<div class="flex items-center gap-1">
Серверы
<Tooltip.Root>
<Tooltip.Trigger
class="inline-flex size-3.5 shrink-0 items-center justify-center rounded-sm text-muted-foreground hover:text-foreground"
aria-label="Пояснение колонки"
>
<InfoIcon class="size-3.5" />
</Tooltip.Trigger>
<Tooltip.Content side="bottom" class="max-w-xs">
<p class="text-xs">
Активные и недавние — списки на момент снимка шлюза; в ячейке пояснены группы.
</p>
</Tooltip.Content>
</Tooltip.Root>
</div>
</Table.Head>
</Table.Row>
</Table.Header>
<Table.Body>
@@ -554,37 +576,12 @@
<Badge variant="outline" class="ml-1">AS{ip.asn}</Badge>
{/if}
</Table.Cell>
<Table.Cell class="max-w-[240px] text-xs">
{@const activeServers = (ip.active_on_servers ?? []).filter(Boolean)}
{@const recentServers = (ip.recent_on_servers ?? []).filter(Boolean)}
{@const primaryServer = ip.primary_server ?? null}
{@const recentOnly = recentServers.filter((s) => !activeServers.includes(s))}
{#if activeServers.length === 0}
<span class="text-muted-foreground"></span>
{:else}
<div class="flex flex-wrap items-center gap-1">
{#each activeServers as srv (srv)}
<Badge variant="secondary" class="font-normal px-2 py-0.5">
{srv}
</Badge>
{#if primaryServer && srv === primaryServer}
<Badge class="font-normal px-2 py-0.5">primary</Badge>
{/if}
{/each}
</div>
{/if}
{#if recentOnly.length > 0}
<div class="mt-1 flex flex-wrap items-center gap-1">
<Badge variant="outline" class="font-normal px-2 py-0.5">recent</Badge>
{#each recentOnly as srv (srv)}
<Badge variant="outline" class="font-normal px-2 py-0.5">
{srv}
</Badge>
{/each}
</div>
{/if}
<Table.Cell class="max-w-[280px] align-top text-xs">
<IpServerPresence
active_on_servers={ip.active_on_servers}
recent_on_servers={ip.recent_on_servers}
primary_server={ip.primary_server ?? null}
/>
</Table.Cell>
</Table.Row>
{/each}
+31 -12
View File
@@ -9,7 +9,10 @@
import { Badge } from '$lib/components/ui/badge/index.js';
import { Button } from '$lib/components/ui/button/index.js';
import * as Table from '$lib/components/ui/table/index.js';
import * as Tooltip from '$lib/components/ui/tooltip/index.js';
import IpServerPresence from '$lib/components/ip-server-presence.svelte';
import CopyIcon from '@lucide/svelte/icons/copy';
import InfoIcon from '@lucide/svelte/icons/info';
import { toast } from 'svelte-sonner';
let loading = $state(true);
@@ -187,7 +190,9 @@
<Card.Root class="mt-4">
<Card.Header>
<Card.Title>IP и подключения</Card.Title>
<Card.Description>active/recent с серверами и Geo/ASN (если доступно)</Card.Description>
<Card.Description>
Снимок шлюза: активные и недавние списки по серверам; Geo/ASN при доступности данных.
</Card.Description>
</Card.Header>
<Card.Content class="p-0">
{#if ipErr}
@@ -202,7 +207,25 @@
<Table.Header>
<Table.Row>
<Table.Head class="text-xs sm:text-sm">IP</Table.Head>
<Table.Head class="text-xs sm:text-sm">Серверы</Table.Head>
<Table.Head class="text-xs sm:text-sm">
<div class="flex items-center gap-1">
Серверы
<Tooltip.Root>
<Tooltip.Trigger
class="inline-flex size-3.5 shrink-0 items-center justify-center rounded-sm text-muted-foreground hover:text-foreground"
aria-label="Пояснение колонки"
>
<InfoIcon class="size-3.5" />
</Tooltip.Trigger>
<Tooltip.Content side="bottom" class="max-w-xs">
<p class="text-xs">
Активные и недавние — это списки на момент снимка шлюза; в ячейке коротко
пояснено, что означает каждая группа.
</p>
</Tooltip.Content>
</Tooltip.Root>
</div>
</Table.Head>
<Table.Head class="text-xs sm:text-sm">Geo / ASN</Table.Head>
</Table.Row>
</Table.Header>
@@ -224,16 +247,12 @@
>
{ip.ip}
</Table.Cell>
<Table.Cell class="text-xs sm:text-sm text-muted-foreground whitespace-normal">
active: {((ip.active_on_servers ?? []).filter(Boolean) as string[]).join(', ') || '—'}
{#if (ip.recent_on_servers?.length ?? 0) > 0}
<br />
recent: {(ip.recent_on_servers ?? []).filter(Boolean).join(', ')}
{/if}
{#if ip.primary_server}
<br />
primary: {ip.primary_server}
{/if}
<Table.Cell class="text-xs sm:text-sm whitespace-normal align-top min-w-[12rem]">
<IpServerPresence
active_on_servers={ip.active_on_servers}
recent_on_servers={ip.recent_on_servers}
primary_server={ip.primary_server ?? null}
/>
</Table.Cell>
<Table.Cell class="text-xs sm:text-sm whitespace-normal">
<div class="space-y-1">