Refactor project structure to use pnpm monorepo; update Dockerfile and related configurations for frontend build process. Adjust .dockerignore and .gitignore to reflect new paths. Modify .env.example for cron job timing. Update CONTRIBUTING.md and README.md for new development instructions.
Build, Test, and Push CFDM Docker Image / test (push) Failing after 3h13m2s
Build, Test, and Push CFDM Docker Image / create-release (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 / update-wiki (push) Has been cancelled
Build, Test, and Push CFDM Docker Image / test (push) Failing after 3h13m2s
Build, Test, and Push CFDM Docker Image / create-release (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 / update-wiki (push) Has been cancelled
This commit is contained in:
@@ -0,0 +1,74 @@
|
||||
import { Link } from '@tanstack/react-router'
|
||||
import {
|
||||
LayoutDashboardIcon,
|
||||
GlobeIcon,
|
||||
FolderTreeIcon,
|
||||
ServerIcon,
|
||||
ShieldCheckIcon,
|
||||
CloudIcon,
|
||||
} from 'lucide-react'
|
||||
import { NavUser } from '@/components/nav-user'
|
||||
import {
|
||||
Sidebar,
|
||||
SidebarContent,
|
||||
SidebarFooter,
|
||||
SidebarGroup,
|
||||
SidebarGroupLabel,
|
||||
SidebarHeader,
|
||||
SidebarMenu,
|
||||
SidebarMenuButton,
|
||||
SidebarMenuItem,
|
||||
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 },
|
||||
] as const
|
||||
|
||||
export function AppSidebar() {
|
||||
return (
|
||||
<Sidebar collapsible="icon">
|
||||
<SidebarHeader>
|
||||
<SidebarMenu>
|
||||
<SidebarMenuItem>
|
||||
<SidebarMenuButton size="lg" render={<Link to="/" />}>
|
||||
<div className="flex aspect-square size-8 items-center justify-center rounded-lg bg-sidebar-primary text-sidebar-primary-foreground">
|
||||
<CloudIcon />
|
||||
</div>
|
||||
<div className="grid flex-1 text-left text-sm leading-tight">
|
||||
<span className="truncate font-medium">CF Domain Manager</span>
|
||||
<span className="truncate text-xs">Управление доменами</span>
|
||||
</div>
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
</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>
|
||||
</SidebarContent>
|
||||
<SidebarFooter>
|
||||
<NavUser />
|
||||
</SidebarFooter>
|
||||
<SidebarRail />
|
||||
</Sidebar>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from '@cfdm/ui/components/card'
|
||||
|
||||
interface DataTableCardProps {
|
||||
title: string
|
||||
description?: string
|
||||
children: React.ReactNode
|
||||
}
|
||||
|
||||
export function DataTableCard({
|
||||
title,
|
||||
description,
|
||||
children,
|
||||
}: DataTableCardProps) {
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>{title}</CardTitle>
|
||||
{description && <CardDescription>{description}</CardDescription>}
|
||||
</CardHeader>
|
||||
<CardContent className="p-0">{children}</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import { AppSidebar } from '@/components/app-sidebar'
|
||||
import { SiteHeader } from '@/components/layout/site-header'
|
||||
import { SidebarInset, SidebarProvider } from '@cfdm/ui/components/sidebar'
|
||||
|
||||
export function AppShell({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<SidebarProvider>
|
||||
<AppSidebar />
|
||||
<SidebarInset>
|
||||
<SiteHeader />
|
||||
<div className="flex flex-1 flex-col gap-4 p-4 pt-0">
|
||||
{children}
|
||||
</div>
|
||||
</SidebarInset>
|
||||
</SidebarProvider>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
import { Link, useRouterState } from '@tanstack/react-router'
|
||||
import {
|
||||
Breadcrumb,
|
||||
BreadcrumbItem,
|
||||
BreadcrumbLink,
|
||||
BreadcrumbList,
|
||||
BreadcrumbPage,
|
||||
BreadcrumbSeparator,
|
||||
} from '@cfdm/ui/components/breadcrumb'
|
||||
import { Separator } from '@cfdm/ui/components/separator'
|
||||
import { SidebarTrigger } from '@cfdm/ui/components/sidebar'
|
||||
|
||||
const routeTitles: Record<string, string> = {
|
||||
'/': 'Панель управления',
|
||||
'/domains': 'Домены',
|
||||
'/groups': 'Группы',
|
||||
'/services': 'Сервисы',
|
||||
'/certificates': 'Сертификаты',
|
||||
}
|
||||
|
||||
function getBreadcrumbs(pathname: string) {
|
||||
if (pathname === '/') {
|
||||
return [{ label: 'Панель управления', href: '/' }]
|
||||
}
|
||||
|
||||
if (pathname.match(/^\/domains\/\d+\/dns$/)) {
|
||||
const domainId = pathname.split('/')[2]
|
||||
return [
|
||||
{ label: 'Домены', href: '/domains' },
|
||||
{ label: 'Домен', href: `/domains/${domainId}` },
|
||||
{ label: 'DNS', href: pathname },
|
||||
]
|
||||
}
|
||||
|
||||
if (pathname.match(/^\/domains\/\d+$/)) {
|
||||
return [
|
||||
{ label: 'Домены', href: '/domains' },
|
||||
{ label: 'Обзор домена', href: pathname },
|
||||
]
|
||||
}
|
||||
|
||||
const title = routeTitles[pathname]
|
||||
if (title) {
|
||||
return [{ label: title, href: pathname }]
|
||||
}
|
||||
|
||||
return [{ label: 'Панель управления', href: '/' }]
|
||||
}
|
||||
|
||||
export function SiteHeader() {
|
||||
const pathname = useRouterState({ select: (s) => s.location.pathname })
|
||||
const crumbs = getBreadcrumbs(pathname)
|
||||
|
||||
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">
|
||||
<div className="flex items-center gap-2 px-4">
|
||||
<SidebarTrigger className="-ml-1" />
|
||||
<Separator
|
||||
orientation="vertical"
|
||||
className="mr-2 data-[orientation=vertical]:h-4"
|
||||
/>
|
||||
<Breadcrumb>
|
||||
<BreadcrumbList>
|
||||
{crumbs.map((crumb, index) => {
|
||||
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}>
|
||||
{isLast ? (
|
||||
<BreadcrumbPage>{crumb.label}</BreadcrumbPage>
|
||||
) : (
|
||||
<BreadcrumbLink render={<Link to={crumb.href} />}>
|
||||
{crumb.label}
|
||||
</BreadcrumbLink>
|
||||
)}
|
||||
</BreadcrumbItem>
|
||||
</span>
|
||||
)
|
||||
})}
|
||||
</BreadcrumbList>
|
||||
</Breadcrumb>
|
||||
</div>
|
||||
</header>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
import { useForm } from 'react-hook-form'
|
||||
import { zodResolver } from '@hookform/resolvers/zod'
|
||||
import { useNavigate } from '@tanstack/react-router'
|
||||
import { CircleAlertIcon } from 'lucide-react'
|
||||
import { cn } from '@cfdm/ui/lib/utils'
|
||||
import { api } from '@/lib/api-client'
|
||||
import { setToken } from '@/lib/auth'
|
||||
import { loginSchema, type LoginInput } from '@/lib/schemas'
|
||||
import { Button } from '@cfdm/ui/components/button'
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from '@cfdm/ui/components/card'
|
||||
import {
|
||||
Field,
|
||||
FieldGroup,
|
||||
FieldLabel,
|
||||
} from '@cfdm/ui/components/field'
|
||||
import { Input } from '@cfdm/ui/components/input'
|
||||
import { Alert, AlertDescription, AlertTitle } from '@cfdm/ui/components/alert'
|
||||
import { Spinner } from '@cfdm/ui/components/spinner'
|
||||
|
||||
type LoginFormProps = React.ComponentProps<'div'>
|
||||
|
||||
export function LoginForm({ className, ...props }: LoginFormProps) {
|
||||
const navigate = useNavigate()
|
||||
const form = useForm<LoginInput>({
|
||||
resolver: zodResolver(loginSchema),
|
||||
defaultValues: { username: 'admin', password: 'admin' },
|
||||
})
|
||||
|
||||
const handleSubmit = form.handleSubmit(async (values) => {
|
||||
try {
|
||||
const res = await api.post<{ token: string }>('/api/v1/auth/login', values)
|
||||
setToken(res.token)
|
||||
navigate({ to: '/' })
|
||||
} catch (err) {
|
||||
form.setError('root', {
|
||||
message: err instanceof Error ? err.message : 'Не удалось войти',
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
const rootError = form.formState.errors.root?.message
|
||||
const isLoading = form.formState.isSubmitting
|
||||
|
||||
return (
|
||||
<div className={cn('flex flex-col gap-6', className)} {...props}>
|
||||
<Card>
|
||||
<CardHeader className="text-center">
|
||||
<CardTitle className="text-xl">Вход в систему</CardTitle>
|
||||
<CardDescription>
|
||||
Введите учётные данные для доступа к панели управления
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<form onSubmit={handleSubmit}>
|
||||
<FieldGroup>
|
||||
<Field>
|
||||
<FieldLabel htmlFor="username">Имя пользователя</FieldLabel>
|
||||
<Input
|
||||
id="username"
|
||||
placeholder="admin"
|
||||
autoComplete="username"
|
||||
{...form.register('username')}
|
||||
aria-invalid={!!form.formState.errors.username}
|
||||
/>
|
||||
</Field>
|
||||
<Field>
|
||||
<FieldLabel htmlFor="password">Пароль</FieldLabel>
|
||||
<Input
|
||||
id="password"
|
||||
type="password"
|
||||
autoComplete="current-password"
|
||||
{...form.register('password')}
|
||||
aria-invalid={!!form.formState.errors.password}
|
||||
/>
|
||||
</Field>
|
||||
{rootError && (
|
||||
<Alert variant="destructive">
|
||||
<CircleAlertIcon />
|
||||
<AlertTitle>Ошибка входа</AlertTitle>
|
||||
<AlertDescription>{rootError}</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
<Field>
|
||||
<Button type="submit" className="w-full" disabled={isLoading}>
|
||||
{isLoading && <Spinner data-icon="inline-start" />}
|
||||
{isLoading ? 'Вход…' : 'Войти'}
|
||||
</Button>
|
||||
</Field>
|
||||
</FieldGroup>
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
import { useNavigate } from '@tanstack/react-router'
|
||||
import { Avatar, AvatarFallback } from '@cfdm/ui/components/avatar'
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuGroup,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from '@cfdm/ui/components/dropdown-menu'
|
||||
import {
|
||||
SidebarMenu,
|
||||
SidebarMenuButton,
|
||||
SidebarMenuItem,
|
||||
useSidebar,
|
||||
} from '@cfdm/ui/components/sidebar'
|
||||
import { ChevronsUpDownIcon, LogOutIcon } from 'lucide-react'
|
||||
import { clearToken } from '@/lib/auth'
|
||||
|
||||
export function NavUser() {
|
||||
const navigate = useNavigate()
|
||||
const { isMobile } = useSidebar()
|
||||
|
||||
const handleLogout = () => {
|
||||
clearToken()
|
||||
navigate({ to: '/login' })
|
||||
}
|
||||
|
||||
return (
|
||||
<SidebarMenu>
|
||||
<SidebarMenuItem>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger
|
||||
render={
|
||||
<SidebarMenuButton size="lg" className="aria-expanded:bg-muted" />
|
||||
}
|
||||
>
|
||||
<Avatar>
|
||||
<AvatarFallback>АД</AvatarFallback>
|
||||
</Avatar>
|
||||
<div className="grid flex-1 text-left text-sm leading-tight">
|
||||
<span className="truncate font-medium">Администратор</span>
|
||||
<span className="truncate text-xs">admin</span>
|
||||
</div>
|
||||
<ChevronsUpDownIcon className="ml-auto size-4" />
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
className="w-56"
|
||||
side={isMobile ? 'bottom' : 'right'}
|
||||
align="end"
|
||||
sideOffset={4}
|
||||
>
|
||||
<DropdownMenuGroup>
|
||||
<DropdownMenuLabel className="p-0 font-normal">
|
||||
<div className="flex items-center gap-2 px-1 py-1.5 text-left text-sm">
|
||||
<Avatar>
|
||||
<AvatarFallback>АД</AvatarFallback>
|
||||
</Avatar>
|
||||
<div className="grid flex-1 text-left text-sm leading-tight">
|
||||
<span className="truncate font-medium">Администратор</span>
|
||||
<span className="truncate text-xs">admin</span>
|
||||
</div>
|
||||
</div>
|
||||
</DropdownMenuLabel>
|
||||
</DropdownMenuGroup>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem onClick={handleLogout}>
|
||||
<LogOutIcon />
|
||||
Выйти
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</SidebarMenuItem>
|
||||
</SidebarMenu>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
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
|
||||
}
|
||||
|
||||
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',
|
||||
)}
|
||||
>
|
||||
{title}
|
||||
</h1>
|
||||
{description && (
|
||||
<p className="text-muted-foreground">{description}</p>
|
||||
)}
|
||||
</div>
|
||||
{actions}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
} from '@cfdm/ui/components/card'
|
||||
import {
|
||||
Empty,
|
||||
EmptyDescription,
|
||||
EmptyHeader,
|
||||
EmptyTitle,
|
||||
} from '@cfdm/ui/components/empty'
|
||||
|
||||
export interface ResourceListItem {
|
||||
id: string | number
|
||||
primary: string
|
||||
secondary?: string
|
||||
}
|
||||
|
||||
interface ResourceListProps {
|
||||
items: ResourceListItem[]
|
||||
emptyTitle?: string
|
||||
emptyDescription?: string
|
||||
renderActions?: (item: ResourceListItem) => React.ReactNode
|
||||
}
|
||||
|
||||
export function ResourceList({
|
||||
items,
|
||||
emptyTitle = 'Нет записей',
|
||||
emptyDescription,
|
||||
renderActions,
|
||||
}: ResourceListProps) {
|
||||
if (!items.length) {
|
||||
return (
|
||||
<Empty className="border">
|
||||
<EmptyHeader>
|
||||
<EmptyTitle>{emptyTitle}</EmptyTitle>
|
||||
{emptyDescription && (
|
||||
<EmptyDescription>{emptyDescription}</EmptyDescription>
|
||||
)}
|
||||
</EmptyHeader>
|
||||
</Empty>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardContent className="p-0">
|
||||
<ul>
|
||||
{items.map((item) => (
|
||||
<li
|
||||
key={item.id}
|
||||
className="flex items-center justify-between gap-2 border-b px-4 py-3 last:border-b-0"
|
||||
>
|
||||
<span className="font-medium">{item.primary}</span>
|
||||
<div className="flex items-center gap-2">
|
||||
{item.secondary && (
|
||||
<span className="text-muted-foreground">{item.secondary}</span>
|
||||
)}
|
||||
{renderActions?.(item)}
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import { Badge, badgeVariants } from '@cfdm/ui/components/badge'
|
||||
import type { VariantProps } from 'class-variance-authority'
|
||||
import { cn } from '@cfdm/ui/lib/utils'
|
||||
|
||||
type BadgeVariant = NonNullable<VariantProps<typeof badgeVariants>['variant']>
|
||||
|
||||
const statusVariants: Record<string, BadgeVariant> = {
|
||||
synced: 'default',
|
||||
ok: 'default',
|
||||
pending_push: 'secondary',
|
||||
warning: 'secondary',
|
||||
conflict: 'destructive',
|
||||
error: 'destructive',
|
||||
expired: 'destructive',
|
||||
unknown: 'outline',
|
||||
}
|
||||
|
||||
const labels: Record<string, string> = {
|
||||
synced: 'Синхронизировано',
|
||||
pending_push: 'Ожидает отправки',
|
||||
conflict: 'Конфликт',
|
||||
error: 'Ошибка',
|
||||
ok: 'OK',
|
||||
warning: 'Предупреждение',
|
||||
expired: 'Истёк',
|
||||
unknown: 'Неизвестно',
|
||||
}
|
||||
|
||||
interface StatusBadgeProps {
|
||||
status: string
|
||||
className?: string
|
||||
}
|
||||
|
||||
export function StatusBadge({ status, className }: StatusBadgeProps) {
|
||||
const variant = statusVariants[status] ?? 'outline'
|
||||
return (
|
||||
<Badge variant={variant} className={cn(className)}>
|
||||
{labels[status] ?? status}
|
||||
</Badge>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
import { ThemeProvider as NextThemesProvider } from 'next-themes'
|
||||
|
||||
export function ThemeProvider({
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof NextThemesProvider>) {
|
||||
return <NextThemesProvider {...props}>{children}</NextThemesProvider>
|
||||
}
|
||||
Reference in New Issue
Block a user