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

This commit is contained in:
Denozordec
2026-06-25 15:47:29 +07:00
parent 3632bd25e4
commit 9569c22e4d
31 changed files with 1125 additions and 547 deletions
+29 -9
View File
@@ -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}
+112 -77
View File
@@ -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>
)
}
+122 -72
View File
@@ -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>
)
}
+2
View File
@@ -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()