feat: integrate ReUI components and update MCP configuration
Build, Test, and Push CFDM Docker Image / test (push) Failing after 49s
Build, Test, and Push CFDM Docker Image / build-and-push (push) Has been skipped
Build, Test, and Push CFDM Docker Image / update-wiki (push) Has been skipped
Build, Test, and Push CFDM Docker Image / create-release (push) Has been skipped
Build, Test, and Push CFDM Docker Image / test (push) Failing after 49s
Build, Test, and Push CFDM Docker Image / build-and-push (push) Has been skipped
Build, Test, and Push CFDM Docker Image / update-wiki (push) Has been skipped
Build, Test, and Push CFDM Docker Image / create-release (push) Has been skipped
- Added ReUI configuration to .mcp.json and .cursor/mcp.json for component integration. - Updated pnpm-lock.yaml with new dependencies including react-phone-number-input and adjustments to existing packages. - Enhanced SKILL.md documentation for ReUI to clarify usage and features. - Removed unused components (ChartCard, DataGridCard, etc.) to streamline the codebase. - Adjusted domain-related components and filters for improved functionality and UI consistency. Co-authored-by: Cursor <[email protected]>
This commit is contained in:
@@ -0,0 +1,109 @@
|
||||
import { useMemo } from 'react'
|
||||
import type { ColumnDef } from '@tanstack/react-table'
|
||||
import { GlobeIcon, SearchIcon } from 'lucide-react'
|
||||
|
||||
import { DataGridColumnHeader } from '@/components/reui/data-grid/data-grid-column-header'
|
||||
import { createFilter, type FilterFieldConfig } from '@/components/reui/filters'
|
||||
import { StatusBadge } from '@/components/status-badge'
|
||||
import type { Certificate } from '@/lib/schemas'
|
||||
import { formatDate, formatRelative } from '@/lib/format'
|
||||
|
||||
export const CERT_TABS = [
|
||||
{ id: 'all', label: 'Все' },
|
||||
{ id: 'active', label: 'Активен' },
|
||||
{ id: 'warning', label: 'Предупреждение' },
|
||||
{ id: 'expired', label: 'Истёк' },
|
||||
] as const
|
||||
|
||||
const ACTIVE_STATUSES = new Set(['active', 'ok', 'synced'])
|
||||
const EXPIRED_STATUSES = new Set(['expired', 'error', 'conflict'])
|
||||
|
||||
export function certTabFilter(item: Certificate, tabId: string) {
|
||||
if (tabId === 'active') return ACTIVE_STATUSES.has(item.status)
|
||||
if (tabId === 'warning') return item.status === 'warning'
|
||||
if (tabId === 'expired') return EXPIRED_STATUSES.has(item.status)
|
||||
return true
|
||||
}
|
||||
|
||||
export function createDefaultCertFilters() {
|
||||
return [createFilter('hostname', 'contains', [''])]
|
||||
}
|
||||
|
||||
export function useCertFilterFields() {
|
||||
return useMemo<FilterFieldConfig[]>(
|
||||
() => [
|
||||
{
|
||||
key: 'hostname',
|
||||
label: 'Хост',
|
||||
icon: <SearchIcon className="size-3.5" aria-hidden />,
|
||||
type: 'text',
|
||||
className: 'w-52',
|
||||
placeholder: 'Поиск по хосту…',
|
||||
},
|
||||
],
|
||||
[],
|
||||
)
|
||||
}
|
||||
|
||||
export function certFilterFieldValue(item: Certificate, field: string) {
|
||||
if (field === 'hostname') {
|
||||
return `${item.hostname} ${item.status}`.toLowerCase()
|
||||
}
|
||||
return ''
|
||||
}
|
||||
|
||||
export function useCertificateColumns() {
|
||||
return useMemo<ColumnDef<Certificate>[]>(
|
||||
() => [
|
||||
{
|
||||
id: 'hostname',
|
||||
accessorKey: 'hostname',
|
||||
header: ({ column }) => (
|
||||
<DataGridColumnHeader column={column} title="Хост" icon={<GlobeIcon className="size-3.5" />} />
|
||||
),
|
||||
cell: ({ row }) => (
|
||||
<span className="truncate font-medium">{row.original.hostname}</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'status',
|
||||
header: 'Статус',
|
||||
cell: ({ row }) => <StatusBadge status={row.original.status} />,
|
||||
},
|
||||
{
|
||||
id: 'expires_at',
|
||||
accessorKey: 'expires_at',
|
||||
header: ({ column }) => (
|
||||
<DataGridColumnHeader column={column} title="Истекает" />
|
||||
),
|
||||
cell: ({ row }) => (
|
||||
<span className="text-muted-foreground tabular-nums">
|
||||
{formatDate(row.original.expires_at)}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'relative',
|
||||
header: 'Срок',
|
||||
cell: ({ row }) => (
|
||||
<span className="text-muted-foreground tabular-nums">
|
||||
{formatRelative(row.original.expires_at)}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'last_checked_at',
|
||||
accessorKey: 'last_checked_at',
|
||||
header: ({ column }) => (
|
||||
<DataGridColumnHeader column={column} title="Проверка" />
|
||||
),
|
||||
cell: ({ row }) => (
|
||||
<span className="text-muted-foreground tabular-nums">
|
||||
{formatDate(row.original.last_checked_at)}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
],
|
||||
[],
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,210 @@
|
||||
import { useMemo } from 'react'
|
||||
import type { ColumnDef } from '@tanstack/react-table'
|
||||
import { MoreHorizontalIcon, SearchIcon } from 'lucide-react'
|
||||
import { cn } from '@cfdm/ui/lib/utils'
|
||||
|
||||
import { Badge } from '@/components/reui/badge'
|
||||
import { DataGridColumnHeader } from '@/components/reui/data-grid/data-grid-column-header'
|
||||
import { createFilter, type FilterFieldConfig } from '@/components/reui/filters'
|
||||
import { StatusBadge } from '@/components/status-badge'
|
||||
import type { DnsRecord, IpHealthStatus } from '@/lib/schemas'
|
||||
import { formatDate } from '@/lib/format'
|
||||
import { Button } from '@cfdm/ui/components/button'
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from '@cfdm/ui/components/dropdown-menu'
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from '@cfdm/ui/components/tooltip'
|
||||
|
||||
export const DNS_TABS = [
|
||||
{ id: 'all', label: 'Все' },
|
||||
{ id: 'proxied', label: 'Proxied' },
|
||||
{ id: 'dns_only', label: 'DNS only' },
|
||||
] as const
|
||||
|
||||
const DNS_TYPES = ['A', 'AAAA', 'CNAME', 'TXT', 'MX'] as const
|
||||
|
||||
export function dnsTabFilter(item: DnsRecord, tabId: string) {
|
||||
if (tabId === 'proxied') return item.proxied === true
|
||||
if (tabId === 'dns_only') return item.proxied !== true
|
||||
return true
|
||||
}
|
||||
|
||||
export function createDefaultDnsFilters() {
|
||||
return [createFilter('name', 'contains', [''])]
|
||||
}
|
||||
|
||||
export function useDnsFilterFields() {
|
||||
return useMemo<FilterFieldConfig[]>(
|
||||
() => [
|
||||
{
|
||||
key: 'name',
|
||||
label: 'Имя',
|
||||
icon: <SearchIcon className="size-3.5" aria-hidden />,
|
||||
type: 'text',
|
||||
className: 'w-52',
|
||||
placeholder: 'Поиск…',
|
||||
},
|
||||
{
|
||||
key: 'record_type',
|
||||
label: 'Тип',
|
||||
type: 'select',
|
||||
className: 'w-[120px]',
|
||||
options: DNS_TYPES.map((type) => ({ label: type, value: type })),
|
||||
},
|
||||
],
|
||||
[],
|
||||
)
|
||||
}
|
||||
|
||||
export function dnsFilterFieldValue(item: DnsRecord, field: string) {
|
||||
switch (field) {
|
||||
case 'name':
|
||||
return `${item.name} ${item.content}`.toLowerCase()
|
||||
case 'record_type':
|
||||
return item.record_type
|
||||
default:
|
||||
return ''
|
||||
}
|
||||
}
|
||||
|
||||
const healthDotClass: Record<IpHealthStatus['status'], string> = {
|
||||
up: 'bg-success',
|
||||
degraded: 'bg-warning',
|
||||
down: 'bg-destructive',
|
||||
unknown: 'bg-muted-foreground/40',
|
||||
}
|
||||
|
||||
const healthLabel: Record<IpHealthStatus['status'], string> = {
|
||||
up: 'OK',
|
||||
degraded: 'Деград.',
|
||||
down: 'Down',
|
||||
unknown: '—',
|
||||
}
|
||||
|
||||
function IpHealthDot({ health }: { health: IpHealthStatus }) {
|
||||
const tooltipParts: string[] = [`Статус: ${healthLabel[health.status]}`]
|
||||
if (health.latency_ms != null) tooltipParts.push(`Задержка: ${health.latency_ms} мс`)
|
||||
if (health.last_checked_at) tooltipParts.push(`Проверка: ${formatDate(health.last_checked_at)}`)
|
||||
if (health.last_error) tooltipParts.push(`Ошибка: ${health.last_error}`)
|
||||
return (
|
||||
<Tooltip>
|
||||
<TooltipTrigger
|
||||
render={
|
||||
<span
|
||||
className={cn(
|
||||
'inline-flex size-2 shrink-0 cursor-default rounded-full',
|
||||
healthDotClass[health.status],
|
||||
)}
|
||||
aria-label={`Health: ${healthLabel[health.status]}`}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<TooltipContent className="whitespace-pre-line">{tooltipParts.join('\n')}</TooltipContent>
|
||||
</Tooltip>
|
||||
)
|
||||
}
|
||||
|
||||
export function useDnsColumns({
|
||||
onDelete,
|
||||
isDeleting,
|
||||
healthByIp,
|
||||
}: {
|
||||
onDelete: (recordId: number) => void
|
||||
isDeleting?: boolean
|
||||
healthByIp?: Record<string, IpHealthStatus>
|
||||
}) {
|
||||
return useMemo<ColumnDef<DnsRecord>[]>(
|
||||
() => [
|
||||
{
|
||||
id: 'record_type',
|
||||
accessorKey: 'record_type',
|
||||
header: ({ column }) => (
|
||||
<DataGridColumnHeader column={column} title="Тип" />
|
||||
),
|
||||
cell: ({ row }) => (
|
||||
<Badge variant="outline">{row.original.record_type}</Badge>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'name',
|
||||
accessorKey: 'name',
|
||||
header: ({ column }) => (
|
||||
<DataGridColumnHeader column={column} title="Имя" />
|
||||
),
|
||||
cell: ({ row }) => (
|
||||
<span className="font-medium">{row.original.name}</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'content',
|
||||
accessorKey: 'content',
|
||||
header: ({ column }) => (
|
||||
<DataGridColumnHeader column={column} title="Значение" />
|
||||
),
|
||||
cell: ({ row }) => {
|
||||
const health = healthByIp?.[row.original.content]
|
||||
return (
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
{health ? <IpHealthDot health={health} /> : null}
|
||||
<span className="truncate font-mono text-sm">{row.original.content}</span>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'proxied',
|
||||
header: 'Прокси',
|
||||
cell: ({ row }) =>
|
||||
row.original.proxied ? (
|
||||
<StatusBadge status="active" label="Proxied" />
|
||||
) : (
|
||||
<Badge variant="outline">DNS only</Badge>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'ttl',
|
||||
accessorKey: 'ttl',
|
||||
header: ({ column }) => (
|
||||
<DataGridColumnHeader column={column} title="TTL" />
|
||||
),
|
||||
cell: ({ row }) => (
|
||||
<span className="text-muted-foreground tabular-nums">{row.original.ttl}</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'actions',
|
||||
header: '',
|
||||
enableHiding: false,
|
||||
cell: ({ row }) => (
|
||||
<div className="text-right">
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger
|
||||
render={<Button variant="outline" size="icon" className="size-8" />}
|
||||
>
|
||||
<MoreHorizontalIcon />
|
||||
<span className="sr-only">Действия</span>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem
|
||||
variant="destructive"
|
||||
disabled={isDeleting}
|
||||
onClick={() => onDelete(row.original.id)}
|
||||
>
|
||||
Удалить
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
],
|
||||
[healthByIp, isDeleting, onDelete],
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,221 @@
|
||||
import { useMemo } from 'react'
|
||||
import { Link } from '@tanstack/react-router'
|
||||
import type { ColumnDef } from '@tanstack/react-table'
|
||||
import { MoreHorizontalIcon, SearchIcon } from 'lucide-react'
|
||||
|
||||
import { Badge } from '@/components/reui/badge'
|
||||
import { DataGridColumnHeader } from '@/components/reui/data-grid/data-grid-column-header'
|
||||
import { createFilter, type FilterFieldConfig } from '@/components/reui/filters'
|
||||
import { StatusBadge } from '@/components/status-badge'
|
||||
import type { DomainListItem } from '@/lib/schemas'
|
||||
import { renderSingleSelectedLabel } from '@/components/reui-kit/filter-utils'
|
||||
import { Button } from '@cfdm/ui/components/button'
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from '@cfdm/ui/components/dropdown-menu'
|
||||
|
||||
export const DOMAIN_TABS = [
|
||||
{ id: 'all', label: 'Все' },
|
||||
{ id: 'with_group', label: 'С группой' },
|
||||
{ id: 'without_group', label: 'Без группы' },
|
||||
] as const
|
||||
|
||||
export function domainTabFilter(item: DomainListItem, tabId: string) {
|
||||
if (tabId === 'with_group') return item.group_id != null
|
||||
if (tabId === 'without_group') return item.group_id == null
|
||||
return true
|
||||
}
|
||||
|
||||
export function createDefaultDomainFilters() {
|
||||
return [createFilter('zone_name', 'contains', [''])]
|
||||
}
|
||||
|
||||
export function useDomainFilterFields(
|
||||
groupOptions: { value: string; label: string }[],
|
||||
) {
|
||||
return useMemo<FilterFieldConfig[]>(
|
||||
() => [
|
||||
{
|
||||
key: 'zone_name',
|
||||
label: 'Зона',
|
||||
icon: <SearchIcon className="size-3.5" aria-hidden />,
|
||||
type: 'text',
|
||||
className: 'w-52',
|
||||
placeholder: 'Поиск…',
|
||||
},
|
||||
{
|
||||
key: 'group_id',
|
||||
label: 'Группа',
|
||||
type: 'select',
|
||||
searchable: true,
|
||||
className: 'w-[168px]',
|
||||
options: groupOptions,
|
||||
customValueRenderer: (values) =>
|
||||
renderSingleSelectedLabel(values, groupOptions),
|
||||
},
|
||||
],
|
||||
[groupOptions],
|
||||
)
|
||||
}
|
||||
|
||||
export function domainFilterFieldValue(item: DomainListItem, field: string) {
|
||||
switch (field) {
|
||||
case 'zone_name':
|
||||
return `${item.zone_name} ${item.group_name ?? ''}`.toLowerCase()
|
||||
case 'group_id':
|
||||
return item.group_id != null ? String(item.group_id) : 'none'
|
||||
default:
|
||||
return ''
|
||||
}
|
||||
}
|
||||
|
||||
export function useDomainColumns({
|
||||
onRequestDelete,
|
||||
isDeleting,
|
||||
}: {
|
||||
onRequestDelete: (domain: DomainListItem) => void
|
||||
isDeleting?: boolean
|
||||
}) {
|
||||
const columns = useMemo<ColumnDef<DomainListItem>[]>(
|
||||
() => [
|
||||
{
|
||||
id: 'zone_name',
|
||||
accessorKey: 'zone_name',
|
||||
header: ({ column }) => (
|
||||
<DataGridColumnHeader column={column} title="Зона" />
|
||||
),
|
||||
cell: ({ row }) => (
|
||||
<Button
|
||||
variant="link"
|
||||
className="h-auto p-0 font-medium"
|
||||
nativeButton={false}
|
||||
render={
|
||||
<Link
|
||||
to="/domains/$domainId"
|
||||
params={{ domainId: String(row.original.id) }}
|
||||
/>
|
||||
}
|
||||
>
|
||||
{row.original.zone_name}
|
||||
</Button>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'group',
|
||||
header: ({ column }) => (
|
||||
<DataGridColumnHeader column={column} title="Группа" />
|
||||
),
|
||||
cell: ({ row }) => {
|
||||
const domain = row.original
|
||||
if (domain.group_id && domain.group_name) {
|
||||
return (
|
||||
<Button
|
||||
variant="link"
|
||||
className="h-auto p-0"
|
||||
nativeButton={false}
|
||||
render={
|
||||
<Link
|
||||
to="/groups/$groupId"
|
||||
params={{ groupId: String(domain.group_id) }}
|
||||
/>
|
||||
}
|
||||
>
|
||||
{domain.group_name}
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
return <Badge variant="outline">Без группы</Badge>
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'service_count',
|
||||
accessorKey: 'service_count',
|
||||
header: ({ column }) => (
|
||||
<DataGridColumnHeader column={column} title="Сервисы" />
|
||||
),
|
||||
cell: ({ row }) => (
|
||||
<Button
|
||||
variant="link"
|
||||
className="h-auto p-0 tabular-nums"
|
||||
nativeButton={false}
|
||||
render={
|
||||
<Link to="/services" search={{ domainId: row.original.id }} />
|
||||
}
|
||||
>
|
||||
{row.original.service_count}
|
||||
</Button>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'status',
|
||||
header: 'Статус',
|
||||
cell: ({ row }) => <StatusBadge status={row.original.status} />,
|
||||
},
|
||||
{
|
||||
id: 'last_synced_at',
|
||||
accessorKey: 'last_synced_at',
|
||||
header: ({ column }) => (
|
||||
<DataGridColumnHeader column={column} title="Синхронизация" />
|
||||
),
|
||||
cell: ({ row }) => (
|
||||
<span className="text-muted-foreground tabular-nums">
|
||||
{row.original.last_synced_at ?? '—'}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'actions',
|
||||
header: '',
|
||||
enableHiding: false,
|
||||
cell: ({ row }) => (
|
||||
<div className="text-right">
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger
|
||||
render={<Button variant="outline" size="icon" className="size-8" />}
|
||||
>
|
||||
<MoreHorizontalIcon />
|
||||
<span className="sr-only">Действия</span>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem
|
||||
render={
|
||||
<Link
|
||||
to="/domains/$domainId"
|
||||
params={{ domainId: String(row.original.id) }}
|
||||
/>
|
||||
}
|
||||
>
|
||||
Обзор
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
render={
|
||||
<Link
|
||||
to="/domains/$domainId/dns"
|
||||
params={{ domainId: String(row.original.id) }}
|
||||
search={{ host: undefined }}
|
||||
/>
|
||||
}
|
||||
>
|
||||
DNS
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
variant="destructive"
|
||||
disabled={isDeleting}
|
||||
onClick={() => onRequestDelete(row.original)}
|
||||
>
|
||||
Удалить
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
],
|
||||
[isDeleting, onRequestDelete],
|
||||
)
|
||||
|
||||
return { columns }
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
import { useMemo } from 'react'
|
||||
import { Link } from '@tanstack/react-router'
|
||||
import type { ColumnDef } from '@tanstack/react-table'
|
||||
import { GlobeIcon, SearchIcon } from 'lucide-react'
|
||||
|
||||
import { DataGridColumnHeader } from '@/components/reui/data-grid/data-grid-column-header'
|
||||
import { createFilter, type FilterFieldConfig } from '@/components/reui/filters'
|
||||
import { StatusBadge } from '@/components/status-badge'
|
||||
import type { DomainListItem } from '@/lib/schemas'
|
||||
import { Button } from '@cfdm/ui/components/button'
|
||||
|
||||
export function createDefaultGroupDomainFilters() {
|
||||
return [createFilter('zone_name', 'contains', [''])]
|
||||
}
|
||||
|
||||
export function useGroupDomainFilterFields() {
|
||||
return useMemo<FilterFieldConfig[]>(
|
||||
() => [
|
||||
{
|
||||
key: 'zone_name',
|
||||
label: 'Зона',
|
||||
icon: <SearchIcon className="size-3.5" aria-hidden />,
|
||||
type: 'text',
|
||||
className: 'w-52',
|
||||
placeholder: 'Поиск…',
|
||||
},
|
||||
],
|
||||
[],
|
||||
)
|
||||
}
|
||||
|
||||
export function groupDomainFilterFieldValue(item: DomainListItem, field: string) {
|
||||
if (field === 'zone_name') return item.zone_name.toLowerCase()
|
||||
return ''
|
||||
}
|
||||
|
||||
export function useGroupDomainColumns() {
|
||||
return useMemo<ColumnDef<DomainListItem>[]>(
|
||||
() => [
|
||||
{
|
||||
id: 'zone_name',
|
||||
accessorKey: 'zone_name',
|
||||
header: ({ column }) => (
|
||||
<DataGridColumnHeader column={column} title="Зона" icon={<GlobeIcon className="size-3.5" />} />
|
||||
),
|
||||
cell: ({ row }) => (
|
||||
<span className="font-medium">{row.original.zone_name}</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'status',
|
||||
header: 'Статус',
|
||||
cell: ({ row }) => <StatusBadge status={row.original.status} />,
|
||||
},
|
||||
{
|
||||
id: 'service_count',
|
||||
accessorKey: 'service_count',
|
||||
header: ({ column }) => (
|
||||
<DataGridColumnHeader column={column} title="Сервисы" />
|
||||
),
|
||||
cell: ({ row }) => (
|
||||
<Button
|
||||
variant="link"
|
||||
className="h-auto p-0 tabular-nums"
|
||||
nativeButton={false}
|
||||
render={
|
||||
<Link to="/services" search={{ domainId: row.original.id }} />
|
||||
}
|
||||
>
|
||||
{row.original.service_count}
|
||||
</Button>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'actions',
|
||||
header: '',
|
||||
enableHiding: false,
|
||||
cell: ({ row }) => (
|
||||
<div className="text-right">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
nativeButton={false}
|
||||
render={
|
||||
<Link
|
||||
to="/domains/$domainId"
|
||||
params={{ domainId: String(row.original.id) }}
|
||||
/>
|
||||
}
|
||||
>
|
||||
Обзор
|
||||
</Button>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
],
|
||||
[],
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,166 @@
|
||||
import { useMemo } from 'react'
|
||||
import { Link } from '@tanstack/react-router'
|
||||
import type { ColumnDef } from '@tanstack/react-table'
|
||||
import { MoreHorizontalIcon, SearchIcon } from 'lucide-react'
|
||||
|
||||
import { Badge } from '@/components/reui/badge'
|
||||
import { DataGridColumnHeader } from '@/components/reui/data-grid/data-grid-column-header'
|
||||
import { createFilter, type FilterFieldConfig } from '@/components/reui/filters'
|
||||
import type { SubdomainTableRow } from '@/hooks/use-domain-page'
|
||||
import { formatSubdomainServiceLinks } from '@/hooks/use-domain-page'
|
||||
import { formatDate } from '@/lib/format'
|
||||
import { Button } from '@cfdm/ui/components/button'
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from '@cfdm/ui/components/dropdown-menu'
|
||||
|
||||
export const SUBDOMAIN_TABS = [
|
||||
{ id: 'all', label: 'Все' },
|
||||
{ id: 'active', label: 'Активные' },
|
||||
] as const
|
||||
|
||||
export function subdomainTabFilter(item: SubdomainTableRow, tabId: string) {
|
||||
if (tabId === 'active') return item.subdomain.enabled
|
||||
return true
|
||||
}
|
||||
|
||||
export function createDefaultSubdomainFilters() {
|
||||
return [createFilter('fqdn', 'contains', [''])]
|
||||
}
|
||||
|
||||
export function useSubdomainFilterFields() {
|
||||
return useMemo<FilterFieldConfig[]>(
|
||||
() => [
|
||||
{
|
||||
key: 'fqdn',
|
||||
label: 'Поддомен',
|
||||
icon: <SearchIcon className="size-3.5" aria-hidden />,
|
||||
type: 'text',
|
||||
className: 'w-52',
|
||||
placeholder: 'Поиск…',
|
||||
},
|
||||
],
|
||||
[],
|
||||
)
|
||||
}
|
||||
|
||||
export function subdomainFilterFieldValue(item: SubdomainTableRow, field: string) {
|
||||
if (field === 'fqdn') {
|
||||
return `${item.subdomain.fqdn} ${formatSubdomainServiceLinks(item.serviceLinks)}`.toLowerCase()
|
||||
}
|
||||
return ''
|
||||
}
|
||||
|
||||
export function useSubdomainColumns({
|
||||
domainId,
|
||||
onEdit,
|
||||
onDelete,
|
||||
onToggleEnabled,
|
||||
isDeleting,
|
||||
isToggling,
|
||||
}: {
|
||||
domainId: string
|
||||
onEdit: (row: SubdomainTableRow) => void
|
||||
onDelete: (row: SubdomainTableRow) => void
|
||||
onToggleEnabled: (row: SubdomainTableRow) => void
|
||||
isDeleting?: boolean
|
||||
isToggling?: boolean
|
||||
}) {
|
||||
return useMemo<ColumnDef<SubdomainTableRow>[]>(
|
||||
() => [
|
||||
{
|
||||
id: 'fqdn',
|
||||
accessorFn: (row) => row.subdomain.fqdn,
|
||||
header: ({ column }) => (
|
||||
<DataGridColumnHeader column={column} title="Поддомен" />
|
||||
),
|
||||
cell: ({ row }) => (
|
||||
<span className="truncate font-mono">{row.original.subdomain.fqdn}</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'status',
|
||||
header: 'Статус',
|
||||
cell: ({ row }) => (
|
||||
<Badge variant={row.original.subdomain.enabled ? 'success' : 'outline'}>
|
||||
{row.original.subdomain.enabled ? 'Активен' : 'Неактивен'}
|
||||
</Badge>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'service',
|
||||
header: 'Группа / Сервис',
|
||||
accessorFn: (row) => formatSubdomainServiceLinks(row.serviceLinks),
|
||||
cell: ({ row }) => (
|
||||
<span className="text-muted-foreground">
|
||||
{formatSubdomainServiceLinks(row.original.serviceLinks)}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'created_at',
|
||||
accessorFn: (row) => row.subdomain.created_at,
|
||||
header: ({ column }) => (
|
||||
<DataGridColumnHeader column={column} title="Создан" />
|
||||
),
|
||||
cell: ({ row }) => (
|
||||
<span className="text-muted-foreground tabular-nums">
|
||||
{formatDate(row.original.subdomain.created_at)}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'actions',
|
||||
header: '',
|
||||
enableHiding: false,
|
||||
cell: ({ row }) => (
|
||||
<div className="text-right">
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger
|
||||
render={<Button variant="outline" size="icon" className="size-8" />}
|
||||
>
|
||||
<MoreHorizontalIcon />
|
||||
<span className="sr-only">Действия</span>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem onClick={() => onEdit(row.original)}>
|
||||
Редактировать
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
disabled={isToggling}
|
||||
onClick={() => onToggleEnabled(row.original)}
|
||||
>
|
||||
{row.original.subdomain.enabled ? 'Деактивировать' : 'Активировать'}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
render={
|
||||
<Link
|
||||
to="/domains/$domainId/dns"
|
||||
params={{ domainId }}
|
||||
search={{ host: row.original.subdomain.name }}
|
||||
/>
|
||||
}
|
||||
>
|
||||
DNS
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
variant="destructive"
|
||||
disabled={isDeleting}
|
||||
onClick={() => onDelete(row.original)}
|
||||
>
|
||||
Удалить
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
],
|
||||
[domainId, isDeleting, isToggling, onDelete, onEdit, onToggleEnabled],
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user