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
+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>
)
}