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,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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user