Enhance frontend documentation and UI components; update frontend-shadcn.mdc to include UI patterns reference; improve navigation structure in AppSidebar by categorizing items; refactor DataTableCard to support empty states with EmptyState component; implement ConfirmDialog for delete actions in DnsRecordsTable and GroupsPage; add search functionality in CertificatesPage and GroupsPage for better user experience; update ServiceEditSheet to utilize tabs for organization.
Build, Test, and Push CFDM Docker Image / test (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 / create-release (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) Has been cancelled
Build, Test, and Push CFDM Docker Image / build-and-push (push) Has been cancelled
Build, Test, and Push CFDM Docker Image / create-release (push) Has been cancelled
Build, Test, and Push CFDM Docker Image / update-wiki (push) Has been cancelled
This commit is contained in:
@@ -1,12 +1,19 @@
|
||||
import { createFileRoute } from '@tanstack/react-router'
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { useMemo, useState } from 'react'
|
||||
import { Bar, BarChart, CartesianGrid, XAxis } from 'recharts'
|
||||
import { ShieldCheckIcon } from 'lucide-react'
|
||||
import { toast } from 'sonner'
|
||||
import { certificatesQueryOptions, certKeys, certSummaryQueryOptions } from '@/queries'
|
||||
import { api } from '@/lib/api-client'
|
||||
import { formatDate } from '@/lib/format'
|
||||
import { PageHeader } from '@/components/page-header'
|
||||
import { PageShell } from '@/components/page-shell'
|
||||
import { QueryState } from '@/components/query-state'
|
||||
import { EmptyState } from '@/components/empty-state'
|
||||
import { StatusBadge } from '@/components/status-badge'
|
||||
import { DataTableCard } from '@/components/data-table-card'
|
||||
import { TableToolbar } from '@/components/table-toolbar'
|
||||
import { Button } from '@cfdm/ui/components/button'
|
||||
import {
|
||||
Card,
|
||||
@@ -48,8 +55,15 @@ export const Route = createFileRoute('/_auth/certificates')({
|
||||
})
|
||||
|
||||
function CertificatesPage() {
|
||||
const [searchQuery, setSearchQuery] = useState('')
|
||||
const queryClient = useQueryClient()
|
||||
const { data: certs } = useQuery(certificatesQueryOptions())
|
||||
const {
|
||||
data: certs,
|
||||
isLoading,
|
||||
isError,
|
||||
error,
|
||||
refetch,
|
||||
} = useQuery(certificatesQueryOptions())
|
||||
const { data: summary } = useQuery(certSummaryQueryOptions())
|
||||
|
||||
const checkMutation = useMutation({
|
||||
@@ -66,8 +80,21 @@ function CertificatesPage() {
|
||||
|
||||
const chartData = summary?.map(([status, count]) => ({ status, count })) ?? []
|
||||
|
||||
const filteredCerts = useMemo(() => {
|
||||
const list = certs ?? []
|
||||
const query = searchQuery.trim().toLowerCase()
|
||||
if (!query) return list
|
||||
return list.filter(
|
||||
(c) =>
|
||||
c.hostname.toLowerCase().includes(query) ||
|
||||
c.status.toLowerCase().includes(query),
|
||||
)
|
||||
}, [certs, searchQuery])
|
||||
|
||||
const isFilteredEmpty = (certs?.length ?? 0) > 0 && filteredCerts.length === 0
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4 py-4 md:gap-6 md:py-6">
|
||||
<PageShell>
|
||||
<PageHeader
|
||||
title="Сертификаты"
|
||||
description="Мониторинг SSL-сертификатов"
|
||||
@@ -83,51 +110,81 @@ function CertificatesPage() {
|
||||
</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}
|
||||
<QueryState isLoading={isLoading} isError={isError} error={error} onRetry={refetch}>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Обзор статусов</CardTitle>
|
||||
<CardDescription>Распределение сертификатов по статусам</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{chartData.length > 0 ? (
|
||||
<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>
|
||||
) : (
|
||||
<EmptyState
|
||||
icon={ShieldCheckIcon}
|
||||
title="Нет данных для графика"
|
||||
description="Запустите проверку сертификатов"
|
||||
/>
|
||||
<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>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
<DataTableCard
|
||||
title="Сертификаты"
|
||||
description="Все отслеживаемые хосты"
|
||||
isEmpty={!filteredCerts.length}
|
||||
emptyTitle={
|
||||
isFilteredEmpty ? 'Ничего не найдено' : 'Сертификаты не найдены'
|
||||
}
|
||||
emptyDescription={
|
||||
isFilteredEmpty
|
||||
? 'Измените поисковый запрос'
|
||||
: 'Сертификаты появятся после проверки доменов'
|
||||
}
|
||||
emptyIcon={ShieldCheckIcon}
|
||||
toolbar={
|
||||
<TableToolbar
|
||||
value={searchQuery}
|
||||
onChange={setSearchQuery}
|
||||
placeholder="Поиск по хосту или статусу…"
|
||||
/>
|
||||
}
|
||||
>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Хост</TableHead>
|
||||
<TableHead>Статус</TableHead>
|
||||
<TableHead>Истекает</TableHead>
|
||||
<TableHead>Последняя проверка</TableHead>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</DataTableCard>
|
||||
</div>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{filteredCerts.map((c) => (
|
||||
<TableRow key={c.id}>
|
||||
<TableCell className="font-medium">{c.hostname}</TableCell>
|
||||
<TableCell>
|
||||
<StatusBadge status={c.status} />
|
||||
</TableCell>
|
||||
<TableCell>{formatDate(c.expires_at)}</TableCell>
|
||||
<TableCell>{formatDate(c.last_checked_at)}</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</DataTableCard>
|
||||
</QueryState>
|
||||
</PageShell>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -3,12 +3,15 @@ import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { useMemo, useState } from 'react'
|
||||
import { useForm, Controller } from 'react-hook-form'
|
||||
import { zodResolver } from '@hookform/resolvers/zod'
|
||||
import { NetworkIcon } from 'lucide-react'
|
||||
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 { PageShell } from '@/components/page-shell'
|
||||
import { DataTableCard } from '@/components/data-table-card'
|
||||
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'
|
||||
@@ -40,19 +43,23 @@ const DNS_TYPES = ['A', 'AAAA', 'CNAME', 'TXT', 'MX'] as const
|
||||
const dnsTypeItems = DNS_TYPES.map((type) => ({ label: type, value: type }))
|
||||
|
||||
export const Route = createFileRoute('/_auth/domains/$domainId/dns')({
|
||||
loader: ({ context: { queryClient }, params }) => {
|
||||
validateSearch: (search: Record<string, unknown>) => ({
|
||||
host: typeof search.host === 'string' ? search.host : undefined,
|
||||
}),
|
||||
loader: async ({ context: { queryClient }, params }) => {
|
||||
const id = Number(params.domainId)
|
||||
return Promise.all([
|
||||
queryClient.ensureQueryData(domainDetailQueryOptions(id)),
|
||||
queryClient.ensureQueryData(dnsListQueryOptions(id)),
|
||||
])
|
||||
const domain = await queryClient.ensureQueryData(domainDetailQueryOptions(id))
|
||||
await queryClient.ensureQueryData(dnsListQueryOptions(id))
|
||||
return { breadcrumb: domain.zone_name }
|
||||
},
|
||||
component: DnsPage,
|
||||
})
|
||||
|
||||
function DnsPage() {
|
||||
const [sheetOpen, setSheetOpen] = useState(false)
|
||||
const [searchQuery, setSearchQuery] = useState('')
|
||||
const { domainId } = Route.useParams()
|
||||
const { host } = Route.useSearch()
|
||||
const id = Number(domainId)
|
||||
const queryClient = useQueryClient()
|
||||
const { data: domain } = useQuery(domainDetailQueryOptions(id))
|
||||
@@ -117,14 +124,30 @@ function DnsPage() {
|
||||
createMutation.mutate(values)
|
||||
})
|
||||
|
||||
const hasRecords = useMemo(() => (records?.length ?? 0) > 0, [records])
|
||||
const filteredRecords = useMemo(() => {
|
||||
const list = records ?? []
|
||||
const query = (host ?? searchQuery).trim().toLowerCase()
|
||||
if (!query) return list
|
||||
return list.filter(
|
||||
(r) =>
|
||||
r.name.toLowerCase().includes(query) ||
|
||||
r.content.toLowerCase().includes(query) ||
|
||||
r.record_type.toLowerCase().includes(query),
|
||||
)
|
||||
}, [records, host, searchQuery])
|
||||
|
||||
const hasRecords = useMemo(() => filteredRecords.length > 0, [filteredRecords])
|
||||
const isFilteredEmpty = (records?.length ?? 0) > 0 && filteredRecords.length === 0
|
||||
|
||||
return (
|
||||
<div className="@container/main flex flex-col gap-4 py-4 md:gap-6 md:py-6">
|
||||
<PageShell>
|
||||
<PageHeader
|
||||
title={`${domain?.zone_name ?? ''} — DNS`}
|
||||
description="Управление DNS-записями зоны"
|
||||
back={{ to: '/domains', label: '← К списку доменов' }}
|
||||
description={
|
||||
host
|
||||
? `Фильтр по хосту: ${host}`
|
||||
: 'Управление DNS-записями зоны'
|
||||
}
|
||||
actions={
|
||||
<>
|
||||
<Button variant="outline" onClick={() => setSheetOpen(true)}>
|
||||
@@ -146,14 +169,30 @@ function DnsPage() {
|
||||
<DataTableCard
|
||||
title="DNS-записи"
|
||||
description="Записи в зоне. Несколько A/AAAA с одним именем выделены фоном и разделителем"
|
||||
emptyTitle="DNS-записи не найдены"
|
||||
emptyDescription="Создайте запись или синхронизируйте зону с Cloudflare"
|
||||
emptyTitle={
|
||||
isFilteredEmpty ? 'Ничего не найдено' : 'DNS-записи не найдены'
|
||||
}
|
||||
emptyDescription={
|
||||
isFilteredEmpty
|
||||
? 'Измените поисковый запрос'
|
||||
: 'Создайте запись или синхронизируйте зону с Cloudflare'
|
||||
}
|
||||
emptyIcon={NetworkIcon}
|
||||
isEmpty={!hasRecords}
|
||||
toolbar={
|
||||
!host ? (
|
||||
<TableToolbar
|
||||
value={searchQuery}
|
||||
onChange={setSearchQuery}
|
||||
placeholder="Поиск по имени или значению…"
|
||||
/>
|
||||
) : undefined
|
||||
}
|
||||
>
|
||||
{hasRecords && (
|
||||
<div className="p-4">
|
||||
<DnsRecordsTable
|
||||
records={records ?? []}
|
||||
records={filteredRecords}
|
||||
onDelete={(recordId) => deleteMutation.mutate(recordId)}
|
||||
isDeleting={deleteMutation.isPending}
|
||||
/>
|
||||
@@ -238,6 +277,6 @@ function DnsPage() {
|
||||
</form>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
</div>
|
||||
</PageShell>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { createFileRoute, Link } from '@tanstack/react-router'
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { GlobeIcon } from 'lucide-react'
|
||||
import { toast } from 'sonner'
|
||||
import {
|
||||
domainDetailQueryOptions,
|
||||
@@ -9,7 +10,10 @@ import {
|
||||
subdomainsListQueryOptions,
|
||||
} from '@/queries'
|
||||
import { api } from '@/lib/api-client'
|
||||
import { formatDate } from '@/lib/format'
|
||||
import { PageHeader } from '@/components/page-header'
|
||||
import { PageShell } from '@/components/page-shell'
|
||||
import { EmptyState } from '@/components/empty-state'
|
||||
import { StatusBadge } from '@/components/status-badge'
|
||||
import { DomainBindingsCard } from '@/components/domain-bindings-card'
|
||||
import { Badge } from '@cfdm/ui/components/badge'
|
||||
@@ -21,14 +25,9 @@ import {
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from '@cfdm/ui/components/card'
|
||||
import {
|
||||
Empty,
|
||||
EmptyDescription,
|
||||
EmptyHeader,
|
||||
EmptyTitle,
|
||||
} from '@cfdm/ui/components/empty'
|
||||
import {
|
||||
Item,
|
||||
ItemActions,
|
||||
ItemContent,
|
||||
ItemGroup,
|
||||
ItemSeparator,
|
||||
@@ -37,13 +36,14 @@ import {
|
||||
import { Spinner } from '@cfdm/ui/components/spinner'
|
||||
|
||||
export const Route = createFileRoute('/_auth/domains/$domainId/')({
|
||||
loader: ({ context: { queryClient }, params }) => {
|
||||
loader: async ({ context: { queryClient }, params }) => {
|
||||
const id = Number(params.domainId)
|
||||
return Promise.all([
|
||||
queryClient.ensureQueryData(domainDetailQueryOptions(id)),
|
||||
const domain = await queryClient.ensureQueryData(domainDetailQueryOptions(id))
|
||||
await Promise.all([
|
||||
queryClient.ensureQueryData(subdomainsListQueryOptions(id)),
|
||||
queryClient.ensureQueryData(domainServiceBindingsQueryOptions(id)),
|
||||
])
|
||||
return { breadcrumb: domain.zone_name }
|
||||
},
|
||||
component: DomainOverviewPage,
|
||||
})
|
||||
@@ -70,11 +70,10 @@ function DomainOverviewPage() {
|
||||
})
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4 py-4 md:gap-6 md:py-6">
|
||||
<PageShell>
|
||||
<PageHeader
|
||||
title={domain?.zone_name ?? ''}
|
||||
description="Обзор домена и поддоменов"
|
||||
back={{ to: '/domains', label: '← К списку доменов' }}
|
||||
actions={
|
||||
<>
|
||||
<Button
|
||||
@@ -93,6 +92,7 @@ function DomainOverviewPage() {
|
||||
<Link
|
||||
to="/domains/$domainId/dns"
|
||||
params={{ domainId }}
|
||||
search={{ host: undefined }}
|
||||
/>
|
||||
}
|
||||
>
|
||||
@@ -133,7 +133,7 @@ function DomainOverviewPage() {
|
||||
</div>
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<span className="text-muted-foreground">Последняя синхронизация</span>
|
||||
<span className="font-medium">{domain?.last_synced_at ?? '—'}</span>
|
||||
<span className="font-medium">{formatDate(domain?.last_synced_at)}</span>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
@@ -153,23 +153,35 @@ function DomainOverviewPage() {
|
||||
<ItemContent>
|
||||
<ItemTitle className="font-mono font-normal">{s.fqdn}</ItemTitle>
|
||||
</ItemContent>
|
||||
<ItemActions>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
render={
|
||||
<Link
|
||||
to="/domains/$domainId/dns"
|
||||
params={{ domainId }}
|
||||
search={{ host: s.fqdn }}
|
||||
/>
|
||||
}
|
||||
>
|
||||
DNS
|
||||
</Button>
|
||||
</ItemActions>
|
||||
</Item>
|
||||
{index < subdomains.length - 1 && <ItemSeparator />}
|
||||
</div>
|
||||
))}
|
||||
</ItemGroup>
|
||||
) : (
|
||||
<Empty className="border border-dashed p-4">
|
||||
<EmptyHeader>
|
||||
<EmptyTitle>Поддомены не найдены</EmptyTitle>
|
||||
<EmptyDescription>
|
||||
Нажмите «Синхронизировать» — поддомены извлекаются из DNS-записей Cloudflare
|
||||
</EmptyDescription>
|
||||
</EmptyHeader>
|
||||
</Empty>
|
||||
<EmptyState
|
||||
icon={GlobeIcon}
|
||||
title="Поддомены не найдены"
|
||||
description="Нажмите «Синхронизировать» — поддомены извлекаются из DNS-записей Cloudflare"
|
||||
/>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</PageShell>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -14,16 +14,11 @@ import {
|
||||
serviceBindingsQueryOptions,
|
||||
} from '@/queries'
|
||||
import { PageHeader } from '@/components/page-header'
|
||||
import { PageShell } from '@/components/page-shell'
|
||||
import { DataTableCard } from '@/components/data-table-card'
|
||||
import { DomainsDataTable } from '@/components/domains-data-table'
|
||||
import { Button } from '@cfdm/ui/components/button'
|
||||
import { Input } from '@cfdm/ui/components/input'
|
||||
import {
|
||||
Card,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from '@cfdm/ui/components/card'
|
||||
import {
|
||||
Field,
|
||||
FieldGroup,
|
||||
@@ -137,10 +132,10 @@ function DomainsPage() {
|
||||
)
|
||||
|
||||
return (
|
||||
<div className="@container/main flex flex-col gap-4 py-4 md:gap-6 md:py-6">
|
||||
<PageShell>
|
||||
<PageHeader
|
||||
title="Домены"
|
||||
description="Импорт и управление зонами Cloudflare"
|
||||
description={`${tableData.length} зон · ${tableData.filter((d) => d.ips.length > 0).length} с IP · ${tableData.reduce((sum, d) => sum + d.service_count, 0)} привязок сервисов`}
|
||||
actions={
|
||||
<>
|
||||
<Button variant="outline" render={<Link to="/groups" />}>
|
||||
@@ -151,17 +146,6 @@ function DomainsPage() {
|
||||
}
|
||||
/>
|
||||
|
||||
<Card className="border-dashed">
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-base">Быстрый обзор</CardTitle>
|
||||
<CardDescription>
|
||||
{tableData.length} зон ·{' '}
|
||||
{tableData.filter((d) => d.ips.length > 0).length} с IP-адресами ·{' '}
|
||||
{tableData.reduce((sum, d) => sum + d.service_count, 0)} привязок сервисов
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
</Card>
|
||||
|
||||
<DataTableCard
|
||||
title="Список доменов"
|
||||
description="Импортированные зоны Cloudflare"
|
||||
@@ -222,6 +206,6 @@ function DomainsPage() {
|
||||
</form>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
</div>
|
||||
</PageShell>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { createFileRoute, Link } from '@tanstack/react-router'
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { useMemo } from 'react'
|
||||
import { useMemo, useState } from 'react'
|
||||
import { useForm } from 'react-hook-form'
|
||||
import { zodResolver } from '@hookform/resolvers/zod'
|
||||
import { FolderTreeIcon } from 'lucide-react'
|
||||
import { toast } from 'sonner'
|
||||
import {
|
||||
domainKeys,
|
||||
@@ -14,6 +15,9 @@ import {
|
||||
import { api } from '@/lib/api-client'
|
||||
import { createGroupSchema, type CreateGroupInput, type DomainListItem } from '@/lib/schemas'
|
||||
import { PageHeader } from '@/components/page-header'
|
||||
import { PageShell } from '@/components/page-shell'
|
||||
import { ConfirmDialog } from '@/components/confirm-dialog'
|
||||
import { TableToolbar } from '@/components/table-toolbar'
|
||||
import { KanbanBoard } from '@/components/kanban-board'
|
||||
import { DomainGroupCard } from '@/components/domain-group-card'
|
||||
import { DataTableCard } from '@/components/data-table-card'
|
||||
@@ -70,6 +74,7 @@ function parseGroupColumnId(columnId: string): number | null {
|
||||
}
|
||||
|
||||
function GroupsPage() {
|
||||
const [catalogSearch, setCatalogSearch] = useState('')
|
||||
const queryClient = useQueryClient()
|
||||
const { data: groups } = useQuery(groupsQueryOptions())
|
||||
const { data: domains } = useQuery(domainsListQueryOptions())
|
||||
@@ -163,10 +168,24 @@ function GroupsPage() {
|
||||
createMutation.mutate(values)
|
||||
})
|
||||
|
||||
const filteredGroups = useMemo(() => {
|
||||
const list = groups ?? []
|
||||
const query = catalogSearch.trim().toLowerCase()
|
||||
if (!query) return list
|
||||
return list.filter(
|
||||
(g) =>
|
||||
g.name.toLowerCase().includes(query) ||
|
||||
g.slug.toLowerCase().includes(query),
|
||||
)
|
||||
}, [groups, catalogSearch])
|
||||
|
||||
const isCatalogFilteredEmpty =
|
||||
(groups?.length ?? 0) > 0 && filteredGroups.length === 0
|
||||
|
||||
return (
|
||||
<div className="@container/main flex flex-col gap-4 py-4 md:gap-6 md:py-6">
|
||||
<PageShell>
|
||||
<PageHeader
|
||||
title="Группы"
|
||||
title="Группы доменов"
|
||||
description="Канбан-доска доменов по группам и справочник групп"
|
||||
/>
|
||||
<Tabs defaultValue="kanban" orientation="horizontal" className="flex w-full flex-col gap-4">
|
||||
@@ -237,9 +256,23 @@ function GroupsPage() {
|
||||
<DataTableCard
|
||||
title="Справочник групп"
|
||||
description="Все группы доменов"
|
||||
isEmpty={!groups?.length}
|
||||
emptyTitle="Группы не найдены"
|
||||
emptyDescription="Создайте первую группу в форме выше"
|
||||
isEmpty={!filteredGroups.length}
|
||||
emptyTitle={
|
||||
isCatalogFilteredEmpty ? 'Ничего не найдено' : 'Группы не найдены'
|
||||
}
|
||||
emptyDescription={
|
||||
isCatalogFilteredEmpty
|
||||
? 'Измените поисковый запрос'
|
||||
: 'Создайте первую группу в форме выше'
|
||||
}
|
||||
emptyIcon={FolderTreeIcon}
|
||||
toolbar={
|
||||
<TableToolbar
|
||||
value={catalogSearch}
|
||||
onChange={setCatalogSearch}
|
||||
placeholder="Поиск по названию или slug…"
|
||||
/>
|
||||
}
|
||||
>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
@@ -250,7 +283,7 @@ function GroupsPage() {
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{groups?.map((group) => (
|
||||
{filteredGroups.map((group) => (
|
||||
<TableRow key={group.id}>
|
||||
<TableCell className="font-medium">{group.name}</TableCell>
|
||||
<TableCell className="text-muted-foreground">{group.slug}</TableCell>
|
||||
@@ -268,14 +301,20 @@ function GroupsPage() {
|
||||
>
|
||||
Открыть
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
onClick={() => deleteMutation.mutate(group.id)}
|
||||
disabled={deleteMutation.isPending}
|
||||
>
|
||||
Удалить
|
||||
</Button>
|
||||
<ConfirmDialog
|
||||
trigger={
|
||||
<Button
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
disabled={deleteMutation.isPending}
|
||||
>
|
||||
Удалить
|
||||
</Button>
|
||||
}
|
||||
title="Удалить группу?"
|
||||
description={`Группа «${group.name}» будет удалена. Домены останутся без группы.`}
|
||||
onConfirm={() => deleteMutation.mutate(group.id)}
|
||||
/>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
@@ -285,6 +324,6 @@ function GroupsPage() {
|
||||
</DataTableCard>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
</PageShell>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,12 +1,16 @@
|
||||
import { createFileRoute, Link } from '@tanstack/react-router'
|
||||
import { useMemo, useState } from 'react'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { GlobeIcon } from 'lucide-react'
|
||||
import {
|
||||
domainsListQueryOptions,
|
||||
groupDetailQueryOptions,
|
||||
} from '@/queries'
|
||||
import { PageHeader } from '@/components/page-header'
|
||||
import { PageShell } from '@/components/page-shell'
|
||||
import { StatusBadge } from '@/components/status-badge'
|
||||
import { DataTableCard } from '@/components/data-table-card'
|
||||
import { TableToolbar } from '@/components/table-toolbar'
|
||||
import { Button } from '@cfdm/ui/components/button'
|
||||
import {
|
||||
Table,
|
||||
@@ -18,24 +22,33 @@ import {
|
||||
} from '@cfdm/ui/components/table'
|
||||
|
||||
export const Route = createFileRoute('/_auth/groups/$groupId')({
|
||||
loader: ({ context: { queryClient }, params }) => {
|
||||
loader: async ({ context: { queryClient }, params }) => {
|
||||
const id = Number(params.groupId)
|
||||
return Promise.all([
|
||||
queryClient.ensureQueryData(groupDetailQueryOptions(id)),
|
||||
queryClient.ensureQueryData(domainsListQueryOptions(id)),
|
||||
])
|
||||
const group = await queryClient.ensureQueryData(groupDetailQueryOptions(id))
|
||||
await queryClient.ensureQueryData(domainsListQueryOptions(id))
|
||||
return { breadcrumb: group.name }
|
||||
},
|
||||
component: GroupDetailPage,
|
||||
})
|
||||
|
||||
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 filteredDomains = useMemo(() => {
|
||||
const list = domains ?? []
|
||||
const query = searchQuery.trim().toLowerCase()
|
||||
if (!query) return list
|
||||
return list.filter((d) => d.zone_name.toLowerCase().includes(query))
|
||||
}, [domains, searchQuery])
|
||||
|
||||
const isFilteredEmpty = (domains?.length ?? 0) > 0 && filteredDomains.length === 0
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4 py-4 md:gap-6 md:py-6">
|
||||
<PageShell>
|
||||
<PageHeader
|
||||
title={group?.name ?? 'Группа'}
|
||||
description={
|
||||
@@ -43,7 +56,6 @@ function GroupDetailPage() {
|
||||
? `${group.domain_count} домен(ов) · slug: ${group.slug}`
|
||||
: 'Домены в группе'
|
||||
}
|
||||
back={{ to: '/groups', label: '← К группам' }}
|
||||
actions={
|
||||
<Button
|
||||
variant="outline"
|
||||
@@ -56,9 +68,23 @@ function GroupDetailPage() {
|
||||
<DataTableCard
|
||||
title="Домены группы"
|
||||
description="Список доменов, назначенных этой группе"
|
||||
emptyTitle="В группе нет доменов"
|
||||
emptyDescription="Перетащите домены на канбане групп или назначьте группу при импорте"
|
||||
isEmpty={!domains?.length}
|
||||
emptyTitle={
|
||||
isFilteredEmpty ? 'Ничего не найдено' : 'В группе нет доменов'
|
||||
}
|
||||
emptyDescription={
|
||||
isFilteredEmpty
|
||||
? 'Измените поисковый запрос'
|
||||
: 'Перетащите домены на канбане групп или назначьте группу при импорте'
|
||||
}
|
||||
emptyIcon={GlobeIcon}
|
||||
isEmpty={!filteredDomains.length}
|
||||
toolbar={
|
||||
<TableToolbar
|
||||
value={searchQuery}
|
||||
onChange={setSearchQuery}
|
||||
placeholder="Поиск по зоне…"
|
||||
/>
|
||||
}
|
||||
>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
@@ -70,13 +96,26 @@ function GroupDetailPage() {
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{domains?.map((domain) => (
|
||||
{filteredDomains.map((domain) => (
|
||||
<TableRow key={domain.id}>
|
||||
<TableCell className="font-medium">{domain.zone_name}</TableCell>
|
||||
<TableCell>
|
||||
<StatusBadge status={domain.status} />
|
||||
</TableCell>
|
||||
<TableCell>{domain.service_count}</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"
|
||||
@@ -96,6 +135,6 @@ function GroupDetailPage() {
|
||||
</TableBody>
|
||||
</Table>
|
||||
</DataTableCard>
|
||||
</div>
|
||||
</PageShell>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,89 +1,72 @@
|
||||
import { createFileRoute, Link } from '@tanstack/react-router'
|
||||
import { createFileRoute } 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 { certificatesQueryOptions, certSummaryQueryOptions, domainsListQueryOptions, groupsQueryOptions, serviceGroupsQueryOptions } from '@/queries'
|
||||
import { PageHeader } from '@/components/page-header'
|
||||
import { PageShell } from '@/components/page-shell'
|
||||
import { QueryState } from '@/components/query-state'
|
||||
import { SectionCards } from '@/components/section-cards'
|
||||
|
||||
export const Route = createFileRoute('/_auth/')({
|
||||
loader: ({ context: { queryClient } }) =>
|
||||
Promise.all([
|
||||
queryClient.ensureQueryData(domainsListQueryOptions()),
|
||||
queryClient.ensureQueryData(certSummaryQueryOptions()),
|
||||
queryClient.ensureQueryData(groupsQueryOptions()),
|
||||
queryClient.ensureQueryData(serviceGroupsQueryOptions()),
|
||||
]),
|
||||
component: DashboardPage,
|
||||
})
|
||||
|
||||
function DashboardPage() {
|
||||
const { data: domains } = useQuery(domainsListQueryOptions())
|
||||
const { data: summary } = useQuery(certSummaryQueryOptions())
|
||||
const {
|
||||
data: domains,
|
||||
isLoading: domainsLoading,
|
||||
isError: domainsError,
|
||||
error: domainsErr,
|
||||
refetch: refetchDomains,
|
||||
} = useQuery(domainsListQueryOptions())
|
||||
const {
|
||||
data: summary,
|
||||
isLoading: summaryLoading,
|
||||
isError: summaryError,
|
||||
error: summaryErr,
|
||||
refetch: refetchSummary,
|
||||
} = useQuery(certSummaryQueryOptions())
|
||||
const { data: certs } = useQuery(certificatesQueryOptions())
|
||||
const { data: groups } = useQuery(groupsQueryOptions())
|
||||
const { data: serviceData } = useQuery(serviceGroupsQueryOptions())
|
||||
|
||||
const serviceCount =
|
||||
(serviceData?.groups.reduce((sum, g) => sum + g.services.length, 0) ?? 0) +
|
||||
(serviceData?.ungrouped.length ?? 0)
|
||||
|
||||
const isLoading = domainsLoading || summaryLoading
|
||||
const isError = domainsError || summaryError
|
||||
const error = domainsErr ?? summaryErr
|
||||
|
||||
return (
|
||||
<div className="@container/main flex flex-col gap-4 py-4 md:gap-6 md:py-6">
|
||||
<PageShell>
|
||||
<PageHeader
|
||||
title="Панель управления"
|
||||
description="Обзор доменов и сертификатов Cloudflare"
|
||||
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>
|
||||
<QueryState
|
||||
isLoading={isLoading}
|
||||
isError={isError}
|
||||
error={error}
|
||||
onRetry={() => {
|
||||
void refetchDomains()
|
||||
void refetchSummary()
|
||||
}}
|
||||
>
|
||||
<SectionCards
|
||||
domainCount={domains?.length ?? 0}
|
||||
certCount={certs?.length ?? 0}
|
||||
groupCount={groups?.length ?? 0}
|
||||
serviceCount={serviceCount}
|
||||
certSummary={summary}
|
||||
/>
|
||||
</QueryState>
|
||||
</PageShell>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { createFileRoute } from '@tanstack/react-router'
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { useState } from 'react'
|
||||
import { useMemo, useState } from 'react'
|
||||
import { ServerIcon } from 'lucide-react'
|
||||
import { toast } from 'sonner'
|
||||
import {
|
||||
domainKeys,
|
||||
@@ -20,6 +21,9 @@ import type {
|
||||
UpdateServiceConfigInput,
|
||||
} from '@/lib/schemas'
|
||||
import { PageHeader } from '@/components/page-header'
|
||||
import { PageShell } from '@/components/page-shell'
|
||||
import { QueryState } from '@/components/query-state'
|
||||
import { EmptyState } from '@/components/empty-state'
|
||||
import { ServiceEditSheet } from '@/components/service-edit-sheet'
|
||||
import { ServiceGroupCard } from '@/components/service-group-card'
|
||||
import { ServiceGroupEditSheet } from '@/components/service-group-edit-sheet'
|
||||
@@ -31,17 +35,16 @@ import {
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from '@cfdm/ui/components/card'
|
||||
import {
|
||||
Empty,
|
||||
EmptyContent,
|
||||
EmptyDescription,
|
||||
EmptyHeader,
|
||||
EmptyTitle,
|
||||
} from '@cfdm/ui/components/empty'
|
||||
import { ItemGroup } from '@cfdm/ui/components/item'
|
||||
import { Spinner } from '@cfdm/ui/components/spinner'
|
||||
import { Skeleton } from '@cfdm/ui/components/skeleton'
|
||||
|
||||
export const Route = createFileRoute('/_auth/services')({
|
||||
validateSearch: (search: Record<string, unknown>) => ({
|
||||
domainId:
|
||||
search.domainId != null && search.domainId !== ''
|
||||
? Number(search.domainId)
|
||||
: undefined,
|
||||
}),
|
||||
loader: ({ context: { queryClient } }) =>
|
||||
Promise.all([
|
||||
queryClient.ensureQueryData(serviceGroupsQueryOptions()),
|
||||
@@ -88,7 +91,13 @@ function setGroupEnabled(
|
||||
}
|
||||
}
|
||||
|
||||
function serviceMatchesDomain(service: ServiceView, domainId?: number) {
|
||||
if (!domainId) return true
|
||||
return service.domains?.some((d) => d.domain_id === domainId) ?? false
|
||||
}
|
||||
|
||||
function ServicesPage() {
|
||||
const { domainId } = Route.useSearch()
|
||||
const [createSheetOpen, setCreateSheetOpen] = useState(false)
|
||||
const [createGroupSheetOpen, setCreateGroupSheetOpen] = useState(false)
|
||||
const [editingGroup, setEditingGroup] = useState<ServiceGroupView | null>(null)
|
||||
@@ -98,9 +107,19 @@ function ServicesPage() {
|
||||
const [togglingServiceId, setTogglingServiceId] = useState<number | null>(null)
|
||||
const [togglingGroupId, setTogglingGroupId] = useState<number | null>(null)
|
||||
const queryClient = useQueryClient()
|
||||
const { data, isLoading } = useQuery(serviceGroupsQueryOptions())
|
||||
const {
|
||||
data,
|
||||
isLoading,
|
||||
isError,
|
||||
error,
|
||||
refetch,
|
||||
} = useQuery(serviceGroupsQueryOptions())
|
||||
const { data: domains } = useQuery(domainsListQueryOptions())
|
||||
|
||||
const filteredDomain = domainId
|
||||
? domains?.find((d) => d.id === domainId)
|
||||
: undefined
|
||||
|
||||
function invalidateAll() {
|
||||
queryClient.invalidateQueries({ queryKey: serviceGroupKeys.all })
|
||||
queryClient.invalidateQueries({ queryKey: serviceKeys.all })
|
||||
@@ -286,15 +305,34 @@ function ServicesPage() {
|
||||
toggleGroupMutation.mutate({ id: groupId, enabled })
|
||||
}
|
||||
|
||||
const groups = data?.groups ?? []
|
||||
const ungrouped = data?.ungrouped ?? []
|
||||
const groups = useMemo(() => {
|
||||
const list = data?.groups ?? []
|
||||
if (!domainId) return list
|
||||
return list
|
||||
.map((group) => ({
|
||||
...group,
|
||||
services: group.services.filter((s) => serviceMatchesDomain(s, domainId)),
|
||||
}))
|
||||
.filter((group) => group.services.length > 0)
|
||||
}, [data?.groups, domainId])
|
||||
|
||||
const ungrouped = useMemo(() => {
|
||||
const list = data?.ungrouped ?? []
|
||||
if (!domainId) return list
|
||||
return list.filter((s) => serviceMatchesDomain(s, domainId))
|
||||
}, [data?.ungrouped, domainId])
|
||||
|
||||
const isEmpty = groups.length === 0 && ungrouped.length === 0
|
||||
|
||||
return (
|
||||
<div className="@container/main flex flex-col gap-4 py-4 md:gap-6 md:py-6">
|
||||
<PageShell>
|
||||
<PageHeader
|
||||
title="Сервисы"
|
||||
description="Домен группы (FQDN) и FQDN сервисов синхронизируются в Cloudflare при включении"
|
||||
description={
|
||||
filteredDomain
|
||||
? `Сервисы с привязками к домену ${filteredDomain.zone_name}`
|
||||
: 'Сервисы и группы сервисов — FQDN синхронизируются в Cloudflare при включении'
|
||||
}
|
||||
actions={
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button variant="outline" onClick={() => setCreateGroupSheetOpen(true)}>
|
||||
@@ -305,28 +343,37 @@ function ServicesPage() {
|
||||
}
|
||||
/>
|
||||
|
||||
{isLoading ? (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<Spinner className="size-8" />
|
||||
</div>
|
||||
) : isEmpty ? (
|
||||
<Empty>
|
||||
<EmptyHeader>
|
||||
<EmptyTitle>Сервисы не найдены</EmptyTitle>
|
||||
<EmptyDescription>
|
||||
Создайте группу и сервис — например VPN Panel или Home Assistant.
|
||||
</EmptyDescription>
|
||||
</EmptyHeader>
|
||||
<EmptyContent>
|
||||
<div className="flex flex-wrap justify-center gap-2">
|
||||
<Button variant="outline" onClick={() => setCreateGroupSheetOpen(true)}>
|
||||
Добавить группу
|
||||
</Button>
|
||||
<Button onClick={() => setCreateSheetOpen(true)}>Добавить сервис</Button>
|
||||
</div>
|
||||
</EmptyContent>
|
||||
</Empty>
|
||||
) : (
|
||||
<QueryState
|
||||
isLoading={isLoading}
|
||||
isError={isError}
|
||||
error={error}
|
||||
onRetry={refetch}
|
||||
skeleton={
|
||||
<div className="flex flex-col gap-4">
|
||||
<Skeleton className="h-32 w-full" />
|
||||
<Skeleton className="h-32 w-full" />
|
||||
</div>
|
||||
}
|
||||
>
|
||||
{isEmpty ? (
|
||||
<EmptyState
|
||||
icon={ServerIcon}
|
||||
title="Сервисы не найдены"
|
||||
description={
|
||||
filteredDomain
|
||||
? `Нет сервисов с привязками к ${filteredDomain.zone_name}`
|
||||
: 'Создайте группу и сервис — например VPN Panel или Home Assistant.'
|
||||
}
|
||||
action={
|
||||
<div className="flex flex-wrap justify-center gap-2">
|
||||
<Button variant="outline" onClick={() => setCreateGroupSheetOpen(true)}>
|
||||
Добавить группу
|
||||
</Button>
|
||||
<Button onClick={() => setCreateSheetOpen(true)}>Добавить сервис</Button>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<div className="flex flex-col gap-4">
|
||||
{groups.map((group) => (
|
||||
<ServiceGroupCard
|
||||
@@ -362,7 +409,8 @@ function ServicesPage() {
|
||||
</Card>
|
||||
) : null}
|
||||
</div>
|
||||
)}
|
||||
)}
|
||||
</QueryState>
|
||||
|
||||
<ServiceEditSheet
|
||||
mode="edit"
|
||||
@@ -409,6 +457,6 @@ function ServicesPage() {
|
||||
}}
|
||||
onSave={(id, body) => updateGroupMutation.mutate({ id, body })}
|
||||
/>
|
||||
</div>
|
||||
</PageShell>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user