Files
telemtPanel/apps/web/src/components/users/users-columns.tsx
T
DenozordecandCursor 14911cd067
Build and Push Telemt Panel Docker Image / build-and-push (push) Successful in 1m57s
Build and Push Telemt Panel Docker Image / create-release (push) Skipped
fix(frontend): починить сортировку DataGrid и стиль колонки actions
Клик по заголовку снова сортирует напрямую; убран pin последней колонки, из-за которого actions выглядел отрезанным от строки.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-04 22:22:29 +07:00

592 lines
18 KiB
TypeScript

'use no memo'
import { memo, useMemo, useState } from 'react'
import { type ColumnDef, type Row } from '@tanstack/react-table'
import {
CopyIcon,
EyeIcon,
MoreHorizontalIcon,
Pin,
PinOffIcon,
Trash2Icon,
} from 'lucide-react'
import { toast } from 'sonner'
import { Badge } from '@/components/reui/badge'
import { DataGridColumnHeader } from '@/components/reui/data-grid/data-grid-column-header'
import { DataGridTableRowPin } from '@/components/reui/data-grid/data-grid-table'
import { progressToneClass, statusDotClass } from '@/components/reui-kit/grid-tokens'
import { useCopyToClipboard } from '@/hooks/use-copy-to-clipboard'
import { cn } from '@telemt/ui/lib/utils'
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from '@telemt/ui/components/alert-dialog'
import { Avatar, AvatarFallback } from '@telemt/ui/components/avatar'
import { Button } from '@telemt/ui/components/button'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@telemt/ui/components/dropdown-menu'
import {
Progress,
ProgressLabel,
ProgressValue,
} from '@telemt/ui/components/progress'
import { Skeleton } from '@telemt/ui/components/skeleton'
import {
formatBytes,
formatNumber,
type UserInfo,
} from '@/lib/telemt'
export function EnabledBadge({ enabled }: { enabled: boolean }) {
return (
<Badge variant="outline">
<span
className={cn(
'size-1.5 shrink-0 rounded-full!',
statusDotClass(enabled),
)}
/>
{enabled ? 'Вкл' : 'Выкл'}
</Badge>
)
}
function ConnectionsCell({ user }: { user: UserInfo }) {
const current = Number(user.current_connections ?? 0)
const hasMax =
typeof user.max_tcp_conns === 'number' && user.max_tcp_conns > 0
const cap = hasMax ? user.max_tcp_conns! : Math.max(current, 10)
const score = Math.min(100, Math.round((current / cap) * 100))
const indicatorClass = progressToneClass(score)
const label = hasMax
? `${formatNumber(current)} / ${formatNumber(user.max_tcp_conns)}`
: `${formatNumber(current)} / ∞`
return (
<div className="flex min-w-0 flex-col gap-1">
<Progress
value={hasMax ? score : Math.min(100, current > 0 ? 40 : 0)}
className={cn(
'min-w-0 flex-1 flex-row flex-nowrap items-center gap-2 **:data-[slot=progress-track]:order-1 **:data-[slot=progress-track]:min-w-12 **:data-[slot=progress-track]:flex-1 **:data-[slot=progress-value]:order-2',
indicatorClass,
)}
>
<ProgressLabel className="sr-only">Соединения</ProgressLabel>
<ProgressValue className="text-muted-foreground shrink-0 text-[10px] leading-none tabular-nums">
{() => label}
</ProgressValue>
</Progress>
</div>
)
}
function IpListCell({ user }: { user: UserInfo }) {
const ips = user.active_unique_ips_list ?? []
if (ips.length === 0) {
return <span className="text-muted-foreground text-xs"></span>
}
const shown = ips.slice(0, 2)
const rest = ips.length - shown.length
return (
<div className="flex min-w-0 flex-wrap items-center gap-1" title={ips.join(', ')}>
{shown.map((ip) => (
<Badge key={ip} variant="secondary" size="sm" className="font-mono text-[10px]">
{ip}
</Badge>
))}
{rest > 0 ? (
<Badge variant="outline" size="sm">
+{rest}
</Badge>
) : null}
</div>
)
}
function QuotaCell({ user }: { user: UserInfo }) {
const used = Number(user.total_octets ?? 0)
const quota =
typeof user.data_quota_bytes === 'number' && user.data_quota_bytes > 0
? user.data_quota_bytes
: null
if (quota == null) {
return (
<span className="text-muted-foreground text-xs">
{formatBytes(used)} · без квоты
</span>
)
}
const pct = Math.min(100, Math.round((used / quota) * 100))
const indicatorClass = progressToneClass(
pct >= 90 ? 20 : pct >= 70 ? 50 : 90,
)
return (
<div className="flex min-w-0 flex-col gap-1">
<Progress
value={pct}
className={cn(
'min-w-0 flex-1 flex-row flex-nowrap items-center gap-2 **:data-[slot=progress-track]:order-1 **:data-[slot=progress-track]:min-w-12 **:data-[slot=progress-track]:flex-1 **:data-[slot=progress-value]:order-2',
indicatorClass,
)}
>
<ProgressLabel className="sr-only">Квота</ProgressLabel>
<ProgressValue className="text-muted-foreground shrink-0 text-[10px] leading-none tabular-nums">
{() => `${pct}%`}
</ProgressValue>
</Progress>
<span className="text-muted-foreground text-[10px] tabular-nums">
{formatBytes(used)} / {formatBytes(quota)}
</span>
</div>
)
}
const UserCell = memo(function UserCell({ row }: { row: Row<UserInfo> }) {
const o = row.original
const enabled = o.enabled !== false
const initials = o.username.slice(0, 2).toUpperCase()
return (
<div className="flex items-center gap-2">
<div className="relative shrink-0">
<Avatar className="size-8">
<AvatarFallback>{initials}</AvatarFallback>
</Avatar>
<span
className={cn(
'ring-background absolute right-0 bottom-0.5 size-2 rounded-full ring-2',
enabled ? 'bg-green-500' : 'bg-gray-500',
)}
aria-hidden
/>
</div>
<div className="min-w-0">
<div className="text-foreground line-clamp-1 font-medium">{o.username}</div>
<div className="text-muted-foreground line-clamp-1 text-xs">
{formatBytes(o.total_octets)} · {formatNumber(o.active_unique_ips)} IP
</div>
</div>
</div>
)
})
export function ActionsCell({
row,
onOpen,
onDelete,
}: {
row: Row<UserInfo>
onOpen: (user: UserInfo) => void
onDelete: (user: UserInfo) => void
}) {
const { copyToClipboard } = useCopyToClipboard()
const [deleteOpen, setDeleteOpen] = useState(false)
const isPinned = row.getIsPinned()
return (
<>
<div
className="flex items-center justify-center"
onClick={(event) => event.stopPropagation()}
onPointerDown={(event) => event.stopPropagation()}
>
<DropdownMenu>
<DropdownMenuTrigger
render={
<Button
size="icon"
variant="ghost"
className="size-7"
aria-label="Действия строки"
/>
}
>
<MoreHorizontalIcon aria-hidden="true" />
</DropdownMenuTrigger>
<DropdownMenuContent side="bottom" align="start" className="w-44">
<DropdownMenuGroup>
<DropdownMenuItem onClick={() => row.pin(isPinned ? false : 'top')}>
{isPinned ? (
<PinOffIcon className="size-4" aria-hidden="true" />
) : (
<Pin className="size-4" aria-hidden="true" />
)}
{isPinned ? 'Открепить' : 'Закрепить'}
</DropdownMenuItem>
<DropdownMenuItem onClick={() => onOpen(row.original)}>
<EyeIcon className="size-4" aria-hidden="true" />
Открыть
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => {
copyToClipboard(row.original.username)
toast.success('Имя скопировано', {
description: row.original.username,
})
}}
>
<CopyIcon className="size-4" aria-hidden="true" />
Копировать имя
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
variant="destructive"
onClick={() => setDeleteOpen(true)}
>
<Trash2Icon className="size-4" aria-hidden="true" />
Удалить
</DropdownMenuItem>
</DropdownMenuGroup>
</DropdownMenuContent>
</DropdownMenu>
</div>
<AlertDialog open={deleteOpen} onOpenChange={setDeleteOpen}>
<AlertDialogContent size="sm">
<AlertDialogHeader>
<AlertDialogTitle>Удалить пользователя?</AlertDialogTitle>
<AlertDialogDescription>
Будет удалён{' '}
<span className="text-foreground font-medium">
{row.original.username}
</span>
.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Отмена</AlertDialogCancel>
<AlertDialogAction
variant="destructive"
onClick={() => {
setDeleteOpen(false)
onDelete(row.original)
}}
>
Удалить
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</>
)
}
export function createUsersColumns(opts: {
onOpen: (user: UserInfo) => void
onDelete: (user: UserInfo) => void
}): ColumnDef<UserInfo>[] {
return [
{
id: 'pin',
header: '',
cell: ({ row }) => <DataGridTableRowPin row={row} />,
enableSorting: false,
size: 40,
enableResizing: false,
enableHiding: false,
meta: {
skeleton: <Skeleton className="mx-auto size-7 rounded-md" />,
},
},
{
accessorKey: 'username',
id: 'username',
header: ({ column }) => (
<DataGridColumnHeader
title="Пользователь"
visibility={true}
column={column}
/>
),
cell: ({ row }) => <UserCell row={row} />,
enableSorting: true,
enableHiding: false,
enableResizing: true,
minSize: 200,
meta: {
autoSize: true,
skeleton: (
<div className="flex min-w-0 items-center gap-2">
<Skeleton className="size-8 shrink-0 rounded-full" />
<div className="flex min-w-0 flex-col gap-0.5">
<Skeleton className="h-3.5 w-32" />
<Skeleton className="h-3 w-40" />
</div>
</div>
),
},
},
{
id: 'enabled',
accessorFn: (row) => (row.enabled === false ? 'off' : 'on'),
header: ({ column }) => (
<DataGridColumnHeader title="Статус" visibility={true} column={column} />
),
cell: ({ row }) => (
<EnabledBadge enabled={row.original.enabled !== false} />
),
size: 110,
enableSorting: true,
enableHiding: true,
enableResizing: true,
meta: {
skeleton: <Skeleton className="h-5 w-16 rounded-full" />,
},
},
{
id: 'current_connections',
accessorFn: (row) => Number(row.current_connections ?? 0),
header: ({ column }) => (
<DataGridColumnHeader
title="Соединения"
visibility={true}
column={column}
/>
),
cell: ({ row }) => <ConnectionsCell user={row.original} />,
size: 168,
enableSorting: true,
enableHiding: true,
enableResizing: true,
sortingFn: 'basic',
meta: {
skeleton: (
<div className="flex min-w-0 items-center gap-2">
<Skeleton className="h-2 min-w-12 flex-1 rounded-full" />
<Skeleton className="h-3 w-10 shrink-0" />
</div>
),
},
},
{
id: 'ips',
accessorFn: (row) =>
[...(row.active_unique_ips_list ?? []), ...(row.recent_unique_ips_list ?? [])].join(
' ',
),
header: ({ column }) => (
<DataGridColumnHeader title="IP" visibility={true} column={column} />
),
cell: ({ row }) => <IpListCell user={row.original} />,
size: 200,
enableSorting: false,
enableHiding: true,
enableResizing: true,
meta: {
skeleton: (
<div className="flex gap-1">
<Skeleton className="h-5 w-20 rounded-full" />
<Skeleton className="h-5 w-16 rounded-full" />
</div>
),
},
},
{
id: 'active_unique_ips',
accessorKey: 'active_unique_ips',
header: ({ column }) => (
<DataGridColumnHeader
title="Активные IP"
visibility={true}
column={column}
/>
),
cell: ({ row }) => (
<span className="text-foreground tabular-nums">
{formatNumber(row.original.active_unique_ips)}
</span>
),
size: 120,
enableSorting: true,
enableHiding: true,
enableResizing: true,
meta: {
skeleton: <Skeleton className="h-4 w-10" />,
},
},
{
id: 'recent_unique_ips',
accessorKey: 'recent_unique_ips',
header: ({ column }) => (
<DataGridColumnHeader
title="Недавние IP"
visibility={true}
column={column}
/>
),
cell: ({ row }) => (
<span className="text-foreground tabular-nums">
{formatNumber(row.original.recent_unique_ips)}
</span>
),
size: 120,
enableSorting: true,
enableHiding: true,
enableResizing: true,
meta: {
skeleton: <Skeleton className="h-4 w-10" />,
},
},
{
id: 'data_quota_bytes',
accessorFn: (row) => {
const quota = row.data_quota_bytes
if (typeof quota === 'number' && quota > 0) return quota
// «Без квоты» в UI показывает usage — сортируем по тому же смыслу
return Number(row.total_octets ?? 0)
},
header: ({ column }) => (
<DataGridColumnHeader title="Квота" visibility={true} column={column} />
),
cell: ({ row }) => <QuotaCell user={row.original} />,
size: 160,
enableSorting: true,
enableHiding: true,
enableResizing: true,
sortingFn: 'basic',
meta: {
skeleton: (
<div className="flex min-w-0 flex-col gap-1">
<Skeleton className="h-2 w-full rounded-full" />
<Skeleton className="h-3 w-24" />
</div>
),
},
},
{
id: 'total_octets',
accessorKey: 'total_octets',
header: ({ column }) => (
<DataGridColumnHeader title="Трафик" visibility={true} column={column} />
),
cell: ({ row }) => (
<span className="text-foreground tabular-nums">
{formatBytes(row.original.total_octets)}
</span>
),
size: 120,
enableSorting: true,
enableHiding: true,
enableResizing: true,
meta: {
skeleton: <Skeleton className="h-4 w-16" />,
},
},
{
id: 'max_tcp_conns',
accessorKey: 'max_tcp_conns',
header: ({ column }) => (
<DataGridColumnHeader title="Лимит TCP" visibility={true} column={column} />
),
cell: ({ row }) => (
<span className="text-muted-foreground tabular-nums">
{row.original.max_tcp_conns != null
? formatNumber(row.original.max_tcp_conns)
: '∞'}
</span>
),
size: 110,
enableSorting: true,
enableHiding: true,
enableResizing: true,
meta: {
skeleton: <Skeleton className="h-4 w-12" />,
},
},
{
id: 'expiration_rfc3339',
accessorKey: 'expiration_rfc3339',
header: ({ column }) => (
<DataGridColumnHeader
title="Истекает"
visibility={true}
column={column}
/>
),
cell: ({ row }) => (
<span className="text-muted-foreground text-sm">
{row.original.expiration_rfc3339
? new Date(row.original.expiration_rfc3339).toLocaleString('ru-RU')
: '—'}
</span>
),
size: 160,
enableSorting: true,
enableHiding: true,
enableResizing: true,
meta: {
skeleton: <Skeleton className="h-4 w-28" />,
},
},
{
id: 'actions',
header: '',
cell: ({ row }) => (
<ActionsCell row={row} onOpen={opts.onOpen} onDelete={opts.onDelete} />
),
enableSorting: false,
enableHiding: false,
enableResizing: false,
enablePinning: false,
size: 52,
meta: {
skeleton: <Skeleton className="size-7 rounded-md" />,
cellClassName: 'bg-transparent!',
headerClassName: 'bg-transparent!',
},
},
]
}
export function useUsersFilterFields() {
return useMemo(
() => [
{
key: 'username',
label: 'Имя',
type: 'text' as const,
className: 'w-40',
placeholder: 'Поиск…',
},
{
key: 'ip',
label: 'IP',
type: 'text' as const,
className: 'w-44',
placeholder: '1.2.3.4…',
},
{
key: 'enabled',
label: 'Статус',
type: 'select' as const,
searchable: false,
className: 'w-[140px]',
options: [
{ value: 'on', label: 'Вкл' },
{ value: 'off', label: 'Выкл' },
],
customValueRenderer: (values: unknown[]) => {
if (values.length === 0) return 'Выберите…'
if (values.length > 1) return `${values.length} выбрано`
return <EnabledBadge enabled={values[0] === 'on'} />
},
},
],
[],
)
}