Refactor IP server presence display and enhance tooltip functionality
Publish telemt-api gateway Docker image / test (push) Successful in 35s
Publish telemt-api gateway Docker image / build-and-push (push) Successful in 2m48s

- Updated the `IpServerPresence` component to improve the display of active and recent servers for IPs, enhancing code clarity.
- Added tooltips to the "Servers" column in various tables, providing users with additional context about the displayed server lists.
- Refactored server-related data handling to ensure consistency and better user experience across multiple routes.
This commit is contained in:
Denozordec
2026-03-31 00:06:32 +07:00
parent 46d080e70a
commit 992eebd842
6 changed files with 139 additions and 36 deletions
@@ -64,7 +64,10 @@
class="font-normal px-2 py-0.5 gap-1" class="font-normal px-2 py-0.5 gap-1"
href={serverHref(srv)} href={serverHref(srv)}
> >
<StarIcon class="size-3 shrink-0 opacity-90" aria-hidden="true" /> <StarIcon
class="size-3 shrink-0 fill-amber-400 stroke-amber-400 text-amber-400 drop-shadow-[0_0_5px_rgba(251,191,36,0.55)]"
aria-hidden="true"
/>
{srv} {srv}
</Badge> </Badge>
</Tooltip.Trigger> </Tooltip.Trigger>
@@ -0,0 +1,35 @@
<script lang="ts">
import { Badge, type BadgeVariant } from '$lib/components/ui/badge/index.js';
import { cn } from '$lib/utils.js';
let {
aliases,
variant = 'outline',
linkToServer = true,
class: className
}: {
aliases: string[] | null | undefined;
variant?: BadgeVariant;
linkToServer?: boolean;
class?: string;
} = $props();
const list = $derived(((aliases ?? []) as string[]).filter(Boolean));
function href(alias: string): string | undefined {
if (!linkToServer) return undefined;
return `/servers/${encodeURIComponent(alias)}`;
}
</script>
{#if list.length === 0}
<span class="text-muted-foreground"></span>
{:else}
<div class={cn('flex flex-wrap items-center gap-1', className)}>
{#each list as alias (alias)}
<Badge variant={variant} class="font-normal px-2 py-0.5" href={href(alias)}>
{alias}
</Badge>
{/each}
</div>
{/if}
+56 -20
View File
@@ -13,6 +13,8 @@
import type { components } from '$lib/api/aggregate.gen.js'; import type { components } from '$lib/api/aggregate.gen.js';
import { formatDurationSeconds, formatMiB, flagEmoji } from '$lib/format.js'; import { formatDurationSeconds, formatMiB, flagEmoji } from '$lib/format.js';
import { Badge } from '$lib/components/ui/badge/index.js'; import { Badge } from '$lib/components/ui/badge/index.js';
import * as Tooltip from '$lib/components/ui/tooltip/index.js';
import IpServerPresence from '$lib/components/ip-server-presence.svelte';
import * as Card from '$lib/components/ui/card/index.js'; import * as Card from '$lib/components/ui/card/index.js';
import { Alert, AlertDescription, AlertTitle } from '$lib/components/ui/alert/index.js'; import { Alert, AlertDescription, AlertTitle } from '$lib/components/ui/alert/index.js';
import * as Table from '$lib/components/ui/table/index.js'; import * as Table from '$lib/components/ui/table/index.js';
@@ -31,6 +33,7 @@
import GlobeIcon from '@lucide/svelte/icons/globe'; import GlobeIcon from '@lucide/svelte/icons/globe';
import ActivityIcon from '@lucide/svelte/icons/activity'; import ActivityIcon from '@lucide/svelte/icons/activity';
import LockKeyholeIcon from '@lucide/svelte/icons/lock-keyhole'; import LockKeyholeIcon from '@lucide/svelte/icons/lock-keyhole';
import InfoIcon from '@lucide/svelte/icons/info';
/** Семантика дашборда: граница + иконка + при необходимости цвет числа (WCAG: акцент не только цветом — иконка + подпись). */ /** Семантика дашборда: граница + иконка + при необходимости цвет числа (WCAG: акцент не только цветом — иконка + подпись). */
const kpiAccent = { const kpiAccent = {
@@ -331,14 +334,23 @@
username: string; username: string;
ip: string; ip: string;
country_code: string | null | undefined; country_code: string | null | undefined;
servers: string[];
activeLabel: string; activeLabel: string;
active_on_servers: string[];
recent_on_servers: string[];
primary_server: string | null;
}; };
let activeIpServerRows = $derived.by((): ActiveIpServerRow[] => { let activeIpServerRows = $derived.by((): ActiveIpServerRow[] => {
const map = new Map< const map = new Map<
string, string,
{ row: Omit<ActiveIpServerRow, 'servers'>; serversSet: Set<string> } {
row: Omit<
ActiveIpServerRow,
'active_on_servers' | 'recent_on_servers' | 'primary_server'
>;
activeSet: Set<string>;
recentSet: Set<string>;
}
>(); >();
const maxUniqueIpRows = 80; const maxUniqueIpRows = 80;
const maxOutRows = 2000; const maxOutRows = 2000;
@@ -351,8 +363,8 @@
const ip = ipa.ip ?? ''; const ip = ipa.ip ?? '';
if (!ip) continue; if (!ip) continue;
const servers = (ipa.active_on_servers ?? []).filter(Boolean); const active = (ipa.active_on_servers ?? []).filter(Boolean);
if (servers.length === 0) continue; if (active.length === 0) continue;
const key = `${username}|${ip}`; const key = `${username}|${ip}`;
let item = map.get(key); let item = map.get(key);
@@ -365,23 +377,33 @@
country_code: ipa.country_code, country_code: ipa.country_code,
activeLabel: chipLabel(ipa) activeLabel: chipLabel(ipa)
}, },
serversSet: new Set<string>() activeSet: new Set<string>(),
recentSet: new Set<string>()
}; };
map.set(key, item); map.set(key, item);
} }
for (const server of servers) { for (const server of active) {
if (!server) continue; if (!server) continue;
item.serversSet.add(server); item.activeSet.add(server);
}
for (const server of (ipa.recent_on_servers ?? []).filter(Boolean)) {
if (!server) continue;
item.recentSet.add(server);
} }
} }
} }
const out: ActiveIpServerRow[] = []; const out: ActiveIpServerRow[] = [];
for (const { row, serversSet } of map.values()) { for (const { row, activeSet, recentSet } of map.values()) {
const active_on_servers = [...activeSet].sort((a, b) => a.localeCompare(b));
const recent_on_servers = [...recentSet].sort((a, b) => a.localeCompare(b));
const primary_server = active_on_servers.length === 1 ? active_on_servers[0] : null;
out.push({ out.push({
...row, ...row,
servers: [...serversSet].sort((a, b) => a.localeCompare(b)) active_on_servers,
recent_on_servers,
primary_server
}); });
} }
@@ -594,7 +616,25 @@
<Table.Row> <Table.Row>
<Table.Head class="text-xs sm:text-sm">IP со страной</Table.Head> <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">Клиент</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.Row> </Table.Row>
</Table.Header> </Table.Header>
<Table.Body> <Table.Body>
@@ -618,16 +658,12 @@
{r.username} {r.username}
</a> </a>
</Table.Cell> </Table.Cell>
<Table.Cell class="text-xs sm:text-sm whitespace-normal"> <Table.Cell class="max-w-[280px] align-top text-xs sm:text-sm whitespace-normal">
<div class="flex flex-wrap items-center gap-1"> <IpServerPresence
{#each r.servers as srv (srv)} active_on_servers={r.active_on_servers}
<a href="/servers/{encodeURIComponent(srv)}" class="inline-flex"> recent_on_servers={r.recent_on_servers}
<Badge variant="secondary" class="font-normal px-2 py-0.5"> primary_server={r.primary_server}
{srv} />
</Badge>
</a>
{/each}
</div>
</Table.Cell> </Table.Cell>
</Table.Row> </Table.Row>
{/each} {/each}
+21 -7
View File
@@ -12,6 +12,8 @@
import * as Card from '$lib/components/ui/card/index.js'; import * as Card from '$lib/components/ui/card/index.js';
import * as Table from '$lib/components/ui/table/index.js'; import * as Table from '$lib/components/ui/table/index.js';
import { Badge } from '$lib/components/ui/badge/index.js'; import { Badge } from '$lib/components/ui/badge/index.js';
import * as Tooltip from '$lib/components/ui/tooltip/index.js';
import ServerAliasBadges from '$lib/components/server-alias-badges.svelte';
import { Button } from '$lib/components/ui/button/index.js'; import { Button } from '$lib/components/ui/button/index.js';
import { Input } from '$lib/components/ui/input/index.js'; import { Input } from '$lib/components/ui/input/index.js';
import { Skeleton } from '$lib/components/ui/skeleton/index.js'; import { Skeleton } from '$lib/components/ui/skeleton/index.js';
@@ -20,6 +22,7 @@
import FleetLiveStaleBadge from '$lib/components/fleet/fleet-live-stale-badge.svelte'; import FleetLiveStaleBadge from '$lib/components/fleet/fleet-live-stale-badge.svelte';
import FleetRefreshInput from '$lib/components/fleet/fleet-refresh-input.svelte'; import FleetRefreshInput from '$lib/components/fleet/fleet-refresh-input.svelte';
import RefreshCwIcon from '@lucide/svelte/icons/refresh-cw'; import RefreshCwIcon from '@lucide/svelte/icons/refresh-cw';
import InfoIcon from '@lucide/svelte/icons/info';
type TriageState = { type TriageState = {
ack?: boolean; ack?: boolean;
@@ -295,7 +298,22 @@
<Table.Row> <Table.Row>
<Table.Head>Severity</Table.Head> <Table.Head>Severity</Table.Head>
<Table.Head>Инцидент</Table.Head> <Table.Head>Инцидент</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.Head>Runbook</Table.Head> <Table.Head>Runbook</Table.Head>
<Table.Head>Triage</Table.Head> <Table.Head>Triage</Table.Head>
</Table.Row> </Table.Row>
@@ -322,12 +340,8 @@
</div> </div>
{/if} {/if}
</Table.Cell> </Table.Cell>
<Table.Cell> <Table.Cell class="max-w-[240px] align-top text-xs">
<div class="flex flex-wrap gap-1"> <ServerAliasBadges aliases={item.affected_aliases ?? []} variant="outline" />
{#each item.affected_aliases ?? [] as alias (alias)}
<Badge variant="outline">{alias}</Badge>
{/each}
</div>
</Table.Cell> </Table.Cell>
<Table.Cell class="max-w-[240px]"> <Table.Cell class="max-w-[240px]">
<div class="flex flex-wrap gap-1"> <div class="flex flex-wrap gap-1">
+21 -7
View File
@@ -12,11 +12,14 @@
import * as Card from '$lib/components/ui/card/index.js'; import * as Card from '$lib/components/ui/card/index.js';
import { Alert, AlertDescription, AlertTitle } from '$lib/components/ui/alert/index.js'; import { Alert, AlertDescription, AlertTitle } from '$lib/components/ui/alert/index.js';
import { Badge } from '$lib/components/ui/badge/index.js'; import { Badge } from '$lib/components/ui/badge/index.js';
import * as Tooltip from '$lib/components/ui/tooltip/index.js';
import ServerAliasBadges from '$lib/components/server-alias-badges.svelte';
import { Button } from '$lib/components/ui/button/index.js'; import { Button } from '$lib/components/ui/button/index.js';
import * as Table from '$lib/components/ui/table/index.js'; import * as Table from '$lib/components/ui/table/index.js';
import AliasFilterSelect from '$lib/components/alias-filter-select.svelte'; import AliasFilterSelect from '$lib/components/alias-filter-select.svelte';
import FleetLiveStaleBadge from '$lib/components/fleet/fleet-live-stale-badge.svelte'; import FleetLiveStaleBadge from '$lib/components/fleet/fleet-live-stale-badge.svelte';
import RefreshCwIcon from '@lucide/svelte/icons/refresh-cw'; import RefreshCwIcon from '@lucide/svelte/icons/refresh-cw';
import InfoIcon from '@lucide/svelte/icons/info';
type LiveStatus = 'healthy' | 'degraded' | 'critical'; type LiveStatus = 'healthy' | 'degraded' | 'critical';
@@ -337,7 +340,22 @@
<Table.Head>Severity</Table.Head> <Table.Head>Severity</Table.Head>
<Table.Head>Title</Table.Head> <Table.Head>Title</Table.Head>
<Table.Head>Summary</Table.Head> <Table.Head>Summary</Table.Head>
<Table.Head>Aliases</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.Row>
</Table.Header> </Table.Header>
<Table.Body> <Table.Body>
@@ -355,12 +373,8 @@
</Table.Cell> </Table.Cell>
<Table.Cell class="font-medium">{item.title}</Table.Cell> <Table.Cell class="font-medium">{item.title}</Table.Cell>
<Table.Cell class="max-w-[380px] text-sm text-muted-foreground">{item.summary}</Table.Cell> <Table.Cell class="max-w-[380px] text-sm text-muted-foreground">{item.summary}</Table.Cell>
<Table.Cell> <Table.Cell class="max-w-[240px] align-top text-xs">
<div class="flex flex-wrap gap-1"> <ServerAliasBadges aliases={item.affected_aliases ?? []} variant="outline" />
{#each item.affected_aliases ?? [] as alias (alias)}
<Badge variant="outline">{alias}</Badge>
{/each}
</div>
</Table.Cell> </Table.Cell>
</Table.Row> </Table.Row>
{/each} {/each}
+2 -1
View File
@@ -11,6 +11,7 @@
import * as Table from '$lib/components/ui/table/index.js'; import * as Table from '$lib/components/ui/table/index.js';
import * as Tooltip from '$lib/components/ui/tooltip/index.js'; import * as Tooltip from '$lib/components/ui/tooltip/index.js';
import IpServerPresence from '$lib/components/ip-server-presence.svelte'; import IpServerPresence from '$lib/components/ip-server-presence.svelte';
import ServerAliasBadges from '$lib/components/server-alias-badges.svelte';
import CopyIcon from '@lucide/svelte/icons/copy'; import CopyIcon from '@lucide/svelte/icons/copy';
import InfoIcon from '@lucide/svelte/icons/info'; import InfoIcon from '@lucide/svelte/icons/info';
import { toast } from 'svelte-sonner'; import { toast } from 'svelte-sonner';
@@ -159,7 +160,7 @@
<Card.Content class="space-y-2"> <Card.Content class="space-y-2">
{#each Object.entries(row.by_server ?? {}) as [alias, st] (alias)} {#each Object.entries(row.by_server ?? {}) as [alias, st] (alias)}
<div class="flex flex-wrap items-center justify-between gap-2 rounded-md border border-border px-3 py-2 text-sm"> <div class="flex flex-wrap items-center justify-between gap-2 rounded-md border border-border px-3 py-2 text-sm">
<Badge variant="secondary">{alias}</Badge> <ServerAliasBadges aliases={[alias]} variant="secondary" />
<span class="text-muted-foreground tabular-nums"> <span class="text-muted-foreground tabular-nums">
{formatMiB(st.total_megabytes ?? 0)} · conn {st.current_connections ?? 0} {formatMiB(st.total_megabytes ?? 0)} · conn {st.current_connections ?? 0}
</span> </span>