From 4e536c3b7ee3651dbeea4c965d6e1431107575ce Mon Sep 17 00:00:00 2001 From: Denozordec Date: Thu, 25 Jun 2026 17:27:21 +0700 Subject: [PATCH] refactor: Enhance UI components in various files by implementing new layouts, improving accessibility, and optimizing data handling for better performance and user experience --- apps/web/src/components/chart-card.tsx | 65 +++++ .../src/components/data-table-pagination.tsx | 87 +++++++ .../web/src/components/data-table-toolbar.tsx | 142 +++++++++++ apps/web/src/components/data-table-view.tsx | 124 ++++++++++ apps/web/src/components/dns-records-table.tsx | 24 +- .../src/components/domain-bindings-card.tsx | 30 ++- .../web/src/components/domains-data-table.tsx | 219 +++++------------ .../src/components/expiring-certs-card.tsx | 87 +++++++ apps/web/src/components/kpi-card.tsx | 99 ++++++++ apps/web/src/components/page-header.tsx | 15 +- .../src/components/section-cards-skeleton.tsx | 28 ++- .../src/components/service-binding-card.tsx | 2 + apps/web/src/components/subdomains-table.tsx | 227 +++++++++++------- apps/web/src/routes/_auth/certificates.tsx | 212 ++++++++++------ apps/web/src/routes/_auth/index.tsx | 221 ++++++++++++++++- apps/web/src/routes/login.tsx | 12 +- 16 files changed, 1236 insertions(+), 358 deletions(-) create mode 100644 apps/web/src/components/chart-card.tsx create mode 100644 apps/web/src/components/data-table-pagination.tsx create mode 100644 apps/web/src/components/data-table-toolbar.tsx create mode 100644 apps/web/src/components/data-table-view.tsx create mode 100644 apps/web/src/components/expiring-certs-card.tsx create mode 100644 apps/web/src/components/kpi-card.tsx diff --git a/apps/web/src/components/chart-card.tsx b/apps/web/src/components/chart-card.tsx new file mode 100644 index 0000000..c3c0d4a --- /dev/null +++ b/apps/web/src/components/chart-card.tsx @@ -0,0 +1,65 @@ +import type { ReactNode } from 'react' +import { + AppCard, + AppCardContent, + AppCardDescription, + AppCardHeader, + AppCardTitle, +} from '@/components/app-card' +import { + Tabs, + TabsContent, + TabsList, + TabsTrigger, +} from '@cfdm/ui/components/tabs' + +interface ChartCardProps { + title: string + description?: string + chart: ReactNode + table?: ReactNode + chartTabLabel?: string + tableTabLabel?: string + action?: ReactNode + className?: string +} + +export function ChartCard({ + title, + description, + chart, + table, + chartTabLabel = 'График', + tableTabLabel = 'Таблица', + action, + className, +}: ChartCardProps) { + const hasTabs = !!table + return ( + + + {title} + {description && {description}} + {action} + + + {hasTabs ? ( + + + {chartTabLabel} + {tableTabLabel} + + + {chart} + + + {table} + + + ) : ( + chart + )} + + + ) +} diff --git a/apps/web/src/components/data-table-pagination.tsx b/apps/web/src/components/data-table-pagination.tsx new file mode 100644 index 0000000..8913e02 --- /dev/null +++ b/apps/web/src/components/data-table-pagination.tsx @@ -0,0 +1,87 @@ +import type { Table } from '@tanstack/react-table' +import { + Pagination, + PaginationContent, + PaginationEllipsis, + PaginationItem, + PaginationLink, + PaginationNext, + PaginationPrevious, +} from '@cfdm/ui/components/pagination' + +interface DataTablePaginationProps { + table: Table + selectedCount?: number +} + +function buildPageRange(current: number, total: number): (number | 'ellipsis')[] { + if (total <= 7) { + return Array.from({ length: total }, (_, i) => i + 1) + } + const pages: (number | 'ellipsis')[] = [1] + const start = Math.max(2, current - 1) + const end = Math.min(total - 1, current + 1) + if (start > 2) pages.push('ellipsis') + for (let i = start; i <= end; i++) pages.push(i) + if (end < total - 1) pages.push('ellipsis') + pages.push(total) + return pages +} + +export function DataTablePagination({ + table, + selectedCount, +}: DataTablePaginationProps) { + const { pageIndex } = table.getState().pagination + const pageCount = table.getPageCount() + + if (pageCount <= 1) return null + + const pages = buildPageRange(pageIndex + 1, pageCount) + + return ( +
+
+ {selectedCount != null && selectedCount > 0 + ? `Выбрано: ${selectedCount} из ${table.getFilteredRowModel().rows.length}` + : `Всего: ${table.getFilteredRowModel().rows.length}`} +
+ + + + table.previousPage()} + aria-disabled={!table.getCanPreviousPage()} + className={!table.getCanPreviousPage() ? 'pointer-events-none opacity-50' : undefined} + /> + + {pages.map((page, idx) => + page === 'ellipsis' ? ( + + + + ) : ( + + table.setPageIndex(page - 1)} + > + {page} + + + ), + )} + + table.nextPage()} + aria-disabled={!table.getCanNextPage()} + className={!table.getCanNextPage() ? 'pointer-events-none opacity-50' : undefined} + /> + + + +
+ ) +} diff --git a/apps/web/src/components/data-table-toolbar.tsx b/apps/web/src/components/data-table-toolbar.tsx new file mode 100644 index 0000000..de8df13 --- /dev/null +++ b/apps/web/src/components/data-table-toolbar.tsx @@ -0,0 +1,142 @@ +import type { ReactNode } from 'react' +import type { Table } from '@tanstack/react-table' +import { ColumnsIcon, SearchIcon } from 'lucide-react' +import { + InputGroup, + InputGroupAddon, + InputGroupInput, +} from '@cfdm/ui/components/input-group' +import { + DropdownMenu, + DropdownMenuCheckboxItem, + DropdownMenuContent, + DropdownMenuLabel, + DropdownMenuRadioGroup, + DropdownMenuRadioItem, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from '@cfdm/ui/components/dropdown-menu' +import { AppButton } from '@/components/app-button' + +type Density = 'compact' | 'comfortable' | 'spacious' + +const densityRowClass: Record = { + compact: '[&_tr]:h-9 [&_tr_td]:py-1 [&_tr_th]:py-1 text-xs', + comfortable: '[&_tr]:h-10 [&_tr_td]:py-2 [&_tr_th]:py-2 text-sm', + spacious: '[&_tr]:h-12 [&_tr_td]:py-3 [&_tr_th]:py-3 text-sm', +} + +const densityLabel: Record = { + compact: 'Компактная', + comfortable: 'Обычная', + spacious: 'Просторная', +} + +const pageSizeOptions = [10, 20, 50] as const + +interface DataTableToolbarProps { + table: Table + searchPlaceholder?: string + searchValue?: string + onSearchChange?: (value: string) => void + filters?: ReactNode + density: Density + onDensityChange: (density: Density) => void +} + +export function DataTableToolbar({ + table, + searchPlaceholder = 'Поиск…', + searchValue, + onSearchChange, + filters, + density, + onDensityChange, +}: DataTableToolbarProps) { + const columns = table + .getAllColumns() + .filter((col) => typeof col.getCanHide === 'function' && col.getCanHide()) + + return ( +
+
+ {onSearchChange && ( + + + + + onSearchChange(event.target.value)} + /> + + )} + {filters} +
+
+ + + + Колонки + + } + /> + + Видимость + {columns.map((column) => ( + column.toggleVisibility(!!value)} + > + {typeof column.columnDef.header === 'string' + ? column.columnDef.header + : column.id} + + ))} + + + + + Плотность + + } + /> + + Плотность строк + onDensityChange(val as Density)} + > + {(Object.keys(densityLabel) as Density[]).map((d) => ( + + {densityLabel[d]} + + ))} + + + Строк на странице + table.setPageSize(Number(val))} + > + {pageSizeOptions.map((size) => ( + + {size} + + ))} + + + +
+
+ ) +} + +export type { Density } +export { densityRowClass } diff --git a/apps/web/src/components/data-table-view.tsx b/apps/web/src/components/data-table-view.tsx new file mode 100644 index 0000000..44e2bcf --- /dev/null +++ b/apps/web/src/components/data-table-view.tsx @@ -0,0 +1,124 @@ +import type { ReactNode } from 'react' +import { flexRender } from '@tanstack/react-table' +import type { Table as TableType } from '@tanstack/react-table' +import { + Empty, + EmptyDescription, + EmptyHeader, + EmptyTitle, +} from '@cfdm/ui/components/empty' +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from '@cfdm/ui/components/table' +import { cn } from '@cfdm/ui/lib/utils' +import { DataTablePagination } from '@/components/data-table-pagination' +import { + DataTableToolbar, + densityRowClass, + type Density, +} from '@/components/data-table-toolbar' + +interface DataTableViewProps { + table: TableType + density: Density + onDensityChange: (density: Density) => void + searchPlaceholder?: string + searchValue?: string + onSearchChange?: (value: string) => void + filters?: ReactNode + emptyTitle?: string + emptyDescription?: string + selectedCount?: number + className?: string +} + +export function DataTableView({ + table, + density, + onDensityChange, + searchPlaceholder, + searchValue, + onSearchChange, + filters, + emptyTitle = 'Ничего не найдено', + emptyDescription = 'Измените фильтры или поисковый запрос', + selectedCount, + className, +}: DataTableViewProps) { + const columns = table.getAllColumns() + const showToolbar = onSearchChange || filters || columns.some((c) => c.getCanHide?.()) + + return ( +
+ {showToolbar && ( + + )} +
+
+ + + {table.getHeaderGroups().map((headerGroup) => ( + + {headerGroup.headers.map((header) => ( + + {header.isPlaceholder + ? null + : flexRender(header.column.columnDef.header, header.getContext())} + + ))} + + ))} + + + {table.getRowModel().rows.length > 0 ? ( + table.getRowModel().rows.map((row) => ( + + {row.getVisibleCells().map((cell) => ( + + {flexRender(cell.column.columnDef.cell, cell.getContext())} + + ))} + + )) + ) : ( + + + + + {emptyTitle} + {emptyDescription} + + + + + )} + +
+
+
+ +
+ ) +} diff --git a/apps/web/src/components/dns-records-table.tsx b/apps/web/src/components/dns-records-table.tsx index 4fa38f3..42a45e5 100644 --- a/apps/web/src/components/dns-records-table.tsx +++ b/apps/web/src/components/dns-records-table.tsx @@ -121,17 +121,18 @@ export function DnsRecordsTable({ records, onDelete, isDeleting }: DnsRecordsTab return ( - - - - Тип - Имя - Значение - TTL - Синхронизация - - - +
+
+ + + Тип + Имя + Значение + TTL + Синхронизация + + + {groups.map((group, index) => group.isMultiValue ? ( @@ -154,6 +155,7 @@ export function DnsRecordsTable({ records, onDelete, isDeleting }: DnsRecordsTab )}
+
) } diff --git a/apps/web/src/components/domain-bindings-card.tsx b/apps/web/src/components/domain-bindings-card.tsx index 431ce03..cc2bdb8 100644 --- a/apps/web/src/components/domain-bindings-card.tsx +++ b/apps/web/src/components/domain-bindings-card.tsx @@ -22,6 +22,11 @@ import { AppItemSeparator, AppItemTitle, } from '@/components/app-item' +import { + Tooltip, + TooltipContent, + TooltipTrigger, +} from '@cfdm/ui/components/tooltip' interface DomainBindingsCardProps { bindings: ServiceBinding[] @@ -50,19 +55,40 @@ export function DomainBindingsCard({ bindings }: DomainBindingsCardProps) { className="border border-dashed p-4" /> ) : ( - + {entries.map(([hostname, hostnameBindings], index) => { const services = uniqueServices(hostnameBindings) + const uniqueIps = [ + ...new Set( + hostnameBindings + .map((b) => b.target_ip) + .filter((ip): ip is string => Boolean(ip)), + ), + ] return (
- {hostname} + + + {hostname} + + } + /> + {hostname} +
{services.map((name) => ( {name} ))} + {uniqueIps.length > 0 && ( + + {uniqueIps.join(', ')} + + )} {[ ...new Set( hostnameBindings diff --git a/apps/web/src/components/domains-data-table.tsx b/apps/web/src/components/domains-data-table.tsx index 75733dc..f64be8a 100644 --- a/apps/web/src/components/domains-data-table.tsx +++ b/apps/web/src/components/domains-data-table.tsx @@ -1,44 +1,28 @@ import { useMemo, useState } from 'react' import { Link } from '@tanstack/react-router' import { - flexRender, getCoreRowModel, getFilteredRowModel, getPaginationRowModel, getSortedRowModel, useReactTable, type ColumnDef, - type ColumnFiltersState, type SortingState, } from '@tanstack/react-table' -import { - ArrowUpDownIcon, - GlobeIcon, - MoreHorizontalIcon, - SearchIcon, -} from 'lucide-react' +import { ArrowUpDownIcon, MoreHorizontalIcon } from 'lucide-react' import { ConfirmDialog } from '@/components/confirm-dialog' import { StatusBadge } from '@/components/status-badge' import type { DomainListItem } from '@/lib/schemas' import { AppBadge } from '@/components/app-badge' import { AppButton } from '@/components/app-button' +import { DataTableView } from '@/components/data-table-view' +import type { Density } from '@/components/data-table-toolbar' import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger, } from '@cfdm/ui/components/dropdown-menu' -import { - Empty, - EmptyDescription, - EmptyHeader, - EmptyTitle, -} from '@cfdm/ui/components/empty' -import { - InputGroup, - InputGroupAddon, - InputGroupInput, -} from '@cfdm/ui/components/input-group' import { Select, SelectContent, @@ -46,14 +30,6 @@ import { SelectTrigger, SelectValue, } from '@cfdm/ui/components/select' -import { - Table, - TableBody, - TableCell, - TableHead, - TableHeader, - TableRow, -} from '@cfdm/ui/components/table' export type DomainTableRow = DomainListItem @@ -80,7 +56,8 @@ export function DomainsDataTable({ isDeleting = false, }: DomainsDataTableProps) { const [sorting, setSorting] = useState([]) - const [columnFilters, setColumnFilters] = useState([]) + const [globalFilter, setGlobalFilter] = useState('') + const [density, setDensity] = useState('comfortable') const [deleteTarget, setDeleteTarget] = useState(null) const columns = useMemo[]>( @@ -147,12 +124,7 @@ export function DomainsDataTable({ variant="link" className="h-auto p-0 tabular-nums" nativeButton={false} - render={ - - } + render={} > {row.original.service_count} @@ -179,11 +151,11 @@ export function DomainsDataTable({ cell: ({ row }) => (
- - } - > + + } + > Действия @@ -228,138 +200,61 @@ export function DomainsDataTable({ const table = useReactTable({ data, columns, - state: { sorting, columnFilters }, + state: { sorting, globalFilter }, onSortingChange: setSorting, - onColumnFiltersChange: setColumnFilters, + onGlobalFilterChange: setGlobalFilter, + globalFilterFn: (row, _columnId, filterValue: string) => { + const value = filterValue.toLowerCase() + const zone = row.original.zone_name.toLowerCase() + const group = (row.original.group_name ?? '').toLowerCase() + return zone.includes(value) || group.includes(value) + }, getCoreRowModel: getCoreRowModel(), getSortedRowModel: getSortedRowModel(), getFilteredRowModel: getFilteredRowModel(), getPaginationRowModel: getPaginationRowModel(), - initialState: { - pagination: { pageSize: 10 }, - }, + initialState: { pagination: { pageSize: 10 } }, }) - const filterGroupItems = useMemo( - () => [{ label: 'Все группы', value: 'all' }, ...groupFilterItems], - [groupFilterItems], + const filterSelect = ( + ) return ( -
-
- - - - - - table.getColumn('zone_name')?.setFilterValue(event.target.value) - } - /> - - -
- - - {table.getFilteredRowModel().rows.length} зон - -
-
- -
-
- - - {table.getHeaderGroups().map((headerGroup) => ( - - {headerGroup.headers.map((header) => ( - - {header.isPlaceholder - ? null - : flexRender(header.column.columnDef.header, header.getContext())} - - ))} - - ))} - - - {table.getRowModel().rows.length > 0 ? ( - table.getRowModel().rows.map((row) => ( - - {row.getVisibleCells().map((cell) => ( - - {flexRender(cell.column.columnDef.cell, cell.getContext())} - - ))} - - )) - ) : ( - - - - - Домены не найдены - - Импортируйте зону из Cloudflare или измените фильтры - - - - - - )} - -
-
-
- - {table.getPageCount() > 1 && ( -
- - Стр. {table.getState().pagination.pageIndex + 1} из {table.getPageCount()} - - table.previousPage()} - disabled={!table.getCanPreviousPage()} - > - Назад - - table.nextPage()} - disabled={!table.getCanNextPage()} - > - Вперёд - -
- )} - + <> + { @@ -378,6 +273,6 @@ export function DomainsDataTable({ }} disabled={isDeleting} /> -
+ ) } diff --git a/apps/web/src/components/expiring-certs-card.tsx b/apps/web/src/components/expiring-certs-card.tsx new file mode 100644 index 0000000..28f847f --- /dev/null +++ b/apps/web/src/components/expiring-certs-card.tsx @@ -0,0 +1,87 @@ +import { ShieldCheckIcon } from 'lucide-react' +import type { Certificate } from '@/lib/schemas' +import { formatRelative } from '@/lib/format' +import { EmptyState } from '@/components/empty-state' +import { StatusBadge } from '@/components/status-badge' +import { + AppCard, + AppCardContent, + AppCardDescription, + AppCardHeader, + AppCardTitle, +} from '@/components/app-card' +import { + AppItem, + AppItemContent, + AppItemDescription, + AppItemGroup, + AppItemTitle, +} from '@/components/app-item' +import { ScrollArea } from '@cfdm/ui/components/scroll-area' + +const WARN_DAYS = 14 + +interface ExpiringCertsCardProps { + certificates: Certificate[] + limit?: number +} + +export function ExpiringCertsCard({ certificates, limit = 6 }: ExpiringCertsCardProps) { + const now = Date.now() + const upcoming = certificates + .filter((c) => c.expires_at) + .map((c) => ({ cert: c, ts: new Date(c.expires_at as string).getTime() })) + .filter((entry) => Number.isNaN(entry.ts) === false) + .sort((a, b) => a.ts - b.ts) + .filter((entry) => { + const days = (entry.ts - now) / (1000 * 60 * 60 * 24) + return days <= WARN_DAYS + }) + .slice(0, limit) + .map((entry) => entry.cert) + + return ( + + + Истекающие сертификаты + + Хосты с истечением срока в течение {WARN_DAYS} дней + + + + {upcoming.length === 0 ? ( + + ) : ( + + + {upcoming.map((cert) => { + const expired = new Date(cert.expires_at as string).getTime() < now + return ( + + +
+ + {cert.hostname} + + + {formatRelative(cert.expires_at)} + +
+ +
+
+ ) + })} +
+
+ )} +
+
+ ) +} diff --git a/apps/web/src/components/kpi-card.tsx b/apps/web/src/components/kpi-card.tsx new file mode 100644 index 0000000..9929fe9 --- /dev/null +++ b/apps/web/src/components/kpi-card.tsx @@ -0,0 +1,99 @@ +import type { ComponentProps, ReactNode } from 'react' +import type { LucideIcon } from 'lucide-react' +import { MinusIcon, TrendingDownIcon, TrendingUpIcon } from 'lucide-react' +import { AppButton } from '@/components/app-button' +import { + AppCard, + AppCardAction, + AppCardDescription, + AppCardFooter, + AppCardHeader, + AppCardTitle, +} from '@/components/app-card' + +type TrendDirection = 'up' | 'down' | 'flat' + +export interface KpiTrend { + value: string + direction: TrendDirection + hint?: string +} + +interface KpiCardProps { + label: string + value: number | string + icon: LucideIcon + trend?: KpiTrend + actionLabel?: string + actionRender?: ComponentProps['render'] + onAction?: () => void + footer?: ReactNode +} + +const trendIcon: Record = { + up: TrendingUpIcon, + down: TrendingDownIcon, + flat: MinusIcon, +} + +const trendTone: Record = { + up: 'text-success', + down: 'text-destructive', + flat: 'text-muted-foreground', +} + +export function KpiCard({ + label, + value, + icon: Icon, + trend, + actionLabel, + actionRender, + onAction, + footer, +}: KpiCardProps) { + const TrendIcon = trend ? trendIcon[trend.direction] : null + return ( + + + {label} + + {value} + + + + + + + {footer ?? + (trend ? ( + + {TrendIcon && ( + + )} + {trend.value} + {trend.hint && ( + · {trend.hint} + )} + + ) : ( + + ))} + {actionLabel && (actionRender || onAction) && ( + + {actionLabel} + + )} + + + ) +} diff --git a/apps/web/src/components/page-header.tsx b/apps/web/src/components/page-header.tsx index c306db6..9a4b799 100644 --- a/apps/web/src/components/page-header.tsx +++ b/apps/web/src/components/page-header.tsx @@ -3,6 +3,7 @@ import { cn } from '@cfdm/ui/lib/utils' interface PageHeaderProps { title: string description?: string + meta?: React.ReactNode actions?: React.ReactNode className?: string } @@ -10,6 +11,7 @@ interface PageHeaderProps { export function PageHeader({ title, description, + meta, actions, className, }: PageHeaderProps) { @@ -20,10 +22,15 @@ export function PageHeader({ className, )} > -
-

- {title} -

+
+
+

+ {title} +

+ {meta && ( + {meta} + )} +
{description && (

{description} diff --git a/apps/web/src/components/section-cards-skeleton.tsx b/apps/web/src/components/section-cards-skeleton.tsx index c65fca5..353ee9d 100644 --- a/apps/web/src/components/section-cards-skeleton.tsx +++ b/apps/web/src/components/section-cards-skeleton.tsx @@ -1,5 +1,6 @@ import { Card, + CardContent, CardFooter, CardHeader, } from '@cfdm/ui/components/card' @@ -10,7 +11,7 @@ export function SectionCardsSkeleton() {

{Array.from({ length: 4 }).map((_, index) => ( - + @@ -21,16 +22,31 @@ export function SectionCardsSkeleton() { ))}
+
+ {Array.from({ length: 2 }).map((_, index) => ( + + + + + + + + + + ))} +
-
- {Array.from({ length: 3 }).map((_, index) => ( - - ))} -
+ +
+ {Array.from({ length: 4 }).map((_, index) => ( + + ))} +
+
) diff --git a/apps/web/src/components/service-binding-card.tsx b/apps/web/src/components/service-binding-card.tsx index b8e5eb6..01b586f 100644 --- a/apps/web/src/components/service-binding-card.tsx +++ b/apps/web/src/components/service-binding-card.tsx @@ -78,6 +78,7 @@ export function ServiceBindingCard({ id={`hostname-${binding.id}`} value={hostname} placeholder="@" + className="font-mono tabular-nums" onPointerDown={(e) => e.stopPropagation()} onChange={(e) => setHostname(e.target.value)} onBlur={() => { @@ -93,6 +94,7 @@ export function ServiceBindingCard({ id={`ip-${binding.id}`} value={ip} placeholder="192.168.1.1" + className="font-mono tabular-nums" onPointerDown={(e) => e.stopPropagation()} onChange={(e) => setIp(e.target.value)} onBlur={() => { diff --git a/apps/web/src/components/subdomains-table.tsx b/apps/web/src/components/subdomains-table.tsx index a21ba93..3bc79f3 100644 --- a/apps/web/src/components/subdomains-table.tsx +++ b/apps/web/src/components/subdomains-table.tsx @@ -1,8 +1,18 @@ -import { useState } from 'react' +import { useMemo, useState } from 'react' import { Link } from '@tanstack/react-router' +import { + getCoreRowModel, + getFilteredRowModel, + getPaginationRowModel, + getSortedRowModel, + useReactTable, + type ColumnDef, + type SortingState, +} from '@tanstack/react-table' import { MoreHorizontalIcon } from 'lucide-react' import { ConfirmDialog } from '@/components/confirm-dialog' -import { TableCard } from '@/components/table-card' +import { DataTableView } from '@/components/data-table-view' +import type { Density } from '@/components/data-table-toolbar' import type { SubdomainTableRow } from '@/hooks/use-domain-page' import { formatSubdomainServiceLinks } from '@/hooks/use-domain-page' import { formatDate } from '@/lib/format' @@ -15,14 +25,6 @@ import { DropdownMenuSeparator, DropdownMenuTrigger, } from '@cfdm/ui/components/dropdown-menu' -import { - Table, - TableBody, - TableCell, - TableHead, - TableHeader, - TableRow, -} from '@cfdm/ui/components/table' interface SubdomainsTableProps { domainId: string @@ -44,80 +46,143 @@ export function SubdomainsTable({ onToggleEnabled, }: SubdomainsTableProps) { const [deleteTarget, setDeleteTarget] = useState(null) + const [sorting, setSorting] = useState([]) + const [globalFilter, setGlobalFilter] = useState('') + const [density, setDensity] = useState('comfortable') + + const columns = useMemo[]>( + () => [ + { + accessorKey: 'fqdn', + header: ({ column }) => ( + column.toggleSorting(column.getIsSorted() === 'asc')} + > + Поддомен + + ), + accessorFn: (row) => row.subdomain.fqdn, + cell: ({ row }) => ( + {row.original.subdomain.fqdn} + ), + }, + { + id: 'status', + header: 'Статус', + enableHiding: true, + cell: ({ row }) => ( + + {row.original.subdomain.enabled ? 'Активен' : 'Неактивен'} + + ), + }, + { + id: 'service', + header: 'Группа / Сервис', + accessorFn: (row) => formatSubdomainServiceLinks(row.serviceLinks), + cell: ({ row }) => ( + + {formatSubdomainServiceLinks(row.original.serviceLinks)} + + ), + }, + { + accessorKey: 'created_at', + header: 'Создан', + cell: ({ row }) => ( + + {formatDate(row.original.subdomain.created_at)} + + ), + }, + { + id: 'actions', + enableHiding: false, + header: () => Действия, + cell: ({ row }) => ( +
+ + + } + > + + Действия + + + onEdit(row.original)}> + Редактировать + + onToggleEnabled(row.original)} + > + {row.original.subdomain.enabled ? 'Деактивировать' : 'Активировать'} + + + } + > + DNS-записи + + + setDeleteTarget(row.original)} + > + Удалить + + + +
+ ), + }, + ], + [domainId, isToggling, onEdit, onToggleEnabled], + ) + + const table = useReactTable({ + data: rows, + columns, + state: { sorting, globalFilter }, + onSortingChange: setSorting, + onGlobalFilterChange: setGlobalFilter, + globalFilterFn: (row, _columnId, filterValue: string) => { + const value = filterValue.toLowerCase() + return ( + row.original.subdomain.fqdn.toLowerCase().includes(value) || + formatSubdomainServiceLinks(row.original.serviceLinks) + .toLowerCase() + .includes(value) + ) + }, + getCoreRowModel: getCoreRowModel(), + getSortedRowModel: getSortedRowModel(), + getFilteredRowModel: getFilteredRowModel(), + getPaginationRowModel: getPaginationRowModel(), + initialState: { pagination: { pageSize: 10 } }, + }) return ( <> - - - - - Поддомен - Статус - Группа / Сервис - Создан - - Действия - - - - - {rows.map((row) => ( - - {row.subdomain.fqdn} - - - {row.subdomain.enabled ? 'Активен' : 'Неактивен'} - - - {formatSubdomainServiceLinks(row.serviceLinks)} - {formatDate(row.subdomain.created_at)} - - - - } - > - - Действия - - - onEdit(row)}> - Редактировать - - onToggleEnabled(row)} - > - {row.subdomain.enabled ? 'Деактивировать' : 'Активировать'} - - - } - > - DNS-записи - - - setDeleteTarget(row)} - > - Удалить - - - - - - ))} - -
-
- + { diff --git a/apps/web/src/routes/_auth/certificates.tsx b/apps/web/src/routes/_auth/certificates.tsx index 0ea726e..50c4082 100644 --- a/apps/web/src/routes/_auth/certificates.tsx +++ b/apps/web/src/routes/_auth/certificates.tsx @@ -1,41 +1,44 @@ import { createFileRoute } from '@tanstack/react-router' import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query' import { useMemo, useState } from 'react' +import { + getCoreRowModel, + getFilteredRowModel, + getPaginationRowModel, + getSortedRowModel, + useReactTable, + type ColumnDef, + type SortingState, +} from '@tanstack/react-table' import { Bar, BarChart, CartesianGrid, XAxis } from 'recharts' import { ShieldCheckIcon } from 'lucide-react' import { toast } from 'sonner' import { certificatesQueryOptions, certKeys, certSummaryQueryOptions } from '@/queries' import { api } from '@/lib/api-client' -import { formatDate } from '@/lib/format' +import { formatDate, formatRelative } from '@/lib/format' +import type { Certificate } from '@/lib/schemas' import { PageHeader } from '@/components/page-header' import { PageShell } from '@/components/page-shell' import { QueryState } from '@/components/query-state' import { EmptyState } from '@/components/empty-state' import { StatusBadge } from '@/components/status-badge' import { DataTableCard } from '@/components/data-table-card' -import { TableToolbar } from '@/components/table-toolbar' +import { DataTableView } from '@/components/data-table-view' +import type { Density } from '@/components/data-table-toolbar' +import { ChartCard } from '@/components/chart-card' import { AppButton } from '@/components/app-button' import { - AppCard, - AppCardContent, - AppCardDescription, - AppCardHeader, - AppCardTitle, -} from '@/components/app-card' + AppItem, + AppItemContent, + AppItemGroup, + AppItemTitle, +} from '@/components/app-item' import { ChartContainer, ChartTooltip, ChartTooltipContent, type ChartConfig, } from '@cfdm/ui/components/chart' -import { - Table, - TableBody, - TableCell, - TableHead, - TableHeader, - TableRow, -} from '@cfdm/ui/components/table' import { LoadingButton } from '@/components/loading-button' import { TableSkeleton } from '@/components/table-skeleton' @@ -57,6 +60,8 @@ export const Route = createFileRoute('/_auth/certificates')({ function CertificatesPage() { const [searchQuery, setSearchQuery] = useState('') + const [sorting, setSorting] = useState([{ id: 'expires_at', desc: false }]) + const [density, setDensity] = useState('comfortable') const queryClient = useQueryClient() const { data: certs, @@ -94,6 +99,80 @@ function CertificatesPage() { const isFilteredEmpty = (certs?.length ?? 0) > 0 && filteredCerts.length === 0 + const columns = useMemo[]>( + () => [ + { + accessorKey: 'hostname', + header: ({ column }) => ( + column.toggleSorting(column.getIsSorted() === 'asc')} + > + Хост + + ), + cell: ({ row }) => ( + {row.original.hostname} + ), + }, + { + accessorKey: 'status', + header: 'Статус', + cell: ({ row }) => , + }, + { + accessorKey: 'expires_at', + header: 'Истекает', + cell: ({ row }) => ( + + {formatDate(row.original.expires_at)} + + ), + }, + { + id: 'relative', + header: 'Срок', + cell: ({ row }) => ( + + {formatRelative(row.original.expires_at)} + + ), + enableHiding: true, + }, + { + accessorKey: 'last_checked_at', + header: 'Проверка', + cell: ({ row }) => ( + + {formatDate(row.original.last_checked_at)} + + ), + enableHiding: true, + }, + ], + [], + ) + + const table = useReactTable({ + data: filteredCerts, + columns, + state: { sorting, globalFilter: searchQuery }, + onSortingChange: setSorting, + globalFilterFn: (row, _columnId, value: string) => { + const q = value.toLowerCase() + return ( + row.original.hostname.toLowerCase().includes(q) || + row.original.status.toLowerCase().includes(q) + ) + }, + getCoreRowModel: getCoreRowModel(), + getSortedRowModel: getSortedRowModel(), + getFilteredRowModel: getFilteredRowModel(), + getPaginationRowModel: getPaginationRowModel(), + initialState: { pagination: { pageSize: 10 } }, + }) + return ( } > - - - Обзор статусов - Распределение сертификатов по статусам - - - {chartData.length > 0 ? ( + 0 ? ( @@ -142,67 +219,42 @@ function CertificatesPage() { title="Нет данных для графика" description="Запустите проверку сертификатов" /> - )} - - + ) + } + table={ + + {chartData.map((entry) => ( + + + + + {entry.count} + + + + ))} + + } + /> setSearchQuery('')}> - Сбросить фильтр - - ) : ( - checkMutation.mutate()} - isLoading={checkMutation.isPending} - loadingLabel="Проверка…" - > - Запустить проверку - - ) - } - emptyIcon={ShieldCheckIcon} - toolbar={ - - } + isEmpty={false} > - - - - Хост - Статус - Истекает - Последняя проверка - - - - {filteredCerts.map((c) => ( - - {c.hostname} - - - - {formatDate(c.expires_at)} - {formatDate(c.last_checked_at)} - - ))} - -
+
diff --git a/apps/web/src/routes/_auth/index.tsx b/apps/web/src/routes/_auth/index.tsx index f0aa24a..a9b912c 100644 --- a/apps/web/src/routes/_auth/index.tsx +++ b/apps/web/src/routes/_auth/index.tsx @@ -1,11 +1,51 @@ -import { createFileRoute } from '@tanstack/react-router' +import { createFileRoute, Link } from '@tanstack/react-router' import { useQuery } from '@tanstack/react-query' -import { certificatesQueryOptions, certSummaryQueryOptions, domainsListQueryOptions, groupsQueryOptions, serviceGroupsQueryOptions } from '@/queries' +import { useMemo } from 'react' +import { + Bar, + BarChart, + CartesianGrid, + Cell, + Label, + Pie, + PieChart, + XAxis, +} from 'recharts' +import { + FolderTreeIcon, + GlobeIcon, + ServerIcon, + ShieldCheckIcon, +} from 'lucide-react' +import { + certificatesQueryOptions, + certSummaryQueryOptions, + domainsListQueryOptions, + groupsQueryOptions, + serviceGroupsQueryOptions, +} from '@/queries' import { PageHeader } from '@/components/page-header' import { PageShell } from '@/components/page-shell' import { QueryState } from '@/components/query-state' -import { SectionCards } from '@/components/section-cards' import { SectionCardsSkeleton } from '@/components/section-cards-skeleton' +import { KpiCard } from '@/components/kpi-card' +import { ChartCard } from '@/components/chart-card' +import { ExpiringCertsCard } from '@/components/expiring-certs-card' +import { StatusBadge } from '@/components/status-badge' +import { + AppItem, + AppItemContent, + AppItemGroup, + AppItemTitle, +} from '@/components/app-item' +import { + ChartContainer, + ChartLegend, + ChartLegendContent, + ChartTooltip, + ChartTooltipContent, + type ChartConfig, +} from '@cfdm/ui/components/chart' export const Route = createFileRoute('/_auth/')({ loader: ({ context: { queryClient } }) => @@ -14,10 +54,26 @@ export const Route = createFileRoute('/_auth/')({ queryClient.ensureQueryData(certSummaryQueryOptions()), queryClient.ensureQueryData(groupsQueryOptions()), queryClient.ensureQueryData(serviceGroupsQueryOptions()), + queryClient.ensureQueryData(certificatesQueryOptions()), ]), component: DashboardPage, }) +const statusChartConfig = { + count: { label: 'Сертификаты' }, + active: { label: 'Активен', color: 'var(--success)' }, + ok: { label: 'OK', color: 'var(--success)' }, + warning: { label: 'Предупреждение', color: 'var(--chart-3)' }, + pending_push: { label: 'Ожидает', color: 'var(--chart-2)' }, + expired: { label: 'Истёк', color: 'var(--destructive)' }, + error: { label: 'Ошибка', color: 'var(--destructive)' }, + unknown: { label: 'Неизвестно', color: 'var(--muted-foreground)' }, +} satisfies ChartConfig + +const groupChartConfig = { + count: { label: 'Домены', color: 'var(--chart-2)' }, +} satisfies ChartConfig + function DashboardPage() { const { data: domains, @@ -45,6 +101,23 @@ function DashboardPage() { const isError = domainsError || summaryError const error = domainsErr ?? summaryErr + const statusChartData = useMemo( + () => (summary ?? []).map(([status, count]) => ({ status, count })), + [summary], + ) + + const groupChartData = useMemo(() => { + const list = groups ?? [] + return list + .map((g) => ({ + name: g.name, + count: + domains?.filter((d) => d.group_id === g.id).length ?? 0, + })) + .sort((a, b) => b.count - a.count) + .slice(0, 6) + }, [groups, domains]) + return ( - +
+
+ } + /> + } + /> + + } + /> + } + /> +
+ +
+ + + } + /> + + {statusChartData.map((entry) => { + const cfg = (statusChartConfig as Record)[entry.status] + return ( + + ) + })} + + } /> + + + ) + } + table={ + + {statusChartData.map((entry) => ( + + + + + {entry.count} + + + + ))} + + } + /> + + + + + value.length > 10 ? `${value.slice(0, 9)}…` : value + } + /> + } /> + + + + ) + } + /> +
+ + +
) diff --git a/apps/web/src/routes/login.tsx b/apps/web/src/routes/login.tsx index 4121835..4f5079d 100644 --- a/apps/web/src/routes/login.tsx +++ b/apps/web/src/routes/login.tsx @@ -10,13 +10,21 @@ function LoginPage() { return (
- + +
+ Войдите учётной записью администратора для доступа к управлению доменами, + сервисами и сертификатами Cloudflare. +
)