Docker images / prepare-release (push) Successful in 10s
Docker images / backend-image (push) Successful in 1m57s
Docker images / frontend-image (push) Successful in 4m8s
Docker images / notify-webhook (push) Skipped
Docker images / updater-image (push) Successful in 44s
Docker images / publish-release (push) Successful in 11s
Added user management functionality, including the creation of app_users and user_interface_bindings tables in the database. Implemented API routes for user data retrieval and permissions handling. Enhanced the traffic monitoring system to include user traffic statistics and interface bindings. Updated relevant components and services to support the new user features, improving overall application functionality and user experience.
245 lines
7.6 KiB
TypeScript
245 lines
7.6 KiB
TypeScript
"use client"
|
|
|
|
import { useMemo } from "react"
|
|
import {
|
|
type ColumnDef,
|
|
getCoreRowModel,
|
|
getExpandedRowModel,
|
|
getSortedRowModel,
|
|
useReactTable,
|
|
} from "@tanstack/react-table"
|
|
import { Button } from "@/components/ui/button"
|
|
import { Badge } from "@/components/reui/badge"
|
|
import { cn } from "@/lib/utils"
|
|
import { DataGridShell } from "@/components/data-grids/shared/data-grid-shell"
|
|
import {
|
|
DATA_GRID_CELL_PAD,
|
|
DATA_GRID_CELL_PAD_FIRST,
|
|
DATA_GRID_CELL_PAD_LAST,
|
|
} from "@/components/data-grids/shared/data-grid-layout"
|
|
import { DataGridSortHeader } from "@/components/data-grids/shared/data-grid-sort-header"
|
|
import { UsersExpandedDetail } from "@/components/data-grids/users-expanded-detail"
|
|
import { EmptyState } from "@/components/empty-state"
|
|
import { ALL_SECTIONS, ROLE_LABEL, type AppUser } from "@/lib/users"
|
|
import {
|
|
CableIcon,
|
|
ChevronDownIcon,
|
|
ChevronRightIcon,
|
|
PencilIcon,
|
|
TrashIcon,
|
|
UsersIcon,
|
|
} from "lucide-react"
|
|
|
|
function AvatarCircle({ avatar, active }: { avatar: string; active: boolean }) {
|
|
return (
|
|
<div
|
|
className={cn(
|
|
"size-8 rounded-full flex items-center justify-center text-white text-[10px] font-semibold shrink-0 bg-gradient-to-br from-blue-500 to-violet-500",
|
|
!active && "opacity-50",
|
|
)}
|
|
>
|
|
{avatar}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
interface UsersDataGridProps {
|
|
users: AppUser[]
|
|
serversCount: number
|
|
allSectionsCount?: number
|
|
isLoading?: boolean
|
|
onEdit: (user: AppUser) => void
|
|
onDelete: (user: AppUser) => void
|
|
}
|
|
|
|
function UsersDataGrid({
|
|
users,
|
|
serversCount,
|
|
allSectionsCount = ALL_SECTIONS.length,
|
|
isLoading,
|
|
onEdit,
|
|
onDelete,
|
|
}: UsersDataGridProps) {
|
|
const columns = useMemo<ColumnDef<AppUser>[]>(
|
|
() => [
|
|
{
|
|
id: "name",
|
|
accessorKey: "name",
|
|
header: ({ column }) => (
|
|
<DataGridSortHeader column={column} title="Пользователь" className="ml-1" />
|
|
),
|
|
cell: ({ row }) => {
|
|
const u = row.original
|
|
const expanded = row.getIsExpanded()
|
|
return (
|
|
<div className="flex items-start gap-2 min-w-0">
|
|
{expanded ? (
|
|
<ChevronDownIcon className="size-3.5 mt-2 shrink-0 text-muted-foreground" />
|
|
) : (
|
|
<ChevronRightIcon className="size-3.5 mt-2 shrink-0 text-muted-foreground/40" />
|
|
)}
|
|
<AvatarCircle avatar={u.avatar} active={u.active} />
|
|
<div className="min-w-0">
|
|
<p className="text-sm font-medium truncate">{u.name}</p>
|
|
<p className="text-xs font-mono text-muted-foreground truncate">{u.email || u.login}</p>
|
|
</div>
|
|
</div>
|
|
)
|
|
},
|
|
meta: {
|
|
headerTitle: "Пользователь",
|
|
headerClassName: DATA_GRID_CELL_PAD_FIRST,
|
|
cellClassName: DATA_GRID_CELL_PAD_FIRST,
|
|
expandedContent: (row: AppUser) => (
|
|
<UsersExpandedDetail
|
|
user={row}
|
|
serversCount={serversCount}
|
|
allSectionsCount={allSectionsCount}
|
|
/>
|
|
),
|
|
},
|
|
},
|
|
{
|
|
id: "role",
|
|
accessorKey: "role",
|
|
header: ({ column }) => <DataGridSortHeader column={column} title="Роль" />,
|
|
cell: ({ row }) => (
|
|
<Badge
|
|
variant={row.original.role === "admin" ? "primary-light" : row.original.role === "operator" ? "info-light" : "outline"}
|
|
size="sm"
|
|
>
|
|
{ROLE_LABEL[row.original.role]}
|
|
</Badge>
|
|
),
|
|
meta: {
|
|
headerTitle: "Роль",
|
|
headerClassName: DATA_GRID_CELL_PAD,
|
|
cellClassName: DATA_GRID_CELL_PAD,
|
|
},
|
|
},
|
|
{
|
|
id: "last",
|
|
accessorKey: "last",
|
|
header: ({ column }) => <DataGridSortHeader column={column} title="Последний вход" />,
|
|
cell: ({ row }) => (
|
|
<span className="text-xs text-muted-foreground">{row.original.last}</span>
|
|
),
|
|
meta: {
|
|
headerTitle: "Последний вход",
|
|
headerClassName: DATA_GRID_CELL_PAD,
|
|
cellClassName: DATA_GRID_CELL_PAD,
|
|
},
|
|
},
|
|
{
|
|
id: "ifaces",
|
|
accessorFn: (u) => u.bindings.length,
|
|
header: ({ column }) => <DataGridSortHeader column={column} title="Интерфейсы" />,
|
|
cell: ({ row }) => (
|
|
<span className="inline-flex items-center gap-1 text-xs text-muted-foreground tabular-nums">
|
|
<CableIcon className="size-3.5" />
|
|
{row.original.bindings.length}
|
|
</span>
|
|
),
|
|
meta: {
|
|
headerTitle: "Интерфейсы",
|
|
headerClassName: DATA_GRID_CELL_PAD,
|
|
cellClassName: DATA_GRID_CELL_PAD,
|
|
},
|
|
},
|
|
{
|
|
id: "status",
|
|
accessorKey: "active",
|
|
header: ({ column }) => <DataGridSortHeader column={column} title="Статус" />,
|
|
cell: ({ row }) => (
|
|
<span
|
|
className={cn(
|
|
"text-[11px] font-medium",
|
|
row.original.active ? "text-success" : "text-muted-foreground",
|
|
)}
|
|
>
|
|
{row.original.active ? "Активен" : "Заблокирован"}
|
|
</span>
|
|
),
|
|
meta: {
|
|
headerTitle: "Статус",
|
|
headerClassName: DATA_GRID_CELL_PAD,
|
|
cellClassName: DATA_GRID_CELL_PAD,
|
|
},
|
|
},
|
|
{
|
|
id: "actions",
|
|
enableSorting: false,
|
|
header: () => <span className="sr-only">Действия</span>,
|
|
cell: ({ row }) => {
|
|
const u = row.original
|
|
return (
|
|
<div
|
|
className="flex items-center justify-end gap-0.5"
|
|
onClick={(e) => e.stopPropagation()}
|
|
onKeyDown={(e) => e.stopPropagation()}
|
|
>
|
|
<Button
|
|
size="sm"
|
|
variant="ghost"
|
|
className="size-7 p-0 text-muted-foreground hover:text-foreground"
|
|
onClick={() => onEdit(u)}
|
|
title="Редактировать"
|
|
>
|
|
<PencilIcon className="size-3.5" />
|
|
</Button>
|
|
<Button
|
|
size="sm"
|
|
variant="ghost"
|
|
className="size-7 p-0 text-muted-foreground hover:text-destructive"
|
|
onClick={() => onDelete(u)}
|
|
title="Удалить"
|
|
>
|
|
<TrashIcon className="size-3.5" />
|
|
</Button>
|
|
</div>
|
|
)
|
|
},
|
|
meta: {
|
|
headerTitle: "Действия",
|
|
headerClassName: DATA_GRID_CELL_PAD_LAST,
|
|
cellClassName: DATA_GRID_CELL_PAD_LAST,
|
|
},
|
|
},
|
|
],
|
|
[allSectionsCount, onDelete, onEdit, serversCount],
|
|
)
|
|
|
|
const table = useReactTable({
|
|
data: users,
|
|
columns,
|
|
getCoreRowModel: getCoreRowModel(),
|
|
getSortedRowModel: getSortedRowModel(),
|
|
getExpandedRowModel: getExpandedRowModel(),
|
|
getRowId: (row) => row.id,
|
|
getRowCanExpand: () => true,
|
|
})
|
|
|
|
if (!isLoading && users.length === 0) {
|
|
return (
|
|
<EmptyState
|
|
icon={<UsersIcon className="size-4" />}
|
|
title="Нет пользователей"
|
|
description="Добавьте пользователя, чтобы привязать интерфейсы и учитывать трафик"
|
|
className="border-0 py-12"
|
|
/>
|
|
)
|
|
}
|
|
|
|
return (
|
|
<DataGridShell
|
|
table={table}
|
|
recordCount={users.length}
|
|
isLoading={isLoading}
|
|
loadingMode="skeleton"
|
|
onRowClick={(row) => table.getRow(row.id).toggleExpanded()}
|
|
/>
|
|
)
|
|
}
|
|
|
|
export { UsersDataGrid, type UsersDataGridProps }
|