chore: Update package.json and pnpm-lock.yaml to add husky for Git hooks; enhance Docker workflow with pnpm setup and linting steps; refactor various components to utilize new form components and improve UI consistency.
Build, Test, and Push CFDM Docker Image / test (push) Successful in 4m5s
Build, Test, and Push CFDM Docker Image / build-and-push (push) Successful in 2m26s
Build, Test, and Push CFDM Docker Image / update-wiki (push) Successful in 8s
Build, Test, and Push CFDM Docker Image / create-release (push) Has been skipped
Build, Test, and Push CFDM Docker Image / test (push) Successful in 4m5s
Build, Test, and Push CFDM Docker Image / build-and-push (push) Successful in 2m26s
Build, Test, and Push CFDM Docker Image / update-wiki (push) Successful in 8s
Build, Test, and Push CFDM Docker Image / create-release (push) Has been skipped
This commit is contained in:
@@ -36,7 +36,8 @@ import {
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@cfdm/ui/components/table'
|
||||
import { Spinner } from '@cfdm/ui/components/spinner'
|
||||
import { LoadingButton } from '@/components/loading-button'
|
||||
import { TableSkeleton } from '@/components/table-skeleton'
|
||||
|
||||
const chartConfig = {
|
||||
count: {
|
||||
@@ -99,18 +100,22 @@ function CertificatesPage() {
|
||||
title="Сертификаты"
|
||||
description="Мониторинг SSL: только хосты с активными сервисами или режимом «Обязательно»"
|
||||
actions={
|
||||
<Button
|
||||
<LoadingButton
|
||||
onClick={() => checkMutation.mutate()}
|
||||
disabled={checkMutation.isPending}
|
||||
isLoading={checkMutation.isPending}
|
||||
loadingLabel="Проверка…"
|
||||
>
|
||||
{checkMutation.isPending && (
|
||||
<Spinner data-icon="inline-start" />
|
||||
)}
|
||||
{checkMutation.isPending ? 'Проверка…' : 'Запустить проверку'}
|
||||
</Button>
|
||||
Запустить проверку
|
||||
</LoadingButton>
|
||||
}
|
||||
/>
|
||||
<QueryState isLoading={isLoading} isError={isError} error={error} onRetry={refetch}>
|
||||
<QueryState
|
||||
isLoading={isLoading}
|
||||
isError={isError}
|
||||
error={error}
|
||||
onRetry={refetch}
|
||||
skeleton={<TableSkeleton rows={5} columns={4} />}
|
||||
>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Обзор статусов</CardTitle>
|
||||
@@ -152,6 +157,21 @@ function CertificatesPage() {
|
||||
? 'Измените поисковый запрос'
|
||||
: 'Сертификаты появятся после проверки доменов'
|
||||
}
|
||||
emptyAction={
|
||||
isFilteredEmpty ? (
|
||||
<Button variant="outline" onClick={() => setSearchQuery('')}>
|
||||
Сбросить фильтр
|
||||
</Button>
|
||||
) : (
|
||||
<LoadingButton
|
||||
onClick={() => checkMutation.mutate()}
|
||||
isLoading={checkMutation.isPending}
|
||||
loadingLabel="Проверка…"
|
||||
>
|
||||
Запустить проверку
|
||||
</LoadingButton>
|
||||
)
|
||||
}
|
||||
emptyIcon={ShieldCheckIcon}
|
||||
toolbar={
|
||||
<TableToolbar
|
||||
|
||||
@@ -10,16 +10,17 @@ import { api } from '@/lib/api-client'
|
||||
import { createDnsRecordSchema, type CreateDnsRecordInput } from '@/lib/schemas'
|
||||
import { PageHeader } from '@/components/page-header'
|
||||
import { PageShell } from '@/components/page-shell'
|
||||
import { QueryState } from '@/components/query-state'
|
||||
import { DataTableCard } from '@/components/data-table-card'
|
||||
import { FormSheet } from '@/components/form-sheet'
|
||||
import { FormFieldSimple } from '@/components/form-field'
|
||||
import { LoadingButton } from '@/components/loading-button'
|
||||
import { TableSkeleton } from '@/components/table-skeleton'
|
||||
import { TableToolbar } from '@/components/table-toolbar'
|
||||
import { DnsRecordsTable } from '@/components/dns-records-table'
|
||||
import { Button } from '@cfdm/ui/components/button'
|
||||
import { Input } from '@cfdm/ui/components/input'
|
||||
import {
|
||||
Field,
|
||||
FieldGroup,
|
||||
FieldLabel,
|
||||
} from '@cfdm/ui/components/field'
|
||||
import { Field, FieldGroup, FieldLabel } from '@cfdm/ui/components/field'
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
@@ -27,16 +28,7 @@ import {
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@cfdm/ui/components/select'
|
||||
import {
|
||||
Sheet,
|
||||
SheetContent,
|
||||
SheetDescription,
|
||||
SheetFooter,
|
||||
SheetHeader,
|
||||
SheetTitle,
|
||||
} from '@cfdm/ui/components/sheet'
|
||||
import { Switch } from '@cfdm/ui/components/switch'
|
||||
import { Spinner } from '@cfdm/ui/components/spinner'
|
||||
|
||||
const DNS_TYPES = ['A', 'AAAA', 'CNAME', 'TXT', 'MX'] as const
|
||||
|
||||
@@ -62,8 +54,24 @@ function DnsPage() {
|
||||
const { host } = Route.useSearch()
|
||||
const id = Number(domainId)
|
||||
const queryClient = useQueryClient()
|
||||
const { data: domain } = useQuery(domainDetailQueryOptions(id))
|
||||
const { data: records } = useQuery(dnsListQueryOptions(id))
|
||||
const {
|
||||
data: domain,
|
||||
isLoading: domainLoading,
|
||||
isError: domainError,
|
||||
error: domainErr,
|
||||
refetch: refetchDomain,
|
||||
} = useQuery(domainDetailQueryOptions(id))
|
||||
const {
|
||||
data: records,
|
||||
isLoading: recordsLoading,
|
||||
isError: recordsError,
|
||||
error: recordsErr,
|
||||
refetch: refetchRecords,
|
||||
} = useQuery(dnsListQueryOptions(id))
|
||||
|
||||
const isLoading = domainLoading || recordsLoading
|
||||
const isError = domainError || recordsError
|
||||
const error = domainErr ?? recordsErr
|
||||
|
||||
const form = useForm<CreateDnsRecordInput>({
|
||||
resolver: zodResolver(createDnsRecordSchema),
|
||||
@@ -120,9 +128,9 @@ function DnsPage() {
|
||||
},
|
||||
})
|
||||
|
||||
const handleCreate = form.handleSubmit((values) => {
|
||||
const handleCreate = (values: CreateDnsRecordInput) => {
|
||||
createMutation.mutate(values)
|
||||
})
|
||||
}
|
||||
|
||||
const filteredRecords = useMemo(() => {
|
||||
const list = records ?? []
|
||||
@@ -136,7 +144,7 @@ function DnsPage() {
|
||||
)
|
||||
}, [records, host, searchQuery])
|
||||
|
||||
const hasRecords = useMemo(() => filteredRecords.length > 0, [filteredRecords])
|
||||
const hasRecords = filteredRecords.length > 0
|
||||
const isFilteredEmpty = (records?.length ?? 0) > 0 && filteredRecords.length === 0
|
||||
|
||||
return (
|
||||
@@ -153,130 +161,166 @@ function DnsPage() {
|
||||
<Button variant="outline" onClick={() => setSheetOpen(true)}>
|
||||
Новая запись
|
||||
</Button>
|
||||
<Button
|
||||
<LoadingButton
|
||||
onClick={() => syncMutation.mutate()}
|
||||
disabled={syncMutation.isPending}
|
||||
isLoading={syncMutation.isPending}
|
||||
loadingLabel="Синхронизация…"
|
||||
>
|
||||
{syncMutation.isPending && (
|
||||
<Spinner data-icon="inline-start" />
|
||||
)}
|
||||
{syncMutation.isPending ? 'Синхронизация…' : 'Синхронизировать с Cloudflare'}
|
||||
</Button>
|
||||
Синхронизировать с Cloudflare
|
||||
</LoadingButton>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
|
||||
<DataTableCard
|
||||
title="DNS-записи"
|
||||
description="Записи в зоне. Несколько A/AAAA с одним именем выделены фоном и разделителем"
|
||||
emptyTitle={
|
||||
isFilteredEmpty ? 'Ничего не найдено' : 'DNS-записи не найдены'
|
||||
}
|
||||
emptyDescription={
|
||||
isFilteredEmpty
|
||||
? 'Измените поисковый запрос'
|
||||
: 'Создайте запись или синхронизируйте зону с Cloudflare'
|
||||
}
|
||||
emptyIcon={NetworkIcon}
|
||||
isEmpty={!hasRecords}
|
||||
toolbar={
|
||||
!host ? (
|
||||
<TableToolbar
|
||||
value={searchQuery}
|
||||
onChange={setSearchQuery}
|
||||
placeholder="Поиск по имени или значению…"
|
||||
/>
|
||||
) : undefined
|
||||
<QueryState
|
||||
isLoading={isLoading}
|
||||
isError={isError}
|
||||
error={error}
|
||||
onRetry={() => {
|
||||
void refetchDomain()
|
||||
void refetchRecords()
|
||||
}}
|
||||
skeleton={<TableSkeleton rows={6} columns={6} />}
|
||||
>
|
||||
<DataTableCard
|
||||
title="DNS-записи"
|
||||
description="Записи в зоне. Несколько A/AAAA с одним именем выделены фоном и разделителем"
|
||||
emptyTitle={
|
||||
isFilteredEmpty ? 'Ничего не найдено' : 'DNS-записи не найдены'
|
||||
}
|
||||
emptyDescription={
|
||||
isFilteredEmpty
|
||||
? 'Измените поисковый запрос'
|
||||
: 'Создайте запись или синхронизируйте зону с Cloudflare'
|
||||
}
|
||||
emptyIcon={NetworkIcon}
|
||||
isEmpty={!hasRecords}
|
||||
emptyAction={
|
||||
isFilteredEmpty ? (
|
||||
<Button variant="outline" onClick={() => setSearchQuery('')}>
|
||||
Сбросить фильтр
|
||||
</Button>
|
||||
) : (
|
||||
<Button onClick={() => setSheetOpen(true)}>Новая запись</Button>
|
||||
)
|
||||
}
|
||||
toolbar={
|
||||
!host ? (
|
||||
<TableToolbar
|
||||
value={searchQuery}
|
||||
onChange={setSearchQuery}
|
||||
placeholder="Поиск по имени или значению…"
|
||||
/>
|
||||
) : undefined
|
||||
}
|
||||
>
|
||||
{hasRecords && (
|
||||
<div className="p-4">
|
||||
<DnsRecordsTable
|
||||
records={filteredRecords}
|
||||
onDelete={(recordId) => deleteMutation.mutate(recordId)}
|
||||
isDeleting={deleteMutation.isPending}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</DataTableCard>
|
||||
</QueryState>
|
||||
|
||||
<FormSheet
|
||||
open={sheetOpen}
|
||||
onOpenChange={setSheetOpen}
|
||||
title="Новая DNS-запись"
|
||||
description={`Добавить запись в зону ${domain?.zone_name ?? ''}`}
|
||||
form={form}
|
||||
onSubmit={handleCreate}
|
||||
footer={
|
||||
<LoadingButton
|
||||
type="submit"
|
||||
className="w-full"
|
||||
isLoading={createMutation.isPending}
|
||||
loadingLabel="Создание…"
|
||||
>
|
||||
Создать
|
||||
</LoadingButton>
|
||||
}
|
||||
>
|
||||
{hasRecords && (
|
||||
<div className="p-4">
|
||||
<DnsRecordsTable
|
||||
records={filteredRecords}
|
||||
onDelete={(recordId) => deleteMutation.mutate(recordId)}
|
||||
isDeleting={deleteMutation.isPending}
|
||||
<FieldGroup>
|
||||
<FormFieldSimple label="Тип" htmlFor="record_type">
|
||||
<Controller
|
||||
control={form.control}
|
||||
name="record_type"
|
||||
render={({ field }) => (
|
||||
<Select
|
||||
items={dnsTypeItems}
|
||||
value={field.value}
|
||||
onValueChange={field.onChange}
|
||||
>
|
||||
<SelectTrigger id="record_type" className="w-full">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{dnsTypeItems.map((item) => (
|
||||
<SelectItem key={item.value} value={item.value}>
|
||||
{item.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</DataTableCard>
|
||||
|
||||
<Sheet open={sheetOpen} onOpenChange={setSheetOpen}>
|
||||
<SheetContent>
|
||||
<SheetHeader>
|
||||
<SheetTitle>Новая DNS-запись</SheetTitle>
|
||||
<SheetDescription>
|
||||
Добавить запись в зону {domain?.zone_name ?? ''}
|
||||
</SheetDescription>
|
||||
</SheetHeader>
|
||||
<form onSubmit={handleCreate} className="flex flex-col gap-4 px-4">
|
||||
<FieldGroup>
|
||||
<Field>
|
||||
<FieldLabel htmlFor="record_type">Тип</FieldLabel>
|
||||
<Controller
|
||||
control={form.control}
|
||||
name="record_type"
|
||||
render={({ field }) => (
|
||||
<Select
|
||||
items={dnsTypeItems}
|
||||
value={field.value}
|
||||
onValueChange={field.onChange}
|
||||
>
|
||||
<SelectTrigger id="record_type" className="w-full">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{dnsTypeItems.map((item) => (
|
||||
<SelectItem key={item.value} value={item.value}>
|
||||
{item.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
</FormFieldSimple>
|
||||
<FormFieldSimple
|
||||
label="Имя"
|
||||
htmlFor="name"
|
||||
error={form.formState.errors.name}
|
||||
>
|
||||
<Input
|
||||
id="name"
|
||||
placeholder="@"
|
||||
{...form.register('name')}
|
||||
aria-invalid={!!form.formState.errors.name}
|
||||
/>
|
||||
</FormFieldSimple>
|
||||
<FormFieldSimple
|
||||
label="Значение"
|
||||
htmlFor="content"
|
||||
error={form.formState.errors.content}
|
||||
>
|
||||
<Input
|
||||
id="content"
|
||||
placeholder="192.168.1.1"
|
||||
{...form.register('content')}
|
||||
aria-invalid={!!form.formState.errors.content}
|
||||
/>
|
||||
</FormFieldSimple>
|
||||
<FormFieldSimple
|
||||
label="TTL"
|
||||
htmlFor="ttl"
|
||||
error={form.formState.errors.ttl}
|
||||
>
|
||||
<Input
|
||||
id="ttl"
|
||||
type="number"
|
||||
{...form.register('ttl', { valueAsNumber: true })}
|
||||
aria-invalid={!!form.formState.errors.ttl}
|
||||
/>
|
||||
</FormFieldSimple>
|
||||
<Field className="flex flex-row items-center justify-between gap-4">
|
||||
<FieldLabel htmlFor="proxied">Прокси Cloudflare</FieldLabel>
|
||||
<Controller
|
||||
control={form.control}
|
||||
name="proxied"
|
||||
render={({ field }) => (
|
||||
<Switch
|
||||
id="proxied"
|
||||
checked={field.value}
|
||||
onCheckedChange={field.onChange}
|
||||
/>
|
||||
</Field>
|
||||
<Field>
|
||||
<FieldLabel htmlFor="name">Имя</FieldLabel>
|
||||
<Input id="name" placeholder="@" {...form.register('name')} />
|
||||
</Field>
|
||||
<Field>
|
||||
<FieldLabel htmlFor="content">Значение</FieldLabel>
|
||||
<Input id="content" placeholder="192.168.1.1" {...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-row items-center justify-between gap-4">
|
||||
<FieldLabel htmlFor="proxied">Прокси Cloudflare</FieldLabel>
|
||||
<Controller
|
||||
control={form.control}
|
||||
name="proxied"
|
||||
render={({ field }) => (
|
||||
<Switch
|
||||
id="proxied"
|
||||
checked={field.value}
|
||||
onCheckedChange={field.onChange}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</Field>
|
||||
</FieldGroup>
|
||||
<SheetFooter>
|
||||
<Button type="submit" disabled={createMutation.isPending} className="w-full">
|
||||
{createMutation.isPending && (
|
||||
<Spinner data-icon="inline-start" />
|
||||
)}
|
||||
{createMutation.isPending ? 'Создание…' : 'Создать'}
|
||||
</Button>
|
||||
</SheetFooter>
|
||||
</form>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
)}
|
||||
/>
|
||||
</Field>
|
||||
</FieldGroup>
|
||||
</FormSheet>
|
||||
</PageShell>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -33,8 +33,8 @@ import {
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from '@cfdm/ui/components/card'
|
||||
import { Skeleton } from '@cfdm/ui/components/skeleton'
|
||||
import { Spinner } from '@cfdm/ui/components/spinner'
|
||||
import { LoadingButton } from '@/components/loading-button'
|
||||
import { TableSkeleton } from '@/components/table-skeleton'
|
||||
|
||||
export const Route = createFileRoute('/_auth/domains/$domainId/')({
|
||||
loader: async ({ context: { queryClient }, params }) => {
|
||||
@@ -54,12 +54,12 @@ export const Route = createFileRoute('/_auth/domains/$domainId/')({
|
||||
function DomainPageSkeleton() {
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
<Skeleton className="h-10 w-1/3" />
|
||||
<TableSkeleton withCard={false} rows={1} columns={2} />
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<Skeleton className="h-40 w-full" />
|
||||
<Skeleton className="h-40 w-full" />
|
||||
<TableSkeleton withCard rows={3} columns={2} />
|
||||
<TableSkeleton withCard rows={3} columns={2} />
|
||||
</div>
|
||||
<Skeleton className="h-64 w-full" />
|
||||
<TableSkeleton rows={5} columns={5} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -166,16 +166,14 @@ function DomainOverviewPage() {
|
||||
description="Обзор домена и поддоменов"
|
||||
actions={
|
||||
<>
|
||||
<Button
|
||||
<LoadingButton
|
||||
variant="outline"
|
||||
onClick={() => syncMutation.mutate()}
|
||||
disabled={syncMutation.isPending}
|
||||
isLoading={syncMutation.isPending}
|
||||
loadingLabel="Синхронизация…"
|
||||
>
|
||||
{syncMutation.isPending && (
|
||||
<Spinner data-icon="inline-start" />
|
||||
)}
|
||||
{syncMutation.isPending ? 'Синхронизация…' : 'Синхронизировать'}
|
||||
</Button>
|
||||
Синхронизировать
|
||||
</LoadingButton>
|
||||
<Button
|
||||
variant="outline"
|
||||
nativeButton={false}
|
||||
|
||||
@@ -3,6 +3,7 @@ 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 { GlobeIcon } from 'lucide-react'
|
||||
import { toast } from 'sonner'
|
||||
import { api } from '@/lib/api-client'
|
||||
import { createDomainSchema, type CreateDomainInput } from '@/lib/schemas'
|
||||
@@ -14,15 +15,16 @@ import {
|
||||
} from '@/queries'
|
||||
import { PageHeader } from '@/components/page-header'
|
||||
import { PageShell } from '@/components/page-shell'
|
||||
import { QueryState } from '@/components/query-state'
|
||||
import { DataTableCard } from '@/components/data-table-card'
|
||||
import { DomainsDataTable, type DomainTableRow } from '@/components/domains-data-table'
|
||||
import { FormSheet } from '@/components/form-sheet'
|
||||
import { FormFieldSimple } from '@/components/form-field'
|
||||
import { LoadingButton } from '@/components/loading-button'
|
||||
import { TableSkeleton } from '@/components/table-skeleton'
|
||||
import { Button } from '@cfdm/ui/components/button'
|
||||
import { Input } from '@cfdm/ui/components/input'
|
||||
import {
|
||||
Field,
|
||||
FieldGroup,
|
||||
FieldLabel,
|
||||
} from '@cfdm/ui/components/field'
|
||||
import { FieldGroup } from '@cfdm/ui/components/field'
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
@@ -30,15 +32,6 @@ import {
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@cfdm/ui/components/select'
|
||||
import {
|
||||
Sheet,
|
||||
SheetContent,
|
||||
SheetDescription,
|
||||
SheetFooter,
|
||||
SheetHeader,
|
||||
SheetTitle,
|
||||
} from '@cfdm/ui/components/sheet'
|
||||
import { Spinner } from '@cfdm/ui/components/spinner'
|
||||
|
||||
export const Route = createFileRoute('/_auth/domains/')({
|
||||
loader: ({ context: { queryClient } }) =>
|
||||
@@ -56,7 +49,13 @@ function DomainsPage() {
|
||||
const listGroupId =
|
||||
filterGroupId && filterGroupId !== 'none' ? Number(filterGroupId) : undefined
|
||||
const queryClient = useQueryClient()
|
||||
const { data: domains } = useQuery(domainsListQueryOptions(listGroupId))
|
||||
const {
|
||||
data: domains,
|
||||
isLoading,
|
||||
isError,
|
||||
error,
|
||||
refetch,
|
||||
} = useQuery(domainsListQueryOptions(listGroupId))
|
||||
const { data: groups } = useQuery(groupsQueryOptions())
|
||||
|
||||
const groupItems = useMemo(
|
||||
@@ -102,12 +101,12 @@ function DomainsPage() {
|
||||
deleteMutation.mutate(domain.id)
|
||||
}
|
||||
|
||||
const handleCreate = form.handleSubmit((values) => {
|
||||
const handleCreate = (values: CreateDomainInput) => {
|
||||
createMutation.mutate({
|
||||
zone_name: values.zone_name.trim(),
|
||||
group_id: importGroupId ? Number(importGroupId) : undefined,
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
const handleImportGroupChange = (value: string | null) => {
|
||||
const next = value === 'none' || !value ? '' : value
|
||||
@@ -135,6 +134,10 @@ function DomainsPage() {
|
||||
[filteredDomains],
|
||||
)
|
||||
|
||||
const hasActiveFilter = filterGroupId !== ''
|
||||
const isZeroResults = hasActiveFilter && tableData.length === 0 && (domains?.length ?? 0) > 0
|
||||
const isTrulyEmpty = tableData.length === 0 && !hasActiveFilter
|
||||
|
||||
return (
|
||||
<PageShell>
|
||||
<PageHeader
|
||||
@@ -150,68 +153,100 @@ function DomainsPage() {
|
||||
}
|
||||
/>
|
||||
|
||||
<DataTableCard
|
||||
title="Список доменов"
|
||||
description="Импортированные зоны Cloudflare"
|
||||
<QueryState
|
||||
isLoading={isLoading}
|
||||
isError={isError}
|
||||
error={error}
|
||||
onRetry={refetch}
|
||||
skeleton={<TableSkeleton />}
|
||||
>
|
||||
<DomainsDataTable
|
||||
data={tableData}
|
||||
groupFilterItems={groupItems}
|
||||
groupFilterValue={filterGroupId || 'all'}
|
||||
onGroupFilterChange={handleFilterGroupChange}
|
||||
onDelete={handleDeleteDomain}
|
||||
isDeleting={deleteMutation.isPending}
|
||||
/>
|
||||
</DataTableCard>
|
||||
|
||||
<Sheet open={sheetOpen} onOpenChange={setSheetOpen}>
|
||||
<SheetContent>
|
||||
<SheetHeader>
|
||||
<SheetTitle>Импорт домена</SheetTitle>
|
||||
<SheetDescription>
|
||||
Добавить зону из аккаунта Cloudflare в менеджер
|
||||
</SheetDescription>
|
||||
</SheetHeader>
|
||||
<form onSubmit={handleCreate} className="flex flex-col gap-4 px-4">
|
||||
<FieldGroup>
|
||||
<Field>
|
||||
<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>
|
||||
<FieldLabel htmlFor="import_group_id">Группа</FieldLabel>
|
||||
<Select
|
||||
items={groupItems}
|
||||
value={importGroupId || 'none'}
|
||||
onValueChange={handleImportGroupChange}
|
||||
>
|
||||
<SelectTrigger id="import_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>
|
||||
</FieldGroup>
|
||||
<SheetFooter>
|
||||
<Button type="submit" disabled={createMutation.isPending} className="w-full">
|
||||
{createMutation.isPending && <Spinner data-icon="inline-start" />}
|
||||
{createMutation.isPending ? 'Импорт…' : 'Импортировать'}
|
||||
<DataTableCard
|
||||
title="Список доменов"
|
||||
description="Импортированные зоны Cloudflare"
|
||||
isEmpty={isTrulyEmpty || isZeroResults}
|
||||
emptyTitle={
|
||||
isZeroResults ? 'Ничего не найдено' : 'Домены не импортированы'
|
||||
}
|
||||
emptyDescription={
|
||||
isZeroResults
|
||||
? 'По выбранному фильтру группы нет подходящих зон'
|
||||
: 'Импортируйте зону из аккаунта Cloudflare'
|
||||
}
|
||||
emptyIcon={GlobeIcon}
|
||||
emptyAction={
|
||||
isZeroResults ? (
|
||||
<Button variant="outline" onClick={() => setFilterGroupId('')}>
|
||||
Сбросить фильтр
|
||||
</Button>
|
||||
</SheetFooter>
|
||||
</form>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
) : (
|
||||
<Button onClick={() => setSheetOpen(true)}>Импортировать домен</Button>
|
||||
)
|
||||
}
|
||||
>
|
||||
{!isTrulyEmpty && !isZeroResults && (
|
||||
<DomainsDataTable
|
||||
data={tableData}
|
||||
groupFilterItems={groupItems}
|
||||
groupFilterValue={filterGroupId || 'all'}
|
||||
onGroupFilterChange={handleFilterGroupChange}
|
||||
onDelete={handleDeleteDomain}
|
||||
isDeleting={deleteMutation.isPending}
|
||||
/>
|
||||
)}
|
||||
</DataTableCard>
|
||||
</QueryState>
|
||||
|
||||
<FormSheet
|
||||
open={sheetOpen}
|
||||
onOpenChange={setSheetOpen}
|
||||
title="Импорт домена"
|
||||
description="Добавить зону из аккаунта Cloudflare в менеджер"
|
||||
form={form}
|
||||
onSubmit={handleCreate}
|
||||
footer={
|
||||
<LoadingButton
|
||||
type="submit"
|
||||
className="w-full"
|
||||
isLoading={createMutation.isPending}
|
||||
loadingLabel="Импорт…"
|
||||
>
|
||||
Импортировать
|
||||
</LoadingButton>
|
||||
}
|
||||
>
|
||||
<FieldGroup>
|
||||
<FormFieldSimple
|
||||
label="Имя зоны"
|
||||
htmlFor="zone_name"
|
||||
error={form.formState.errors.zone_name}
|
||||
>
|
||||
<Input
|
||||
id="zone_name"
|
||||
placeholder="example.com"
|
||||
{...form.register('zone_name')}
|
||||
aria-invalid={!!form.formState.errors.zone_name}
|
||||
/>
|
||||
</FormFieldSimple>
|
||||
<FormFieldSimple label="Группа" htmlFor="import_group_id">
|
||||
<Select
|
||||
items={groupItems}
|
||||
value={importGroupId || 'none'}
|
||||
onValueChange={handleImportGroupChange}
|
||||
>
|
||||
<SelectTrigger id="import_group_id" className="w-full">
|
||||
<SelectValue placeholder="Без группы" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{groupItems.map((item) => (
|
||||
<SelectItem key={item.value} value={item.value}>
|
||||
{item.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</FormFieldSimple>
|
||||
</FieldGroup>
|
||||
</FormSheet>
|
||||
</PageShell>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -8,8 +8,10 @@ import {
|
||||
} from '@/queries'
|
||||
import { PageHeader } from '@/components/page-header'
|
||||
import { PageShell } from '@/components/page-shell'
|
||||
import { QueryState } from '@/components/query-state'
|
||||
import { StatusBadge } from '@/components/status-badge'
|
||||
import { DataTableCard } from '@/components/data-table-card'
|
||||
import { TableSkeleton } from '@/components/table-skeleton'
|
||||
import { TableToolbar } from '@/components/table-toolbar'
|
||||
import { Button } from '@cfdm/ui/components/button'
|
||||
import {
|
||||
@@ -35,8 +37,24 @@ function GroupDetailPage() {
|
||||
const [searchQuery, setSearchQuery] = useState('')
|
||||
const { groupId } = Route.useParams()
|
||||
const id = Number(groupId)
|
||||
const { data: group } = useQuery(groupDetailQueryOptions(id))
|
||||
const { data: domains } = useQuery(domainsListQueryOptions(id))
|
||||
const {
|
||||
data: group,
|
||||
isLoading: groupLoading,
|
||||
isError: groupError,
|
||||
error: groupErr,
|
||||
refetch: refetchGroup,
|
||||
} = useQuery(groupDetailQueryOptions(id))
|
||||
const {
|
||||
data: domains,
|
||||
isLoading: domainsLoading,
|
||||
isError: domainsError,
|
||||
error: domainsErr,
|
||||
refetch: refetchDomains,
|
||||
} = useQuery(domainsListQueryOptions(id))
|
||||
|
||||
const isLoading = groupLoading || domainsLoading
|
||||
const isError = groupError || domainsError
|
||||
const error = groupErr ?? domainsErr
|
||||
|
||||
const filteredDomains = useMemo(() => {
|
||||
const list = domains ?? []
|
||||
@@ -46,6 +64,7 @@ function GroupDetailPage() {
|
||||
}, [domains, searchQuery])
|
||||
|
||||
const isFilteredEmpty = (domains?.length ?? 0) > 0 && filteredDomains.length === 0
|
||||
const hasRecords = filteredDomains.length > 0
|
||||
|
||||
return (
|
||||
<PageShell>
|
||||
@@ -59,82 +78,113 @@ function GroupDetailPage() {
|
||||
actions={
|
||||
<Button
|
||||
variant="outline"
|
||||
nativeButton={false}
|
||||
render={<Link to="/groups" />}
|
||||
>
|
||||
На группам
|
||||
К группам
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
<DataTableCard
|
||||
title="Домены группы"
|
||||
description="Список доменов, назначенных этой группе"
|
||||
emptyTitle={
|
||||
isFilteredEmpty ? 'Ничего не найдено' : 'В группе нет доменов'
|
||||
}
|
||||
emptyDescription={
|
||||
isFilteredEmpty
|
||||
? 'Измените поисковый запрос'
|
||||
: 'Перетащите домены на канбане групп или назначьте группу при импорте'
|
||||
}
|
||||
emptyIcon={GlobeIcon}
|
||||
isEmpty={!filteredDomains.length}
|
||||
toolbar={
|
||||
<TableToolbar
|
||||
value={searchQuery}
|
||||
onChange={setSearchQuery}
|
||||
placeholder="Поиск по зоне…"
|
||||
/>
|
||||
}
|
||||
<QueryState
|
||||
isLoading={isLoading}
|
||||
isError={isError}
|
||||
error={error}
|
||||
onRetry={() => {
|
||||
void refetchGroup()
|
||||
void refetchDomains()
|
||||
}}
|
||||
skeleton={<TableSkeleton rows={5} columns={4} />}
|
||||
>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Зона</TableHead>
|
||||
<TableHead>Статус</TableHead>
|
||||
<TableHead>Сервисы</TableHead>
|
||||
<TableHead className="text-right">Действия</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{filteredDomains.map((domain) => (
|
||||
<TableRow key={domain.id}>
|
||||
<TableCell className="font-medium">{domain.zone_name}</TableCell>
|
||||
<TableCell>
|
||||
<StatusBadge status={domain.status} />
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Button
|
||||
variant="link"
|
||||
className="h-auto p-0 tabular-nums"
|
||||
render={
|
||||
<Link
|
||||
to="/services"
|
||||
search={{ domainId: domain.id }}
|
||||
/>
|
||||
}
|
||||
>
|
||||
{domain.service_count}
|
||||
</Button>
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
render={
|
||||
<Link
|
||||
to="/domains/$domainId"
|
||||
params={{ domainId: String(domain.id) }}
|
||||
/>
|
||||
}
|
||||
>
|
||||
Обзор
|
||||
</Button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</DataTableCard>
|
||||
<DataTableCard
|
||||
title="Домены группы"
|
||||
description="Список доменов, назначенных этой группе"
|
||||
emptyTitle={
|
||||
isFilteredEmpty ? 'Ничего не найдено' : 'В группе нет доменов'
|
||||
}
|
||||
emptyDescription={
|
||||
isFilteredEmpty
|
||||
? 'Измените поисковый запрос'
|
||||
: 'Перетащите домены на канбане групп или назначьте группу при импорте'
|
||||
}
|
||||
emptyIcon={GlobeIcon}
|
||||
isEmpty={!hasRecords}
|
||||
emptyAction={
|
||||
isFilteredEmpty ? (
|
||||
<Button variant="outline" onClick={() => setSearchQuery('')}>
|
||||
Сбросить фильтр
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
variant="outline"
|
||||
nativeButton={false}
|
||||
render={<Link to="/groups" />}
|
||||
>
|
||||
Открыть канбан групп
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
toolbar={
|
||||
<TableToolbar
|
||||
value={searchQuery}
|
||||
onChange={setSearchQuery}
|
||||
placeholder="Поиск по зоне…"
|
||||
/>
|
||||
}
|
||||
>
|
||||
{hasRecords && (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow className="h-10">
|
||||
<TableHead>Зона</TableHead>
|
||||
<TableHead>Статус</TableHead>
|
||||
<TableHead>Сервисы</TableHead>
|
||||
<TableHead className="text-right">Действия</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{filteredDomains.map((domain) => (
|
||||
<TableRow key={domain.id} className="h-10 text-sm">
|
||||
<TableCell className="font-medium">{domain.zone_name}</TableCell>
|
||||
<TableCell>
|
||||
<StatusBadge status={domain.status} />
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Button
|
||||
variant="link"
|
||||
className="h-auto p-0 tabular-nums"
|
||||
nativeButton={false}
|
||||
render={
|
||||
<Link
|
||||
to="/services"
|
||||
search={{ domainId: domain.id }}
|
||||
/>
|
||||
}
|
||||
>
|
||||
{domain.service_count}
|
||||
</Button>
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
nativeButton={false}
|
||||
render={
|
||||
<Link
|
||||
to="/domains/$domainId"
|
||||
params={{ domainId: String(domain.id) }}
|
||||
/>
|
||||
}
|
||||
>
|
||||
Обзор
|
||||
</Button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)}
|
||||
</DataTableCard>
|
||||
</QueryState>
|
||||
</PageShell>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import { PageHeader } from '@/components/page-header'
|
||||
import { PageShell } from '@/components/page-shell'
|
||||
import { QueryState } from '@/components/query-state'
|
||||
import { SectionCards } from '@/components/section-cards'
|
||||
import { SectionCardsSkeleton } from '@/components/section-cards-skeleton'
|
||||
|
||||
export const Route = createFileRoute('/_auth/')({
|
||||
loader: ({ context: { queryClient } }) =>
|
||||
@@ -54,6 +55,7 @@ function DashboardPage() {
|
||||
isLoading={isLoading}
|
||||
isError={isError}
|
||||
error={error}
|
||||
skeleton={<SectionCardsSkeleton />}
|
||||
onRetry={() => {
|
||||
void refetchDomains()
|
||||
void refetchSummary()
|
||||
|
||||
Reference in New Issue
Block a user