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

This commit is contained in:
Denozordec
2026-06-25 17:27:21 +07:00
parent 2147782ba6
commit 4e536c3b7e
16 changed files with 1236 additions and 358 deletions
+65
View File
@@ -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 }
+124
View File
@@ -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>
)
}
+13 -11
View File
@@ -121,17 +121,18 @@ export function DnsRecordsTable({ records, onDelete, isDeleting }: DnsRecordsTab
return (
<TableCard>
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-20">Тип</TableHead>
<TableHead>Имя</TableHead>
<TableHead>Значение</TableHead>
<TableHead className="w-16">TTL</TableHead>
<TableHead>Синхронизация</TableHead>
<TableHead className="w-28" />
</TableRow>
</TableHeader>
<div className="relative overflow-auto max-h-[60vh]">
<Table className="text-sm [&_tr]:h-10 [&_tr_td]:py-2 [&_tr_th]:py-2">
<TableHeader className="sticky top-0 z-10 bg-card">
<TableRow>
<TableHead className="w-20">Тип</TableHead>
<TableHead>Имя</TableHead>
<TableHead>Значение</TableHead>
<TableHead className="w-16">TTL</TableHead>
<TableHead>Синхронизация</TableHead>
<TableHead className="w-28" />
</TableRow>
</TableHeader>
<TableBody>
{groups.map((group, index) =>
group.isMultiValue ? (
@@ -154,6 +155,7 @@ export function DnsRecordsTable({ records, onDelete, isDeleting }: DnsRecordsTab
)}
</TableBody>
</Table>
</div>
</TableCard>
)
}
@@ -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"
/>
) : (
<AppItemGroup className="gap-0">
<AppItemGroup className="gap-3">
{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 (
<div key={hostname}>
<AppItem variant="outline">
<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">
{services.map((name) => (
<AppBadge key={name}>{name}</AppBadge>
))}
{uniqueIps.length > 0 && (
<span className="tabular-nums text-xs text-muted-foreground">
{uniqueIps.join(', ')}
</span>
)}
{[
...new Set(
hostnameBindings
+57 -162
View File
@@ -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<SortingState>([])
const [columnFilters, setColumnFilters] = useState<ColumnFiltersState>([])
const [globalFilter, setGlobalFilter] = useState('')
const [density, setDensity] = useState<Density>('comfortable')
const [deleteTarget, setDeleteTarget] = useState<DomainTableRow | null>(null)
const columns = useMemo<ColumnDef<DomainTableRow>[]>(
@@ -147,12 +124,7 @@ export function DomainsDataTable({
variant="link"
className="h-auto p-0 tabular-nums"
nativeButton={false}
render={
<Link
to="/services"
search={{ domainId: row.original.id }}
/>
}
render={<Link to="/services" search={{ domainId: row.original.id }} />}
>
{row.original.service_count}
</AppButton>
@@ -179,11 +151,11 @@ export function DomainsDataTable({
cell: ({ row }) => (
<div className="text-right">
<DropdownMenu>
<DropdownMenuTrigger
render={
<AppButton variant="outline" size="icon" className="size-8" />
}
>
<DropdownMenuTrigger
render={
<AppButton variant="outline" size="icon" className="size-8" />
}
>
<MoreHorizontalIcon />
<span className="sr-only">Действия</span>
</DropdownMenuTrigger>
@@ -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 = (
<Select
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 (
<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">
<InputGroup className="max-w-sm">
<InputGroupAddon>
<SearchIcon />
</InputGroupAddon>
<InputGroupInput
placeholder="Поиск по зоне…"
value={(table.getColumn('zone_name')?.getFilterValue() as string) ?? ''}
onChange={(event) =>
table.getColumn('zone_name')?.setFilterValue(event.target.value)
}
/>
</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>
)}
<>
<DataTableView
table={table}
density={density}
onDensityChange={setDensity}
searchPlaceholder="Поиск по зоне или группе…"
searchValue={globalFilter}
onSearchChange={setGlobalFilter}
filters={filterSelect}
emptyTitle="Домены не найдены"
emptyDescription="Импортируйте зону из Cloudflare или измените фильтры"
/>
<ConfirmDialog
open={deleteTarget !== null}
onOpenChange={(open) => {
@@ -378,6 +273,6 @@ export function DomainsDataTable({
}}
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>
)
}
+99
View File
@@ -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>
)
}
+11 -4
View File
@@ -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,
)}
>
<div className="flex flex-col gap-1">
<h1 className="text-xl font-semibold tracking-tight md:text-2xl">
{title}
</h1>
<div className="flex flex-col gap-1.5">
<div className="flex flex-wrap items-center gap-2">
<h1 className="text-xl font-semibold tracking-tight md:text-2xl">
{title}
</h1>
{meta && (
<span className="text-sm text-muted-foreground">{meta}</span>
)}
</div>
{description && (
<p className="max-w-2xl text-sm text-muted-foreground">
{description}
@@ -1,5 +1,6 @@
import {
Card,
CardContent,
CardFooter,
CardHeader,
} from '@cfdm/ui/components/card'
@@ -10,7 +11,7 @@ export function SectionCardsSkeleton() {
<div className="flex flex-col gap-4">
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
{Array.from({ length: 4 }).map((_, index) => (
<Card key={index}>
<Card key={index} className="gap-0">
<CardHeader>
<Skeleton className="h-4 w-24" />
<Skeleton className="h-8 w-16" />
@@ -21,16 +22,31 @@ export function SectionCardsSkeleton() {
</Card>
))}
</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>
<CardHeader>
<Skeleton className="h-5 w-48" />
<Skeleton className="h-4 w-64" />
</CardHeader>
<div className="flex flex-col gap-2 px-6 pb-6">
{Array.from({ length: 3 }).map((_, index) => (
<Skeleton key={index} className="h-10 w-full" />
))}
</div>
<CardContent>
<div className="flex flex-col gap-2">
{Array.from({ length: 4 }).map((_, index) => (
<Skeleton key={index} className="h-10 w-full" />
))}
</div>
</CardContent>
</Card>
</div>
)
@@ -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={() => {
+146 -81
View File
@@ -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<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 (
<>
<TableCard>
<Table>
<TableHeader>
<TableRow>
<TableHead>Поддомен</TableHead>
<TableHead>Статус</TableHead>
<TableHead>Группа / Сервис</TableHead>
<TableHead>Создан</TableHead>
<TableHead className="w-12">
<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>
<DataTableView
table={table}
density={density}
onDensityChange={setDensity}
searchPlaceholder="Поиск по поддомену…"
searchValue={globalFilter}
onSearchChange={setGlobalFilter}
emptyTitle="Поддомены не найдены"
emptyDescription="Создайте поддомен или измените запрос"
/>
<ConfirmDialog
open={deleteTarget !== null}
onOpenChange={(open) => {
+132 -80
View File
@@ -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<SortingState>([{ id: 'expires_at', desc: false }])
const [density, setDensity] = useState<Density>('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<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 (
<PageShell>
<PageHeader
@@ -116,13 +195,11 @@ function CertificatesPage() {
onRetry={refetch}
skeleton={<TableSkeleton rows={5} columns={4} />}
>
<AppCard>
<AppCardHeader>
<AppCardTitle>Обзор статусов</AppCardTitle>
<AppCardDescription>Распределение сертификатов по статусам</AppCardDescription>
</AppCardHeader>
<AppCardContent>
{chartData.length > 0 ? (
<ChartCard
title="Обзор статусов"
description="Распределение сертификатов по статусам"
chart={
chartData.length > 0 ? (
<ChartContainer config={chartConfig} className="aspect-auto h-64 w-full">
<BarChart data={chartData}>
<CartesianGrid vertical={false} />
@@ -142,67 +219,42 @@ function CertificatesPage() {
title="Нет данных для графика"
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
title="Сертификаты"
description="Хосты с активными сервисами или ручным мониторингом"
isEmpty={!filteredCerts.length}
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="Поиск по хосту или статусу…"
/>
}
isEmpty={false}
>
<Table>
<TableHeader>
<TableRow>
<TableHead>Хост</TableHead>
<TableHead>Статус</TableHead>
<TableHead>Истекает</TableHead>
<TableHead>Последняя проверка</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{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>
<DataTableView
table={table}
density={density}
onDensityChange={setDensity}
searchPlaceholder="Поиск по хосту или статусу…"
searchValue={searchQuery}
onSearchChange={setSearchQuery}
emptyTitle={isFilteredEmpty ? 'Ничего не найдено' : 'Сертификаты не найдены'}
emptyDescription={
isFilteredEmpty
? 'Измените поисковый запрос'
: 'Сертификаты появятся после проверки доменов'
}
/>
</DataTableCard>
</QueryState>
</PageShell>
+211 -10
View File
@@ -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 (
<PageShell>
<PageHeader
@@ -61,13 +134,141 @@ function DashboardPage() {
void refetchSummary()
}}
>
<SectionCards
domainCount={domains?.length ?? 0}
certCount={certs?.length ?? 0}
groupCount={groups?.length ?? 0}
serviceCount={serviceCount}
certSummary={summary}
/>
<div className="flex flex-col gap-4">
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
<KpiCard
label="Домены"
value={domains?.length ?? 0}
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>
</PageShell>
)
+10 -2
View File
@@ -10,13 +10,21 @@ function LoginPage() {
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 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">
<CloudIcon className="size-4" />
</div>
CF Domain Manager
</div>
</a>
<LoginForm />
<div className="text-balance text-center text-xs text-muted-foreground">
Войдите учётной записью администратора для доступа к управлению доменами,
сервисами и сертификатами Cloudflare.
</div>
</div>
</div>
)