Refactor user and IP data handling in user pages
- Simplified the user data loading process by consolidating API calls for user and unique IP data, enhancing error handling and data presentation. - Updated the user table to improve interaction, allowing users to click on usernames for detailed views. - Introduced a new section for displaying active IPs and their associated server information, improving the clarity and usability of the user details page. - Enhanced the overall user experience with better error messaging for IP data retrieval.
This commit is contained in:
@@ -1,11 +1,11 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { onMount } from 'svelte';
|
import { onMount } from 'svelte';
|
||||||
import { fetchAggUsers, fetchAggUniqueIps, ApiError } from '$lib/api/client.js';
|
import { goto } from '$app/navigation';
|
||||||
|
import { fetchAggUsers, ApiError } from '$lib/api/client.js';
|
||||||
import type { components } from '$lib/api/aggregate.gen.js';
|
import type { components } from '$lib/api/aggregate.gen.js';
|
||||||
import { formatBytes, formatMiB } from '$lib/format.js';
|
import { formatMiB } from '$lib/format.js';
|
||||||
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 { Badge } from '$lib/components/ui/badge/index.js';
|
|
||||||
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 CopyIcon from '@lucide/svelte/icons/copy';
|
import CopyIcon from '@lucide/svelte/icons/copy';
|
||||||
@@ -14,7 +14,6 @@
|
|||||||
let loading = $state(true);
|
let loading = $state(true);
|
||||||
let err = $state<string | null>(null);
|
let err = $state<string | null>(null);
|
||||||
let rows = $state<components['schemas']['UsersRow'][]>([]);
|
let rows = $state<components['schemas']['UsersRow'][]>([]);
|
||||||
let uniqueIps = $state<components['schemas']['UniqueIPsRow'][]>([]);
|
|
||||||
let partial = $state(false);
|
let partial = $state(false);
|
||||||
let includeLinks = $state(false);
|
let includeLinks = $state(false);
|
||||||
|
|
||||||
@@ -22,13 +21,9 @@
|
|||||||
loading = true;
|
loading = true;
|
||||||
err = null;
|
err = null;
|
||||||
try {
|
try {
|
||||||
const [usersEnv, uniqueIpsEnv] = await Promise.all([
|
const usersEnv = await fetchAggUsers({ include_links: includeLinks });
|
||||||
fetchAggUsers({ include_links: includeLinks }),
|
partial = !!usersEnv.partial;
|
||||||
fetchAggUniqueIps({ geo: false })
|
|
||||||
]);
|
|
||||||
partial = !!(usersEnv.partial || uniqueIpsEnv.partial);
|
|
||||||
rows = usersEnv.data ?? [];
|
rows = usersEnv.data ?? [];
|
||||||
uniqueIps = uniqueIpsEnv.data ?? [];
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
err = e instanceof ApiError ? e.message : String(e);
|
err = e instanceof ApiError ? e.message : String(e);
|
||||||
} finally {
|
} finally {
|
||||||
@@ -38,41 +33,15 @@
|
|||||||
|
|
||||||
onMount(load);
|
onMount(load);
|
||||||
|
|
||||||
type IPServerPair = {
|
|
||||||
ip: string;
|
|
||||||
server: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
let ipServerPairsByUser = $derived.by((): Record<string, IPServerPair[]> => {
|
|
||||||
const byUser: Record<string, IPServerPair[]> = {};
|
|
||||||
for (const ur of uniqueIps) {
|
|
||||||
const username = ur.username ?? '';
|
|
||||||
if (!username) continue;
|
|
||||||
|
|
||||||
// Дедупликация по ключу `${ip}|${server}`.
|
|
||||||
const dedupe: Record<string, IPServerPair> = {};
|
|
||||||
|
|
||||||
for (const ipa of ur.ips ?? []) {
|
|
||||||
const ip = ipa.ip;
|
|
||||||
if (!ip) continue;
|
|
||||||
|
|
||||||
for (const server of ipa.active_on_servers ?? []) {
|
|
||||||
if (!server) continue;
|
|
||||||
const key = `${ip}|${server}`;
|
|
||||||
dedupe[key] = { ip, server };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const pairs = Object.values(dedupe);
|
|
||||||
pairs.sort((a, b) => a.ip.localeCompare(b.ip) || a.server.localeCompare(b.server));
|
|
||||||
byUser[username] = pairs;
|
|
||||||
}
|
|
||||||
return byUser;
|
|
||||||
});
|
|
||||||
|
|
||||||
function copy(text: string) {
|
function copy(text: string) {
|
||||||
void navigator.clipboard.writeText(text);
|
void navigator.clipboard.writeText(text);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function openUser(username: string | null | undefined) {
|
||||||
|
const u = username ?? '';
|
||||||
|
if (!u) return;
|
||||||
|
void goto(`/users/${encodeURIComponent(u)}`);
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="mb-6 flex flex-wrap items-end justify-between gap-4">
|
<div class="mb-6 flex flex-wrap items-end justify-between gap-4">
|
||||||
@@ -117,101 +86,51 @@
|
|||||||
<Table.Head>Имя</Table.Head>
|
<Table.Head>Имя</Table.Head>
|
||||||
<Table.Head>Ссылки</Table.Head>
|
<Table.Head>Ссылки</Table.Head>
|
||||||
<Table.Head class="text-right">Трафик</Table.Head>
|
<Table.Head class="text-right">Трафик</Table.Head>
|
||||||
<Table.Head class="text-right">IP - сервер</Table.Head>
|
<Table.Head class="text-right">Активных IP</Table.Head>
|
||||||
<Table.Head class="text-right">Квота</Table.Head>
|
|
||||||
<Table.Head>Истекает</Table.Head>
|
<Table.Head>Истекает</Table.Head>
|
||||||
</Table.Row>
|
</Table.Row>
|
||||||
</Table.Header>
|
</Table.Header>
|
||||||
<Table.Body>
|
<Table.Body>
|
||||||
{#each rows as row (row.username)}
|
{#each rows as row (row.username ?? '')}
|
||||||
{@const username = row.username ?? ''}
|
<Table.Row class="cursor-pointer" onclick={() => openUser(row.username)}>
|
||||||
{@const pairs = ipServerPairsByUser[username] ?? []}
|
<Table.Cell class="font-medium">{row.username}</Table.Cell>
|
||||||
|
<Table.Cell>
|
||||||
{#if pairs.length === 0}
|
<div class="flex flex-wrap gap-1">
|
||||||
<Table.Row>
|
{#each row.links?.tls ?? [] as link (link)}
|
||||||
<Table.Cell class="font-medium">
|
<Button
|
||||||
<a href="/users/{encodeURIComponent(row.username ?? '')}" class="text-primary hover:underline">
|
variant="outline"
|
||||||
{row.username}
|
size="xs"
|
||||||
</a>
|
class="h-7 gap-1 px-2 text-xs"
|
||||||
</Table.Cell>
|
onclick|stopPropagation={() => copy(link)}
|
||||||
<Table.Cell>
|
>
|
||||||
<div class="flex flex-wrap gap-1">
|
TLS
|
||||||
{#each row.links?.tls ?? [] as link (link)}
|
<CopyIcon class="size-3 opacity-60" />
|
||||||
<Button variant="outline" size="xs" class="h-7 gap-1 px-2 text-xs" onclick={() => copy(link)}>
|
</Button>
|
||||||
TLS
|
{/each}
|
||||||
<CopyIcon class="size-3 opacity-60" />
|
{#each row.links?.secure ?? [] as link (link)}
|
||||||
</Button>
|
<Button
|
||||||
{/each}
|
variant="outline"
|
||||||
{#each row.links?.secure ?? [] as link (link)}
|
size="xs"
|
||||||
<Button variant="outline" size="xs" class="h-7 gap-1 px-2 text-xs" onclick={() => copy(link)}>
|
class="h-7 gap-1 px-2 text-xs"
|
||||||
t.me
|
onclick|stopPropagation={() => copy(link)}
|
||||||
<CopyIcon class="size-3 opacity-60" />
|
>
|
||||||
</Button>
|
t.me
|
||||||
{/each}
|
<CopyIcon class="size-3 opacity-60" />
|
||||||
</div>
|
</Button>
|
||||||
</Table.Cell>
|
{/each}
|
||||||
<Table.Cell class="text-right tabular-nums">{formatMiB(row.total_megabytes ?? 0)}</Table.Cell>
|
</div>
|
||||||
<Table.Cell class="text-right text-sm font-mono">—</Table.Cell>
|
</Table.Cell>
|
||||||
<Table.Cell class="text-right text-sm">
|
<Table.Cell class="text-right tabular-nums">{formatMiB(row.total_megabytes ?? 0)}</Table.Cell>
|
||||||
{row.data_quota_bytes != null ? formatBytes(row.data_quota_bytes) : '—'}
|
<Table.Cell class="text-right text-sm font-mono tabular-nums">
|
||||||
</Table.Cell>
|
{row.active_unique_ips ?? 0}
|
||||||
<Table.Cell class="text-sm text-muted-foreground">
|
{#if row.max_unique_ips != null}
|
||||||
{row.expiration_rfc3339 ? new Date(row.expiration_rfc3339).toLocaleString() : '—'}
|
<span class="text-muted-foreground"> / {row.max_unique_ips}</span>
|
||||||
</Table.Cell>
|
{/if}
|
||||||
</Table.Row>
|
</Table.Cell>
|
||||||
{:else}
|
<Table.Cell class="text-sm text-muted-foreground">
|
||||||
{#each pairs as pair, idx (pair.ip + '|' + pair.server)}
|
{row.expiration_rfc3339 ? new Date(row.expiration_rfc3339).toLocaleString() : '—'}
|
||||||
<Table.Row>
|
</Table.Cell>
|
||||||
<Table.Cell class="font-medium">
|
</Table.Row>
|
||||||
<a href="/users/{encodeURIComponent(row.username ?? '')}" class="text-primary hover:underline">
|
|
||||||
{row.username}
|
|
||||||
</a>
|
|
||||||
</Table.Cell>
|
|
||||||
<Table.Cell>
|
|
||||||
{#if idx === 0}
|
|
||||||
<div class="flex flex-wrap gap-1">
|
|
||||||
{#each row.links?.tls ?? [] as link (link)}
|
|
||||||
<Button variant="outline" size="xs" class="h-7 gap-1 px-2 text-xs" onclick={() => copy(link)}>
|
|
||||||
TLS
|
|
||||||
<CopyIcon class="size-3 opacity-60" />
|
|
||||||
</Button>
|
|
||||||
{/each}
|
|
||||||
{#each row.links?.secure ?? [] as link (link)}
|
|
||||||
<Button variant="outline" size="xs" class="h-7 gap-1 px-2 text-xs" onclick={() => copy(link)}>
|
|
||||||
t.me
|
|
||||||
<CopyIcon class="size-3 opacity-60" />
|
|
||||||
</Button>
|
|
||||||
{/each}
|
|
||||||
</div>
|
|
||||||
{:else}
|
|
||||||
|
|
||||||
{/if}
|
|
||||||
</Table.Cell>
|
|
||||||
<Table.Cell class="text-right tabular-nums">
|
|
||||||
{#if idx === 0}
|
|
||||||
{formatMiB(row.total_megabytes ?? 0)}
|
|
||||||
{:else}
|
|
||||||
|
|
||||||
{/if}
|
|
||||||
</Table.Cell>
|
|
||||||
<Table.Cell class="text-right text-sm font-mono">{pair.ip} - {pair.server}</Table.Cell>
|
|
||||||
<Table.Cell class="text-right text-sm tabular-nums">
|
|
||||||
{#if idx === 0}
|
|
||||||
{row.data_quota_bytes != null ? formatBytes(row.data_quota_bytes) : '—'}
|
|
||||||
{:else}
|
|
||||||
|
|
||||||
{/if}
|
|
||||||
</Table.Cell>
|
|
||||||
<Table.Cell class="text-sm text-muted-foreground">
|
|
||||||
{#if idx === 0}
|
|
||||||
{row.expiration_rfc3339 ? new Date(row.expiration_rfc3339).toLocaleString() : '—'}
|
|
||||||
{:else}
|
|
||||||
|
|
||||||
{/if}
|
|
||||||
</Table.Cell>
|
|
||||||
</Table.Row>
|
|
||||||
{/each}
|
|
||||||
{/if}
|
|
||||||
{/each}
|
{/each}
|
||||||
</Table.Body>
|
</Table.Body>
|
||||||
</Table.Root>
|
</Table.Root>
|
||||||
|
|||||||
@@ -1,29 +1,50 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { page } from '$app/stores';
|
import { page } from '$app/stores';
|
||||||
import { onMount } from 'svelte';
|
import { onMount } from 'svelte';
|
||||||
import { fetchAggUser, ApiError } from '$lib/api/client.js';
|
import { fetchAggUniqueIps, fetchAggUser, ApiError } from '$lib/api/client.js';
|
||||||
import type { components } from '$lib/api/aggregate.gen.js';
|
import type { components } from '$lib/api/aggregate.gen.js';
|
||||||
import { formatBytes, formatMiB } from '$lib/format.js';
|
import { formatBytes, formatMiB, flagEmoji } from '$lib/format.js';
|
||||||
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 { 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 CopyIcon from '@lucide/svelte/icons/copy';
|
import CopyIcon from '@lucide/svelte/icons/copy';
|
||||||
|
|
||||||
let loading = $state(true);
|
let loading = $state(true);
|
||||||
let err = $state<string | null>(null);
|
let err = $state<string | null>(null);
|
||||||
let row = $state<components['schemas']['UsersRow'] | null>(null);
|
let row = $state<components['schemas']['UsersRow'] | null>(null);
|
||||||
let partial = $state(false);
|
let partial = $state(false);
|
||||||
|
let uniqueIps = $state<components['schemas']['UniqueIPsRow'][]>([]);
|
||||||
|
let ipErr = $state<string | null>(null);
|
||||||
|
|
||||||
const username = $derived($page.params.username ?? '');
|
const username = $derived($page.params.username ?? '');
|
||||||
|
|
||||||
async function load() {
|
async function load() {
|
||||||
loading = true;
|
loading = true;
|
||||||
err = null;
|
err = null;
|
||||||
|
ipErr = null;
|
||||||
try {
|
try {
|
||||||
const env = await fetchAggUser(username, { include_links: true });
|
const [userEnv, uniqueIpsEnv] = await Promise.allSettled([
|
||||||
partial = !!env.partial;
|
fetchAggUser(username, { include_links: true }),
|
||||||
row = env.data ?? null;
|
fetchAggUniqueIps({ geo: true })
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (userEnv.status === 'fulfilled') {
|
||||||
|
partial = !!userEnv.value.partial;
|
||||||
|
row = userEnv.value.data ?? null;
|
||||||
|
} else {
|
||||||
|
err = userEnv.reason instanceof ApiError ? userEnv.reason.message : String(userEnv.reason);
|
||||||
|
row = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (uniqueIpsEnv.status === 'fulfilled') {
|
||||||
|
partial = partial || !!uniqueIpsEnv.value.partial;
|
||||||
|
uniqueIps = uniqueIpsEnv.value.data ?? [];
|
||||||
|
} else {
|
||||||
|
ipErr = uniqueIpsEnv.reason instanceof ApiError ? uniqueIpsEnv.reason.message : String(uniqueIpsEnv.reason);
|
||||||
|
uniqueIps = [];
|
||||||
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
err = e instanceof ApiError ? e.message : String(e);
|
err = e instanceof ApiError ? e.message : String(e);
|
||||||
row = null;
|
row = null;
|
||||||
@@ -37,6 +58,14 @@
|
|||||||
function copy(text: string) {
|
function copy(text: string) {
|
||||||
void navigator.clipboard.writeText(text);
|
void navigator.clipboard.writeText(text);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let ipsForUser = $derived.by(() => {
|
||||||
|
const ur = uniqueIps.find((x) => x.username === username);
|
||||||
|
const arr = ur?.ips ?? [];
|
||||||
|
return [...arr]
|
||||||
|
.filter((x) => x.ip)
|
||||||
|
.sort((a, b) => (a.ip ?? '').localeCompare(b.ip ?? ''));
|
||||||
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="mb-6">
|
<div class="mb-6">
|
||||||
@@ -125,4 +154,73 @@
|
|||||||
{/each}
|
{/each}
|
||||||
</Card.Content>
|
</Card.Content>
|
||||||
</Card.Root>
|
</Card.Root>
|
||||||
|
<Card.Root class="mt-4">
|
||||||
|
<Card.Header>
|
||||||
|
<Card.Title>IP и подключения</Card.Title>
|
||||||
|
<Card.Description>active/recent с серверами и Geo/ASN (если доступно)</Card.Description>
|
||||||
|
</Card.Header>
|
||||||
|
<Card.Content class="p-0">
|
||||||
|
{#if ipErr}
|
||||||
|
<Alert variant="destructive" class="m-4">
|
||||||
|
<AlertTitle>Ошибка IP данных</AlertTitle>
|
||||||
|
<AlertDescription>{ipErr}</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<Table.Root>
|
||||||
|
<Table.Header>
|
||||||
|
<Table.Row>
|
||||||
|
<Table.Head>IP</Table.Head>
|
||||||
|
<Table.Head>Серверы</Table.Head>
|
||||||
|
<Table.Head>Geo / ASN</Table.Head>
|
||||||
|
</Table.Row>
|
||||||
|
</Table.Header>
|
||||||
|
<Table.Body>
|
||||||
|
{#if ipsForUser.length === 0}
|
||||||
|
<Table.Row>
|
||||||
|
<Table.Cell colspan="3" class="p-4 text-center text-muted-foreground">
|
||||||
|
Нет активных IP
|
||||||
|
</Table.Cell>
|
||||||
|
</Table.Row>
|
||||||
|
{:else}
|
||||||
|
{#each ipsForUser as ip (ip.ip ?? '')}
|
||||||
|
<Table.Row>
|
||||||
|
<Table.Cell class="font-mono text-sm">{ip.ip}</Table.Cell>
|
||||||
|
<Table.Cell class="text-sm text-muted-foreground">
|
||||||
|
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>
|
||||||
|
<Table.Cell class="text-sm">
|
||||||
|
<div class="space-y-1">
|
||||||
|
<div>
|
||||||
|
<span class="mr-1">{flagEmoji(ip.country_code ?? undefined)}</span>
|
||||||
|
{ip.country_code ?? '—'}
|
||||||
|
{#if ip.city_name}
|
||||||
|
<span class="text-muted-foreground"> · {ip.city_name}</span>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{#if ip.asn != null}
|
||||||
|
<div class="flex flex-wrap items-center gap-2">
|
||||||
|
<Badge variant="outline">AS{ip.asn}</Badge>
|
||||||
|
<span class="text-muted-foreground">{ip.as_organization ?? ''}</span>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<div class="text-muted-foreground">ASN: —</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</Table.Cell>
|
||||||
|
</Table.Row>
|
||||||
|
{/each}
|
||||||
|
{/if}
|
||||||
|
</Table.Body>
|
||||||
|
</Table.Root>
|
||||||
|
</Card.Content>
|
||||||
|
</Card.Root>
|
||||||
{/if}
|
{/if}
|
||||||
|
|||||||
Reference in New Issue
Block a user