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

This commit is contained in:
Denozordec
2026-06-19 12:15:29 +07:00
parent d11414666f
commit 4a70cf5854
32 changed files with 1546 additions and 435 deletions
+48 -22
View File
@@ -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>
)
}
+17 -4
View File
@@ -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
)}
+11 -8
View File
@@ -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 }}
/>
}
>
+38
View File
@@ -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>
)
}
+9 -4
View File
@@ -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>
)
+38 -10
View File
@@ -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>
) : (
+16 -29
View File
@@ -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>
)
}
+20
View File
@@ -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>
)
}
+60
View File
@@ -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
}
+147
View File
@@ -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>
)
}
+136 -114
View File
@@ -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"
+11 -1
View File
@@ -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" />
+5 -1
View File
@@ -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>
)
+32
View File
@@ -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>
)
}