refactor: Enhance UI components in various files by implementing new layouts, improving accessibility, and optimizing data handling for better performance and user experience
Build, Test, and Push CFDM Docker Image / test (push) Failing after 45s
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 45s
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
This commit is contained in:
@@ -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 (
|
||||||
|
<AppCard className={className}>
|
||||||
|
<AppCardHeader>
|
||||||
|
<AppCardTitle className="text-base">{title}</AppCardTitle>
|
||||||
|
{description && <AppCardDescription>{description}</AppCardDescription>}
|
||||||
|
{action}
|
||||||
|
</AppCardHeader>
|
||||||
|
<AppCardContent>
|
||||||
|
{hasTabs ? (
|
||||||
|
<Tabs defaultValue="chart">
|
||||||
|
<TabsList>
|
||||||
|
<TabsTrigger value="chart">{chartTabLabel}</TabsTrigger>
|
||||||
|
<TabsTrigger value="table">{tableTabLabel}</TabsTrigger>
|
||||||
|
</TabsList>
|
||||||
|
<TabsContent value="chart" className="pt-4">
|
||||||
|
{chart}
|
||||||
|
</TabsContent>
|
||||||
|
<TabsContent value="table" className="pt-4">
|
||||||
|
{table}
|
||||||
|
</TabsContent>
|
||||||
|
</Tabs>
|
||||||
|
) : (
|
||||||
|
chart
|
||||||
|
)}
|
||||||
|
</AppCardContent>
|
||||||
|
</AppCard>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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<TData> {
|
||||||
|
table: Table<TData>
|
||||||
|
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<TData>({
|
||||||
|
table,
|
||||||
|
selectedCount,
|
||||||
|
}: DataTablePaginationProps<TData>) {
|
||||||
|
const { pageIndex } = table.getState().pagination
|
||||||
|
const pageCount = table.getPageCount()
|
||||||
|
|
||||||
|
if (pageCount <= 1) return null
|
||||||
|
|
||||||
|
const pages = buildPageRange(pageIndex + 1, pageCount)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col items-center justify-between gap-4 px-4 py-3 text-sm sm:flex-row">
|
||||||
|
<div className="text-muted-foreground tabular-nums">
|
||||||
|
{selectedCount != null && selectedCount > 0
|
||||||
|
? `Выбрано: ${selectedCount} из ${table.getFilteredRowModel().rows.length}`
|
||||||
|
: `Всего: ${table.getFilteredRowModel().rows.length}`}
|
||||||
|
</div>
|
||||||
|
<Pagination className="mx-0 w-auto">
|
||||||
|
<PaginationContent>
|
||||||
|
<PaginationItem>
|
||||||
|
<PaginationPrevious
|
||||||
|
text="Назад"
|
||||||
|
onClick={() => table.previousPage()}
|
||||||
|
aria-disabled={!table.getCanPreviousPage()}
|
||||||
|
className={!table.getCanPreviousPage() ? 'pointer-events-none opacity-50' : undefined}
|
||||||
|
/>
|
||||||
|
</PaginationItem>
|
||||||
|
{pages.map((page, idx) =>
|
||||||
|
page === 'ellipsis' ? (
|
||||||
|
<PaginationItem key={`ellipsis-${idx}`}>
|
||||||
|
<PaginationEllipsis />
|
||||||
|
</PaginationItem>
|
||||||
|
) : (
|
||||||
|
<PaginationItem key={page}>
|
||||||
|
<PaginationLink
|
||||||
|
isActive={page === pageIndex + 1}
|
||||||
|
onClick={() => table.setPageIndex(page - 1)}
|
||||||
|
>
|
||||||
|
{page}
|
||||||
|
</PaginationLink>
|
||||||
|
</PaginationItem>
|
||||||
|
),
|
||||||
|
)}
|
||||||
|
<PaginationItem>
|
||||||
|
<PaginationNext
|
||||||
|
text="Вперёд"
|
||||||
|
onClick={() => table.nextPage()}
|
||||||
|
aria-disabled={!table.getCanNextPage()}
|
||||||
|
className={!table.getCanNextPage() ? 'pointer-events-none opacity-50' : undefined}
|
||||||
|
/>
|
||||||
|
</PaginationItem>
|
||||||
|
</PaginationContent>
|
||||||
|
</Pagination>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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<Density, string> = {
|
||||||
|
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<Density, string> = {
|
||||||
|
compact: 'Компактная',
|
||||||
|
comfortable: 'Обычная',
|
||||||
|
spacious: 'Просторная',
|
||||||
|
}
|
||||||
|
|
||||||
|
const pageSizeOptions = [10, 20, 50] as const
|
||||||
|
|
||||||
|
interface DataTableToolbarProps<TData> {
|
||||||
|
table: Table<TData>
|
||||||
|
searchPlaceholder?: string
|
||||||
|
searchValue?: string
|
||||||
|
onSearchChange?: (value: string) => void
|
||||||
|
filters?: ReactNode
|
||||||
|
density: Density
|
||||||
|
onDensityChange: (density: Density) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DataTableToolbar<TData>({
|
||||||
|
table,
|
||||||
|
searchPlaceholder = 'Поиск…',
|
||||||
|
searchValue,
|
||||||
|
onSearchChange,
|
||||||
|
filters,
|
||||||
|
density,
|
||||||
|
onDensityChange,
|
||||||
|
}: DataTableToolbarProps<TData>) {
|
||||||
|
const columns = table
|
||||||
|
.getAllColumns()
|
||||||
|
.filter((col) => typeof col.getCanHide === 'function' && col.getCanHide())
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-3 border-b px-4 py-3 sm:flex-row sm:items-center sm:justify-between">
|
||||||
|
<div className="flex flex-1 flex-col gap-2 sm:flex-row sm:items-center">
|
||||||
|
{onSearchChange && (
|
||||||
|
<InputGroup className="max-w-sm">
|
||||||
|
<InputGroupAddon>
|
||||||
|
<SearchIcon />
|
||||||
|
</InputGroupAddon>
|
||||||
|
<InputGroupInput
|
||||||
|
placeholder={searchPlaceholder}
|
||||||
|
value={searchValue ?? ''}
|
||||||
|
onChange={(event) => onSearchChange(event.target.value)}
|
||||||
|
/>
|
||||||
|
</InputGroup>
|
||||||
|
)}
|
||||||
|
{filters}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger
|
||||||
|
render={
|
||||||
|
<AppButton variant="outline" size="sm">
|
||||||
|
<ColumnsIcon data-icon="inline-start" />
|
||||||
|
Колонки
|
||||||
|
</AppButton>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<DropdownMenuContent align="end" className="w-44">
|
||||||
|
<DropdownMenuLabel>Видимость</DropdownMenuLabel>
|
||||||
|
{columns.map((column) => (
|
||||||
|
<DropdownMenuCheckboxItem
|
||||||
|
key={column.id}
|
||||||
|
checked={column.getIsVisible()}
|
||||||
|
onCheckedChange={(value) => column.toggleVisibility(!!value)}
|
||||||
|
>
|
||||||
|
{typeof column.columnDef.header === 'string'
|
||||||
|
? column.columnDef.header
|
||||||
|
: column.id}
|
||||||
|
</DropdownMenuCheckboxItem>
|
||||||
|
))}
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger
|
||||||
|
render={
|
||||||
|
<AppButton variant="outline" size="sm">
|
||||||
|
Плотность
|
||||||
|
</AppButton>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<DropdownMenuContent align="end" className="w-44">
|
||||||
|
<DropdownMenuLabel>Плотность строк</DropdownMenuLabel>
|
||||||
|
<DropdownMenuRadioGroup
|
||||||
|
value={density}
|
||||||
|
onValueChange={(val) => onDensityChange(val as Density)}
|
||||||
|
>
|
||||||
|
{(Object.keys(densityLabel) as Density[]).map((d) => (
|
||||||
|
<DropdownMenuRadioItem key={d} value={d}>
|
||||||
|
{densityLabel[d]}
|
||||||
|
</DropdownMenuRadioItem>
|
||||||
|
))}
|
||||||
|
</DropdownMenuRadioGroup>
|
||||||
|
<DropdownMenuSeparator />
|
||||||
|
<DropdownMenuLabel>Строк на странице</DropdownMenuLabel>
|
||||||
|
<DropdownMenuRadioGroup
|
||||||
|
value={String(table.getState().pagination.pageSize)}
|
||||||
|
onValueChange={(val) => table.setPageSize(Number(val))}
|
||||||
|
>
|
||||||
|
{pageSizeOptions.map((size) => (
|
||||||
|
<DropdownMenuRadioItem key={size} value={String(size)}>
|
||||||
|
{size}
|
||||||
|
</DropdownMenuRadioItem>
|
||||||
|
))}
|
||||||
|
</DropdownMenuRadioGroup>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export type { Density }
|
||||||
|
export { densityRowClass }
|
||||||
@@ -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<TData> {
|
||||||
|
table: TableType<TData>
|
||||||
|
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<TData>({
|
||||||
|
table,
|
||||||
|
density,
|
||||||
|
onDensityChange,
|
||||||
|
searchPlaceholder,
|
||||||
|
searchValue,
|
||||||
|
onSearchChange,
|
||||||
|
filters,
|
||||||
|
emptyTitle = 'Ничего не найдено',
|
||||||
|
emptyDescription = 'Измените фильтры или поисковый запрос',
|
||||||
|
selectedCount,
|
||||||
|
className,
|
||||||
|
}: DataTableViewProps<TData>) {
|
||||||
|
const columns = table.getAllColumns()
|
||||||
|
const showToolbar = onSearchChange || filters || columns.some((c) => c.getCanHide?.())
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={cn('flex flex-col', className)}>
|
||||||
|
{showToolbar && (
|
||||||
|
<DataTableToolbar
|
||||||
|
table={table}
|
||||||
|
density={density}
|
||||||
|
onDensityChange={onDensityChange}
|
||||||
|
searchPlaceholder={searchPlaceholder}
|
||||||
|
searchValue={searchValue}
|
||||||
|
onSearchChange={onSearchChange}
|
||||||
|
filters={filters}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<div className="overflow-hidden">
|
||||||
|
<div className="relative overflow-auto">
|
||||||
|
<Table className={densityRowClass[density]}>
|
||||||
|
<TableHeader className="sticky top-0 z-10 bg-card">
|
||||||
|
{table.getHeaderGroups().map((headerGroup) => (
|
||||||
|
<TableRow key={headerGroup.id}>
|
||||||
|
{headerGroup.headers.map((header) => (
|
||||||
|
<TableHead
|
||||||
|
key={header.id}
|
||||||
|
className={header.column.id === 'actions' ? 'w-12 text-right' : undefined}
|
||||||
|
>
|
||||||
|
{header.isPlaceholder
|
||||||
|
? null
|
||||||
|
: flexRender(header.column.columnDef.header, header.getContext())}
|
||||||
|
</TableHead>
|
||||||
|
))}
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{table.getRowModel().rows.length > 0 ? (
|
||||||
|
table.getRowModel().rows.map((row) => (
|
||||||
|
<TableRow
|
||||||
|
key={row.id}
|
||||||
|
data-state={row.getIsSelected() && 'selected'}
|
||||||
|
>
|
||||||
|
{row.getVisibleCells().map((cell) => (
|
||||||
|
<TableCell
|
||||||
|
key={cell.id}
|
||||||
|
className={cell.column.id === 'actions' ? 'text-right' : undefined}
|
||||||
|
>
|
||||||
|
{flexRender(cell.column.columnDef.cell, cell.getContext())}
|
||||||
|
</TableCell>
|
||||||
|
))}
|
||||||
|
</TableRow>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<TableRow>
|
||||||
|
<TableCell colSpan={columns.length} className="h-48 p-0">
|
||||||
|
<Empty className="border-0">
|
||||||
|
<EmptyHeader>
|
||||||
|
<EmptyTitle>{emptyTitle}</EmptyTitle>
|
||||||
|
<EmptyDescription>{emptyDescription}</EmptyDescription>
|
||||||
|
</EmptyHeader>
|
||||||
|
</Empty>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
)}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<DataTablePagination table={table} selectedCount={selectedCount} />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -121,17 +121,18 @@ export function DnsRecordsTable({ records, onDelete, isDeleting }: DnsRecordsTab
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<TableCard>
|
<TableCard>
|
||||||
<Table>
|
<div className="relative overflow-auto max-h-[60vh]">
|
||||||
<TableHeader>
|
<Table className="text-sm [&_tr]:h-10 [&_tr_td]:py-2 [&_tr_th]:py-2">
|
||||||
<TableRow>
|
<TableHeader className="sticky top-0 z-10 bg-card">
|
||||||
<TableHead className="w-20">Тип</TableHead>
|
<TableRow>
|
||||||
<TableHead>Имя</TableHead>
|
<TableHead className="w-20">Тип</TableHead>
|
||||||
<TableHead>Значение</TableHead>
|
<TableHead>Имя</TableHead>
|
||||||
<TableHead className="w-16">TTL</TableHead>
|
<TableHead>Значение</TableHead>
|
||||||
<TableHead>Синхронизация</TableHead>
|
<TableHead className="w-16">TTL</TableHead>
|
||||||
<TableHead className="w-28" />
|
<TableHead>Синхронизация</TableHead>
|
||||||
</TableRow>
|
<TableHead className="w-28" />
|
||||||
</TableHeader>
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
<TableBody>
|
<TableBody>
|
||||||
{groups.map((group, index) =>
|
{groups.map((group, index) =>
|
||||||
group.isMultiValue ? (
|
group.isMultiValue ? (
|
||||||
@@ -154,6 +155,7 @@ export function DnsRecordsTable({ records, onDelete, isDeleting }: DnsRecordsTab
|
|||||||
)}
|
)}
|
||||||
</TableBody>
|
</TableBody>
|
||||||
</Table>
|
</Table>
|
||||||
|
</div>
|
||||||
</TableCard>
|
</TableCard>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,6 +22,11 @@ import {
|
|||||||
AppItemSeparator,
|
AppItemSeparator,
|
||||||
AppItemTitle,
|
AppItemTitle,
|
||||||
} from '@/components/app-item'
|
} from '@/components/app-item'
|
||||||
|
import {
|
||||||
|
Tooltip,
|
||||||
|
TooltipContent,
|
||||||
|
TooltipTrigger,
|
||||||
|
} from '@cfdm/ui/components/tooltip'
|
||||||
|
|
||||||
interface DomainBindingsCardProps {
|
interface DomainBindingsCardProps {
|
||||||
bindings: ServiceBinding[]
|
bindings: ServiceBinding[]
|
||||||
@@ -50,19 +55,40 @@ export function DomainBindingsCard({ bindings }: DomainBindingsCardProps) {
|
|||||||
className="border border-dashed p-4"
|
className="border border-dashed p-4"
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<AppItemGroup className="gap-0">
|
<AppItemGroup className="gap-3">
|
||||||
{entries.map(([hostname, hostnameBindings], index) => {
|
{entries.map(([hostname, hostnameBindings], index) => {
|
||||||
const services = uniqueServices(hostnameBindings)
|
const services = uniqueServices(hostnameBindings)
|
||||||
|
const uniqueIps = [
|
||||||
|
...new Set(
|
||||||
|
hostnameBindings
|
||||||
|
.map((b) => b.target_ip)
|
||||||
|
.filter((ip): ip is string => Boolean(ip)),
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div key={hostname}>
|
<div key={hostname}>
|
||||||
<AppItem variant="outline">
|
<AppItem variant="outline">
|
||||||
<AppItemContent className="gap-2">
|
<AppItemContent className="gap-2">
|
||||||
<AppItemTitle className="font-mono">{hostname}</AppItemTitle>
|
<Tooltip>
|
||||||
|
<TooltipTrigger
|
||||||
|
render={
|
||||||
|
<AppItemTitle className="cursor-default truncate font-mono">
|
||||||
|
{hostname}
|
||||||
|
</AppItemTitle>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<TooltipContent>{hostname}</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
<div className="flex flex-wrap items-center gap-2">
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
{services.map((name) => (
|
{services.map((name) => (
|
||||||
<AppBadge key={name}>{name}</AppBadge>
|
<AppBadge key={name}>{name}</AppBadge>
|
||||||
))}
|
))}
|
||||||
|
{uniqueIps.length > 0 && (
|
||||||
|
<span className="tabular-nums text-xs text-muted-foreground">
|
||||||
|
{uniqueIps.join(', ')}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
{[
|
{[
|
||||||
...new Set(
|
...new Set(
|
||||||
hostnameBindings
|
hostnameBindings
|
||||||
|
|||||||
@@ -1,44 +1,28 @@
|
|||||||
import { useMemo, useState } from 'react'
|
import { useMemo, useState } from 'react'
|
||||||
import { Link } from '@tanstack/react-router'
|
import { Link } from '@tanstack/react-router'
|
||||||
import {
|
import {
|
||||||
flexRender,
|
|
||||||
getCoreRowModel,
|
getCoreRowModel,
|
||||||
getFilteredRowModel,
|
getFilteredRowModel,
|
||||||
getPaginationRowModel,
|
getPaginationRowModel,
|
||||||
getSortedRowModel,
|
getSortedRowModel,
|
||||||
useReactTable,
|
useReactTable,
|
||||||
type ColumnDef,
|
type ColumnDef,
|
||||||
type ColumnFiltersState,
|
|
||||||
type SortingState,
|
type SortingState,
|
||||||
} from '@tanstack/react-table'
|
} from '@tanstack/react-table'
|
||||||
import {
|
import { ArrowUpDownIcon, MoreHorizontalIcon } from 'lucide-react'
|
||||||
ArrowUpDownIcon,
|
|
||||||
GlobeIcon,
|
|
||||||
MoreHorizontalIcon,
|
|
||||||
SearchIcon,
|
|
||||||
} from 'lucide-react'
|
|
||||||
import { ConfirmDialog } from '@/components/confirm-dialog'
|
import { ConfirmDialog } from '@/components/confirm-dialog'
|
||||||
import { StatusBadge } from '@/components/status-badge'
|
import { StatusBadge } from '@/components/status-badge'
|
||||||
import type { DomainListItem } from '@/lib/schemas'
|
import type { DomainListItem } from '@/lib/schemas'
|
||||||
import { AppBadge } from '@/components/app-badge'
|
import { AppBadge } from '@/components/app-badge'
|
||||||
import { AppButton } from '@/components/app-button'
|
import { AppButton } from '@/components/app-button'
|
||||||
|
import { DataTableView } from '@/components/data-table-view'
|
||||||
|
import type { Density } from '@/components/data-table-toolbar'
|
||||||
import {
|
import {
|
||||||
DropdownMenu,
|
DropdownMenu,
|
||||||
DropdownMenuContent,
|
DropdownMenuContent,
|
||||||
DropdownMenuItem,
|
DropdownMenuItem,
|
||||||
DropdownMenuTrigger,
|
DropdownMenuTrigger,
|
||||||
} from '@cfdm/ui/components/dropdown-menu'
|
} 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 {
|
import {
|
||||||
Select,
|
Select,
|
||||||
SelectContent,
|
SelectContent,
|
||||||
@@ -46,14 +30,6 @@ import {
|
|||||||
SelectTrigger,
|
SelectTrigger,
|
||||||
SelectValue,
|
SelectValue,
|
||||||
} from '@cfdm/ui/components/select'
|
} from '@cfdm/ui/components/select'
|
||||||
import {
|
|
||||||
Table,
|
|
||||||
TableBody,
|
|
||||||
TableCell,
|
|
||||||
TableHead,
|
|
||||||
TableHeader,
|
|
||||||
TableRow,
|
|
||||||
} from '@cfdm/ui/components/table'
|
|
||||||
|
|
||||||
export type DomainTableRow = DomainListItem
|
export type DomainTableRow = DomainListItem
|
||||||
|
|
||||||
@@ -80,7 +56,8 @@ export function DomainsDataTable({
|
|||||||
isDeleting = false,
|
isDeleting = false,
|
||||||
}: DomainsDataTableProps) {
|
}: DomainsDataTableProps) {
|
||||||
const [sorting, setSorting] = useState<SortingState>([])
|
const [sorting, setSorting] = useState<SortingState>([])
|
||||||
const [columnFilters, setColumnFilters] = useState<ColumnFiltersState>([])
|
const [globalFilter, setGlobalFilter] = useState('')
|
||||||
|
const [density, setDensity] = useState<Density>('comfortable')
|
||||||
const [deleteTarget, setDeleteTarget] = useState<DomainTableRow | null>(null)
|
const [deleteTarget, setDeleteTarget] = useState<DomainTableRow | null>(null)
|
||||||
|
|
||||||
const columns = useMemo<ColumnDef<DomainTableRow>[]>(
|
const columns = useMemo<ColumnDef<DomainTableRow>[]>(
|
||||||
@@ -147,12 +124,7 @@ export function DomainsDataTable({
|
|||||||
variant="link"
|
variant="link"
|
||||||
className="h-auto p-0 tabular-nums"
|
className="h-auto p-0 tabular-nums"
|
||||||
nativeButton={false}
|
nativeButton={false}
|
||||||
render={
|
render={<Link to="/services" search={{ domainId: row.original.id }} />}
|
||||||
<Link
|
|
||||||
to="/services"
|
|
||||||
search={{ domainId: row.original.id }}
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
>
|
>
|
||||||
{row.original.service_count}
|
{row.original.service_count}
|
||||||
</AppButton>
|
</AppButton>
|
||||||
@@ -179,11 +151,11 @@ export function DomainsDataTable({
|
|||||||
cell: ({ row }) => (
|
cell: ({ row }) => (
|
||||||
<div className="text-right">
|
<div className="text-right">
|
||||||
<DropdownMenu>
|
<DropdownMenu>
|
||||||
<DropdownMenuTrigger
|
<DropdownMenuTrigger
|
||||||
render={
|
render={
|
||||||
<AppButton variant="outline" size="icon" className="size-8" />
|
<AppButton variant="outline" size="icon" className="size-8" />
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<MoreHorizontalIcon />
|
<MoreHorizontalIcon />
|
||||||
<span className="sr-only">Действия</span>
|
<span className="sr-only">Действия</span>
|
||||||
</DropdownMenuTrigger>
|
</DropdownMenuTrigger>
|
||||||
@@ -228,138 +200,61 @@ export function DomainsDataTable({
|
|||||||
const table = useReactTable({
|
const table = useReactTable({
|
||||||
data,
|
data,
|
||||||
columns,
|
columns,
|
||||||
state: { sorting, columnFilters },
|
state: { sorting, globalFilter },
|
||||||
onSortingChange: setSorting,
|
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(),
|
getCoreRowModel: getCoreRowModel(),
|
||||||
getSortedRowModel: getSortedRowModel(),
|
getSortedRowModel: getSortedRowModel(),
|
||||||
getFilteredRowModel: getFilteredRowModel(),
|
getFilteredRowModel: getFilteredRowModel(),
|
||||||
getPaginationRowModel: getPaginationRowModel(),
|
getPaginationRowModel: getPaginationRowModel(),
|
||||||
initialState: {
|
initialState: { pagination: { pageSize: 10 } },
|
||||||
pagination: { pageSize: 10 },
|
|
||||||
},
|
|
||||||
})
|
})
|
||||||
|
|
||||||
const filterGroupItems = useMemo(
|
const filterSelect = (
|
||||||
() => [{ label: 'Все группы', value: 'all' }, ...groupFilterItems],
|
<Select
|
||||||
[groupFilterItems],
|
items={groupFilterItems}
|
||||||
|
value={groupFilterValue || 'all'}
|
||||||
|
onValueChange={(value) => {
|
||||||
|
if (!value || value === 'all') {
|
||||||
|
onGroupFilterChange(null)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
onGroupFilterChange(value)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="w-full sm:w-56">
|
||||||
|
<SelectValue placeholder="Все группы" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="all">Все группы</SelectItem>
|
||||||
|
{groupFilterItems.map((item) => (
|
||||||
|
<SelectItem key={item.value} value={item.value}>
|
||||||
|
{item.label}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
)
|
)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-4">
|
<>
|
||||||
<div className="flex flex-col gap-3 border-b px-4 py-3 sm:flex-row sm:items-center">
|
<DataTableView
|
||||||
<InputGroup className="max-w-sm">
|
table={table}
|
||||||
<InputGroupAddon>
|
density={density}
|
||||||
<SearchIcon />
|
onDensityChange={setDensity}
|
||||||
</InputGroupAddon>
|
searchPlaceholder="Поиск по зоне или группе…"
|
||||||
<InputGroupInput
|
searchValue={globalFilter}
|
||||||
placeholder="Поиск по зоне…"
|
onSearchChange={setGlobalFilter}
|
||||||
value={(table.getColumn('zone_name')?.getFilterValue() as string) ?? ''}
|
filters={filterSelect}
|
||||||
onChange={(event) =>
|
emptyTitle="Домены не найдены"
|
||||||
table.getColumn('zone_name')?.setFilterValue(event.target.value)
|
emptyDescription="Импортируйте зону из Cloudflare или измените фильтры"
|
||||||
}
|
/>
|
||||||
/>
|
|
||||||
</InputGroup>
|
|
||||||
<Select
|
|
||||||
items={filterGroupItems}
|
|
||||||
value={groupFilterValue || 'all'}
|
|
||||||
onValueChange={onGroupFilterChange}
|
|
||||||
>
|
|
||||||
<SelectTrigger className="w-full sm:w-56">
|
|
||||||
<SelectValue placeholder="Все группы" />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
{filterGroupItems.map((item) => (
|
|
||||||
<SelectItem key={item.value} value={item.value}>
|
|
||||||
{item.label}
|
|
||||||
</SelectItem>
|
|
||||||
))}
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
<div className="flex items-center gap-2 text-sm text-muted-foreground sm:ml-auto">
|
|
||||||
<GlobeIcon className="size-4" />
|
|
||||||
<span className="tabular-nums">
|
|
||||||
{table.getFilteredRowModel().rows.length} зон
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="overflow-hidden px-4 pb-4">
|
|
||||||
<div className="overflow-hidden rounded-lg border">
|
|
||||||
<Table>
|
|
||||||
<TableHeader>
|
|
||||||
{table.getHeaderGroups().map((headerGroup) => (
|
|
||||||
<TableRow key={headerGroup.id}>
|
|
||||||
{headerGroup.headers.map((header) => (
|
|
||||||
<TableHead
|
|
||||||
key={header.id}
|
|
||||||
className={header.id === 'actions' ? 'w-12 text-right' : undefined}
|
|
||||||
>
|
|
||||||
{header.isPlaceholder
|
|
||||||
? null
|
|
||||||
: flexRender(header.column.columnDef.header, header.getContext())}
|
|
||||||
</TableHead>
|
|
||||||
))}
|
|
||||||
</TableRow>
|
|
||||||
))}
|
|
||||||
</TableHeader>
|
|
||||||
<TableBody>
|
|
||||||
{table.getRowModel().rows.length > 0 ? (
|
|
||||||
table.getRowModel().rows.map((row) => (
|
|
||||||
<TableRow key={row.id} data-state={row.getIsSelected() && 'selected'}>
|
|
||||||
{row.getVisibleCells().map((cell) => (
|
|
||||||
<TableCell
|
|
||||||
key={cell.id}
|
|
||||||
className={cell.column.id === 'actions' ? 'text-right' : undefined}
|
|
||||||
>
|
|
||||||
{flexRender(cell.column.columnDef.cell, cell.getContext())}
|
|
||||||
</TableCell>
|
|
||||||
))}
|
|
||||||
</TableRow>
|
|
||||||
))
|
|
||||||
) : (
|
|
||||||
<TableRow>
|
|
||||||
<TableCell colSpan={columns.length} className="h-48 p-0">
|
|
||||||
<Empty className="border-0">
|
|
||||||
<EmptyHeader>
|
|
||||||
<EmptyTitle>Домены не найдены</EmptyTitle>
|
|
||||||
<EmptyDescription>
|
|
||||||
Импортируйте зону из Cloudflare или измените фильтры
|
|
||||||
</EmptyDescription>
|
|
||||||
</EmptyHeader>
|
|
||||||
</Empty>
|
|
||||||
</TableCell>
|
|
||||||
</TableRow>
|
|
||||||
)}
|
|
||||||
</TableBody>
|
|
||||||
</Table>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{table.getPageCount() > 1 && (
|
|
||||||
<div className="flex items-center justify-end gap-2 px-4 pb-4">
|
|
||||||
<span className="text-sm text-muted-foreground tabular-nums">
|
|
||||||
Стр. {table.getState().pagination.pageIndex + 1} из {table.getPageCount()}
|
|
||||||
</span>
|
|
||||||
<AppButton
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
onClick={() => table.previousPage()}
|
|
||||||
disabled={!table.getCanPreviousPage()}
|
|
||||||
>
|
|
||||||
Назад
|
|
||||||
</AppButton>
|
|
||||||
<AppButton
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
onClick={() => table.nextPage()}
|
|
||||||
disabled={!table.getCanNextPage()}
|
|
||||||
>
|
|
||||||
Вперёд
|
|
||||||
</AppButton>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<ConfirmDialog
|
<ConfirmDialog
|
||||||
open={deleteTarget !== null}
|
open={deleteTarget !== null}
|
||||||
onOpenChange={(open) => {
|
onOpenChange={(open) => {
|
||||||
@@ -378,6 +273,6 @@ export function DomainsDataTable({
|
|||||||
}}
|
}}
|
||||||
disabled={isDeleting}
|
disabled={isDeleting}
|
||||||
/>
|
/>
|
||||||
</div>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 (
|
||||||
|
<AppCard>
|
||||||
|
<AppCardHeader>
|
||||||
|
<AppCardTitle className="text-base">Истекающие сертификаты</AppCardTitle>
|
||||||
|
<AppCardDescription>
|
||||||
|
Хосты с истечением срока в течение {WARN_DAYS} дней
|
||||||
|
</AppCardDescription>
|
||||||
|
</AppCardHeader>
|
||||||
|
<AppCardContent>
|
||||||
|
{upcoming.length === 0 ? (
|
||||||
|
<EmptyState
|
||||||
|
icon={ShieldCheckIcon}
|
||||||
|
title="Все сертификаты в норме"
|
||||||
|
description="Ближайшие истечения в пределах двух недель не обнаружены"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<ScrollArea className="h-72">
|
||||||
|
<AppItemGroup className="gap-2 pr-3">
|
||||||
|
{upcoming.map((cert) => {
|
||||||
|
const expired = new Date(cert.expires_at as string).getTime() < now
|
||||||
|
return (
|
||||||
|
<AppItem key={cert.id} variant="outline" size="sm">
|
||||||
|
<AppItemContent className="flex flex-row items-center justify-between gap-3">
|
||||||
|
<div className="flex min-w-0 flex-col gap-0.5">
|
||||||
|
<AppItemTitle className="truncate font-medium">
|
||||||
|
{cert.hostname}
|
||||||
|
</AppItemTitle>
|
||||||
|
<AppItemDescription className="tabular-nums">
|
||||||
|
{formatRelative(cert.expires_at)}
|
||||||
|
</AppItemDescription>
|
||||||
|
</div>
|
||||||
|
<StatusBadge
|
||||||
|
status={expired ? 'expired' : cert.status}
|
||||||
|
/>
|
||||||
|
</AppItemContent>
|
||||||
|
</AppItem>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</AppItemGroup>
|
||||||
|
</ScrollArea>
|
||||||
|
)}
|
||||||
|
</AppCardContent>
|
||||||
|
</AppCard>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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<typeof AppButton>['render']
|
||||||
|
onAction?: () => void
|
||||||
|
footer?: ReactNode
|
||||||
|
}
|
||||||
|
|
||||||
|
const trendIcon: Record<TrendDirection, LucideIcon> = {
|
||||||
|
up: TrendingUpIcon,
|
||||||
|
down: TrendingDownIcon,
|
||||||
|
flat: MinusIcon,
|
||||||
|
}
|
||||||
|
|
||||||
|
const trendTone: Record<TrendDirection, string> = {
|
||||||
|
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 (
|
||||||
|
<AppCard className="gap-0">
|
||||||
|
<AppCardHeader>
|
||||||
|
<AppCardDescription>{label}</AppCardDescription>
|
||||||
|
<AppCardTitle className="text-3xl font-semibold tabular-nums">
|
||||||
|
{value}
|
||||||
|
</AppCardTitle>
|
||||||
|
<AppCardAction>
|
||||||
|
<Icon className="size-4 text-muted-foreground" />
|
||||||
|
</AppCardAction>
|
||||||
|
</AppCardHeader>
|
||||||
|
<AppCardFooter className="flex items-center justify-between gap-2 text-sm">
|
||||||
|
{footer ??
|
||||||
|
(trend ? (
|
||||||
|
<span className="flex items-center gap-1 text-muted-foreground">
|
||||||
|
{TrendIcon && (
|
||||||
|
<TrendIcon
|
||||||
|
className={`size-3.5 ${trendTone[trend.direction]}`}
|
||||||
|
aria-hidden
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<span className="tabular-nums">{trend.value}</span>
|
||||||
|
{trend.hint && (
|
||||||
|
<span className="text-muted-foreground/70">· {trend.hint}</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span className="text-muted-foreground">—</span>
|
||||||
|
))}
|
||||||
|
{actionLabel && (actionRender || onAction) && (
|
||||||
|
<AppButton
|
||||||
|
variant="link"
|
||||||
|
className="h-auto p-0"
|
||||||
|
nativeButton={actionRender ? false : undefined}
|
||||||
|
render={actionRender}
|
||||||
|
onClick={onAction}
|
||||||
|
>
|
||||||
|
{actionLabel}
|
||||||
|
</AppButton>
|
||||||
|
)}
|
||||||
|
</AppCardFooter>
|
||||||
|
</AppCard>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -3,6 +3,7 @@ import { cn } from '@cfdm/ui/lib/utils'
|
|||||||
interface PageHeaderProps {
|
interface PageHeaderProps {
|
||||||
title: string
|
title: string
|
||||||
description?: string
|
description?: string
|
||||||
|
meta?: React.ReactNode
|
||||||
actions?: React.ReactNode
|
actions?: React.ReactNode
|
||||||
className?: string
|
className?: string
|
||||||
}
|
}
|
||||||
@@ -10,6 +11,7 @@ interface PageHeaderProps {
|
|||||||
export function PageHeader({
|
export function PageHeader({
|
||||||
title,
|
title,
|
||||||
description,
|
description,
|
||||||
|
meta,
|
||||||
actions,
|
actions,
|
||||||
className,
|
className,
|
||||||
}: PageHeaderProps) {
|
}: PageHeaderProps) {
|
||||||
@@ -20,10 +22,15 @@ export function PageHeader({
|
|||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<div className="flex flex-col gap-1">
|
<div className="flex flex-col gap-1.5">
|
||||||
<h1 className="text-xl font-semibold tracking-tight md:text-2xl">
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
{title}
|
<h1 className="text-xl font-semibold tracking-tight md:text-2xl">
|
||||||
</h1>
|
{title}
|
||||||
|
</h1>
|
||||||
|
{meta && (
|
||||||
|
<span className="text-sm text-muted-foreground">{meta}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
{description && (
|
{description && (
|
||||||
<p className="max-w-2xl text-sm text-muted-foreground">
|
<p className="max-w-2xl text-sm text-muted-foreground">
|
||||||
{description}
|
{description}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import {
|
import {
|
||||||
Card,
|
Card,
|
||||||
|
CardContent,
|
||||||
CardFooter,
|
CardFooter,
|
||||||
CardHeader,
|
CardHeader,
|
||||||
} from '@cfdm/ui/components/card'
|
} from '@cfdm/ui/components/card'
|
||||||
@@ -10,7 +11,7 @@ export function SectionCardsSkeleton() {
|
|||||||
<div className="flex flex-col gap-4">
|
<div className="flex flex-col gap-4">
|
||||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
|
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
|
||||||
{Array.from({ length: 4 }).map((_, index) => (
|
{Array.from({ length: 4 }).map((_, index) => (
|
||||||
<Card key={index}>
|
<Card key={index} className="gap-0">
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<Skeleton className="h-4 w-24" />
|
<Skeleton className="h-4 w-24" />
|
||||||
<Skeleton className="h-8 w-16" />
|
<Skeleton className="h-8 w-16" />
|
||||||
@@ -21,16 +22,31 @@ export function SectionCardsSkeleton() {
|
|||||||
</Card>
|
</Card>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
<div className="grid gap-4 lg:grid-cols-2">
|
||||||
|
{Array.from({ length: 2 }).map((_, index) => (
|
||||||
|
<Card key={index}>
|
||||||
|
<CardHeader>
|
||||||
|
<Skeleton className="h-5 w-48" />
|
||||||
|
<Skeleton className="h-4 w-64" />
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<Skeleton className="h-48 w-full" />
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<Skeleton className="h-5 w-48" />
|
<Skeleton className="h-5 w-48" />
|
||||||
<Skeleton className="h-4 w-64" />
|
<Skeleton className="h-4 w-64" />
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<div className="flex flex-col gap-2 px-6 pb-6">
|
<CardContent>
|
||||||
{Array.from({ length: 3 }).map((_, index) => (
|
<div className="flex flex-col gap-2">
|
||||||
<Skeleton key={index} className="h-10 w-full" />
|
{Array.from({ length: 4 }).map((_, index) => (
|
||||||
))}
|
<Skeleton key={index} className="h-10 w-full" />
|
||||||
</div>
|
))}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -78,6 +78,7 @@ export function ServiceBindingCard({
|
|||||||
id={`hostname-${binding.id}`}
|
id={`hostname-${binding.id}`}
|
||||||
value={hostname}
|
value={hostname}
|
||||||
placeholder="@"
|
placeholder="@"
|
||||||
|
className="font-mono tabular-nums"
|
||||||
onPointerDown={(e) => e.stopPropagation()}
|
onPointerDown={(e) => e.stopPropagation()}
|
||||||
onChange={(e) => setHostname(e.target.value)}
|
onChange={(e) => setHostname(e.target.value)}
|
||||||
onBlur={() => {
|
onBlur={() => {
|
||||||
@@ -93,6 +94,7 @@ export function ServiceBindingCard({
|
|||||||
id={`ip-${binding.id}`}
|
id={`ip-${binding.id}`}
|
||||||
value={ip}
|
value={ip}
|
||||||
placeholder="192.168.1.1"
|
placeholder="192.168.1.1"
|
||||||
|
className="font-mono tabular-nums"
|
||||||
onPointerDown={(e) => e.stopPropagation()}
|
onPointerDown={(e) => e.stopPropagation()}
|
||||||
onChange={(e) => setIp(e.target.value)}
|
onChange={(e) => setIp(e.target.value)}
|
||||||
onBlur={() => {
|
onBlur={() => {
|
||||||
|
|||||||
@@ -1,8 +1,18 @@
|
|||||||
import { useState } from 'react'
|
import { useMemo, useState } from 'react'
|
||||||
import { Link } from '@tanstack/react-router'
|
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 { MoreHorizontalIcon } from 'lucide-react'
|
||||||
import { ConfirmDialog } from '@/components/confirm-dialog'
|
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 type { SubdomainTableRow } from '@/hooks/use-domain-page'
|
||||||
import { formatSubdomainServiceLinks } from '@/hooks/use-domain-page'
|
import { formatSubdomainServiceLinks } from '@/hooks/use-domain-page'
|
||||||
import { formatDate } from '@/lib/format'
|
import { formatDate } from '@/lib/format'
|
||||||
@@ -15,14 +25,6 @@ import {
|
|||||||
DropdownMenuSeparator,
|
DropdownMenuSeparator,
|
||||||
DropdownMenuTrigger,
|
DropdownMenuTrigger,
|
||||||
} from '@cfdm/ui/components/dropdown-menu'
|
} from '@cfdm/ui/components/dropdown-menu'
|
||||||
import {
|
|
||||||
Table,
|
|
||||||
TableBody,
|
|
||||||
TableCell,
|
|
||||||
TableHead,
|
|
||||||
TableHeader,
|
|
||||||
TableRow,
|
|
||||||
} from '@cfdm/ui/components/table'
|
|
||||||
|
|
||||||
interface SubdomainsTableProps {
|
interface SubdomainsTableProps {
|
||||||
domainId: string
|
domainId: string
|
||||||
@@ -44,80 +46,143 @@ export function SubdomainsTable({
|
|||||||
onToggleEnabled,
|
onToggleEnabled,
|
||||||
}: SubdomainsTableProps) {
|
}: SubdomainsTableProps) {
|
||||||
const [deleteTarget, setDeleteTarget] = useState<SubdomainTableRow | null>(null)
|
const [deleteTarget, setDeleteTarget] = useState<SubdomainTableRow | null>(null)
|
||||||
|
const [sorting, setSorting] = useState<SortingState>([])
|
||||||
|
const [globalFilter, setGlobalFilter] = useState('')
|
||||||
|
const [density, setDensity] = useState<Density>('comfortable')
|
||||||
|
|
||||||
|
const columns = useMemo<ColumnDef<SubdomainTableRow>[]>(
|
||||||
|
() => [
|
||||||
|
{
|
||||||
|
accessorKey: 'fqdn',
|
||||||
|
header: ({ column }) => (
|
||||||
|
<AppButton
|
||||||
|
variant="ghost"
|
||||||
|
className="-ml-3 h-8"
|
||||||
|
onClick={() => column.toggleSorting(column.getIsSorted() === 'asc')}
|
||||||
|
>
|
||||||
|
Поддомен
|
||||||
|
</AppButton>
|
||||||
|
),
|
||||||
|
accessorFn: (row) => row.subdomain.fqdn,
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<span className="font-mono truncate">{row.original.subdomain.fqdn}</span>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'status',
|
||||||
|
header: 'Статус',
|
||||||
|
enableHiding: true,
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<AppBadge variant={row.original.subdomain.enabled ? 'default' : 'outline'}>
|
||||||
|
{row.original.subdomain.enabled ? 'Активен' : 'Неактивен'}
|
||||||
|
</AppBadge>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'service',
|
||||||
|
header: 'Группа / Сервис',
|
||||||
|
accessorFn: (row) => formatSubdomainServiceLinks(row.serviceLinks),
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<span className="text-muted-foreground">
|
||||||
|
{formatSubdomainServiceLinks(row.original.serviceLinks)}
|
||||||
|
</span>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: 'created_at',
|
||||||
|
header: 'Создан',
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<span className="tabular-nums text-muted-foreground">
|
||||||
|
{formatDate(row.original.subdomain.created_at)}
|
||||||
|
</span>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'actions',
|
||||||
|
enableHiding: false,
|
||||||
|
header: () => <span className="sr-only">Действия</span>,
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<div className="text-right">
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger
|
||||||
|
render={
|
||||||
|
<AppButton 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.fqdn }}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
DNS-записи
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuSeparator />
|
||||||
|
<DropdownMenuItem
|
||||||
|
variant="destructive"
|
||||||
|
onClick={() => setDeleteTarget(row.original)}
|
||||||
|
>
|
||||||
|
Удалить
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
[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 (
|
return (
|
||||||
<>
|
<>
|
||||||
<TableCard>
|
<DataTableView
|
||||||
<Table>
|
table={table}
|
||||||
<TableHeader>
|
density={density}
|
||||||
<TableRow>
|
onDensityChange={setDensity}
|
||||||
<TableHead>Поддомен</TableHead>
|
searchPlaceholder="Поиск по поддомену…"
|
||||||
<TableHead>Статус</TableHead>
|
searchValue={globalFilter}
|
||||||
<TableHead>Группа / Сервис</TableHead>
|
onSearchChange={setGlobalFilter}
|
||||||
<TableHead>Создан</TableHead>
|
emptyTitle="Поддомены не найдены"
|
||||||
<TableHead className="w-12">
|
emptyDescription="Создайте поддомен или измените запрос"
|
||||||
<span className="sr-only">Действия</span>
|
/>
|
||||||
</TableHead>
|
|
||||||
</TableRow>
|
|
||||||
</TableHeader>
|
|
||||||
<TableBody>
|
|
||||||
{rows.map((row) => (
|
|
||||||
<TableRow key={row.subdomain.id} className="h-10 text-sm">
|
|
||||||
<TableCell className="font-mono">{row.subdomain.fqdn}</TableCell>
|
|
||||||
<TableCell>
|
|
||||||
<AppBadge variant={row.subdomain.enabled ? 'default' : 'outline'}>
|
|
||||||
{row.subdomain.enabled ? 'Активен' : 'Неактивен'}
|
|
||||||
</AppBadge>
|
|
||||||
</TableCell>
|
|
||||||
<TableCell>{formatSubdomainServiceLinks(row.serviceLinks)}</TableCell>
|
|
||||||
<TableCell>{formatDate(row.subdomain.created_at)}</TableCell>
|
|
||||||
<TableCell className="text-right">
|
|
||||||
<DropdownMenu>
|
|
||||||
<DropdownMenuTrigger
|
|
||||||
render={
|
|
||||||
<AppButton variant="outline" size="icon" className="size-8" />
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<MoreHorizontalIcon />
|
|
||||||
<span className="sr-only">Действия</span>
|
|
||||||
</DropdownMenuTrigger>
|
|
||||||
<DropdownMenuContent align="end">
|
|
||||||
<DropdownMenuItem onClick={() => onEdit(row)}>
|
|
||||||
Редактировать
|
|
||||||
</DropdownMenuItem>
|
|
||||||
<DropdownMenuItem
|
|
||||||
disabled={isToggling}
|
|
||||||
onClick={() => onToggleEnabled(row)}
|
|
||||||
>
|
|
||||||
{row.subdomain.enabled ? 'Деактивировать' : 'Активировать'}
|
|
||||||
</DropdownMenuItem>
|
|
||||||
<DropdownMenuItem
|
|
||||||
render={
|
|
||||||
<Link
|
|
||||||
to="/domains/$domainId/dns"
|
|
||||||
params={{ domainId }}
|
|
||||||
search={{ host: row.subdomain.fqdn }}
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
DNS-записи
|
|
||||||
</DropdownMenuItem>
|
|
||||||
<DropdownMenuSeparator />
|
|
||||||
<DropdownMenuItem
|
|
||||||
variant="destructive"
|
|
||||||
onClick={() => setDeleteTarget(row)}
|
|
||||||
>
|
|
||||||
Удалить
|
|
||||||
</DropdownMenuItem>
|
|
||||||
</DropdownMenuContent>
|
|
||||||
</DropdownMenu>
|
|
||||||
</TableCell>
|
|
||||||
</TableRow>
|
|
||||||
))}
|
|
||||||
</TableBody>
|
|
||||||
</Table>
|
|
||||||
</TableCard>
|
|
||||||
|
|
||||||
<ConfirmDialog
|
<ConfirmDialog
|
||||||
open={deleteTarget !== null}
|
open={deleteTarget !== null}
|
||||||
onOpenChange={(open) => {
|
onOpenChange={(open) => {
|
||||||
|
|||||||
@@ -1,41 +1,44 @@
|
|||||||
import { createFileRoute } from '@tanstack/react-router'
|
import { createFileRoute } from '@tanstack/react-router'
|
||||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||||
import { useMemo, useState } from 'react'
|
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 { Bar, BarChart, CartesianGrid, XAxis } from 'recharts'
|
||||||
import { ShieldCheckIcon } from 'lucide-react'
|
import { ShieldCheckIcon } from 'lucide-react'
|
||||||
import { toast } from 'sonner'
|
import { toast } from 'sonner'
|
||||||
import { certificatesQueryOptions, certKeys, certSummaryQueryOptions } from '@/queries'
|
import { certificatesQueryOptions, certKeys, certSummaryQueryOptions } from '@/queries'
|
||||||
import { api } from '@/lib/api-client'
|
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 { PageHeader } from '@/components/page-header'
|
||||||
import { PageShell } from '@/components/page-shell'
|
import { PageShell } from '@/components/page-shell'
|
||||||
import { QueryState } from '@/components/query-state'
|
import { QueryState } from '@/components/query-state'
|
||||||
import { EmptyState } from '@/components/empty-state'
|
import { EmptyState } from '@/components/empty-state'
|
||||||
import { StatusBadge } from '@/components/status-badge'
|
import { StatusBadge } from '@/components/status-badge'
|
||||||
import { DataTableCard } from '@/components/data-table-card'
|
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 { AppButton } from '@/components/app-button'
|
||||||
import {
|
import {
|
||||||
AppCard,
|
AppItem,
|
||||||
AppCardContent,
|
AppItemContent,
|
||||||
AppCardDescription,
|
AppItemGroup,
|
||||||
AppCardHeader,
|
AppItemTitle,
|
||||||
AppCardTitle,
|
} from '@/components/app-item'
|
||||||
} from '@/components/app-card'
|
|
||||||
import {
|
import {
|
||||||
ChartContainer,
|
ChartContainer,
|
||||||
ChartTooltip,
|
ChartTooltip,
|
||||||
ChartTooltipContent,
|
ChartTooltipContent,
|
||||||
type ChartConfig,
|
type ChartConfig,
|
||||||
} from '@cfdm/ui/components/chart'
|
} from '@cfdm/ui/components/chart'
|
||||||
import {
|
|
||||||
Table,
|
|
||||||
TableBody,
|
|
||||||
TableCell,
|
|
||||||
TableHead,
|
|
||||||
TableHeader,
|
|
||||||
TableRow,
|
|
||||||
} from '@cfdm/ui/components/table'
|
|
||||||
import { LoadingButton } from '@/components/loading-button'
|
import { LoadingButton } from '@/components/loading-button'
|
||||||
import { TableSkeleton } from '@/components/table-skeleton'
|
import { TableSkeleton } from '@/components/table-skeleton'
|
||||||
|
|
||||||
@@ -57,6 +60,8 @@ export const Route = createFileRoute('/_auth/certificates')({
|
|||||||
|
|
||||||
function CertificatesPage() {
|
function CertificatesPage() {
|
||||||
const [searchQuery, setSearchQuery] = useState('')
|
const [searchQuery, setSearchQuery] = useState('')
|
||||||
|
const [sorting, setSorting] = useState<SortingState>([{ id: 'expires_at', desc: false }])
|
||||||
|
const [density, setDensity] = useState<Density>('comfortable')
|
||||||
const queryClient = useQueryClient()
|
const queryClient = useQueryClient()
|
||||||
const {
|
const {
|
||||||
data: certs,
|
data: certs,
|
||||||
@@ -94,6 +99,80 @@ function CertificatesPage() {
|
|||||||
|
|
||||||
const isFilteredEmpty = (certs?.length ?? 0) > 0 && filteredCerts.length === 0
|
const isFilteredEmpty = (certs?.length ?? 0) > 0 && filteredCerts.length === 0
|
||||||
|
|
||||||
|
const columns = useMemo<ColumnDef<Certificate>[]>(
|
||||||
|
() => [
|
||||||
|
{
|
||||||
|
accessorKey: 'hostname',
|
||||||
|
header: ({ column }) => (
|
||||||
|
<AppButton
|
||||||
|
variant="ghost"
|
||||||
|
className="-ml-3 h-8"
|
||||||
|
onClick={() => column.toggleSorting(column.getIsSorted() === 'asc')}
|
||||||
|
>
|
||||||
|
Хост
|
||||||
|
</AppButton>
|
||||||
|
),
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<span className="truncate font-medium">{row.original.hostname}</span>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: 'status',
|
||||||
|
header: 'Статус',
|
||||||
|
cell: ({ row }) => <StatusBadge status={row.original.status} />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: 'expires_at',
|
||||||
|
header: 'Истекает',
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<span className="tabular-nums text-muted-foreground">
|
||||||
|
{formatDate(row.original.expires_at)}
|
||||||
|
</span>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'relative',
|
||||||
|
header: 'Срок',
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<span className="tabular-nums text-muted-foreground">
|
||||||
|
{formatRelative(row.original.expires_at)}
|
||||||
|
</span>
|
||||||
|
),
|
||||||
|
enableHiding: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: 'last_checked_at',
|
||||||
|
header: 'Проверка',
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<span className="tabular-nums text-muted-foreground">
|
||||||
|
{formatDate(row.original.last_checked_at)}
|
||||||
|
</span>
|
||||||
|
),
|
||||||
|
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 (
|
return (
|
||||||
<PageShell>
|
<PageShell>
|
||||||
<PageHeader
|
<PageHeader
|
||||||
@@ -116,13 +195,11 @@ function CertificatesPage() {
|
|||||||
onRetry={refetch}
|
onRetry={refetch}
|
||||||
skeleton={<TableSkeleton rows={5} columns={4} />}
|
skeleton={<TableSkeleton rows={5} columns={4} />}
|
||||||
>
|
>
|
||||||
<AppCard>
|
<ChartCard
|
||||||
<AppCardHeader>
|
title="Обзор статусов"
|
||||||
<AppCardTitle>Обзор статусов</AppCardTitle>
|
description="Распределение сертификатов по статусам"
|
||||||
<AppCardDescription>Распределение сертификатов по статусам</AppCardDescription>
|
chart={
|
||||||
</AppCardHeader>
|
chartData.length > 0 ? (
|
||||||
<AppCardContent>
|
|
||||||
{chartData.length > 0 ? (
|
|
||||||
<ChartContainer config={chartConfig} className="aspect-auto h-64 w-full">
|
<ChartContainer config={chartConfig} className="aspect-auto h-64 w-full">
|
||||||
<BarChart data={chartData}>
|
<BarChart data={chartData}>
|
||||||
<CartesianGrid vertical={false} />
|
<CartesianGrid vertical={false} />
|
||||||
@@ -142,67 +219,42 @@ function CertificatesPage() {
|
|||||||
title="Нет данных для графика"
|
title="Нет данных для графика"
|
||||||
description="Запустите проверку сертификатов"
|
description="Запустите проверку сертификатов"
|
||||||
/>
|
/>
|
||||||
)}
|
)
|
||||||
</AppCardContent>
|
}
|
||||||
</AppCard>
|
table={
|
||||||
|
<AppItemGroup className="gap-2">
|
||||||
|
{chartData.map((entry) => (
|
||||||
|
<AppItem key={entry.status} variant="outline" size="sm">
|
||||||
|
<AppItemContent className="flex flex-row items-center justify-between gap-2">
|
||||||
|
<StatusBadge status={entry.status} />
|
||||||
|
<AppItemTitle className="font-medium tabular-nums">
|
||||||
|
{entry.count}
|
||||||
|
</AppItemTitle>
|
||||||
|
</AppItemContent>
|
||||||
|
</AppItem>
|
||||||
|
))}
|
||||||
|
</AppItemGroup>
|
||||||
|
}
|
||||||
|
/>
|
||||||
<DataTableCard
|
<DataTableCard
|
||||||
title="Сертификаты"
|
title="Сертификаты"
|
||||||
description="Хосты с активными сервисами или ручным мониторингом"
|
description="Хосты с активными сервисами или ручным мониторингом"
|
||||||
isEmpty={!filteredCerts.length}
|
isEmpty={false}
|
||||||
emptyTitle={
|
|
||||||
isFilteredEmpty ? 'Ничего не найдено' : 'Сертификаты не найдены'
|
|
||||||
}
|
|
||||||
emptyDescription={
|
|
||||||
isFilteredEmpty
|
|
||||||
? 'Измените поисковый запрос'
|
|
||||||
: 'Сертификаты появятся после проверки доменов'
|
|
||||||
}
|
|
||||||
emptyAction={
|
|
||||||
isFilteredEmpty ? (
|
|
||||||
<AppButton variant="outline" onClick={() => setSearchQuery('')}>
|
|
||||||
Сбросить фильтр
|
|
||||||
</AppButton>
|
|
||||||
) : (
|
|
||||||
<LoadingButton
|
|
||||||
onClick={() => checkMutation.mutate()}
|
|
||||||
isLoading={checkMutation.isPending}
|
|
||||||
loadingLabel="Проверка…"
|
|
||||||
>
|
|
||||||
Запустить проверку
|
|
||||||
</LoadingButton>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
emptyIcon={ShieldCheckIcon}
|
|
||||||
toolbar={
|
|
||||||
<TableToolbar
|
|
||||||
value={searchQuery}
|
|
||||||
onChange={setSearchQuery}
|
|
||||||
placeholder="Поиск по хосту или статусу…"
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
>
|
>
|
||||||
<Table>
|
<DataTableView
|
||||||
<TableHeader>
|
table={table}
|
||||||
<TableRow>
|
density={density}
|
||||||
<TableHead>Хост</TableHead>
|
onDensityChange={setDensity}
|
||||||
<TableHead>Статус</TableHead>
|
searchPlaceholder="Поиск по хосту или статусу…"
|
||||||
<TableHead>Истекает</TableHead>
|
searchValue={searchQuery}
|
||||||
<TableHead>Последняя проверка</TableHead>
|
onSearchChange={setSearchQuery}
|
||||||
</TableRow>
|
emptyTitle={isFilteredEmpty ? 'Ничего не найдено' : 'Сертификаты не найдены'}
|
||||||
</TableHeader>
|
emptyDescription={
|
||||||
<TableBody>
|
isFilteredEmpty
|
||||||
{filteredCerts.map((c) => (
|
? 'Измените поисковый запрос'
|
||||||
<TableRow key={c.id}>
|
: 'Сертификаты появятся после проверки доменов'
|
||||||
<TableCell className="font-medium">{c.hostname}</TableCell>
|
}
|
||||||
<TableCell>
|
/>
|
||||||
<StatusBadge status={c.status} />
|
|
||||||
</TableCell>
|
|
||||||
<TableCell>{formatDate(c.expires_at)}</TableCell>
|
|
||||||
<TableCell>{formatDate(c.last_checked_at)}</TableCell>
|
|
||||||
</TableRow>
|
|
||||||
))}
|
|
||||||
</TableBody>
|
|
||||||
</Table>
|
|
||||||
</DataTableCard>
|
</DataTableCard>
|
||||||
</QueryState>
|
</QueryState>
|
||||||
</PageShell>
|
</PageShell>
|
||||||
|
|||||||
@@ -1,11 +1,51 @@
|
|||||||
import { createFileRoute } from '@tanstack/react-router'
|
import { createFileRoute, Link } from '@tanstack/react-router'
|
||||||
import { useQuery } from '@tanstack/react-query'
|
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 { PageHeader } from '@/components/page-header'
|
||||||
import { PageShell } from '@/components/page-shell'
|
import { PageShell } from '@/components/page-shell'
|
||||||
import { QueryState } from '@/components/query-state'
|
import { QueryState } from '@/components/query-state'
|
||||||
import { SectionCards } from '@/components/section-cards'
|
|
||||||
import { SectionCardsSkeleton } from '@/components/section-cards-skeleton'
|
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/')({
|
export const Route = createFileRoute('/_auth/')({
|
||||||
loader: ({ context: { queryClient } }) =>
|
loader: ({ context: { queryClient } }) =>
|
||||||
@@ -14,10 +54,26 @@ export const Route = createFileRoute('/_auth/')({
|
|||||||
queryClient.ensureQueryData(certSummaryQueryOptions()),
|
queryClient.ensureQueryData(certSummaryQueryOptions()),
|
||||||
queryClient.ensureQueryData(groupsQueryOptions()),
|
queryClient.ensureQueryData(groupsQueryOptions()),
|
||||||
queryClient.ensureQueryData(serviceGroupsQueryOptions()),
|
queryClient.ensureQueryData(serviceGroupsQueryOptions()),
|
||||||
|
queryClient.ensureQueryData(certificatesQueryOptions()),
|
||||||
]),
|
]),
|
||||||
component: DashboardPage,
|
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() {
|
function DashboardPage() {
|
||||||
const {
|
const {
|
||||||
data: domains,
|
data: domains,
|
||||||
@@ -45,6 +101,23 @@ function DashboardPage() {
|
|||||||
const isError = domainsError || summaryError
|
const isError = domainsError || summaryError
|
||||||
const error = domainsErr ?? summaryErr
|
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 (
|
return (
|
||||||
<PageShell>
|
<PageShell>
|
||||||
<PageHeader
|
<PageHeader
|
||||||
@@ -61,13 +134,141 @@ function DashboardPage() {
|
|||||||
void refetchSummary()
|
void refetchSummary()
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<SectionCards
|
<div className="flex flex-col gap-4">
|
||||||
domainCount={domains?.length ?? 0}
|
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
|
||||||
certCount={certs?.length ?? 0}
|
<KpiCard
|
||||||
groupCount={groups?.length ?? 0}
|
label="Домены"
|
||||||
serviceCount={serviceCount}
|
value={domains?.length ?? 0}
|
||||||
certSummary={summary}
|
icon={GlobeIcon}
|
||||||
/>
|
actionLabel="Управление"
|
||||||
|
actionRender={<Link to="/domains" />}
|
||||||
|
/>
|
||||||
|
<KpiCard
|
||||||
|
label="Группы доменов"
|
||||||
|
value={groups?.length ?? 0}
|
||||||
|
icon={FolderTreeIcon}
|
||||||
|
actionLabel="Канбан"
|
||||||
|
actionRender={<Link to="/groups" />}
|
||||||
|
/>
|
||||||
|
<KpiCard
|
||||||
|
label="Сервисы"
|
||||||
|
value={serviceCount}
|
||||||
|
icon={ServerIcon}
|
||||||
|
actionLabel="Управление"
|
||||||
|
actionRender={
|
||||||
|
<Link to="/services" search={{ domainId: undefined }} />
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<KpiCard
|
||||||
|
label="Сертификаты"
|
||||||
|
value={certs?.length ?? 0}
|
||||||
|
icon={ShieldCheckIcon}
|
||||||
|
actionLabel="Мониторинг"
|
||||||
|
actionRender={<Link to="/certificates" />}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-4 lg:grid-cols-2">
|
||||||
|
<ChartCard
|
||||||
|
title="Статусы сертификатов"
|
||||||
|
description="Распределение по последней проверке"
|
||||||
|
chart={
|
||||||
|
statusChartData.length === 0 ? null : (
|
||||||
|
<ChartContainer
|
||||||
|
config={statusChartConfig}
|
||||||
|
className="mx-auto aspect-square h-64"
|
||||||
|
>
|
||||||
|
<PieChart>
|
||||||
|
<ChartTooltip
|
||||||
|
content={<ChartTooltipContent nameKey="status" hideLabel />}
|
||||||
|
/>
|
||||||
|
<Pie
|
||||||
|
data={statusChartData}
|
||||||
|
dataKey="count"
|
||||||
|
nameKey="status"
|
||||||
|
innerRadius={60}
|
||||||
|
strokeWidth={2}
|
||||||
|
>
|
||||||
|
{statusChartData.map((entry) => {
|
||||||
|
const cfg = (statusChartConfig as Record<string, { color?: string }>)[entry.status]
|
||||||
|
return (
|
||||||
|
<Cell key={entry.status} fill={cfg?.color ?? 'var(--chart-1)'} />
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
<Label
|
||||||
|
content={({ viewBox }) => {
|
||||||
|
if (!viewBox || !('cx' in viewBox)) return null
|
||||||
|
const total = statusChartData.reduce(
|
||||||
|
(sum, entry) => sum + entry.count,
|
||||||
|
0,
|
||||||
|
)
|
||||||
|
return (
|
||||||
|
<text
|
||||||
|
x={viewBox.cx}
|
||||||
|
y={viewBox.cy}
|
||||||
|
textAnchor="middle"
|
||||||
|
dominantBaseline="middle"
|
||||||
|
className="fill-foreground text-2xl font-semibold tabular-nums"
|
||||||
|
>
|
||||||
|
{total}
|
||||||
|
</text>
|
||||||
|
)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Pie>
|
||||||
|
<ChartLegend content={<ChartLegendContent nameKey="status" />} />
|
||||||
|
</PieChart>
|
||||||
|
</ChartContainer>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
table={
|
||||||
|
<AppItemGroup className="gap-2">
|
||||||
|
{statusChartData.map((entry) => (
|
||||||
|
<AppItem key={entry.status} variant="outline" size="sm">
|
||||||
|
<AppItemContent className="flex flex-row items-center justify-between gap-2">
|
||||||
|
<StatusBadge status={entry.status} />
|
||||||
|
<AppItemTitle className="font-medium tabular-nums">
|
||||||
|
{entry.count}
|
||||||
|
</AppItemTitle>
|
||||||
|
</AppItemContent>
|
||||||
|
</AppItem>
|
||||||
|
))}
|
||||||
|
</AppItemGroup>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<ChartCard
|
||||||
|
title="Домены по группам"
|
||||||
|
description="Топ-6 групп по количеству зон"
|
||||||
|
chart={
|
||||||
|
groupChartData.length === 0 ? null : (
|
||||||
|
<ChartContainer
|
||||||
|
config={groupChartConfig}
|
||||||
|
className="aspect-auto h-64 w-full"
|
||||||
|
>
|
||||||
|
<BarChart data={groupChartData}>
|
||||||
|
<CartesianGrid vertical={false} />
|
||||||
|
<XAxis
|
||||||
|
dataKey="name"
|
||||||
|
tickLine={false}
|
||||||
|
axisLine={false}
|
||||||
|
tickMargin={8}
|
||||||
|
interval={0}
|
||||||
|
height={36}
|
||||||
|
tickFormatter={(value: string) =>
|
||||||
|
value.length > 10 ? `${value.slice(0, 9)}…` : value
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<ChartTooltip content={<ChartTooltipContent nameKey="count" />} />
|
||||||
|
<Bar dataKey="count" fill="var(--color-count)" radius={4} />
|
||||||
|
</BarChart>
|
||||||
|
</ChartContainer>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ExpiringCertsCard certificates={certs ?? []} />
|
||||||
|
</div>
|
||||||
</QueryState>
|
</QueryState>
|
||||||
</PageShell>
|
</PageShell>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -10,13 +10,21 @@ function LoginPage() {
|
|||||||
return (
|
return (
|
||||||
<div className="flex min-h-svh flex-col items-center justify-center gap-6 bg-muted p-6 md:p-10">
|
<div className="flex min-h-svh flex-col items-center justify-center gap-6 bg-muted p-6 md:p-10">
|
||||||
<div className="flex w-full max-w-sm flex-col gap-6">
|
<div className="flex w-full max-w-sm flex-col gap-6">
|
||||||
<div className="flex items-center gap-2 self-center font-medium">
|
<a
|
||||||
|
href="/"
|
||||||
|
className="flex items-center gap-2 self-center font-medium"
|
||||||
|
aria-label="CF Domain Manager"
|
||||||
|
>
|
||||||
<div className="flex size-6 items-center justify-center rounded-md bg-primary text-primary-foreground">
|
<div className="flex size-6 items-center justify-center rounded-md bg-primary text-primary-foreground">
|
||||||
<CloudIcon className="size-4" />
|
<CloudIcon className="size-4" />
|
||||||
</div>
|
</div>
|
||||||
CF Domain Manager
|
CF Domain Manager
|
||||||
</div>
|
</a>
|
||||||
<LoginForm />
|
<LoginForm />
|
||||||
|
<div className="text-balance text-center text-xs text-muted-foreground">
|
||||||
|
Войдите учётной записью администратора для доступа к управлению доменами,
|
||||||
|
сервисами и сертификатами Cloudflare.
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
Reference in New Issue
Block a user