Клик по заголовку снова сортирует напрямую; убран pin последней колонки, из-за которого actions выглядел отрезанным от строки. Co-authored-by: Cursor <cursoragent@cursor.com>
592 lines
18 KiB
TypeScript
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'} />
|
|
},
|
|
},
|
|
],
|
|
[],
|
|
)
|
|
}
|