Translate UI text to Russian for improved localization across various components and pages, including sidebar, pagination, and server details. Update labels, tooltips, and headers to enhance user experience for Russian-speaking users.
Publish telemt-api gateway Docker image / test (push) Successful in 30s
Publish telemt-api gateway Docker image / build-and-push (push) Successful in 2m29s

This commit is contained in:
Denozordec
2026-04-10 00:17:52 +07:00
parent 297fb79f11
commit 8ad627cb9b
20 changed files with 121 additions and 95 deletions
+4 -4
View File
@@ -4,12 +4,12 @@ const SEG_LABELS: Record<string, string> = {
users: 'Пользователи',
ips: 'Уникальные IP',
incidents: 'Инциденты',
live: 'Live',
live: 'Поток',
mihomo: 'Mihomo',
servers: 'Серверы',
runtime: 'Runtime',
security: 'Security',
upstreams: 'Upstreams',
runtime: 'Состояние',
security: 'Безопасность',
upstreams: 'Апстримы и ДЦ',
config: 'Конфигурация',
update: 'Обновление'
};
+15 -15
View File
@@ -85,7 +85,7 @@
</Sidebar.MenuButton>
</Sidebar.MenuItem>
<Sidebar.MenuItem>
<Sidebar.MenuButton isActive={active('/incidents')} tooltipContent="Incidents">
<Sidebar.MenuButton isActive={active('/incidents')} tooltipContent="Инциденты">
{#snippet child({ props })}
<a href="/incidents" {...props}>
<SirenIcon />
@@ -143,11 +143,11 @@
<Sidebar.GroupLabel>Нода</Sidebar.GroupLabel>
<Sidebar.Menu>
<Sidebar.MenuItem>
<Sidebar.MenuButton isActive={activeExact(serverPath)} tooltipContent="Dashboard">
<Sidebar.MenuButton isActive={activeExact(serverPath)} tooltipContent="Обзор">
{#snippet child({ props })}
<a href={serverPath} {...props}>
<LayoutDashboardIcon />
<span>Dashboard</span>
<span>Обзор</span>
</a>
{/snippet}
</Sidebar.MenuButton>
@@ -163,61 +163,61 @@
</Sidebar.MenuButton>
</Sidebar.MenuItem>
<Sidebar.MenuItem>
<Sidebar.MenuButton isActive={active(serverPath + '/runtime')} tooltipContent="Runtime">
<Sidebar.MenuButton isActive={active(serverPath + '/runtime')} tooltipContent="Состояние">
{#snippet child({ props })}
<a href="{serverPath}/runtime" {...props}>
<ActivityIcon />
<span>Runtime</span>
<span>Состояние</span>
</a>
{/snippet}
</Sidebar.MenuButton>
</Sidebar.MenuItem>
<Sidebar.MenuItem>
<Sidebar.MenuButton isActive={active(serverPath + '/users')} tooltipContent="Users">
<Sidebar.MenuButton isActive={active(serverPath + '/users')} tooltipContent="Пользователи">
{#snippet child({ props })}
<a href="{serverPath}/users" {...props}>
<UsersIcon />
<span>Users</span>
<span>Пользователи</span>
</a>
{/snippet}
</Sidebar.MenuButton>
</Sidebar.MenuItem>
<Sidebar.MenuItem>
<Sidebar.MenuButton isActive={active(serverPath + '/security')} tooltipContent="Security">
<Sidebar.MenuButton isActive={active(serverPath + '/security')} tooltipContent="Безопасность">
{#snippet child({ props })}
<a href="{serverPath}/security" {...props}>
<ShieldIcon />
<span>Security</span>
<span>Безопасность</span>
</a>
{/snippet}
</Sidebar.MenuButton>
</Sidebar.MenuItem>
<Sidebar.MenuItem>
<Sidebar.MenuButton isActive={active(serverPath + '/upstreams')} tooltipContent="Upstreams">
<Sidebar.MenuButton isActive={active(serverPath + '/upstreams')} tooltipContent="Апстримы и ДЦ">
{#snippet child({ props })}
<a href="{serverPath}/upstreams" {...props}>
<NetworkIcon />
<span>Upstreams & DCs</span>
<span>Апстримы и ДЦ</span>
</a>
{/snippet}
</Sidebar.MenuButton>
</Sidebar.MenuItem>
<Sidebar.MenuItem>
<Sidebar.MenuButton isActive={active(serverPath + '/config')} tooltipContent="Configuration">
<Sidebar.MenuButton isActive={active(serverPath + '/config')} tooltipContent="Конфигурация">
{#snippet child({ props })}
<a href="{serverPath}/config" {...props}>
<SettingsIcon />
<span>Configuration</span>
<span>Конфигурация</span>
</a>
{/snippet}
</Sidebar.MenuButton>
</Sidebar.MenuItem>
<Sidebar.MenuItem>
<Sidebar.MenuButton isActive={active(serverPath + '/update')} tooltipContent="Update">
<Sidebar.MenuButton isActive={active(serverPath + '/update')} tooltipContent="Обновление">
{#snippet child({ props })}
<a href="{serverPath}/update" {...props}>
<RefreshCwIcon />
<span>Update</span>
<span>Обновление</span>
</a>
{/snippet}
</Sidebar.MenuButton>
@@ -13,13 +13,13 @@
</script>
{#snippet Fallback()}
<span>Next</span>
<span>Следующая</span>
<ChevronRightIcon class="size-4" />
{/snippet}
<PaginationPrimitive.NextButton
bind:ref
aria-label="Go to next page"
aria-label="Перейти на следующую страницу"
class={cn(buttonVariants({ variant: "ghost" }), "pr-1.5!", className)}
{...restProps}
>
@@ -10,11 +10,11 @@
</script>
<PaginationLink
aria-label="Go to next page"
aria-label="Перейти на следующую страницу"
size="default"
class={cn("pr-1.5!", className)}
{...restProps}
>
<span class="cn-pagination-next-text hidden sm:block">Next</span>
<span class="cn-pagination-next-text hidden sm:block">Следующая</span>
<ChevronRightIcon data-icon="inline-end" />
</PaginationLink>
@@ -14,12 +14,12 @@
{#snippet Fallback()}
<ChevronLeftIcon class="size-4" />
<span>Previous</span>
<span>Предыдущая</span>
{/snippet}
<PaginationPrimitive.PrevButton
bind:ref
aria-label="Go to previous page"
aria-label="Перейти на предыдущую страницу"
class={cn(buttonVariants({ variant: "ghost" }), "pl-1.5!", className)}
{...restProps}
>
@@ -10,11 +10,11 @@
</script>
<PaginationLink
aria-label="Go to previous page"
aria-label="Перейти на предыдущую страницу"
size="default"
class={cn("pl-1.5!", className)}
{...restProps}
>
<ChevronLeftIcon data-icon="inline-start" />
<span class="cn-pagination-previous-text hidden sm:block">Previous</span>
<span class="cn-pagination-previous-text hidden sm:block">Предыдущая</span>
</PaginationLink>
@@ -18,7 +18,7 @@
bind:ref
bind:page
role="navigation"
aria-label="pagination"
aria-label="Пагинация"
data-slot="pagination"
{count}
{perPage}
@@ -51,8 +51,8 @@
{side}
>
<Sheet.Header class="sr-only">
<Sheet.Title>Sidebar</Sheet.Title>
<Sheet.Description>Displays the mobile sidebar.</Sheet.Description>
<Sheet.Title>Боковая панель</Sheet.Title>
<Sheet.Description>Отображает мобильную боковую панель.</Sheet.Description>
</Sheet.Header>
<div class="flex h-full w-full flex-col">
{@render children?.()}
+4 -4
View File
@@ -2,12 +2,12 @@ const SEG_LABELS: Record<string, string> = {
users: 'Пользователи',
ips: 'Уникальные IP',
incidents: 'Инциденты',
live: 'Live',
live: 'Поток',
mihomo: 'Mihomo',
servers: 'Серверы',
runtime: 'Runtime',
security: 'Security',
upstreams: 'Upstreams и DC',
runtime: 'Состояние',
security: 'Безопасность',
upstreams: 'Апстримы и ДЦ',
config: 'Конфигурация',
update: 'Обновление'
};
+7 -1
View File
@@ -1,6 +1,6 @@
<script lang="ts">
import './layout.css';
import { page } from '$app/state';
import { page, navigating } from '$app/state';
import { onMount } from 'svelte';
import { ModeWatcher } from 'mode-watcher';
import favicon from '$lib/assets/favicon.svg';
@@ -15,6 +15,7 @@
let { children } = $props();
const docTitle = $derived(`${panelTitleFromPath(page.url.pathname)} · Telemt Panel`);
const isNavigating = $derived(navigating !== null);
let serverAliases = $state<string[]>([]);
@@ -54,6 +55,11 @@
<Sidebar.Provider>
<AppSidebar {serverAliases} {serverPath} />
<Sidebar.Inset>
{#if isNavigating}
<div class="h-0.5 w-full overflow-hidden bg-transparent">
<div class="h-full w-1/3 animate-pulse rounded-full bg-primary"></div>
</div>
{/if}
<header class="flex h-14 w-full shrink-0 items-center gap-2 border-b border-border px-4">
<SiteHeader />
</header>
+1 -1
View File
@@ -572,7 +572,7 @@
{/snippet}
{summary.fleet_total_connections}
</KpiStatCard>
<KpiStatCard accent="danger" label="Bad connections" footer="сумма по нодам" valueClass="text-xl">
<KpiStatCard accent="danger" label="Плохие соединения" footer="сумма по нодам" valueClass="text-xl">
{#snippet icon()}
<ShieldAlertIcon class="size-5" aria-hidden="true" />
{/snippet}
+23 -17
View File
@@ -124,6 +124,12 @@
return 'outline';
}
function severityLabel(sev: IncidentItem['severity']): string {
if (sev === 'critical') return 'критично';
if (sev === 'warning') return 'предупреждение';
return 'инфо';
}
function restartPolling() {
if (pollTimer) {
clearInterval(pollTimer);
@@ -283,7 +289,7 @@
<div class="mb-6 flex flex-wrap items-center justify-between gap-4">
<div>
<h1 class="text-2xl font-semibold tracking-tight">Инциденты</h1>
<p class="text-sm text-muted-foreground">Сводка и triage по всему флоту.</p>
<p class="text-sm text-muted-foreground">Сводка и разбор инцидентов по всему флоту.</p>
{#if generatedAt}
<p class="mt-1 text-xs text-muted-foreground">Снимок: {new Date(generatedAt).toLocaleString()}</p>
{/if}
@@ -331,38 +337,38 @@
{#snippet children()}
<div class="mb-4 grid gap-4 sm:grid-cols-3">
<KpiTrendCard
label="Critical"
label="Критичные"
valueDisplay={String(data?.critical_total ?? 0)}
prevValue={kpiPrev?.critical ?? null}
currentValue={data != null ? (data.critical_total ?? 0) : null}
invertTrend={true}
insight="Открытые critical; меньше — лучше. К прошлому опросу."
insight="Открытые критичные; меньше — лучше. К прошлому опросу."
/>
<KpiTrendCard
label="Warning"
label="Предупреждения"
valueDisplay={String(data?.warning_total ?? 0)}
prevValue={kpiPrev?.warning ?? null}
currentValue={data != null ? (data.warning_total ?? 0) : null}
invertTrend={true}
insight="Открытые warning; меньше — лучше."
insight="Открытые предупреждения; меньше — лучше."
/>
<KpiTrendCard
label="Info"
label="Инфо"
valueDisplay={String(data?.info_total ?? 0)}
prevValue={kpiPrev?.info ?? null}
currentValue={data != null ? (data.info_total ?? 0) : null}
insight="Открытые info. К прошлому опросу."
insight="Открытые инфо. К прошлому опросу."
/>
</div>
<DataTableCard title="Инциденты" description="Список и triage">
<DataTableCard title="Инциденты" description="Список и разбор">
{#snippet toolbar()}
<DataTableToolbar>
<div class="relative max-w-md flex-1">
<SearchIcon
class="pointer-events-none absolute top-1/2 left-2 size-4 -translate-y-1/2 text-muted-foreground"
/>
<Input class="h-8 ps-8" placeholder="Поиск по заголовку, summary, severity…" bind:value={incSearch} />
<Input class="h-8 ps-8" placeholder="Поиск по заголовку, описанию, критичности…" bind:value={incSearch} />
</div>
<Button
variant="outline"
@@ -384,7 +390,7 @@
active={incSortKey === 'sev'}
dir={incSortDir}
onToggle={() => toggleIncSort('sev')}
>Severity</SortableTh>
>Критичность</SortableTh>
<SortableTh
active={incSortKey === 'title'}
dir={incSortDir}
@@ -406,8 +412,8 @@
</Tooltip.Root>
</div>
</Table.Head>
<Table.Head>Runbook</Table.Head>
<Table.Head>Triage</Table.Head>
<Table.Head>Инструкции</Table.Head>
<Table.Head>Разбор</Table.Head>
</Table.Row>
</Table.Header>
<Table.Body>
@@ -427,7 +433,7 @@
{#each filteredIncidents as item (item.id)}
<Table.Row>
<Table.Cell>
<Badge variant={severityVariant(item.severity)}>{item.severity}</Badge>
<Badge variant={severityVariant(item.severity)}>{severityLabel(item.severity)}</Badge>
</Table.Cell>
<Table.Cell class="max-w-[360px]">
<div class="font-medium">{item.title}</div>
@@ -458,21 +464,21 @@
variant={triage.ack ? 'default' : 'outline'}
onclick={() => toggleAck(item.id)}
>
{triage.ack ? 'unack' : 'ack'}
{triage.ack ? 'Снять подтверждение' : 'Подтвердить'}
</Button>
<Button
size="xs"
variant={triage.resolved ? 'default' : 'outline'}
onclick={() => toggleResolved(item.id)}
>
{triage.resolved ? 'unresolve' : 'resolve'}
{triage.resolved ? 'Вернуть в работу' : 'Решить'}
</Button>
</div>
<div class="mb-1">
<Input
type="text"
class="h-8 w-full text-xs"
placeholder="owner"
placeholder="Ответственный"
value={triage.owner ?? ''}
oninput={(e) =>
patchTriage(item.id, {
@@ -484,7 +490,7 @@
<Input
type="text"
class="h-8 w-full text-xs"
placeholder="note"
placeholder="Примечание"
value={triage.note ?? ''}
oninput={(e) =>
patchTriage(item.id, {
+27 -17
View File
@@ -106,12 +106,24 @@
return 'outline';
}
function severityLabel(sev: IncidentSeverity): string {
if (sev === 'critical') return 'критично';
if (sev === 'warning') return 'предупреждение';
return 'инфо';
}
function statusVariant(st: LiveStatus | undefined): 'default' | 'secondary' | 'destructive' {
if (st === 'critical') return 'destructive';
if (st === 'degraded') return 'secondary';
return 'default';
}
function statusLabel(st: LiveStatus | undefined): string {
if (st === 'critical') return 'критично';
if (st === 'degraded') return 'деградация';
return 'норма';
}
function parseAliasFilter(raw: string | null): string {
const value = raw?.trim() ?? '';
return !value || value.includes(',') ? 'all' : value;
@@ -312,12 +324,12 @@
<div class="mb-6 flex flex-wrap items-center justify-between gap-4">
<div>
<h1 class="text-2xl font-semibold tracking-tight">Live</h1>
<h1 class="text-2xl font-semibold tracking-tight">Поток</h1>
<p class="text-sm text-muted-foreground">SSE поток инцидентов и статуса флота.</p>
</div>
<div class="flex items-center gap-2">
<Badge variant={connected ? 'default' : 'secondary'}>
{connected ? 'connected' : 'disconnected'}
{connected ? 'подключено' : 'отключено'}
</Badge>
<FleetLiveStaleBadge
{isStale}
@@ -329,7 +341,7 @@
/>
<Button variant="outline" size="sm" onclick={() => connectStream()}>
<RefreshCwIcon class="mr-1 size-4" />
Reconnect
Переподключить
</Button>
</div>
</div>
@@ -340,11 +352,11 @@
{#if err}
<Alert class="mb-4">
<AlertTitle>Live stream</AlertTitle>
<AlertTitle>Поток событий</AlertTitle>
<AlertDescription>
{err}
{#if reconnectInSec != null}
Переподключение через {reconnectInSec}s.
Переподключение через {reconnectInSec} с.
{/if}
</AlertDescription>
</Alert>
@@ -355,19 +367,17 @@
{#snippet icon()}
<ActivityIcon class="size-5" aria-hidden="true" />
{/snippet}
<Badge variant={statusVariant(snapshot?.status)}>
{snapshot?.status ?? 'healthy'}
</Badge>
<Badge variant={statusVariant(snapshot?.status)}>{statusLabel(snapshot?.status)}</Badge>
</KpiStatCard>
<KpiStatCard accent="warning" label="Partial" footer="неполный снимок">
<KpiStatCard accent="warning" label="Частичность" footer="неполный снимок">
{#snippet icon()}
<LayersIcon class="size-5" aria-hidden="true" />
{/snippet}
{snapshot?.partial ? 'yes' : 'no'}
{snapshot?.partial ? 'да' : 'нет'}
</KpiStatCard>
<KpiStatCard
accent="info"
label="Timestamp"
label="Временная метка"
footer="время snapshot"
valueClass="text-base font-normal tabular-nums leading-tight"
>
@@ -379,7 +389,7 @@
</div>
<DataTableCard
title="Последние incidents из snapshot"
title="Последние инциденты из снимка"
description="Всего: {snapshot?.counts?.total ?? snapshot?.incidents?.length ?? 0}"
>
{#snippet toolbar()}
@@ -388,7 +398,7 @@
<SearchIcon
class="pointer-events-none absolute top-1/2 left-2 size-4 -translate-y-1/2 text-muted-foreground"
/>
<Input class="h-8 ps-8" placeholder="Поиск по severity, title, summary…" bind:value={liveIncSearch} />
<Input class="h-8 ps-8" placeholder="Поиск по критичности, заголовку, описанию…" bind:value={liveIncSearch} />
</div>
<Button
variant="outline"
@@ -410,13 +420,13 @@
active={liveSortKey === 'sev'}
dir={liveSortDir}
onToggle={() => toggleLiveSort('sev')}
>Severity</SortableTh>
>Критичность</SortableTh>
<SortableTh
active={liveSortKey === 'title'}
dir={liveSortDir}
onToggle={() => toggleLiveSort('title')}
>Title</SortableTh>
<Table.Head>Summary</Table.Head>
>Заголовок</SortableTh>
<Table.Head>Описание</Table.Head>
<Table.Head>
<div class="flex items-center gap-1">
Серверы
@@ -452,7 +462,7 @@
{#each filteredLiveIncidents as item (item.id)}
<Table.Row>
<Table.Cell>
<Badge variant={severityVariant(item.severity)}>{item.severity}</Badge>
<Badge variant={severityVariant(item.severity)}>{severityLabel(item.severity)}</Badge>
</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>
+2 -2
View File
@@ -136,7 +136,7 @@
</KpiStatCard>
<KpiStatCard
accent="danger"
label="Bad connections"
label="Плохие соединения"
valueClass="text-xl"
>
{#snippet icon()}
@@ -146,7 +146,7 @@
</KpiStatCard>
<KpiStatCard
accent="warning"
label="Handshake timeouts"
label="Таймауты рукопожатия"
valueClass="text-xl"
>
{#snippet icon()}
@@ -8,7 +8,7 @@
</script>
<ServerPageHeader
title="Configuration"
title="Конфигурация"
subtitle="Конфигурация этой ноды не отдается через Control API"
showRefresh={false}
/>
@@ -57,22 +57,22 @@
}
</script>
<ServerPageHeader title="Runtime" {loading} onRefresh={load} />
<ServerPageHeader title="Состояние" {loading} onRefresh={load} />
<ServerQueryState {loading} {err} isEmpty={false}>
{#snippet children()}
<div class="mb-4 flex flex-wrap gap-2">
{#if gates}
<Badge variant="secondary" class="px-3 py-1">accepting: {onOff(gates.accepting_new_connections)}</Badge>
<Badge variant="secondary" class="px-3 py-1">conditional cast: {onOff(gates.conditional_cast_enabled)}</Badge>
<Badge variant="secondary" class="px-3 py-1">me2dc fallback: {onOff(gates.me2dc_fallback_enabled)}</Badge>
<Badge variant="secondary" class="px-3 py-1">ME ready: {onOff(gates.me_runtime_ready)}</Badge>
<Badge variant="outline" class="px-3 py-1">middle proxy: {onOff(gates.use_middle_proxy)}</Badge>
<Badge variant="secondary" class="px-3 py-1">Приём новых: {onOff(gates.accepting_new_connections)}</Badge>
<Badge variant="secondary" class="px-3 py-1">Условный cast: {onOff(gates.conditional_cast_enabled)}</Badge>
<Badge variant="secondary" class="px-3 py-1">Резерв me2dc: {onOff(gates.me2dc_fallback_enabled)}</Badge>
<Badge variant="secondary" class="px-3 py-1">ME готов: {onOff(gates.me_runtime_ready)}</Badge>
<Badge variant="outline" class="px-3 py-1">Промежуточный прокси: {onOff(gates.use_middle_proxy)}</Badge>
{/if}
</div>
{#if dcs && dcs.middle_proxy_enabled && dcs.dcs}
<KpiPanelCard class="mb-6" accent="info" title="Datacenter status" contentClass="p-0">
<KpiPanelCard class="mb-6" accent="info" title="Статус дата-центров" contentClass="p-0">
{#snippet icon()}
<BuildingIcon class="size-5" aria-hidden="true" />
{/snippet}
@@ -81,8 +81,8 @@
<Table.Row class="border-b border-border/80 hover:bg-transparent">
<Table.Head>DC</Table.Head>
<Table.Head class="text-right">RTT</Table.Head>
<Table.Head class="text-right">Writers</Table.Head>
<Table.Head class="text-right">Coverage</Table.Head>
<Table.Head class="text-right">Писатели</Table.Head>
<Table.Head class="text-right">Покрытие</Table.Head>
</Table.Row>
</Table.Header>
<Table.Body>
@@ -103,7 +103,7 @@
</KpiPanelCard>
{:else if dcs?.reason}
<Alert class="mb-4">
<AlertDescription>DC status: {dcs.reason}</AlertDescription>
<AlertDescription>Статус ДЦ: {dcs.reason}</AlertDescription>
</Alert>
{/if}
@@ -38,12 +38,12 @@
}
</script>
<ServerPageHeader title="Security" {loading} onRefresh={load} />
<ServerPageHeader title="Безопасность" {loading} onRefresh={load} />
<ServerQueryState {loading} {err} isEmpty={!data} emptyDescription="Endpoint вернул пустой ответ.">
{#snippet children()}
{#if data}
<ServerJsonCard accent="danger" title="Security posture" description="POST /security/posture" payload={data}>
<ServerJsonCard accent="danger" title="Профиль безопасности" description="POST /security/posture" payload={data}>
{#snippet iconSnippet()}
<ShieldIcon class="size-5" aria-hidden="true" />
{/snippet}
@@ -39,7 +39,7 @@
}
</script>
<ServerPageHeader title="Update" {loading} onRefresh={load} />
<ServerPageHeader title="Обновление" {loading} onRefresh={load} />
<ServerQueryState {loading} {err} isEmpty={!sys} emptyDescription="Информация о бинарнике не получена.">
{#snippet children()}
@@ -38,14 +38,14 @@
}
</script>
<ServerPageHeader title="Upstreams &amp; DCs" {loading} onRefresh={load} />
<ServerPageHeader title="Апстримы и ДЦ" {loading} onRefresh={load} />
<ServerQueryState {loading} {err} isEmpty={!data} emptyDescription="Статистика upstream-ов пока не доступна.">
{#snippet children()}
{#if data}
<ServerJsonCard
accent="info"
title="Upstreams &amp; DCs"
title="Апстримы и ДЦ"
description="stats/upstreams"
payload={data}
preClass="max-h-[70vh]"
@@ -199,7 +199,7 @@
</script>
<div class="mb-6 flex flex-wrap items-center justify-between gap-4">
<h1 class="text-2xl font-semibold tracking-tight">Users</h1>
<h1 class="text-2xl font-semibold tracking-tight">Пользователи</h1>
<div class="flex gap-2">
<Button onclick={() => (createOpen = true)} disabled={readOnly}>
<PlusIcon class="mr-1 size-4" />
@@ -236,7 +236,7 @@
<Table.Row class="border-b border-border/80 hover:bg-transparent">
<Table.Head>Имя</Table.Head>
<Table.Head>Ссылки</Table.Head>
<Table.Head class="text-right">Conn</Table.Head>
<Table.Head class="text-right">Соединения</Table.Head>
<Table.Head class="text-right">IP</Table.Head>
<Table.Head class="text-right">Трафик</Table.Head>
<Table.Head class="w-[100px]"></Table.Head>
@@ -248,9 +248,9 @@
<Table.Cell class="font-medium">{u.username}</Table.Cell>
<Table.Cell>
<div class="flex flex-wrap gap-1">
{#each u.links?.tls ?? [] as link (link)}
{#each u.links?.tls ?? [] as link, idx (`${link}:${idx}`)}
<Button variant="outline" size="xs" class="h-7" onclick={() => copy(link)}>
TLS <CopyIcon class="size-3" />
TLS {idx + 1} <CopyIcon class="size-3" />
</Button>
{/each}
</div>
@@ -269,6 +269,8 @@
size="icon-sm"
disabled={readOnly}
onclick={() => openEdit(u)}
aria-label="Редактировать пользователя"
title="Редактировать пользователя"
>
<PencilIcon class="size-4" />
</Button>
@@ -277,6 +279,8 @@
size="icon-sm"
disabled={readOnly}
onclick={() => (deleteUserRow = u)}
aria-label="Удалить пользователя"
title="Удалить пользователя"
>
<TrashIcon class="size-4 text-destructive" />
</Button>
@@ -325,15 +329,15 @@
<Input id="eq" bind:value={editQuota} type="number" min="0" />
</div>
<div class="grid gap-2">
<Label for="em">max_unique_ips</Label>
<Label for="em">Макс. уникальных IP (max_unique_ips)</Label>
<Input id="em" bind:value={editMaxIp} type="number" min="0" />
</div>
<div class="grid gap-2">
<Label for="et">max_tcp_conns</Label>
<Label for="et">Макс. TCP-соединений (max_tcp_conns)</Label>
<Input id="et" bind:value={editMaxTcp} type="number" min="0" />
</div>
<div class="grid gap-2">
<Label for="ex">expiry (RFC3339)</Label>
<Label for="ex">Срок действия (RFC3339)</Label>
<Input id="ex" bind:value={editExp} class="font-mono text-sm" />
</div>
{#if formErr}