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

This commit is contained in:
Denozordec
2026-06-15 15:37:36 +07:00
parent 87789fa82d
commit b1467575c5
803 changed files with 16646 additions and 8251 deletions
+74
View File
@@ -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>
)
}
+101
View File
@@ -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>
)
}
+77
View File
@@ -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>
)
}
+53
View File
@@ -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>
)
}
+66
View File
@@ -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>
)
}
+41
View File
@@ -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>
}
+46
View File
@@ -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' }),
}
+11
View File
@@ -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')
}
+14
View File
@@ -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
},
},
},
})
+12
View File
@@ -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>
+98
View File
@@ -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>
+8
View File
@@ -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')
})
})
+35
View File
@@ -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>,
)
+106
View File
@@ -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)
},
})
+235
View File
@@ -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>()
+21
View File
@@ -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: '/' })
}
},
})
+10
View File
@@ -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>
),
})
+133
View File
@@ -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>
)
}
+204
View File
@@ -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>
)
}
+134
View File
@@ -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>
)
}
+89
View File
@@ -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>
)
}
+112
View File
@@ -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>
)
}
+23
View File
@@ -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>
)
}