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>
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
export class ApiError extends Error {
|
||||
constructor(
|
||||
public status: number,
|
||||
public code: string,
|
||||
message: string,
|
||||
) {
|
||||
super(message)
|
||||
this.name = 'ApiError'
|
||||
}
|
||||
}
|
||||
|
||||
async function request<T>(path: string, init?: RequestInit): Promise<T> {
|
||||
const token = localStorage.getItem('cfdm_token')
|
||||
const headers = new Headers(init?.headers)
|
||||
headers.set('Content-Type', 'application/json')
|
||||
if (token) headers.set('Authorization', `Bearer ${token}`)
|
||||
|
||||
const res = await fetch(path, { ...init, headers })
|
||||
if (res.status === 401 && !path.includes('/auth/login')) {
|
||||
localStorage.removeItem('cfdm_token')
|
||||
window.location.href = '/login'
|
||||
throw new ApiError(401, 'UNAUTHORIZED', 'Unauthorized')
|
||||
}
|
||||
if (!res.ok) {
|
||||
const body = await res.json().catch(() => ({}))
|
||||
const err = body?.error
|
||||
throw new ApiError(
|
||||
res.status,
|
||||
err?.code ?? 'UNKNOWN',
|
||||
err?.message ?? res.statusText,
|
||||
)
|
||||
}
|
||||
if (res.status === 204) return undefined as T
|
||||
return res.json() as Promise<T>
|
||||
}
|
||||
|
||||
export const api = {
|
||||
get: <T>(path: string) => request<T>(path),
|
||||
post: <T>(path: string, body?: unknown) =>
|
||||
request<T>(path, { method: 'POST', body: body ? JSON.stringify(body) : undefined }),
|
||||
patch: <T>(path: string, body: unknown) =>
|
||||
request<T>(path, { method: 'PATCH', body: JSON.stringify(body) }),
|
||||
put: <T>(path: string, body: unknown) =>
|
||||
request<T>(path, { method: 'PUT', body: JSON.stringify(body) }),
|
||||
delete: <T>(path: string) => request<T>(path, { method: 'DELETE' }),
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
export function getToken(): string | null {
|
||||
return localStorage.getItem('cfdm_token')
|
||||
}
|
||||
|
||||
export function setToken(token: string) {
|
||||
localStorage.setItem('cfdm_token', token)
|
||||
}
|
||||
|
||||
export function clearToken() {
|
||||
localStorage.removeItem('cfdm_token')
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import { QueryClient } from '@tanstack/react-query'
|
||||
import { ApiError } from './api-client'
|
||||
|
||||
export const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
staleTime: 1000 * 60,
|
||||
retry: (count, error) => {
|
||||
if (error instanceof ApiError && error.status === 404) return false
|
||||
return count < 2
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,12 @@
|
||||
import { z } from 'zod'
|
||||
|
||||
export const subdomainSchema = z.object({
|
||||
id: z.number(),
|
||||
domain_id: z.number(),
|
||||
name: z.string(),
|
||||
fqdn: z.string(),
|
||||
created_at: z.string(),
|
||||
updated_at: z.string(),
|
||||
})
|
||||
|
||||
export type Subdomain = z.infer<typeof subdomainSchema>
|
||||
@@ -0,0 +1,98 @@
|
||||
import { z } from 'zod'
|
||||
|
||||
export const groupSchema = z.object({
|
||||
id: z.number(),
|
||||
name: z.string(),
|
||||
slug: z.string(),
|
||||
created_at: z.string(),
|
||||
updated_at: z.string(),
|
||||
})
|
||||
|
||||
export const serviceSchema = z.object({
|
||||
id: z.number(),
|
||||
name: z.string(),
|
||||
slug: z.string(),
|
||||
created_at: z.string(),
|
||||
updated_at: z.string(),
|
||||
})
|
||||
|
||||
export const domainSchema = z.object({
|
||||
id: z.number(),
|
||||
group_id: z.number().nullable(),
|
||||
zone_name: z.string(),
|
||||
cf_zone_id: z.string(),
|
||||
status: z.string(),
|
||||
last_synced_at: z.string().nullable(),
|
||||
created_at: z.string(),
|
||||
updated_at: z.string(),
|
||||
})
|
||||
|
||||
export const dnsRecordSchema = z.object({
|
||||
id: z.number(),
|
||||
domain_id: z.number(),
|
||||
cf_record_id: z.string().nullable(),
|
||||
record_type: z.string(),
|
||||
name: z.string(),
|
||||
content: z.string(),
|
||||
ttl: z.number(),
|
||||
proxied: z.boolean(),
|
||||
priority: z.number().nullable(),
|
||||
sync_status: z.string(),
|
||||
origin: z.string(),
|
||||
last_error: z.string().nullable(),
|
||||
created_at: z.string(),
|
||||
updated_at: z.string(),
|
||||
})
|
||||
|
||||
export const certificateSchema = z.object({
|
||||
id: z.number(),
|
||||
domain_id: z.number(),
|
||||
subdomain_id: z.number().nullable(),
|
||||
hostname: z.string(),
|
||||
expires_at: z.string().nullable(),
|
||||
last_checked_at: z.string().nullable(),
|
||||
last_error: z.string().nullable(),
|
||||
status: z.string(),
|
||||
created_at: z.string(),
|
||||
updated_at: z.string(),
|
||||
})
|
||||
|
||||
export type Group = z.infer<typeof groupSchema>
|
||||
export type Service = z.infer<typeof serviceSchema>
|
||||
export type Domain = z.infer<typeof domainSchema>
|
||||
export type DnsRecord = z.infer<typeof dnsRecordSchema>
|
||||
export type Certificate = z.infer<typeof certificateSchema>
|
||||
|
||||
export const createGroupSchema = z.object({
|
||||
name: z.string().min(1, 'Укажите название'),
|
||||
slug: z.string().min(1, 'Укажите slug'),
|
||||
})
|
||||
|
||||
export const createServiceSchema = z.object({
|
||||
name: z.string().min(1, 'Укажите название'),
|
||||
slug: z.string().min(1, 'Укажите slug'),
|
||||
})
|
||||
|
||||
export const createDomainSchema = z.object({
|
||||
zone_name: z.string().min(1, 'Укажите имя зоны'),
|
||||
group_id: z.string(),
|
||||
})
|
||||
|
||||
export const loginSchema = z.object({
|
||||
username: z.string().min(1, 'Укажите имя пользователя'),
|
||||
password: z.string().min(1, 'Укажите пароль'),
|
||||
})
|
||||
|
||||
export const createDnsRecordSchema = z.object({
|
||||
record_type: z.enum(['A', 'AAAA', 'CNAME', 'TXT', 'MX']),
|
||||
name: z.string().min(1, 'Укажите имя'),
|
||||
content: z.string().min(1, 'Укажите значение'),
|
||||
ttl: z.number().int().min(1),
|
||||
proxied: z.boolean(),
|
||||
})
|
||||
|
||||
export type CreateGroupInput = z.infer<typeof createGroupSchema>
|
||||
export type CreateServiceInput = z.infer<typeof createServiceSchema>
|
||||
export type CreateDomainInput = z.infer<typeof createDomainSchema>
|
||||
export type LoginInput = z.infer<typeof loginSchema>
|
||||
export type CreateDnsRecordInput = z.infer<typeof createDnsRecordSchema>
|
||||
@@ -0,0 +1,8 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { cn } from '@cfdm/ui/lib/utils'
|
||||
|
||||
describe('cn', () => {
|
||||
it('merges classes', () => {
|
||||
expect(cn('a', 'b')).toBe('a b')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,35 @@
|
||||
import { StrictMode } from 'react'
|
||||
import { createRoot } from 'react-dom/client'
|
||||
import { RouterProvider, createRouter } from '@tanstack/react-router'
|
||||
import { QueryClientProvider } from '@tanstack/react-query'
|
||||
import { TooltipProvider } from '@cfdm/ui/components/tooltip'
|
||||
import { ThemeProvider } from '@/components/theme-provider'
|
||||
import { Toaster } from '@cfdm/ui/components/sonner'
|
||||
import { routeTree } from './routeTree.gen'
|
||||
import { queryClient } from './lib/queryClient'
|
||||
import '@cfdm/ui/globals.css'
|
||||
|
||||
const router = createRouter({
|
||||
routeTree,
|
||||
context: { queryClient },
|
||||
defaultPreload: 'intent',
|
||||
})
|
||||
|
||||
declare module '@tanstack/react-router' {
|
||||
interface Register {
|
||||
router: typeof router
|
||||
}
|
||||
}
|
||||
|
||||
createRoot(document.getElementById('root')!).render(
|
||||
<StrictMode>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<ThemeProvider attribute="class" defaultTheme="system" enableSystem>
|
||||
<TooltipProvider>
|
||||
<RouterProvider router={router} context={{ queryClient }} />
|
||||
<Toaster />
|
||||
</TooltipProvider>
|
||||
</ThemeProvider>
|
||||
</QueryClientProvider>
|
||||
</StrictMode>,
|
||||
)
|
||||
@@ -0,0 +1,106 @@
|
||||
import { queryOptions } from '@tanstack/react-query'
|
||||
import { api } from '@/lib/api-client'
|
||||
import { certificateSchema, dnsRecordSchema, domainSchema, groupSchema, serviceSchema } from '@/lib/schemas'
|
||||
import { subdomainSchema } from '@/lib/schemas-ext'
|
||||
import { z } from 'zod'
|
||||
|
||||
export const groupKeys = {
|
||||
all: ['groups'] as const,
|
||||
}
|
||||
|
||||
export const groupsQueryOptions = () =>
|
||||
queryOptions({
|
||||
queryKey: groupKeys.all,
|
||||
queryFn: async () => {
|
||||
const data = await api.get<unknown[]>('/api/v1/groups')
|
||||
return z.array(groupSchema).parse(data)
|
||||
},
|
||||
})
|
||||
|
||||
export const serviceKeys = {
|
||||
all: ['services'] as const,
|
||||
}
|
||||
|
||||
export const servicesQueryOptions = () =>
|
||||
queryOptions({
|
||||
queryKey: serviceKeys.all,
|
||||
queryFn: async () => {
|
||||
const data = await api.get<unknown[]>('/api/v1/services')
|
||||
return z.array(serviceSchema).parse(data)
|
||||
},
|
||||
})
|
||||
|
||||
export const domainKeys = {
|
||||
all: ['domains'] as const,
|
||||
list: (groupId?: number) => [...domainKeys.all, 'list', groupId] as const,
|
||||
detail: (id: number) => [...domainKeys.all, 'detail', id] as const,
|
||||
}
|
||||
|
||||
export const domainsListQueryOptions = (groupId?: number) =>
|
||||
queryOptions({
|
||||
queryKey: domainKeys.list(groupId),
|
||||
queryFn: async () => {
|
||||
const qs = groupId ? `?group_id=${groupId}` : ''
|
||||
const data = await api.get<unknown[]>(`/api/v1/domains${qs}`)
|
||||
return z.array(domainSchema).parse(data)
|
||||
},
|
||||
})
|
||||
|
||||
export const domainDetailQueryOptions = (id: number) =>
|
||||
queryOptions({
|
||||
queryKey: domainKeys.detail(id),
|
||||
queryFn: async () => {
|
||||
const data = await api.get<unknown>(`/api/v1/domains/${id}`)
|
||||
return domainSchema.parse(data)
|
||||
},
|
||||
})
|
||||
|
||||
export const dnsKeys = {
|
||||
all: ['dns'] as const,
|
||||
list: (domainId: number) => [...dnsKeys.all, domainId] as const,
|
||||
}
|
||||
|
||||
export const dnsListQueryOptions = (domainId: number) =>
|
||||
queryOptions({
|
||||
queryKey: dnsKeys.list(domainId),
|
||||
queryFn: async () => {
|
||||
const data = await api.get<unknown[]>(`/api/v1/domains/${domainId}/dns`)
|
||||
return z.array(dnsRecordSchema).parse(data)
|
||||
},
|
||||
staleTime: 1000 * 30,
|
||||
})
|
||||
|
||||
export const certKeys = {
|
||||
all: ['certificates'] as const,
|
||||
summary: ['certificates', 'summary'] as const,
|
||||
}
|
||||
|
||||
export const certificatesQueryOptions = () =>
|
||||
queryOptions({
|
||||
queryKey: certKeys.all,
|
||||
queryFn: async () => {
|
||||
const data = await api.get<unknown[]>('/api/v1/certificates')
|
||||
return z.array(certificateSchema).parse(data)
|
||||
},
|
||||
staleTime: 1000 * 60 * 5,
|
||||
})
|
||||
|
||||
export const certSummaryQueryOptions = () =>
|
||||
queryOptions({
|
||||
queryKey: certKeys.summary,
|
||||
queryFn: () => api.get<[string, number][]>('/api/v1/certificates/summary'),
|
||||
})
|
||||
|
||||
export const subdomainKeys = {
|
||||
all: ['subdomains'] as const,
|
||||
list: (domainId: number) => [...subdomainKeys.all, domainId] as const,
|
||||
}
|
||||
|
||||
export const subdomainsListQueryOptions = (domainId: number) =>
|
||||
queryOptions({
|
||||
queryKey: subdomainKeys.list(domainId),
|
||||
queryFn: async () => {
|
||||
const data = await api.get<unknown[]>(`/api/v1/domains/${domainId}/subdomains`)
|
||||
return z.array(subdomainSchema).parse(data)
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,235 @@
|
||||
/* eslint-disable */
|
||||
|
||||
// @ts-nocheck
|
||||
|
||||
// noinspection JSUnusedGlobalSymbols
|
||||
|
||||
// This file was automatically generated by TanStack Router.
|
||||
// You should NOT make any changes in this file as it will be overwritten.
|
||||
// Additionally, you should also exclude this file from your linter and/or formatter to prevent it from being checked or modified.
|
||||
|
||||
import { Route as rootRouteImport } from './routes/__root'
|
||||
import { Route as LoginRouteImport } from './routes/login'
|
||||
import { Route as AuthRouteImport } from './routes/_auth'
|
||||
import { Route as AuthIndexRouteImport } from './routes/_auth/index'
|
||||
import { Route as AuthServicesRouteImport } from './routes/_auth/services'
|
||||
import { Route as AuthGroupsRouteImport } from './routes/_auth/groups'
|
||||
import { Route as AuthCertificatesRouteImport } from './routes/_auth/certificates'
|
||||
import { Route as AuthDomainsIndexRouteImport } from './routes/_auth/domains/index'
|
||||
import { Route as AuthDomainsDomainIdIndexRouteImport } from './routes/_auth/domains/$domainId/index'
|
||||
import { Route as AuthDomainsDomainIdDnsRouteImport } from './routes/_auth/domains/$domainId/dns'
|
||||
|
||||
const LoginRoute = LoginRouteImport.update({
|
||||
id: '/login',
|
||||
path: '/login',
|
||||
getParentRoute: () => rootRouteImport,
|
||||
} as any)
|
||||
const AuthRoute = AuthRouteImport.update({
|
||||
id: '/_auth',
|
||||
getParentRoute: () => rootRouteImport,
|
||||
} as any)
|
||||
const AuthIndexRoute = AuthIndexRouteImport.update({
|
||||
id: '/',
|
||||
path: '/',
|
||||
getParentRoute: () => AuthRoute,
|
||||
} as any)
|
||||
const AuthServicesRoute = AuthServicesRouteImport.update({
|
||||
id: '/services',
|
||||
path: '/services',
|
||||
getParentRoute: () => AuthRoute,
|
||||
} as any)
|
||||
const AuthGroupsRoute = AuthGroupsRouteImport.update({
|
||||
id: '/groups',
|
||||
path: '/groups',
|
||||
getParentRoute: () => AuthRoute,
|
||||
} as any)
|
||||
const AuthCertificatesRoute = AuthCertificatesRouteImport.update({
|
||||
id: '/certificates',
|
||||
path: '/certificates',
|
||||
getParentRoute: () => AuthRoute,
|
||||
} as any)
|
||||
const AuthDomainsIndexRoute = AuthDomainsIndexRouteImport.update({
|
||||
id: '/domains/',
|
||||
path: '/domains/',
|
||||
getParentRoute: () => AuthRoute,
|
||||
} as any)
|
||||
const AuthDomainsDomainIdIndexRoute =
|
||||
AuthDomainsDomainIdIndexRouteImport.update({
|
||||
id: '/domains/$domainId/',
|
||||
path: '/domains/$domainId/',
|
||||
getParentRoute: () => AuthRoute,
|
||||
} as any)
|
||||
const AuthDomainsDomainIdDnsRoute = AuthDomainsDomainIdDnsRouteImport.update({
|
||||
id: '/domains/$domainId/dns',
|
||||
path: '/domains/$domainId/dns',
|
||||
getParentRoute: () => AuthRoute,
|
||||
} as any)
|
||||
|
||||
export interface FileRoutesByFullPath {
|
||||
'/': typeof AuthIndexRoute
|
||||
'/login': typeof LoginRoute
|
||||
'/certificates': typeof AuthCertificatesRoute
|
||||
'/groups': typeof AuthGroupsRoute
|
||||
'/services': typeof AuthServicesRoute
|
||||
'/domains/': typeof AuthDomainsIndexRoute
|
||||
'/domains/$domainId/dns': typeof AuthDomainsDomainIdDnsRoute
|
||||
'/domains/$domainId/': typeof AuthDomainsDomainIdIndexRoute
|
||||
}
|
||||
export interface FileRoutesByTo {
|
||||
'/login': typeof LoginRoute
|
||||
'/certificates': typeof AuthCertificatesRoute
|
||||
'/groups': typeof AuthGroupsRoute
|
||||
'/services': typeof AuthServicesRoute
|
||||
'/': typeof AuthIndexRoute
|
||||
'/domains': typeof AuthDomainsIndexRoute
|
||||
'/domains/$domainId/dns': typeof AuthDomainsDomainIdDnsRoute
|
||||
'/domains/$domainId': typeof AuthDomainsDomainIdIndexRoute
|
||||
}
|
||||
export interface FileRoutesById {
|
||||
__root__: typeof rootRouteImport
|
||||
'/_auth': typeof AuthRouteWithChildren
|
||||
'/login': typeof LoginRoute
|
||||
'/_auth/certificates': typeof AuthCertificatesRoute
|
||||
'/_auth/groups': typeof AuthGroupsRoute
|
||||
'/_auth/services': typeof AuthServicesRoute
|
||||
'/_auth/': typeof AuthIndexRoute
|
||||
'/_auth/domains/': typeof AuthDomainsIndexRoute
|
||||
'/_auth/domains/$domainId/dns': typeof AuthDomainsDomainIdDnsRoute
|
||||
'/_auth/domains/$domainId/': typeof AuthDomainsDomainIdIndexRoute
|
||||
}
|
||||
export interface FileRouteTypes {
|
||||
fileRoutesByFullPath: FileRoutesByFullPath
|
||||
fullPaths:
|
||||
| '/'
|
||||
| '/login'
|
||||
| '/certificates'
|
||||
| '/groups'
|
||||
| '/services'
|
||||
| '/domains/'
|
||||
| '/domains/$domainId/dns'
|
||||
| '/domains/$domainId/'
|
||||
fileRoutesByTo: FileRoutesByTo
|
||||
to:
|
||||
| '/login'
|
||||
| '/certificates'
|
||||
| '/groups'
|
||||
| '/services'
|
||||
| '/'
|
||||
| '/domains'
|
||||
| '/domains/$domainId/dns'
|
||||
| '/domains/$domainId'
|
||||
id:
|
||||
| '__root__'
|
||||
| '/_auth'
|
||||
| '/login'
|
||||
| '/_auth/certificates'
|
||||
| '/_auth/groups'
|
||||
| '/_auth/services'
|
||||
| '/_auth/'
|
||||
| '/_auth/domains/'
|
||||
| '/_auth/domains/$domainId/dns'
|
||||
| '/_auth/domains/$domainId/'
|
||||
fileRoutesById: FileRoutesById
|
||||
}
|
||||
export interface RootRouteChildren {
|
||||
AuthRoute: typeof AuthRouteWithChildren
|
||||
LoginRoute: typeof LoginRoute
|
||||
}
|
||||
|
||||
declare module '@tanstack/react-router' {
|
||||
interface FileRoutesByPath {
|
||||
'/login': {
|
||||
id: '/login'
|
||||
path: '/login'
|
||||
fullPath: '/login'
|
||||
preLoaderRoute: typeof LoginRouteImport
|
||||
parentRoute: typeof rootRouteImport
|
||||
}
|
||||
'/_auth': {
|
||||
id: '/_auth'
|
||||
path: ''
|
||||
fullPath: '/'
|
||||
preLoaderRoute: typeof AuthRouteImport
|
||||
parentRoute: typeof rootRouteImport
|
||||
}
|
||||
'/_auth/': {
|
||||
id: '/_auth/'
|
||||
path: '/'
|
||||
fullPath: '/'
|
||||
preLoaderRoute: typeof AuthIndexRouteImport
|
||||
parentRoute: typeof AuthRoute
|
||||
}
|
||||
'/_auth/services': {
|
||||
id: '/_auth/services'
|
||||
path: '/services'
|
||||
fullPath: '/services'
|
||||
preLoaderRoute: typeof AuthServicesRouteImport
|
||||
parentRoute: typeof AuthRoute
|
||||
}
|
||||
'/_auth/groups': {
|
||||
id: '/_auth/groups'
|
||||
path: '/groups'
|
||||
fullPath: '/groups'
|
||||
preLoaderRoute: typeof AuthGroupsRouteImport
|
||||
parentRoute: typeof AuthRoute
|
||||
}
|
||||
'/_auth/certificates': {
|
||||
id: '/_auth/certificates'
|
||||
path: '/certificates'
|
||||
fullPath: '/certificates'
|
||||
preLoaderRoute: typeof AuthCertificatesRouteImport
|
||||
parentRoute: typeof AuthRoute
|
||||
}
|
||||
'/_auth/domains/': {
|
||||
id: '/_auth/domains/'
|
||||
path: '/domains'
|
||||
fullPath: '/domains/'
|
||||
preLoaderRoute: typeof AuthDomainsIndexRouteImport
|
||||
parentRoute: typeof AuthRoute
|
||||
}
|
||||
'/_auth/domains/$domainId/': {
|
||||
id: '/_auth/domains/$domainId/'
|
||||
path: '/domains/$domainId'
|
||||
fullPath: '/domains/$domainId/'
|
||||
preLoaderRoute: typeof AuthDomainsDomainIdIndexRouteImport
|
||||
parentRoute: typeof AuthRoute
|
||||
}
|
||||
'/_auth/domains/$domainId/dns': {
|
||||
id: '/_auth/domains/$domainId/dns'
|
||||
path: '/domains/$domainId/dns'
|
||||
fullPath: '/domains/$domainId/dns'
|
||||
preLoaderRoute: typeof AuthDomainsDomainIdDnsRouteImport
|
||||
parentRoute: typeof AuthRoute
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
interface AuthRouteChildren {
|
||||
AuthCertificatesRoute: typeof AuthCertificatesRoute
|
||||
AuthGroupsRoute: typeof AuthGroupsRoute
|
||||
AuthServicesRoute: typeof AuthServicesRoute
|
||||
AuthIndexRoute: typeof AuthIndexRoute
|
||||
AuthDomainsIndexRoute: typeof AuthDomainsIndexRoute
|
||||
AuthDomainsDomainIdDnsRoute: typeof AuthDomainsDomainIdDnsRoute
|
||||
AuthDomainsDomainIdIndexRoute: typeof AuthDomainsDomainIdIndexRoute
|
||||
}
|
||||
|
||||
const AuthRouteChildren: AuthRouteChildren = {
|
||||
AuthCertificatesRoute: AuthCertificatesRoute,
|
||||
AuthGroupsRoute: AuthGroupsRoute,
|
||||
AuthServicesRoute: AuthServicesRoute,
|
||||
AuthIndexRoute: AuthIndexRoute,
|
||||
AuthDomainsIndexRoute: AuthDomainsIndexRoute,
|
||||
AuthDomainsDomainIdDnsRoute: AuthDomainsDomainIdDnsRoute,
|
||||
AuthDomainsDomainIdIndexRoute: AuthDomainsDomainIdIndexRoute,
|
||||
}
|
||||
|
||||
const AuthRouteWithChildren = AuthRoute._addFileChildren(AuthRouteChildren)
|
||||
|
||||
const rootRouteChildren: RootRouteChildren = {
|
||||
AuthRoute: AuthRouteWithChildren,
|
||||
LoginRoute: LoginRoute,
|
||||
}
|
||||
export const routeTree = rootRouteImport
|
||||
._addFileChildren(rootRouteChildren)
|
||||
._addFileTypes<FileRouteTypes>()
|
||||
@@ -0,0 +1,21 @@
|
||||
import { createRootRouteWithContext, Outlet, redirect } from '@tanstack/react-router'
|
||||
import type { QueryClient } from '@tanstack/react-query'
|
||||
import { getToken } from '@/lib/auth'
|
||||
|
||||
export interface RouterContext {
|
||||
queryClient: QueryClient
|
||||
}
|
||||
|
||||
export const Route = createRootRouteWithContext<RouterContext>()({
|
||||
component: () => <Outlet />,
|
||||
beforeLoad: ({ location }) => {
|
||||
const isLogin = location.pathname === '/login'
|
||||
const token = getToken()
|
||||
if (!token && !isLogin) {
|
||||
throw redirect({ to: '/login' })
|
||||
}
|
||||
if (token && isLogin) {
|
||||
throw redirect({ to: '/' })
|
||||
}
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,10 @@
|
||||
import { createFileRoute, Outlet } from '@tanstack/react-router'
|
||||
import { AppShell } from '@/components/layout/app-shell'
|
||||
|
||||
export const Route = createFileRoute('/_auth')({
|
||||
component: () => (
|
||||
<AppShell>
|
||||
<Outlet />
|
||||
</AppShell>
|
||||
),
|
||||
})
|
||||
@@ -0,0 +1,133 @@
|
||||
import { createFileRoute } from '@tanstack/react-router'
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { Bar, BarChart, CartesianGrid, XAxis } from 'recharts'
|
||||
import { toast } from 'sonner'
|
||||
import { certificatesQueryOptions, certKeys, certSummaryQueryOptions } from '@/queries'
|
||||
import { api } from '@/lib/api-client'
|
||||
import { PageHeader } from '@/components/page-header'
|
||||
import { StatusBadge } from '@/components/status-badge'
|
||||
import { DataTableCard } from '@/components/data-table-card'
|
||||
import { Button } from '@cfdm/ui/components/button'
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from '@cfdm/ui/components/card'
|
||||
import {
|
||||
ChartContainer,
|
||||
ChartTooltip,
|
||||
ChartTooltipContent,
|
||||
type ChartConfig,
|
||||
} from '@cfdm/ui/components/chart'
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@cfdm/ui/components/table'
|
||||
import { Spinner } from '@cfdm/ui/components/spinner'
|
||||
|
||||
const chartConfig = {
|
||||
count: {
|
||||
label: 'Количество',
|
||||
color: 'var(--chart-1)',
|
||||
},
|
||||
} satisfies ChartConfig
|
||||
|
||||
export const Route = createFileRoute('/_auth/certificates')({
|
||||
loader: ({ context: { queryClient } }) =>
|
||||
Promise.all([
|
||||
queryClient.ensureQueryData(certificatesQueryOptions()),
|
||||
queryClient.ensureQueryData(certSummaryQueryOptions()),
|
||||
]),
|
||||
component: CertificatesPage,
|
||||
})
|
||||
|
||||
function CertificatesPage() {
|
||||
const queryClient = useQueryClient()
|
||||
const { data: certs } = useQuery(certificatesQueryOptions())
|
||||
const { data: summary } = useQuery(certSummaryQueryOptions())
|
||||
|
||||
const checkMutation = useMutation({
|
||||
mutationFn: () => api.post('/api/v1/certificates/check'),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: certKeys.all })
|
||||
queryClient.invalidateQueries({ queryKey: certKeys.summary })
|
||||
toast.success('Проверка сертификатов запущена')
|
||||
},
|
||||
onError: (err) => {
|
||||
toast.error(err instanceof Error ? err.message : 'Не удалось запустить проверку')
|
||||
},
|
||||
})
|
||||
|
||||
const chartData = summary?.map(([status, count]) => ({ status, count })) ?? []
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4 py-4 md:gap-6 md:py-6">
|
||||
<PageHeader
|
||||
title="Сертификаты"
|
||||
description="Мониторинг SSL-сертификатов"
|
||||
actions={
|
||||
<Button
|
||||
onClick={() => checkMutation.mutate()}
|
||||
disabled={checkMutation.isPending}
|
||||
>
|
||||
{checkMutation.isPending && (
|
||||
<Spinner data-icon="inline-start" />
|
||||
)}
|
||||
{checkMutation.isPending ? 'Проверка…' : 'Запустить проверку'}
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Обзор статусов</CardTitle>
|
||||
<CardDescription>Распределение сертификатов по статусам</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<ChartContainer config={chartConfig} className="aspect-auto h-64 w-full">
|
||||
<BarChart data={chartData}>
|
||||
<CartesianGrid vertical={false} />
|
||||
<XAxis
|
||||
dataKey="status"
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
tickMargin={8}
|
||||
/>
|
||||
<ChartTooltip content={<ChartTooltipContent />} />
|
||||
<Bar dataKey="count" fill="var(--color-count)" radius={4} />
|
||||
</BarChart>
|
||||
</ChartContainer>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<DataTableCard title="Сертификаты" description="Все отслеживаемые хосты">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Хост</TableHead>
|
||||
<TableHead>Статус</TableHead>
|
||||
<TableHead>Истекает</TableHead>
|
||||
<TableHead>Последняя проверка</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{certs?.map((c) => (
|
||||
<TableRow key={c.id}>
|
||||
<TableCell className="font-medium">{c.hostname}</TableCell>
|
||||
<TableCell>
|
||||
<StatusBadge status={c.status} />
|
||||
</TableCell>
|
||||
<TableCell>{c.expires_at ?? '—'}</TableCell>
|
||||
<TableCell>{c.last_checked_at ?? '—'}</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</DataTableCard>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,250 @@
|
||||
import { createFileRoute } from '@tanstack/react-router'
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { useForm, Controller } from 'react-hook-form'
|
||||
import { zodResolver } from '@hookform/resolvers/zod'
|
||||
import { toast } from 'sonner'
|
||||
import { domainDetailQueryOptions, dnsKeys, dnsListQueryOptions, subdomainKeys } from '@/queries'
|
||||
import { api } from '@/lib/api-client'
|
||||
import { createDnsRecordSchema, type CreateDnsRecordInput } from '@/lib/schemas'
|
||||
import { PageHeader } from '@/components/page-header'
|
||||
import { StatusBadge } from '@/components/status-badge'
|
||||
import { DataTableCard } from '@/components/data-table-card'
|
||||
import { Button } from '@cfdm/ui/components/button'
|
||||
import { Input } from '@cfdm/ui/components/input'
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from '@cfdm/ui/components/card'
|
||||
import {
|
||||
Field,
|
||||
FieldGroup,
|
||||
FieldLabel,
|
||||
} from '@cfdm/ui/components/field'
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@cfdm/ui/components/select'
|
||||
import { Switch } from '@cfdm/ui/components/switch'
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@cfdm/ui/components/table'
|
||||
import { Spinner } from '@cfdm/ui/components/spinner'
|
||||
|
||||
const DNS_TYPES = ['A', 'AAAA', 'CNAME', 'TXT', 'MX'] as const
|
||||
|
||||
export const Route = createFileRoute('/_auth/domains/$domainId/dns')({
|
||||
loader: ({ context: { queryClient }, params }) => {
|
||||
const id = Number(params.domainId)
|
||||
return Promise.all([
|
||||
queryClient.ensureQueryData(domainDetailQueryOptions(id)),
|
||||
queryClient.ensureQueryData(dnsListQueryOptions(id)),
|
||||
])
|
||||
},
|
||||
component: DnsPage,
|
||||
})
|
||||
|
||||
function DnsPage() {
|
||||
const { domainId } = Route.useParams()
|
||||
const id = Number(domainId)
|
||||
const queryClient = useQueryClient()
|
||||
const { data: domain } = useQuery(domainDetailQueryOptions(id))
|
||||
const { data: records } = useQuery(dnsListQueryOptions(id))
|
||||
|
||||
const form = useForm<CreateDnsRecordInput>({
|
||||
resolver: zodResolver(createDnsRecordSchema),
|
||||
defaultValues: {
|
||||
record_type: 'A',
|
||||
name: '@',
|
||||
content: '',
|
||||
ttl: 1,
|
||||
proxied: false,
|
||||
},
|
||||
})
|
||||
|
||||
const syncMutation = useMutation({
|
||||
mutationFn: () => api.post(`/api/v1/domains/${id}/sync`),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: dnsKeys.list(id) })
|
||||
queryClient.invalidateQueries({ queryKey: subdomainKeys.list(id) })
|
||||
queryClient.invalidateQueries({ queryKey: ['domains'] })
|
||||
toast.success('Синхронизация завершена')
|
||||
},
|
||||
onError: (err) => {
|
||||
toast.error(err instanceof Error ? err.message : 'Ошибка синхронизации')
|
||||
},
|
||||
})
|
||||
|
||||
const createMutation = useMutation({
|
||||
mutationFn: (body: CreateDnsRecordInput) =>
|
||||
api.post(`/api/v1/domains/${id}/dns`, body),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: dnsKeys.list(id) })
|
||||
form.reset({
|
||||
record_type: 'A',
|
||||
name: '@',
|
||||
content: '',
|
||||
ttl: 1,
|
||||
proxied: false,
|
||||
})
|
||||
toast.success('DNS-запись создана')
|
||||
},
|
||||
onError: (err) => {
|
||||
toast.error(err instanceof Error ? err.message : 'Не удалось создать запись')
|
||||
},
|
||||
})
|
||||
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: (recordId: number) => api.delete(`/api/v1/domains/${id}/dns/${recordId}`),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: dnsKeys.list(id) })
|
||||
toast.success('DNS-запись удалена')
|
||||
},
|
||||
onError: (err) => {
|
||||
toast.error(err instanceof Error ? err.message : 'Не удалось удалить запись')
|
||||
},
|
||||
})
|
||||
|
||||
const handleCreate = form.handleSubmit((values) => {
|
||||
createMutation.mutate(values)
|
||||
})
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4 py-4 md:gap-6 md:py-6">
|
||||
<PageHeader
|
||||
title={`${domain?.zone_name ?? ''} — DNS`}
|
||||
description="Управление DNS-записями зоны"
|
||||
back={{ to: '/domains', label: '← К списку доменов' }}
|
||||
actions={
|
||||
<Button
|
||||
onClick={() => syncMutation.mutate()}
|
||||
disabled={syncMutation.isPending}
|
||||
>
|
||||
{syncMutation.isPending && (
|
||||
<Spinner data-icon="inline-start" />
|
||||
)}
|
||||
{syncMutation.isPending ? 'Синхронизация…' : 'Синхронизировать с Cloudflare'}
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Новая запись</CardTitle>
|
||||
<CardDescription>Добавить DNS-запись в зону</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<form onSubmit={handleCreate}>
|
||||
<FieldGroup className="grid gap-4 md:grid-cols-6">
|
||||
<Field>
|
||||
<FieldLabel htmlFor="record_type">Тип</FieldLabel>
|
||||
<Controller
|
||||
control={form.control}
|
||||
name="record_type"
|
||||
render={({ field }) => (
|
||||
<Select value={field.value} onValueChange={field.onChange}>
|
||||
<SelectTrigger id="record_type" className="w-full">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{DNS_TYPES.map((type) => (
|
||||
<SelectItem key={type} value={type}>
|
||||
{type}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
/>
|
||||
</Field>
|
||||
<Field>
|
||||
<FieldLabel htmlFor="name">Имя</FieldLabel>
|
||||
<Input id="name" {...form.register('name')} />
|
||||
</Field>
|
||||
<Field className="md:col-span-2">
|
||||
<FieldLabel htmlFor="content">Значение</FieldLabel>
|
||||
<Input id="content" {...form.register('content')} />
|
||||
</Field>
|
||||
<Field>
|
||||
<FieldLabel htmlFor="ttl">TTL</FieldLabel>
|
||||
<Input
|
||||
id="ttl"
|
||||
type="number"
|
||||
{...form.register('ttl', { valueAsNumber: true })}
|
||||
/>
|
||||
</Field>
|
||||
<Field className="flex flex-col justify-end gap-2">
|
||||
<FieldLabel htmlFor="proxied">Прокси Cloudflare</FieldLabel>
|
||||
<Controller
|
||||
control={form.control}
|
||||
name="proxied"
|
||||
render={({ field }) => (
|
||||
<Switch
|
||||
id="proxied"
|
||||
checked={field.value}
|
||||
onCheckedChange={field.onChange}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</Field>
|
||||
<Field className="flex items-end">
|
||||
<Button type="submit" disabled={createMutation.isPending}>
|
||||
{createMutation.isPending && (
|
||||
<Spinner data-icon="inline-start" />
|
||||
)}
|
||||
{createMutation.isPending ? 'Создание…' : 'Создать'}
|
||||
</Button>
|
||||
</Field>
|
||||
</FieldGroup>
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<DataTableCard title="DNS-записи" description="Записи в зоне">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Тип</TableHead>
|
||||
<TableHead>Имя</TableHead>
|
||||
<TableHead>Значение</TableHead>
|
||||
<TableHead>TTL</TableHead>
|
||||
<TableHead>Синхронизация</TableHead>
|
||||
<TableHead className="w-24" />
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{records?.map((r) => (
|
||||
<TableRow key={r.id}>
|
||||
<TableCell>{r.record_type}</TableCell>
|
||||
<TableCell>{r.name}</TableCell>
|
||||
<TableCell className="max-w-xs truncate">{r.content}</TableCell>
|
||||
<TableCell>{r.ttl}</TableCell>
|
||||
<TableCell>
|
||||
<StatusBadge status={r.sync_status} />
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Button
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
onClick={() => deleteMutation.mutate(r.id)}
|
||||
disabled={deleteMutation.isPending}
|
||||
>
|
||||
Удалить
|
||||
</Button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</DataTableCard>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
import { createFileRoute, Link } from '@tanstack/react-router'
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { toast } from 'sonner'
|
||||
import { domainDetailQueryOptions, domainKeys, subdomainKeys, subdomainsListQueryOptions } from '@/queries'
|
||||
import { api } from '@/lib/api-client'
|
||||
import { PageHeader } from '@/components/page-header'
|
||||
import { Button } from '@cfdm/ui/components/button'
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from '@cfdm/ui/components/card'
|
||||
import {
|
||||
Empty,
|
||||
EmptyDescription,
|
||||
EmptyHeader,
|
||||
EmptyTitle,
|
||||
} from '@cfdm/ui/components/empty'
|
||||
import { Spinner } from '@cfdm/ui/components/spinner'
|
||||
|
||||
export const Route = createFileRoute('/_auth/domains/$domainId/')({
|
||||
loader: ({ context: { queryClient }, params }) => {
|
||||
const id = Number(params.domainId)
|
||||
return Promise.all([
|
||||
queryClient.ensureQueryData(domainDetailQueryOptions(id)),
|
||||
queryClient.ensureQueryData(subdomainsListQueryOptions(id)),
|
||||
])
|
||||
},
|
||||
component: DomainOverviewPage,
|
||||
})
|
||||
|
||||
function DomainOverviewPage() {
|
||||
const { domainId } = Route.useParams()
|
||||
const id = Number(domainId)
|
||||
const queryClient = useQueryClient()
|
||||
const { data: domain } = useQuery(domainDetailQueryOptions(id))
|
||||
const { data: subdomains } = useQuery(subdomainsListQueryOptions(id))
|
||||
|
||||
const syncMutation = useMutation({
|
||||
mutationFn: () => api.post(`/api/v1/domains/${id}/sync`),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: subdomainKeys.list(id) })
|
||||
queryClient.invalidateQueries({ queryKey: domainKeys.all })
|
||||
toast.success('Синхронизация завершена')
|
||||
},
|
||||
onError: (err) => {
|
||||
toast.error(err instanceof Error ? err.message : 'Ошибка синхронизации')
|
||||
},
|
||||
})
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4 py-4 md:gap-6 md:py-6">
|
||||
<PageHeader
|
||||
title={domain?.zone_name ?? ''}
|
||||
description="Обзор домена и поддоменов"
|
||||
back={{ to: '/domains', label: '← К списку доменов' }}
|
||||
actions={
|
||||
<>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => syncMutation.mutate()}
|
||||
disabled={syncMutation.isPending}
|
||||
>
|
||||
{syncMutation.isPending && (
|
||||
<Spinner data-icon="inline-start" />
|
||||
)}
|
||||
{syncMutation.isPending ? 'Синхронизация…' : 'Синхронизировать'}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
render={
|
||||
<Link
|
||||
to="/domains/$domainId/dns"
|
||||
params={{ domainId }}
|
||||
/>
|
||||
}
|
||||
>
|
||||
DNS-записи
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Поддомены</CardTitle>
|
||||
<CardDescription>Обнаруженные поддомены в зоне</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{subdomains?.length ? (
|
||||
<ul className="flex flex-col gap-1">
|
||||
{subdomains.map((s) => (
|
||||
<li key={s.id} className="text-sm">
|
||||
{s.fqdn}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
) : (
|
||||
<Empty className="border border-dashed p-4">
|
||||
<EmptyHeader>
|
||||
<EmptyTitle>Поддомены не найдены</EmptyTitle>
|
||||
<EmptyDescription>
|
||||
Нажмите «Синхронизировать» — поддомены извлекаются из DNS-записей Cloudflare
|
||||
</EmptyDescription>
|
||||
</EmptyHeader>
|
||||
</Empty>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,204 @@
|
||||
import { createFileRoute, Link } from '@tanstack/react-router'
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { useMemo, useState } from 'react'
|
||||
import { useForm } from 'react-hook-form'
|
||||
import { zodResolver } from '@hookform/resolvers/zod'
|
||||
import { toast } from 'sonner'
|
||||
import { api } from '@/lib/api-client'
|
||||
import { createDomainSchema, type CreateDomainInput } from '@/lib/schemas'
|
||||
import { domainKeys, domainsListQueryOptions, groupsQueryOptions } from '@/queries'
|
||||
import { PageHeader } from '@/components/page-header'
|
||||
import { StatusBadge } from '@/components/status-badge'
|
||||
import { DataTableCard } from '@/components/data-table-card'
|
||||
import { Button } from '@cfdm/ui/components/button'
|
||||
import { Input } from '@cfdm/ui/components/input'
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from '@cfdm/ui/components/card'
|
||||
import {
|
||||
Field,
|
||||
FieldGroup,
|
||||
FieldLabel,
|
||||
} from '@cfdm/ui/components/field'
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@cfdm/ui/components/select'
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@cfdm/ui/components/table'
|
||||
import { Spinner } from '@cfdm/ui/components/spinner'
|
||||
|
||||
export const Route = createFileRoute('/_auth/domains/')({
|
||||
loader: ({ context: { queryClient } }) =>
|
||||
Promise.all([
|
||||
queryClient.ensureQueryData(domainsListQueryOptions()),
|
||||
queryClient.ensureQueryData(groupsQueryOptions()),
|
||||
]),
|
||||
component: DomainsPage,
|
||||
})
|
||||
|
||||
function DomainsPage() {
|
||||
const [groupId, setGroupId] = useState('')
|
||||
const filterGroupId = groupId ? Number(groupId) : undefined
|
||||
const queryClient = useQueryClient()
|
||||
const { data: domains } = useQuery(domainsListQueryOptions(filterGroupId))
|
||||
const { data: groups } = useQuery(groupsQueryOptions())
|
||||
|
||||
const groupItems = useMemo(
|
||||
() => [
|
||||
{ label: 'Без группы', value: 'none' },
|
||||
...(groups?.map((g) => ({ label: g.name, value: String(g.id) })) ?? []),
|
||||
],
|
||||
[groups],
|
||||
)
|
||||
|
||||
const form = useForm<CreateDomainInput>({
|
||||
resolver: zodResolver(createDomainSchema),
|
||||
defaultValues: { zone_name: '', group_id: '' },
|
||||
})
|
||||
|
||||
const createMutation = useMutation({
|
||||
mutationFn: (body: { zone_name: string; group_id?: number }) =>
|
||||
api.post('/api/v1/domains', body),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: domainKeys.all })
|
||||
form.reset({ zone_name: '', group_id: groupId })
|
||||
toast.success('Домен импортирован')
|
||||
},
|
||||
onError: (err) => {
|
||||
toast.error(err instanceof Error ? err.message : 'Не удалось импортировать домен')
|
||||
},
|
||||
})
|
||||
|
||||
const handleCreate = form.handleSubmit((values) => {
|
||||
createMutation.mutate({
|
||||
zone_name: values.zone_name.trim(),
|
||||
group_id: groupId ? Number(groupId) : undefined,
|
||||
})
|
||||
})
|
||||
|
||||
const handleGroupChange = (value: string | null) => {
|
||||
const next = value === 'none' || !value ? '' : value
|
||||
setGroupId(next)
|
||||
form.setValue('group_id', next)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4 py-4 md:gap-6 md:py-6">
|
||||
<PageHeader
|
||||
title="Домены"
|
||||
description="Импорт и управление зонами Cloudflare"
|
||||
/>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Добавить домен</CardTitle>
|
||||
<CardDescription>Импортировать зону из Cloudflare</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<form onSubmit={handleCreate}>
|
||||
<FieldGroup className="flex flex-row flex-wrap items-end gap-2">
|
||||
<Field className="max-w-xs flex-1">
|
||||
<FieldLabel htmlFor="zone_name">Имя зоны</FieldLabel>
|
||||
<Input
|
||||
id="zone_name"
|
||||
placeholder="example.com"
|
||||
{...form.register('zone_name')}
|
||||
aria-invalid={!!form.formState.errors.zone_name}
|
||||
/>
|
||||
</Field>
|
||||
<Field className="w-48">
|
||||
<FieldLabel htmlFor="group_id">Группа</FieldLabel>
|
||||
<Select
|
||||
items={groupItems}
|
||||
value={groupId || 'none'}
|
||||
onValueChange={handleGroupChange}
|
||||
>
|
||||
<SelectTrigger id="group_id" className="w-full">
|
||||
<SelectValue placeholder="Без группы" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{groupItems.map((item) => (
|
||||
<SelectItem key={item.value} value={item.value}>
|
||||
{item.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</Field>
|
||||
<Button type="submit" disabled={createMutation.isPending}>
|
||||
{createMutation.isPending && (
|
||||
<Spinner data-icon="inline-start" />
|
||||
)}
|
||||
{createMutation.isPending ? 'Импорт…' : 'Импортировать'}
|
||||
</Button>
|
||||
</FieldGroup>
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<DataTableCard title="Список доменов" description="Импортированные зоны">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Зона</TableHead>
|
||||
<TableHead>Статус</TableHead>
|
||||
<TableHead>Последняя синхронизация</TableHead>
|
||||
<TableHead>Действия</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{domains?.map((d) => (
|
||||
<TableRow key={d.id}>
|
||||
<TableCell className="font-medium">{d.zone_name}</TableCell>
|
||||
<TableCell>
|
||||
<StatusBadge status={d.status} />
|
||||
</TableCell>
|
||||
<TableCell>{d.last_synced_at ?? '—'}</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="link"
|
||||
className="h-auto p-0"
|
||||
render={
|
||||
<Link
|
||||
to="/domains/$domainId"
|
||||
params={{ domainId: String(d.id) }}
|
||||
/>
|
||||
}
|
||||
>
|
||||
Обзор
|
||||
</Button>
|
||||
<Button
|
||||
variant="link"
|
||||
className="h-auto p-0"
|
||||
render={
|
||||
<Link
|
||||
to="/domains/$domainId/dns"
|
||||
params={{ domainId: String(d.id) }}
|
||||
/>
|
||||
}
|
||||
>
|
||||
DNS
|
||||
</Button>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</DataTableCard>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
import { createFileRoute } from '@tanstack/react-router'
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { useForm } from 'react-hook-form'
|
||||
import { zodResolver } from '@hookform/resolvers/zod'
|
||||
import { toast } from 'sonner'
|
||||
import { groupsQueryOptions, groupKeys, domainKeys } from '@/queries'
|
||||
import { api } from '@/lib/api-client'
|
||||
import { createGroupSchema, type CreateGroupInput } from '@/lib/schemas'
|
||||
import { PageHeader } from '@/components/page-header'
|
||||
import { ResourceList } from '@/components/resource-list'
|
||||
import { Button } from '@cfdm/ui/components/button'
|
||||
import { Input } from '@cfdm/ui/components/input'
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from '@cfdm/ui/components/card'
|
||||
import {
|
||||
Field,
|
||||
FieldGroup,
|
||||
FieldLabel,
|
||||
} from '@cfdm/ui/components/field'
|
||||
import { Spinner } from '@cfdm/ui/components/spinner'
|
||||
|
||||
export const Route = createFileRoute('/_auth/groups')({
|
||||
loader: ({ context: { queryClient } }) => queryClient.ensureQueryData(groupsQueryOptions()),
|
||||
component: GroupsPage,
|
||||
})
|
||||
|
||||
function GroupsPage() {
|
||||
const queryClient = useQueryClient()
|
||||
const { data: groups } = useQuery(groupsQueryOptions())
|
||||
|
||||
const form = useForm<CreateGroupInput>({
|
||||
resolver: zodResolver(createGroupSchema),
|
||||
defaultValues: { name: '', slug: '' },
|
||||
})
|
||||
|
||||
const createMutation = useMutation({
|
||||
mutationFn: (body: CreateGroupInput) => api.post('/api/v1/groups', body),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: groupKeys.all })
|
||||
form.reset()
|
||||
toast.success('Группа создана')
|
||||
},
|
||||
onError: (err) => {
|
||||
toast.error(err instanceof Error ? err.message : 'Не удалось создать группу')
|
||||
},
|
||||
})
|
||||
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: (id: number) => api.delete(`/api/v1/groups/${id}`),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: groupKeys.all })
|
||||
queryClient.invalidateQueries({ queryKey: domainKeys.all })
|
||||
toast.success('Группа удалена')
|
||||
},
|
||||
onError: (err) => {
|
||||
toast.error(err instanceof Error ? err.message : 'Не удалось удалить группу')
|
||||
},
|
||||
})
|
||||
|
||||
const handleSubmit = form.handleSubmit((values) => {
|
||||
createMutation.mutate(values)
|
||||
})
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4 py-4 md:gap-6 md:py-6">
|
||||
<PageHeader
|
||||
title="Группы"
|
||||
description="Группировка доменов для удобного управления"
|
||||
/>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Создать группу</CardTitle>
|
||||
<CardDescription>Добавить новую группу доменов</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<form onSubmit={handleSubmit}>
|
||||
<FieldGroup className="flex flex-row flex-wrap items-end gap-2">
|
||||
<Field className="flex-1">
|
||||
<FieldLabel htmlFor="name">Название</FieldLabel>
|
||||
<Input
|
||||
id="name"
|
||||
placeholder="Название"
|
||||
{...form.register('name')}
|
||||
aria-invalid={!!form.formState.errors.name}
|
||||
/>
|
||||
</Field>
|
||||
<Field className="flex-1">
|
||||
<FieldLabel htmlFor="slug">Slug</FieldLabel>
|
||||
<Input
|
||||
id="slug"
|
||||
placeholder="slug"
|
||||
{...form.register('slug')}
|
||||
aria-invalid={!!form.formState.errors.slug}
|
||||
/>
|
||||
</Field>
|
||||
<Button type="submit" disabled={createMutation.isPending}>
|
||||
{createMutation.isPending && (
|
||||
<Spinner data-icon="inline-start" />
|
||||
)}
|
||||
{createMutation.isPending ? 'Создание…' : 'Создать'}
|
||||
</Button>
|
||||
</FieldGroup>
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<ResourceList
|
||||
items={
|
||||
groups?.map((g) => ({
|
||||
id: g.id,
|
||||
primary: g.name,
|
||||
secondary: `(${g.slug})`,
|
||||
})) ?? []
|
||||
}
|
||||
emptyTitle="Группы не найдены"
|
||||
emptyDescription="Создайте первую группу в форме выше"
|
||||
renderActions={(item) => (
|
||||
<Button
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
onClick={() => deleteMutation.mutate(item.id as number)}
|
||||
disabled={deleteMutation.isPending}
|
||||
>
|
||||
Удалить
|
||||
</Button>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
import { createFileRoute, Link } from '@tanstack/react-router'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardFooter,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from '@cfdm/ui/components/card'
|
||||
import { Button } from '@cfdm/ui/components/button'
|
||||
import { certificatesQueryOptions, certSummaryQueryOptions, domainsListQueryOptions } from '@/queries'
|
||||
import { PageHeader } from '@/components/page-header'
|
||||
|
||||
export const Route = createFileRoute('/_auth/')({
|
||||
loader: ({ context: { queryClient } }) =>
|
||||
Promise.all([
|
||||
queryClient.ensureQueryData(domainsListQueryOptions()),
|
||||
queryClient.ensureQueryData(certSummaryQueryOptions()),
|
||||
]),
|
||||
component: DashboardPage,
|
||||
})
|
||||
|
||||
function DashboardPage() {
|
||||
const { data: domains } = useQuery(domainsListQueryOptions())
|
||||
const { data: summary } = useQuery(certSummaryQueryOptions())
|
||||
const { data: certs } = useQuery(certificatesQueryOptions())
|
||||
|
||||
return (
|
||||
<div className="@container/main flex flex-col gap-4 py-4 md:gap-6 md:py-6">
|
||||
<PageHeader
|
||||
title="Панель управления"
|
||||
description="Обзор доменов и сертификатов Cloudflare"
|
||||
/>
|
||||
<div className="grid gap-4 md:grid-cols-3">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardDescription>Домены</CardDescription>
|
||||
<CardTitle className="text-3xl font-semibold tabular-nums">
|
||||
{domains?.length ?? 0}
|
||||
</CardTitle>
|
||||
</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">
|
||||
{certs?.length ?? 0}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardFooter>
|
||||
<Button
|
||||
variant="link"
|
||||
className="h-auto p-0"
|
||||
render={<Link to="/certificates" />}
|
||||
>
|
||||
Мониторинг сертификатов
|
||||
</Button>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardDescription>Статусы сертификатов</CardDescription>
|
||||
<CardTitle className="text-base font-medium">Сводка</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<ul className="flex flex-col gap-1 text-sm">
|
||||
{summary?.map(([status, count]) => (
|
||||
<li key={status} className="flex justify-between">
|
||||
<span className="text-muted-foreground">{status}</span>
|
||||
<span className="font-medium tabular-nums">{count}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
import { createFileRoute } from '@tanstack/react-router'
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { useForm } from 'react-hook-form'
|
||||
import { zodResolver } from '@hookform/resolvers/zod'
|
||||
import { toast } from 'sonner'
|
||||
import { servicesQueryOptions, serviceKeys } from '@/queries'
|
||||
import { api } from '@/lib/api-client'
|
||||
import { createServiceSchema, type CreateServiceInput } from '@/lib/schemas'
|
||||
import { PageHeader } from '@/components/page-header'
|
||||
import { ResourceList } from '@/components/resource-list'
|
||||
import { Button } from '@cfdm/ui/components/button'
|
||||
import { Input } from '@cfdm/ui/components/input'
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from '@cfdm/ui/components/card'
|
||||
import {
|
||||
Field,
|
||||
FieldGroup,
|
||||
FieldLabel,
|
||||
} from '@cfdm/ui/components/field'
|
||||
import { Spinner } from '@cfdm/ui/components/spinner'
|
||||
|
||||
export const Route = createFileRoute('/_auth/services')({
|
||||
loader: ({ context: { queryClient } }) => queryClient.ensureQueryData(servicesQueryOptions()),
|
||||
component: ServicesPage,
|
||||
})
|
||||
|
||||
function ServicesPage() {
|
||||
const queryClient = useQueryClient()
|
||||
const { data: services } = useQuery(servicesQueryOptions())
|
||||
|
||||
const form = useForm<CreateServiceInput>({
|
||||
resolver: zodResolver(createServiceSchema),
|
||||
defaultValues: { name: '', slug: '' },
|
||||
})
|
||||
|
||||
const createMutation = useMutation({
|
||||
mutationFn: (body: CreateServiceInput) => api.post('/api/v1/services', body),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: serviceKeys.all })
|
||||
form.reset()
|
||||
toast.success('Сервис создан')
|
||||
},
|
||||
onError: (err) => {
|
||||
toast.error(err instanceof Error ? err.message : 'Не удалось создать сервис')
|
||||
},
|
||||
})
|
||||
|
||||
const handleSubmit = form.handleSubmit((values) => {
|
||||
createMutation.mutate(values)
|
||||
})
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4 py-4 md:gap-6 md:py-6">
|
||||
<PageHeader
|
||||
title="Сервисы"
|
||||
description="Справочник сервисов для привязки к доменам"
|
||||
/>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Создать сервис</CardTitle>
|
||||
<CardDescription>Добавить новый сервис в справочник</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<form onSubmit={handleSubmit}>
|
||||
<FieldGroup className="flex flex-row flex-wrap items-end gap-2">
|
||||
<Field className="flex-1">
|
||||
<FieldLabel htmlFor="name">Название</FieldLabel>
|
||||
<Input
|
||||
id="name"
|
||||
placeholder="Название"
|
||||
{...form.register('name')}
|
||||
aria-invalid={!!form.formState.errors.name}
|
||||
/>
|
||||
</Field>
|
||||
<Field className="flex-1">
|
||||
<FieldLabel htmlFor="slug">Slug</FieldLabel>
|
||||
<Input
|
||||
id="slug"
|
||||
placeholder="slug"
|
||||
{...form.register('slug')}
|
||||
aria-invalid={!!form.formState.errors.slug}
|
||||
/>
|
||||
</Field>
|
||||
<Button type="submit" disabled={createMutation.isPending}>
|
||||
{createMutation.isPending && (
|
||||
<Spinner data-icon="inline-start" />
|
||||
)}
|
||||
{createMutation.isPending ? 'Создание…' : 'Создать'}
|
||||
</Button>
|
||||
</FieldGroup>
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<ResourceList
|
||||
items={
|
||||
services?.map((s) => ({
|
||||
id: s.id,
|
||||
primary: s.name,
|
||||
secondary: `(${s.slug})`,
|
||||
})) ?? []
|
||||
}
|
||||
emptyTitle="Сервисы не найдены"
|
||||
emptyDescription="Создайте первый сервис в форме выше"
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import { createFileRoute } from '@tanstack/react-router'
|
||||
import { CloudIcon } from 'lucide-react'
|
||||
import { LoginForm } from '@/components/login-form'
|
||||
|
||||
export const Route = createFileRoute('/login')({
|
||||
component: LoginPage,
|
||||
})
|
||||
|
||||
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">
|
||||
<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>
|
||||
<LoginForm />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user