Enhance frontend documentation and UI components; update frontend-shadcn.mdc to include UI patterns reference; improve navigation structure in AppSidebar by categorizing items; refactor DataTableCard to support empty states with EmptyState component; implement ConfirmDialog for delete actions in DnsRecordsTable and GroupsPage; add search functionality in CertificatesPage and GroupsPage for better user experience; update ServiceEditSheet to utilize tabs for organization.
Build, Test, and Push CFDM Docker Image / test (push) Has been cancelled
Build, Test, and Push CFDM Docker Image / build-and-push (push) Has been cancelled
Build, Test, and Push CFDM Docker Image / create-release (push) Has been cancelled
Build, Test, and Push CFDM Docker Image / update-wiki (push) Has been cancelled
Build, Test, and Push CFDM Docker Image / test (push) Has been cancelled
Build, Test, and Push CFDM Docker Image / build-and-push (push) Has been cancelled
Build, Test, and Push CFDM Docker Image / create-release (push) Has been cancelled
Build, Test, and Push CFDM Docker Image / update-wiki (push) Has been cancelled
This commit is contained in:
@@ -21,14 +21,54 @@ import {
|
||||
SidebarRail,
|
||||
} from '@cfdm/ui/components/sidebar'
|
||||
|
||||
const navItems = [
|
||||
{ to: '/', label: 'Панель управления', icon: LayoutDashboardIcon },
|
||||
{ to: '/domains', label: 'Домены', icon: GlobeIcon },
|
||||
{ to: '/groups', label: 'Группы', icon: FolderTreeIcon },
|
||||
{ to: '/services', label: 'Сервисы', icon: ServerIcon },
|
||||
{ to: '/certificates', label: 'Сертификаты', icon: ShieldCheckIcon },
|
||||
const infrastructureNav = [
|
||||
{ to: '/', label: 'Панель управления', icon: LayoutDashboardIcon, exact: true },
|
||||
{ to: '/domains', label: 'Домены', icon: GlobeIcon, exact: false },
|
||||
{ to: '/groups', label: 'Группы доменов', icon: FolderTreeIcon, exact: false },
|
||||
] as const
|
||||
|
||||
const operationsNav = [
|
||||
{ to: '/services', label: 'Сервисы', icon: ServerIcon, exact: false },
|
||||
{ to: '/certificates', label: 'Сертификаты', icon: ShieldCheckIcon, exact: false },
|
||||
] as const
|
||||
|
||||
function NavSection({
|
||||
label,
|
||||
items,
|
||||
}: {
|
||||
label: string
|
||||
items: readonly {
|
||||
to: string
|
||||
label: string
|
||||
icon: typeof GlobeIcon
|
||||
exact: boolean
|
||||
}[]
|
||||
}) {
|
||||
return (
|
||||
<SidebarGroup>
|
||||
<SidebarGroupLabel>{label}</SidebarGroupLabel>
|
||||
<SidebarMenu>
|
||||
{items.map((item) => (
|
||||
<SidebarMenuItem key={item.to}>
|
||||
<SidebarMenuButton
|
||||
tooltip={item.label}
|
||||
render={
|
||||
<Link
|
||||
to={item.to}
|
||||
activeOptions={{ exact: item.exact }}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<item.icon />
|
||||
<span>{item.label}</span>
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
))}
|
||||
</SidebarMenu>
|
||||
</SidebarGroup>
|
||||
)
|
||||
}
|
||||
|
||||
export function AppSidebar() {
|
||||
return (
|
||||
<Sidebar collapsible="icon">
|
||||
@@ -48,22 +88,8 @@ export function AppSidebar() {
|
||||
</SidebarMenu>
|
||||
</SidebarHeader>
|
||||
<SidebarContent>
|
||||
<SidebarGroup>
|
||||
<SidebarGroupLabel>Навигация</SidebarGroupLabel>
|
||||
<SidebarMenu>
|
||||
{navItems.map((item) => (
|
||||
<SidebarMenuItem key={item.to}>
|
||||
<SidebarMenuButton
|
||||
tooltip={item.label}
|
||||
render={<Link to={item.to} activeOptions={{ exact: item.to === '/' }} />}
|
||||
>
|
||||
<item.icon />
|
||||
<span>{item.label}</span>
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
))}
|
||||
</SidebarMenu>
|
||||
</SidebarGroup>
|
||||
<NavSection label="Инфраструктура" items={infrastructureNav} />
|
||||
<NavSection label="Операции" items={operationsNav} />
|
||||
</SidebarContent>
|
||||
<SidebarFooter>
|
||||
<NavUser />
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
import type { ReactElement } from 'react'
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
AlertDialogTrigger,
|
||||
} from '@cfdm/ui/components/alert-dialog'
|
||||
|
||||
interface ConfirmDialogProps {
|
||||
trigger: ReactElement
|
||||
title: string
|
||||
description: string
|
||||
confirmLabel?: string
|
||||
cancelLabel?: string
|
||||
onConfirm: () => void
|
||||
disabled?: boolean
|
||||
}
|
||||
|
||||
export function ConfirmDialog({
|
||||
trigger,
|
||||
title,
|
||||
description,
|
||||
confirmLabel = 'Удалить',
|
||||
cancelLabel = 'Отмена',
|
||||
onConfirm,
|
||||
disabled,
|
||||
}: ConfirmDialogProps) {
|
||||
return (
|
||||
<AlertDialog>
|
||||
<AlertDialogTrigger disabled={disabled} render={trigger} />
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>{title}</AlertDialogTitle>
|
||||
<AlertDialogDescription>{description}</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>{cancelLabel}</AlertDialogCancel>
|
||||
<AlertDialogAction variant="destructive" onClick={onConfirm}>
|
||||
{confirmLabel}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
)
|
||||
}
|
||||
@@ -1,3 +1,5 @@
|
||||
import { InboxIcon, type LucideIcon } from 'lucide-react'
|
||||
import { EmptyState } from '@/components/empty-state'
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
@@ -10,8 +12,11 @@ interface DataTableCardProps {
|
||||
title: string
|
||||
description?: string
|
||||
children: React.ReactNode
|
||||
toolbar?: React.ReactNode
|
||||
emptyTitle?: string
|
||||
emptyDescription?: string
|
||||
emptyIcon?: LucideIcon
|
||||
emptyAction?: React.ReactNode
|
||||
isEmpty?: boolean
|
||||
}
|
||||
|
||||
@@ -19,8 +24,11 @@ export function DataTableCard({
|
||||
title,
|
||||
description,
|
||||
children,
|
||||
toolbar,
|
||||
emptyTitle,
|
||||
emptyDescription,
|
||||
emptyIcon: EmptyIcon = InboxIcon,
|
||||
emptyAction,
|
||||
isEmpty,
|
||||
}: DataTableCardProps) {
|
||||
return (
|
||||
@@ -29,12 +37,17 @@ export function DataTableCard({
|
||||
<CardTitle>{title}</CardTitle>
|
||||
{description && <CardDescription>{description}</CardDescription>}
|
||||
</CardHeader>
|
||||
{toolbar && !isEmpty && (
|
||||
<div className="flex items-center gap-2 px-6 pb-4">{toolbar}</div>
|
||||
)}
|
||||
<CardContent className="p-0">
|
||||
{isEmpty && emptyTitle ? (
|
||||
<div className="flex flex-col gap-1 p-6 text-sm text-muted-foreground">
|
||||
<p className="font-medium text-foreground">{emptyTitle}</p>
|
||||
{emptyDescription && <p>{emptyDescription}</p>}
|
||||
</div>
|
||||
<EmptyState
|
||||
icon={EmptyIcon}
|
||||
title={emptyTitle}
|
||||
description={emptyDescription}
|
||||
action={emptyAction}
|
||||
/>
|
||||
) : (
|
||||
children
|
||||
)}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { StatusBadge } from '@/components/status-badge'
|
||||
import { ConfirmDialog } from '@/components/confirm-dialog'
|
||||
import { groupDnsRecords, type DnsRecordGroup } from '@/lib/dns-grouping'
|
||||
import type { DnsRecord } from '@/lib/schemas'
|
||||
import { Badge } from '@cfdm/ui/components/badge'
|
||||
@@ -36,14 +37,16 @@ function DnsRecordCells({
|
||||
<StatusBadge status={record.sync_status} />
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Button
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
onClick={() => onDelete(record.id)}
|
||||
disabled={isDeleting}
|
||||
>
|
||||
Удалить
|
||||
</Button>
|
||||
<ConfirmDialog
|
||||
trigger={
|
||||
<Button variant="destructive" size="sm" disabled={isDeleting}>
|
||||
Удалить
|
||||
</Button>
|
||||
}
|
||||
title="Удалить DNS-запись?"
|
||||
description={`Запись ${record.name} (${record.record_type}) будет удалена из зоны.`}
|
||||
onConfirm={() => onDelete(record.id)}
|
||||
/>
|
||||
</TableCell>
|
||||
</>
|
||||
)
|
||||
|
||||
@@ -103,7 +103,7 @@ export function DomainBindingsCard({ bindings }: DomainBindingsCardProps) {
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
render={<Link to="/services" />}
|
||||
render={<Link to="/services" search={{ domainId: undefined }} />}
|
||||
>
|
||||
Сервисы
|
||||
</Button>
|
||||
@@ -118,7 +118,7 @@ export function DomainBindingsCard({ bindings }: DomainBindingsCardProps) {
|
||||
</CardContent>
|
||||
{entries.length > 0 && (
|
||||
<CardFooter>
|
||||
<Button variant="link" className="h-auto p-0" render={<Link to="/services" />}>
|
||||
<Button variant="link" className="h-auto p-0" render={<Link to="/services" search={{ domainId: undefined }} />}>
|
||||
Управление привязками
|
||||
</Button>
|
||||
</CardFooter>
|
||||
|
||||
@@ -148,7 +148,12 @@ export function DomainsDataTable({
|
||||
<Button
|
||||
variant="link"
|
||||
className="h-auto p-0 tabular-nums"
|
||||
render={<Link to="/services" />}
|
||||
render={
|
||||
<Link
|
||||
to="/services"
|
||||
search={{ domainId: row.original.id }}
|
||||
/>
|
||||
}
|
||||
>
|
||||
{row.original.service_count}
|
||||
</Button>
|
||||
@@ -199,6 +204,7 @@ export function DomainsDataTable({
|
||||
<Link
|
||||
to="/domains/$domainId/dns"
|
||||
params={{ domainId: String(row.original.id) }}
|
||||
search={{ host: undefined }}
|
||||
/>
|
||||
}
|
||||
>
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
import type { LucideIcon } from 'lucide-react'
|
||||
import {
|
||||
Empty,
|
||||
EmptyContent,
|
||||
EmptyDescription,
|
||||
EmptyHeader,
|
||||
EmptyMedia,
|
||||
EmptyTitle,
|
||||
} from '@cfdm/ui/components/empty'
|
||||
|
||||
interface EmptyStateProps {
|
||||
icon: LucideIcon
|
||||
title: string
|
||||
description?: string
|
||||
action?: React.ReactNode
|
||||
className?: string
|
||||
}
|
||||
|
||||
export function EmptyState({
|
||||
icon: Icon,
|
||||
title,
|
||||
description,
|
||||
action,
|
||||
className,
|
||||
}: EmptyStateProps) {
|
||||
return (
|
||||
<Empty className={className ?? 'border-0'}>
|
||||
<EmptyHeader>
|
||||
<EmptyMedia variant="icon">
|
||||
<Icon />
|
||||
</EmptyMedia>
|
||||
<EmptyTitle>{title}</EmptyTitle>
|
||||
{description && <EmptyDescription>{description}</EmptyDescription>}
|
||||
</EmptyHeader>
|
||||
{action && <EmptyContent>{action}</EmptyContent>}
|
||||
</Empty>
|
||||
)
|
||||
}
|
||||
@@ -5,13 +5,18 @@ import { SidebarInset, SidebarProvider } from '@cfdm/ui/components/sidebar'
|
||||
|
||||
export function AppShell({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<SidebarProvider>
|
||||
<SidebarProvider
|
||||
style={
|
||||
{
|
||||
'--sidebar-width': 'calc(var(--spacing) * 72)',
|
||||
'--header-height': 'calc(var(--spacing) * 12)',
|
||||
} as React.CSSProperties
|
||||
}
|
||||
>
|
||||
<AppSidebar />
|
||||
<SidebarInset>
|
||||
<SiteHeader />
|
||||
<div className="flex flex-1 flex-col gap-4 p-4 pt-0">
|
||||
{children}
|
||||
</div>
|
||||
<div className="flex flex-1 flex-col">{children}</div>
|
||||
</SidebarInset>
|
||||
</SidebarProvider>
|
||||
)
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Link, useRouterState } from '@tanstack/react-router'
|
||||
import { Link, useMatches, useRouterState } from '@tanstack/react-router'
|
||||
import { useMemo } from 'react'
|
||||
import {
|
||||
Breadcrumb,
|
||||
BreadcrumbItem,
|
||||
@@ -10,31 +11,39 @@ import {
|
||||
import { Separator } from '@cfdm/ui/components/separator'
|
||||
import { SidebarTrigger } from '@cfdm/ui/components/sidebar'
|
||||
|
||||
export interface RouteBreadcrumbLoaderData {
|
||||
breadcrumb?: string
|
||||
}
|
||||
|
||||
const routeTitles: Record<string, string> = {
|
||||
'/': 'Панель управления',
|
||||
'/domains': 'Домены',
|
||||
'/groups': 'Группы',
|
||||
'/groups': 'Группы доменов',
|
||||
'/services': 'Сервисы',
|
||||
'/certificates': 'Сертификаты',
|
||||
}
|
||||
|
||||
function getBreadcrumbs(pathname: string) {
|
||||
function getBreadcrumbs(
|
||||
pathname: string,
|
||||
dynamicLabels: Record<string, string>,
|
||||
) {
|
||||
if (pathname === '/') {
|
||||
return [{ label: 'Панель управления', href: '/' }]
|
||||
}
|
||||
|
||||
if (pathname.match(/^\/groups\/\d+$/)) {
|
||||
return [
|
||||
{ label: 'Группы', href: '/groups' },
|
||||
{ label: 'Группа', href: pathname },
|
||||
{ label: 'Группы доменов', href: '/groups' },
|
||||
{ label: dynamicLabels[pathname] ?? 'Группа', href: pathname },
|
||||
]
|
||||
}
|
||||
|
||||
if (pathname.match(/^\/domains\/\d+\/dns$/)) {
|
||||
const domainId = pathname.split('/')[2]
|
||||
const domainPath = `/domains/${domainId}`
|
||||
return [
|
||||
{ label: 'Домены', href: '/domains' },
|
||||
{ label: 'Домен', href: `/domains/${domainId}` },
|
||||
{ label: dynamicLabels[domainPath] ?? 'Домен', href: domainPath },
|
||||
{ label: 'DNS', href: pathname },
|
||||
]
|
||||
}
|
||||
@@ -42,7 +51,7 @@ function getBreadcrumbs(pathname: string) {
|
||||
if (pathname.match(/^\/domains\/\d+$/)) {
|
||||
return [
|
||||
{ label: 'Домены', href: '/domains' },
|
||||
{ label: 'Обзор домена', href: pathname },
|
||||
{ label: dynamicLabels[pathname] ?? 'Обзор домена', href: pathname },
|
||||
]
|
||||
}
|
||||
|
||||
@@ -54,9 +63,24 @@ function getBreadcrumbs(pathname: string) {
|
||||
return [{ label: 'Панель управления', href: '/' }]
|
||||
}
|
||||
|
||||
function useDynamicBreadcrumbLabels() {
|
||||
const matches = useMatches()
|
||||
return useMemo(() => {
|
||||
const labels: Record<string, string> = {}
|
||||
for (const match of matches) {
|
||||
const data = match.loaderData as RouteBreadcrumbLoaderData | undefined
|
||||
if (data?.breadcrumb && match.pathname) {
|
||||
labels[match.pathname] = data.breadcrumb
|
||||
}
|
||||
}
|
||||
return labels
|
||||
}, [matches])
|
||||
}
|
||||
|
||||
export function SiteHeader() {
|
||||
const pathname = useRouterState({ select: (s) => s.location.pathname })
|
||||
const crumbs = getBreadcrumbs(pathname)
|
||||
const dynamicLabels = useDynamicBreadcrumbLabels()
|
||||
const crumbs = getBreadcrumbs(pathname, dynamicLabels)
|
||||
|
||||
return (
|
||||
<header className="flex h-16 shrink-0 items-center gap-2 border-b transition-[width,height] ease-linear group-has-data-[collapsible=icon]/sidebar-wrapper:h-12">
|
||||
@@ -72,8 +96,12 @@ export function SiteHeader() {
|
||||
const isLast = index === crumbs.length - 1
|
||||
return (
|
||||
<span key={crumb.href} className="contents">
|
||||
{index > 0 && <BreadcrumbSeparator className="hidden md:block" />}
|
||||
<BreadcrumbItem className={index === 0 ? 'hidden md:block' : undefined}>
|
||||
{index > 0 && (
|
||||
<BreadcrumbSeparator className="hidden md:block" />
|
||||
)}
|
||||
<BreadcrumbItem
|
||||
className={index === 0 ? 'hidden md:block' : undefined}
|
||||
>
|
||||
{isLast ? (
|
||||
<BreadcrumbPage>{crumb.label}</BreadcrumbPage>
|
||||
) : (
|
||||
|
||||
@@ -1,17 +1,8 @@
|
||||
import { Link } from '@tanstack/react-router'
|
||||
import { Button } from '@cfdm/ui/components/button'
|
||||
import { cn } from '@cfdm/ui/lib/utils'
|
||||
|
||||
interface PageHeaderBack {
|
||||
to: string
|
||||
label: string
|
||||
params?: Record<string, string>
|
||||
}
|
||||
|
||||
interface PageHeaderProps {
|
||||
title: string
|
||||
description?: string
|
||||
back?: PageHeaderBack
|
||||
actions?: React.ReactNode
|
||||
className?: string
|
||||
}
|
||||
@@ -19,35 +10,31 @@ interface PageHeaderProps {
|
||||
export function PageHeader({
|
||||
title,
|
||||
description,
|
||||
back,
|
||||
actions,
|
||||
className,
|
||||
}: PageHeaderProps) {
|
||||
return (
|
||||
<div className={cn('flex items-center justify-between gap-4', className)}>
|
||||
<div>
|
||||
{back && (
|
||||
<Button
|
||||
variant="link"
|
||||
className="h-auto p-0 text-muted-foreground"
|
||||
render={<Link to={back.to} params={back.params} />}
|
||||
>
|
||||
{back.label}
|
||||
</Button>
|
||||
)}
|
||||
<h1
|
||||
className={cn(
|
||||
'text-2xl font-bold tracking-tight',
|
||||
back && 'mt-2',
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
'flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between',
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<div className="flex flex-col gap-1">
|
||||
<h1 className="text-xl font-semibold tracking-tight md:text-2xl">
|
||||
{title}
|
||||
</h1>
|
||||
{description && (
|
||||
<p className="text-muted-foreground">{description}</p>
|
||||
<p className="max-w-2xl text-sm text-muted-foreground">
|
||||
{description}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
{actions}
|
||||
{actions && (
|
||||
<div className="flex shrink-0 flex-wrap items-center gap-2">
|
||||
{actions}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import { cn } from '@cfdm/ui/lib/utils'
|
||||
|
||||
interface PageShellProps {
|
||||
children: ReactNode
|
||||
className?: string
|
||||
}
|
||||
|
||||
export function PageShell({ children, className }: PageShellProps) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'@container/main flex flex-1 flex-col gap-4 px-4 py-4 md:gap-6 md:py-6 lg:px-6',
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
import { CircleAlertIcon } from 'lucide-react'
|
||||
import { Alert, AlertDescription, AlertTitle } from '@cfdm/ui/components/alert'
|
||||
import { Button } from '@cfdm/ui/components/button'
|
||||
import { Skeleton } from '@cfdm/ui/components/skeleton'
|
||||
|
||||
interface QueryStateProps {
|
||||
isLoading?: boolean
|
||||
isError?: boolean
|
||||
error?: Error | null
|
||||
onRetry?: () => void
|
||||
skeleton?: React.ReactNode
|
||||
children: React.ReactNode
|
||||
}
|
||||
|
||||
function DefaultSkeleton() {
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
<Skeleton className="h-8 w-1/3" />
|
||||
<Skeleton className="h-40 w-full" />
|
||||
<Skeleton className="h-40 w-full" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function QueryState({
|
||||
isLoading,
|
||||
isError,
|
||||
error,
|
||||
onRetry,
|
||||
skeleton,
|
||||
children,
|
||||
}: QueryStateProps) {
|
||||
if (isLoading) {
|
||||
return skeleton ?? <DefaultSkeleton />
|
||||
}
|
||||
|
||||
if (isError) {
|
||||
return (
|
||||
<Alert variant="destructive">
|
||||
<CircleAlertIcon />
|
||||
<AlertTitle>Не удалось загрузить данные</AlertTitle>
|
||||
<AlertDescription>
|
||||
{error?.message ?? 'Произошла ошибка при загрузке.'}
|
||||
</AlertDescription>
|
||||
{onRetry && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="mt-2"
|
||||
onClick={onRetry}
|
||||
>
|
||||
Повторить
|
||||
</Button>
|
||||
)}
|
||||
</Alert>
|
||||
)
|
||||
}
|
||||
|
||||
return children
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
import { Link } from '@tanstack/react-router'
|
||||
import {
|
||||
FolderTreeIcon,
|
||||
GlobeIcon,
|
||||
ServerIcon,
|
||||
ShieldCheckIcon,
|
||||
} from 'lucide-react'
|
||||
import { StatusBadge } from '@/components/status-badge'
|
||||
import { Button } from '@cfdm/ui/components/button'
|
||||
import {
|
||||
Card,
|
||||
CardAction,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardFooter,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from '@cfdm/ui/components/card'
|
||||
import {
|
||||
Item,
|
||||
ItemContent,
|
||||
ItemGroup,
|
||||
ItemTitle,
|
||||
} from '@cfdm/ui/components/item'
|
||||
|
||||
interface SectionCardsProps {
|
||||
domainCount: number
|
||||
certCount: number
|
||||
groupCount: number
|
||||
serviceCount: number
|
||||
certSummary?: [string, number][]
|
||||
}
|
||||
|
||||
export function SectionCards({
|
||||
domainCount,
|
||||
certCount,
|
||||
groupCount,
|
||||
serviceCount,
|
||||
certSummary,
|
||||
}: SectionCardsProps) {
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardDescription>Домены</CardDescription>
|
||||
<CardTitle className="text-3xl font-semibold tabular-nums">
|
||||
{domainCount}
|
||||
</CardTitle>
|
||||
<CardAction>
|
||||
<GlobeIcon className="size-4 text-muted-foreground" />
|
||||
</CardAction>
|
||||
</CardHeader>
|
||||
<CardFooter>
|
||||
<Button
|
||||
variant="link"
|
||||
className="h-auto p-0"
|
||||
render={<Link to="/domains" />}
|
||||
>
|
||||
Управление доменами
|
||||
</Button>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardDescription>Группы доменов</CardDescription>
|
||||
<CardTitle className="text-3xl font-semibold tabular-nums">
|
||||
{groupCount}
|
||||
</CardTitle>
|
||||
<CardAction>
|
||||
<FolderTreeIcon className="size-4 text-muted-foreground" />
|
||||
</CardAction>
|
||||
</CardHeader>
|
||||
<CardFooter>
|
||||
<Button
|
||||
variant="link"
|
||||
className="h-auto p-0"
|
||||
render={<Link to="/groups" />}
|
||||
>
|
||||
Канбан групп
|
||||
</Button>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardDescription>Сервисы</CardDescription>
|
||||
<CardTitle className="text-3xl font-semibold tabular-nums">
|
||||
{serviceCount}
|
||||
</CardTitle>
|
||||
<CardAction>
|
||||
<ServerIcon className="size-4 text-muted-foreground" />
|
||||
</CardAction>
|
||||
</CardHeader>
|
||||
<CardFooter>
|
||||
<Button
|
||||
variant="link"
|
||||
className="h-auto p-0"
|
||||
render={<Link to="/services" search={{ domainId: undefined }} />}
|
||||
>
|
||||
Управление сервисами
|
||||
</Button>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardDescription>Сертификаты</CardDescription>
|
||||
<CardTitle className="text-3xl font-semibold tabular-nums">
|
||||
{certCount}
|
||||
</CardTitle>
|
||||
<CardAction>
|
||||
<ShieldCheckIcon className="size-4 text-muted-foreground" />
|
||||
</CardAction>
|
||||
</CardHeader>
|
||||
<CardFooter>
|
||||
<Button
|
||||
variant="link"
|
||||
className="h-auto p-0"
|
||||
render={<Link to="/certificates" />}
|
||||
>
|
||||
Мониторинг сертификатов
|
||||
</Button>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
</div>
|
||||
{certSummary && certSummary.length > 0 ? (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">Статусы сертификатов</CardTitle>
|
||||
<CardDescription>Сводка по последней проверке</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<ItemGroup className="gap-2">
|
||||
{certSummary.map(([status, count]) => (
|
||||
<Item key={status} variant="outline" size="sm">
|
||||
<ItemContent className="flex flex-row items-center justify-between gap-2">
|
||||
<StatusBadge status={status} />
|
||||
<ItemTitle className="font-medium tabular-nums">{count}</ItemTitle>
|
||||
</ItemContent>
|
||||
</Item>
|
||||
))}
|
||||
</ItemGroup>
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { PlusIcon, Trash2Icon } from 'lucide-react'
|
||||
import { ConfirmDialog } from '@/components/confirm-dialog'
|
||||
import { TaggedInput, isValidIpv4 } from '@/components/tagged-input'
|
||||
import { ServiceBindingIpInput } from '@/components/service-binding-ip-input'
|
||||
import type {
|
||||
@@ -32,6 +33,12 @@ import {
|
||||
SheetTitle,
|
||||
} from '@cfdm/ui/components/sheet'
|
||||
import { Spinner } from '@cfdm/ui/components/spinner'
|
||||
import {
|
||||
Tabs,
|
||||
TabsContent,
|
||||
TabsList,
|
||||
TabsTrigger,
|
||||
} from '@cfdm/ui/components/tabs'
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
@@ -199,123 +206,138 @@ export function ServiceEditSheet({
|
||||
</SheetDescription>
|
||||
</SheetHeader>
|
||||
<div className="flex flex-col gap-4 px-4">
|
||||
<FieldGroup className="flex flex-col gap-4">
|
||||
<Field>
|
||||
<FieldLabel htmlFor="edit-service-name">Название</FieldLabel>
|
||||
<Input
|
||||
id="edit-service-name"
|
||||
value={name}
|
||||
placeholder={isCreate ? 'VPN Panel' : undefined}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
/>
|
||||
</Field>
|
||||
<Field>
|
||||
<FieldLabel htmlFor="edit-service-slug">Slug</FieldLabel>
|
||||
<Input
|
||||
id="edit-service-slug"
|
||||
value={slug}
|
||||
placeholder={isCreate ? 'vpn-panel' : undefined}
|
||||
onChange={(e) => setSlug(e.target.value)}
|
||||
/>
|
||||
</Field>
|
||||
<Field>
|
||||
<FieldLabel htmlFor="edit-service-group">Группа сервисов</FieldLabel>
|
||||
<Select
|
||||
items={groupItems}
|
||||
value={serviceGroupId}
|
||||
onValueChange={(value) => setServiceGroupId(value ?? 'none')}
|
||||
>
|
||||
<SelectTrigger id="edit-service-group" className="w-full">
|
||||
<SelectValue placeholder="Без группы" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{groupItems.map((item) => (
|
||||
<SelectItem key={item.value} value={item.value}>
|
||||
{item.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</Field>
|
||||
<Field>
|
||||
<FieldLabel htmlFor="edit-service-ips">IP-адреса сервиса</FieldLabel>
|
||||
<TaggedInput
|
||||
id="edit-service-ips"
|
||||
value={ips}
|
||||
onChange={setIps}
|
||||
placeholder="192.168.1.1"
|
||||
validate={isValidIpv4}
|
||||
/>
|
||||
</Field>
|
||||
</FieldGroup>
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<FieldLabel>Привязки доменов</FieldLabel>
|
||||
<Button type="button" variant="outline" size="sm" onClick={handleAddBinding}>
|
||||
<PlusIcon data-icon="inline-start" />
|
||||
Добавить
|
||||
</Button>
|
||||
</div>
|
||||
{bindings.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Необязательно. Введите FQDN, например newdom.ivx.su — зона ivx.su определится
|
||||
автоматически.
|
||||
</p>
|
||||
) : (
|
||||
<ItemGroup>
|
||||
{bindings.map((binding, index) => (
|
||||
<Item key={`binding-${index}`} variant="outline">
|
||||
<ItemContent className="flex flex-col gap-3">
|
||||
<Field>
|
||||
<FieldLabel htmlFor={`binding-fqdn-${index}`}>FQDN</FieldLabel>
|
||||
<TaggedInput
|
||||
id={`binding-fqdn-${index}`}
|
||||
value={binding.fqdn ? [binding.fqdn] : []}
|
||||
onChange={(tags) => handleFqdnChange(index, tags)}
|
||||
placeholder={zoneHints[0] ? `newdom.${zoneHints[0]}` : 'newdom.ivx.su'}
|
||||
maxItems={1}
|
||||
/>
|
||||
</Field>
|
||||
<Field>
|
||||
<FieldLabel htmlFor={`binding-ip-${index}`}>IP</FieldLabel>
|
||||
<ServiceBindingIpInput
|
||||
id={`binding-ip-${index}`}
|
||||
value={binding.target_ips}
|
||||
pool={ips}
|
||||
onChange={(targetIps) => handleIpsChange(index, targetIps)}
|
||||
/>
|
||||
</Field>
|
||||
</ItemContent>
|
||||
<ItemActions>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
aria-label="Удалить привязку"
|
||||
onClick={() => handleRemoveBinding(index)}
|
||||
>
|
||||
<Trash2Icon />
|
||||
</Button>
|
||||
</ItemActions>
|
||||
</Item>
|
||||
))}
|
||||
</ItemGroup>
|
||||
)}
|
||||
</div>
|
||||
<Tabs defaultValue="general">
|
||||
<TabsList className="w-full">
|
||||
<TabsTrigger value="general">Основное</TabsTrigger>
|
||||
<TabsTrigger value="bindings">Привязки</TabsTrigger>
|
||||
</TabsList>
|
||||
<TabsContent value="general" className="flex flex-col gap-4 pt-4">
|
||||
<FieldGroup className="flex flex-col gap-4">
|
||||
<Field>
|
||||
<FieldLabel htmlFor="edit-service-name">Название</FieldLabel>
|
||||
<Input
|
||||
id="edit-service-name"
|
||||
value={name}
|
||||
placeholder={isCreate ? 'VPN Panel' : undefined}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
/>
|
||||
</Field>
|
||||
<Field>
|
||||
<FieldLabel htmlFor="edit-service-slug">Slug</FieldLabel>
|
||||
<Input
|
||||
id="edit-service-slug"
|
||||
value={slug}
|
||||
placeholder={isCreate ? 'vpn-panel' : undefined}
|
||||
onChange={(e) => setSlug(e.target.value)}
|
||||
/>
|
||||
</Field>
|
||||
<Field>
|
||||
<FieldLabel htmlFor="edit-service-group">Группа сервисов</FieldLabel>
|
||||
<Select
|
||||
items={groupItems}
|
||||
value={serviceGroupId}
|
||||
onValueChange={(value) => setServiceGroupId(value ?? 'none')}
|
||||
>
|
||||
<SelectTrigger id="edit-service-group" className="w-full">
|
||||
<SelectValue placeholder="Без группы" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{groupItems.map((item) => (
|
||||
<SelectItem key={item.value} value={item.value}>
|
||||
{item.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</Field>
|
||||
<Field>
|
||||
<FieldLabel htmlFor="edit-service-ips">IP-адреса сервиса</FieldLabel>
|
||||
<TaggedInput
|
||||
id="edit-service-ips"
|
||||
value={ips}
|
||||
onChange={setIps}
|
||||
placeholder="192.168.1.1"
|
||||
validate={isValidIpv4}
|
||||
/>
|
||||
</Field>
|
||||
</FieldGroup>
|
||||
</TabsContent>
|
||||
<TabsContent value="bindings" className="flex flex-col gap-4 pt-4">
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<FieldLabel>Привязки доменов</FieldLabel>
|
||||
<Button type="button" variant="outline" size="sm" onClick={handleAddBinding}>
|
||||
<PlusIcon data-icon="inline-start" />
|
||||
Добавить
|
||||
</Button>
|
||||
</div>
|
||||
{bindings.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Необязательно. Введите FQDN, например newdom.ivx.su — зона ivx.su определится
|
||||
автоматически.
|
||||
</p>
|
||||
) : (
|
||||
<ItemGroup>
|
||||
{bindings.map((binding, index) => (
|
||||
<Item key={`binding-${index}`} variant="outline">
|
||||
<ItemContent className="flex flex-col gap-3">
|
||||
<Field>
|
||||
<FieldLabel htmlFor={`binding-fqdn-${index}`}>FQDN</FieldLabel>
|
||||
<TaggedInput
|
||||
id={`binding-fqdn-${index}`}
|
||||
value={binding.fqdn ? [binding.fqdn] : []}
|
||||
onChange={(tags) => handleFqdnChange(index, tags)}
|
||||
placeholder={zoneHints[0] ? `newdom.${zoneHints[0]}` : 'newdom.ivx.su'}
|
||||
maxItems={1}
|
||||
/>
|
||||
</Field>
|
||||
<Field>
|
||||
<FieldLabel htmlFor={`binding-ip-${index}`}>IP</FieldLabel>
|
||||
<ServiceBindingIpInput
|
||||
id={`binding-ip-${index}`}
|
||||
value={binding.target_ips}
|
||||
pool={ips}
|
||||
onChange={(targetIps) => handleIpsChange(index, targetIps)}
|
||||
/>
|
||||
</Field>
|
||||
</ItemContent>
|
||||
<ItemActions>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
aria-label="Удалить привязку"
|
||||
onClick={() => handleRemoveBinding(index)}
|
||||
>
|
||||
<Trash2Icon />
|
||||
</Button>
|
||||
</ItemActions>
|
||||
</Item>
|
||||
))}
|
||||
</ItemGroup>
|
||||
)}
|
||||
</div>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
<SheetFooter className="flex flex-row flex-wrap gap-2">
|
||||
{!isCreate ? (
|
||||
<Button
|
||||
type="button"
|
||||
variant="destructive"
|
||||
disabled={!service || isDeleting || isSaving}
|
||||
onClick={handleDelete}
|
||||
>
|
||||
{isDeleting && <Spinner data-icon="inline-start" />}
|
||||
<Trash2Icon data-icon="inline-start" />
|
||||
Удалить
|
||||
</Button>
|
||||
<ConfirmDialog
|
||||
trigger={
|
||||
<Button
|
||||
type="button"
|
||||
variant="destructive"
|
||||
disabled={!service || isDeleting || isSaving}
|
||||
>
|
||||
{isDeleting && <Spinner data-icon="inline-start" />}
|
||||
<Trash2Icon data-icon="inline-start" />
|
||||
Удалить
|
||||
</Button>
|
||||
}
|
||||
title="Удалить сервис?"
|
||||
description="Сервис и связанные DNS-привязки будут удалены. Действие необратимо."
|
||||
onConfirm={handleDelete}
|
||||
/>
|
||||
) : null}
|
||||
<Button
|
||||
type="button"
|
||||
|
||||
@@ -40,7 +40,7 @@ export function ServiceRow({
|
||||
<ItemTitle>{service.name}</ItemTitle>
|
||||
<ItemDescription>{fqdn}</ItemDescription>
|
||||
</ItemContent>
|
||||
<ItemActions className="flex flex-wrap items-center gap-2">
|
||||
<ItemActions className="gap-1">
|
||||
{syncStatus ? <StatusBadge status={syncStatus} /> : null}
|
||||
{isToggling ? (
|
||||
<Spinner className="size-4" />
|
||||
@@ -52,10 +52,20 @@ export function ServiceRow({
|
||||
aria-label={`${service.enabled ? 'Выключить' : 'Включить'} ${service.name}`}
|
||||
/>
|
||||
)}
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
aria-label={`Редактировать ${service.name}`}
|
||||
onClick={() => onEdit(service)}
|
||||
>
|
||||
<PencilIcon />
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="hidden md:inline-flex"
|
||||
onClick={() => onEdit(service)}
|
||||
>
|
||||
<PencilIcon data-icon="inline-start" />
|
||||
|
||||
@@ -36,7 +36,11 @@ interface StatusBadgeProps {
|
||||
export function StatusBadge({ status, className }: StatusBadgeProps) {
|
||||
const variant = statusVariants[status] ?? 'outline'
|
||||
return (
|
||||
<Badge variant={variant} className={cn(className)}>
|
||||
<Badge variant={variant} className={cn('gap-1.5', className)}>
|
||||
<span
|
||||
className="size-1.5 rounded-full bg-current opacity-70"
|
||||
aria-hidden
|
||||
/>
|
||||
{labels[status] ?? status}
|
||||
</Badge>
|
||||
)
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
import { SearchIcon } from 'lucide-react'
|
||||
import {
|
||||
InputGroup,
|
||||
InputGroupAddon,
|
||||
InputGroupInput,
|
||||
} from '@cfdm/ui/components/input-group'
|
||||
|
||||
interface TableToolbarProps {
|
||||
value: string
|
||||
onChange: (value: string) => void
|
||||
placeholder?: string
|
||||
}
|
||||
|
||||
export function TableToolbar({
|
||||
value,
|
||||
onChange,
|
||||
placeholder = 'Поиск…',
|
||||
}: TableToolbarProps) {
|
||||
return (
|
||||
<InputGroup className="max-w-sm">
|
||||
<InputGroupAddon>
|
||||
<SearchIcon />
|
||||
</InputGroupAddon>
|
||||
<InputGroupInput
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
placeholder={placeholder}
|
||||
aria-label={placeholder}
|
||||
/>
|
||||
</InputGroup>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user