feat: integrate ReUI components and update MCP configuration
Build, Test, and Push CFDM Docker Image / test (push) Failing after 49s
Build, Test, and Push CFDM Docker Image / build-and-push (push) Has been skipped
Build, Test, and Push CFDM Docker Image / update-wiki (push) Has been skipped
Build, Test, and Push CFDM Docker Image / create-release (push) Has been skipped

- Added ReUI configuration to .mcp.json and .cursor/mcp.json for component integration.
- Updated pnpm-lock.yaml with new dependencies including react-phone-number-input and adjustments to existing packages.
- Enhanced SKILL.md documentation for ReUI to clarify usage and features.
- Removed unused components (ChartCard, DataGridCard, etc.) to streamline the codebase.
- Adjusted domain-related components and filters for improved functionality and UI consistency.

Co-authored-by: Cursor <[email protected]>
This commit is contained in:
Denozordec
2026-07-10 00:37:40 +07:00
co-authored by Cursor
parent 59163bfab5
commit 29e57666e0
225 changed files with 21908 additions and 4476 deletions
+80 -170
View File
@@ -1,46 +1,30 @@
import { createFileRoute } from '@tanstack/react-router'
import { useMemo, useState } from 'react'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { Bar, BarChart, CartesianGrid, XAxis } from 'recharts'
import { GlobeIcon, ShieldCheckIcon } from 'lucide-react'
import { ShieldCheckIcon } from 'lucide-react'
import { toast } from 'sonner'
import type { Filter } from '@/components/reui/filters'
import { certificatesQueryOptions, certKeys, certSummaryQueryOptions } from '@/queries'
import { api } from '@/lib/api-client'
import { formatDate, formatRelative } from '@/lib/format'
import type { Certificate } 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 { ResourcePage } from '@/components/reui-kit'
import {
CERT_TABS,
certFilterFieldValue,
certTabFilter,
createDefaultCertFilters,
useCertificateColumns,
useCertFilterFields,
} from '@/components/columns/certificates-columns'
import { StatusBadge } from '@/components/status-badge'
import {
DataGridCard,
columnDefFromDataGrid,
} from '@/components/data-grid-card'
import type { DataGridColumn } from '@/components/data-grid-types'
import { ListFiltersBar } from '@/components/list-filters-bar'
import { ChartCard } from '@/components/chart-card'
import {
AppItem,
AppItemContent,
AppItemGroup,
AppItemTitle,
} from '@/components/app-item'
import {
ChartContainer,
ChartTooltip,
ChartTooltipContent,
type ChartConfig,
} from '@cfdm/ui/components/chart'
Frame,
FrameDescription,
FrameHeader,
FramePanel,
FrameTitle,
} from '@/components/reui/frame'
import { LoadingButton } from '@/components/loading-button'
import { TableSkeleton } from '@/components/skeletons'
const chartConfig = {
count: {
label: 'Количество',
color: 'var(--chart-1)',
},
} satisfies ChartConfig
export const Route = createFileRoute('/_auth/certificates')({
loader: ({ context: { queryClient } }) =>
@@ -52,7 +36,7 @@ export const Route = createFileRoute('/_auth/certificates')({
})
function CertificatesPage() {
const [searchQuery, setSearchQuery] = useState('')
const [filters, setFilters] = useState<Filter[]>(createDefaultCertFilters)
const queryClient = useQueryClient()
const {
data: certs,
@@ -75,153 +59,79 @@ function CertificatesPage() {
},
})
const filterFields = useCertFilterFields()
const columns = useCertificateColumns()
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 kpiStrip = useMemo(
() => (
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
{chartData.length === 0 ? (
<Frame dense spacing="sm" className="sm:col-span-2 lg:col-span-4">
<FramePanel className="flex items-center gap-3 p-4">
<ShieldCheckIcon className="text-muted-foreground size-5" />
<p className="text-muted-foreground text-sm">
Нет данных — запустите проверку сертификатов
</p>
</FramePanel>
</Frame>
) : (
chartData.map((entry) => (
<Frame key={entry.status} dense spacing="sm">
<FrameHeader className="px-3! py-2!">
<FrameTitle className="text-sm font-medium">
<StatusBadge status={entry.status} />
</FrameTitle>
<FrameDescription className="text-2xl font-semibold tabular-nums">
{entry.count}
</FrameDescription>
</FrameHeader>
</Frame>
))
)}
</div>
),
[chartData],
)
const columns = useMemo<DataGridColumn<Certificate>[]>(
() => [
{
key: 'hostname',
header: 'Хост',
icon: GlobeIcon,
sortable: true,
sortValue: (row) => row.hostname,
cell: (row) => <span className="truncate font-medium">{row.hostname}</span>,
},
{
key: 'status',
header: 'Статус',
cell: (row) => <StatusBadge status={row.status} />,
},
{
key: 'expires_at',
header: 'Истекает',
sortable: true,
sortValue: (row) => row.expires_at ?? '',
className: 'tabular-nums text-muted-foreground',
cell: (row) => formatDate(row.expires_at),
},
{
key: 'relative',
header: 'Срок',
enableHiding: true,
cell: (row) => (
<span className="tabular-nums text-muted-foreground">
{formatRelative(row.expires_at)}
</span>
),
},
{
key: 'last_checked_at',
header: 'Проверка',
enableHiding: true,
sortable: true,
sortValue: (row) => row.last_checked_at ?? '',
className: 'tabular-nums text-muted-foreground',
cell: (row) => formatDate(row.last_checked_at),
},
],
[],
const primaryAction = (
<LoadingButton
onClick={() => checkMutation.mutate()}
isLoading={checkMutation.isPending}
loadingLabel="Проверка…"
>
Запустить проверку
</LoadingButton>
)
return (
<PageShell>
<PageHeader
<PageShell className="gap-6">
{kpiStrip}
<ResourcePage
title="Сертификаты"
description="Мониторинг SSL: только хосты с активными сервисами или режимом «Обязательно»"
actions={
<LoadingButton
onClick={() => checkMutation.mutate()}
isLoading={checkMutation.isPending}
loadingLabel="Проверка…"
>
Запустить проверку
</LoadingButton>
}
/>
<QueryState
description="Мониторинг SSL: хосты с активными сервисами или режимом «Обязательно»"
tabs={CERT_TABS.map((tab) => ({ ...tab }))}
tabFilter={certTabFilter}
filterFields={filterFields}
filters={filters}
onFiltersChange={setFilters}
onClearFilters={() => setFilters(createDefaultCertFilters())}
getFilterFieldValue={certFilterFieldValue}
columns={columns}
data={certs ?? []}
getRowId={(row) => String(row.id)}
isLoading={isLoading}
isError={isError}
error={error}
onRetry={refetch}
skeleton={<TableSkeleton rows={5} cols={4} />}
>
<ChartCard
title="Обзор статусов"
description="Распределение сертификатов по статусам"
chart={
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="Запустите проверку сертификатов"
/>
)
}
table={
<AppItemGroup className="gap-2">
{chartData.map((entry) => (
<AppItem key={entry.status} variant="outline" size="sm">
<AppItemContent className="flex flex-row items-center justify-between gap-2">
<StatusBadge status={entry.status} />
<AppItemTitle className="font-medium tabular-nums">
{entry.count}
</AppItemTitle>
</AppItemContent>
</AppItem>
))}
</AppItemGroup>
}
/>
<DataGridCard
title="Сертификаты"
description="Хосты с активными сервисами или ручным мониторингом"
columns={columnDefFromDataGrid(columns)}
data={filteredCerts}
rowId={(row) => String(row.id)}
emptyTitle="Сертификаты не найдены"
emptyDescription="Сертификаты появятся после проверки доменов"
initialSorting={[{ id: 'expires_at', desc: false }]}
enableColumnVisibility
actions={
<ListFiltersBar
search={{
value: searchQuery,
onChange: setSearchQuery,
placeholder: 'Поиск по хосту или статусу…',
}}
shown={filteredCerts.length}
total={certs?.length ?? 0}
showReset={Boolean(searchQuery.trim())}
onReset={() => setSearchQuery('')}
/>
}
/>
</QueryState>
primaryAction={primaryAction}
emptyState={{
title: 'Сертификаты не найдены',
description: 'Сертификаты появятся после проверки доменов',
action: primaryAction,
}}
/>
</PageShell>
)
}
@@ -1,23 +1,29 @@
import { createFileRoute } from '@tanstack/react-router'
import { createFileRoute, Link } from '@tanstack/react-router'
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 { toast } from 'sonner'
import type { Filter } from '@/components/reui/filters'
import { api } from '@/lib/api-client'
import { createDnsRecordSchema, type CreateDnsRecordInput } from '@/lib/schemas'
import { domainDetailQueryOptions, dnsKeys, dnsListQueryOptions, subdomainKeys } from '@/queries'
import { PageHeader } from '@/components/page-header'
import { PageShell } from '@/components/page-shell'
import { QueryState } from '@/components/query-state'
import { DnsRecordsDataGrid } from '@/components/dns-records-data-grid'
import { DetailPanel, ResourcePage } from '@/components/reui-kit'
import {
DNS_TABS,
createDefaultDnsFilters,
dnsFilterFieldValue,
dnsTabFilter,
useDnsColumns,
useDnsFilterFields,
} from '@/components/columns/dns-columns'
import { FormSheet } from '@/components/form-sheet'
import { FormFieldSimple } from '@/components/form-field'
import { LoadingButton } from '@/components/loading-button'
import { TableSkeleton } from '@/components/skeletons'
import { AppButton } from '@/components/app-button'
import { AppInput } from '@/components/app-input'
import { AppField, AppFieldGroup, AppFieldLabel } from '@/components/app-field'
import { Button } from '@cfdm/ui/components/button'
import { Input } from '@cfdm/ui/components/input'
import { Field, FieldGroup, FieldLabel } from '@cfdm/ui/components/field'
import {
Select,
SelectContent,
@@ -25,7 +31,7 @@ import {
SelectTrigger,
SelectValue,
} from '@cfdm/ui/components/select'
import { AppSwitch } from '@/components/app-switch'
import { Switch } from '@cfdm/ui/components/switch'
const DNS_TYPES = ['A', 'AAAA', 'CNAME', 'TXT', 'MX'] as const
@@ -46,7 +52,10 @@ export const Route = createFileRoute('/_auth/domains/$domainId/dns')({
function DnsPage() {
const [sheetOpen, setSheetOpen] = useState(false)
const [searchQuery, setSearchQuery] = useState('')
const [filters, setFilters] = useState<Filter[]>(() => {
const defaults = createDefaultDnsFilters()
return defaults
})
const { domainId } = Route.useParams()
const { host } = Route.useSearch()
const id = Number(domainId)
@@ -125,64 +134,95 @@ function DnsPage() {
},
})
const handleCreate = (values: CreateDnsRecordInput) => {
createMutation.mutate(values)
}
const filterFields = useDnsFilterFields()
const columns = useDnsColumns({
onDelete: (recordId) => deleteMutation.mutate(recordId),
isDeleting: deleteMutation.isPending,
})
const filteredRecords = useMemo(() => {
const displayRecords = 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])
if (!host) return list
const query = host.trim().toLowerCase()
return list.filter((r) => r.name.toLowerCase().includes(query))
}, [records, host])
const headerActions = (
<>
<Button variant="outline" onClick={() => setSheetOpen(true)}>
Новая запись
</Button>
<LoadingButton
onClick={() => syncMutation.mutate()}
isLoading={syncMutation.isPending}
loadingLabel="Синхронизация…"
>
Синхронизировать с Cloudflare
</LoadingButton>
</>
)
return (
<PageShell>
<PageHeader
title={`${domain?.zone_name ?? ''} — DNS`}
description={
host ? `Фильтр по хосту: ${host}` : 'Управление DNS-записями зоны'
}
actions={
<>
<AppButton variant="outline" onClick={() => setSheetOpen(true)}>
Новая запись
</AppButton>
<LoadingButton
onClick={() => syncMutation.mutate()}
isLoading={syncMutation.isPending}
loadingLabel="Синхронизация…"
>
Синхронизировать с Cloudflare
</LoadingButton>
</>
}
/>
<DetailPanel>
<DetailPanel.Header
title={`${domain?.zone_name ?? ''} — DNS`}
description={
host ? `Фильтр по хосту: ${host}` : 'Управление DNS-записями зоны'
}
actions={headerActions}
>
<Button
variant="link"
className="h-auto p-0"
nativeButton={false}
render={
<Link to="/domains/$domainId" params={{ domainId }} />
}
>
← Обзор домена
</Button>
</DetailPanel.Header>
<QueryState
isLoading={isLoading}
isError={isError}
error={error}
onRetry={() => {
void refetchDomain()
void refetchRecords()
}}
skeleton={<TableSkeleton rows={6} cols={6} />}
>
<DnsRecordsDataGrid
records={filteredRecords}
onDelete={(recordId) => deleteMutation.mutate(recordId)}
isDeleting={deleteMutation.isPending}
search={host ?? searchQuery}
onSearchChange={setSearchQuery}
hideSearch={Boolean(host)}
/>
</QueryState>
<DetailPanel.Section>
<ResourcePage
title="DNS-записи"
description={domain?.zone_name ?? ''}
hideHeader
tabs={DNS_TABS.map((tab) => ({ ...tab }))}
tabFilter={dnsTabFilter}
filterFields={filterFields}
filters={filters}
onFiltersChange={setFilters}
onClearFilters={() => setFilters(createDefaultDnsFilters())}
getFilterFieldValue={dnsFilterFieldValue}
columns={columns}
data={displayRecords}
getRowId={(row) => String(row.id)}
isLoading={isLoading}
isError={isError}
error={error}
onRetry={() => {
void refetchDomain()
void refetchRecords()
}}
primaryAction={
<Button variant="outline" onClick={() => setSheetOpen(true)}>
Новая запись
</Button>
}
emptyState={{
title: 'DNS-записи не найдены',
description: 'Синхронизируйте зону или создайте запись вручную',
action: (
<Button variant="outline" onClick={() => setSheetOpen(true)}>
Новая запись
</Button>
),
}}
/>
</DetailPanel.Section>
</DetailPanel>
<FormSheet
open={sheetOpen}
@@ -190,7 +230,7 @@ function DnsPage() {
title="Новая DNS-запись"
description={`Добавить запись в зону ${domain?.zone_name ?? ''}`}
form={form}
onSubmit={handleCreate}
onSubmit={(values) => createMutation.mutate(values)}
footer={
<LoadingButton
type="submit"
@@ -202,7 +242,7 @@ function DnsPage() {
</LoadingButton>
}
>
<AppFieldGroup>
<FieldGroup>
<FormFieldSimple label="Тип" htmlFor="record_type">
<Controller
control={form.control}
@@ -228,33 +268,37 @@ function DnsPage() {
/>
</FormFieldSimple>
<FormFieldSimple label="Имя" htmlFor="name" error={form.formState.errors.name}>
<AppInput id="name" placeholder="@" {...form.register('name')} />
<Input id="name" placeholder="@" {...form.register('name')} />
</FormFieldSimple>
<FormFieldSimple
label="Значение"
htmlFor="content"
error={form.formState.errors.content}
>
<AppInput id="content" {...form.register('content')} />
<Input id="content" {...form.register('content')} />
</FormFieldSimple>
<FormFieldSimple label="TTL" htmlFor="ttl" error={form.formState.errors.ttl}>
<AppInput
<Input
id="ttl"
type="number"
{...form.register('ttl', { valueAsNumber: true })}
/>
</FormFieldSimple>
<AppField orientation="horizontal">
<AppFieldLabel htmlFor="proxied">Прокси Cloudflare</AppFieldLabel>
<Field orientation="horizontal">
<FieldLabel htmlFor="proxied">Прокси Cloudflare</FieldLabel>
<Controller
control={form.control}
name="proxied"
render={({ field }) => (
<AppSwitch id="proxied" checked={field.value} onCheckedChange={field.onChange} />
<Switch
id="proxied"
checked={field.value}
onCheckedChange={field.onChange}
/>
)}
/>
</AppField>
</AppFieldGroup>
</Field>
</FieldGroup>
</FormSheet>
</PageShell>
)
@@ -1,5 +1,14 @@
import { createFileRoute, Link } from '@tanstack/react-router'
import { useState } from 'react'
import { useMemo, useState } from 'react'
import {
FolderTreeIcon,
GlobeIcon,
Link2Icon,
ServerIcon,
ShieldCheckIcon,
} from 'lucide-react'
import type { Filter } from '@/components/reui/filters'
import type { CertMonitoring } from '@cfdm/shared'
import {
domainDetailQueryOptions,
domainServiceBindingsQueryOptions,
@@ -9,30 +18,36 @@ import {
} from '@/queries'
import { useDomainPage } from '@/hooks/use-domain-page'
import type { SubdomainTableRow } from '@/hooks/use-domain-page'
import { PageHeader } from '@/components/page-header'
import { PageShell } from '@/components/page-shell'
import { QueryState } from '@/components/query-state'
import { DomainHeader } from '@/components/domain-header'
import { DomainActionsBar } from '@/components/domain-actions-bar'
import { DomainBindingsCard } from '@/components/domain-bindings-card'
import { SubdomainsDataGrid } from '@/components/subdomains-data-grid'
import { DetailPanel, ResourcePage } from '@/components/reui-kit'
import {
SUBDOMAIN_TABS,
createDefaultSubdomainFilters,
subdomainFilterFieldValue,
subdomainTabFilter,
useSubdomainColumns,
useSubdomainFilterFields,
} from '@/components/columns/subdomains-columns'
import {
SubdomainEditSheet,
type SubdomainEditValues,
} from '@/components/subdomain-edit-sheet'
import { DomainBindingsCard } from '@/components/domain-bindings-card'
import { StatusBadge } from '@/components/status-badge'
import { certMonitoringLabel, certMonitoringOptions } from '@/lib/cert-monitoring'
import { formatDate } from '@/lib/format'
import { AppBadge } from '@/components/app-badge'
import { AppButton } from '@/components/app-button'
import {
AppCard,
AppCardContent,
AppCardDescription,
AppCardHeader,
AppCardTitle,
} from '@/components/app-card'
import { Badge } from '@/components/reui/badge'
import { LoadingButton } from '@/components/loading-button'
import { TableSkeleton } from '@/components/skeletons'
import { Button } from '@cfdm/ui/components/button'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@cfdm/ui/components/select'
export const Route = createFileRoute('/_auth/domains/$domainId/')({
loader: async ({ context: { queryClient }, params }) => {
@@ -53,9 +68,10 @@ function DomainPageSkeleton() {
return (
<div className="flex flex-col gap-4">
<TableSkeleton rows={1} cols={2} />
<div className="grid gap-4 md:grid-cols-2">
<TableSkeleton rows={3} cols={2} />
<TableSkeleton rows={3} cols={2} />
<div className="grid gap-4 md:grid-cols-3">
<TableSkeleton rows={2} cols={1} />
<TableSkeleton rows={2} cols={1} />
<TableSkeleton rows={2} cols={1} />
</div>
<TableSkeleton rows={5} cols={5} />
</div>
@@ -65,6 +81,10 @@ function DomainPageSkeleton() {
function DomainOverviewPage() {
const { domainId } = Route.useParams()
const id = Number(domainId)
const [filters, setFilters] = useState<Filter[]>(createDefaultSubdomainFilters)
const [sheetOpen, setSheetOpen] = useState(false)
const [sheetMode, setSheetMode] = useState<'create' | 'edit'>('create')
const [editTarget, setEditTarget] = useState<SubdomainTableRow | null>(null)
const {
domain,
@@ -84,10 +104,6 @@ function DomainOverviewPage() {
linkServiceMutation,
} = useDomainPage(id)
const [sheetOpen, setSheetOpen] = useState(false)
const [sheetMode, setSheetMode] = useState<'create' | 'edit'>('create')
const [editTarget, setEditTarget] = useState<SubdomainTableRow | null>(null)
function openCreateSheet() {
setSheetMode('create')
setEditTarget(null)
@@ -100,6 +116,20 @@ function DomainOverviewPage() {
setSheetOpen(true)
}
const filterFields = useSubdomainFilterFields()
const columns = useSubdomainColumns({
domainId,
onEdit: openEditSheet,
onDelete: (row) => deleteSubdomainMutation.mutate(row.subdomain.id),
onToggleEnabled: (row) =>
updateSubdomainMutation.mutate({
id: row.subdomain.id,
enabled: !row.subdomain.enabled,
}),
isDeleting: deleteSubdomainMutation.isPending,
isToggling: updateSubdomainMutation.isPending,
})
function resolveServiceId(row: SubdomainTableRow): string {
if (row.serviceLinks.length === 0) return 'none'
return String(row.serviceLinks[0].serviceId)
@@ -157,38 +187,97 @@ function DomainOverviewPage() {
updateSubdomainMutation.isPending ||
linkServiceMutation.isPending
const certMonitoringItems = certMonitoringOptions.map((option) => ({
label: option.label,
value: option.value,
}))
const metricCards = useMemo(() => {
if (!domain) return []
return [
{
id: 'status',
icon: <GlobeIcon aria-hidden="true" />,
label: 'Статус зоны',
description: domain.status,
footer: <StatusBadge status={domain.status} />,
},
{
id: 'group',
icon: <FolderTreeIcon aria-hidden="true" />,
label: 'Группа',
description: domain.group_id ? 'Домен в группе' : 'Без группы',
footer: domain.group_id ? (
<Button
variant="link"
className="h-auto p-0"
nativeButton={false}
render={
<Link
to="/groups/$groupId"
params={{ groupId: String(domain.group_id) }}
/>
}
>
Открыть группу
</Button>
) : (
<Badge variant="outline">Без группы</Badge>
),
},
{
id: 'bindings',
icon: <Link2Icon aria-hidden="true" />,
label: 'Привязки',
description: 'Сервисы в зоне',
footer: (
<span className="text-foreground text-lg font-semibold tabular-nums">
{bindings.length}
</span>
),
},
{
id: 'sync',
icon: <ServerIcon aria-hidden="true" />,
label: 'Синхронизация',
description: 'Последняя синхронизация с Cloudflare',
footer: (
<span className="text-foreground text-sm font-medium tabular-nums">
{formatDate(domain.last_synced_at)}
</span>
),
},
]
}, [bindings.length, domain])
const headerActions = (
<>
<LoadingButton
variant="outline"
onClick={() => syncMutation.mutate()}
isLoading={syncMutation.isPending}
loadingLabel="Синхронизация…"
>
Синхронизировать
</LoadingButton>
<Button
variant="outline"
nativeButton={false}
render={
<Link
to="/domains/$domainId/dns"
params={{ domainId }}
search={{ host: undefined }}
/>
}
>
DNS-записи
</Button>
</>
)
return (
<PageShell>
<PageHeader
title={domain?.zone_name ?? ''}
description="Обзор домена и поддоменов"
actions={
<>
<LoadingButton
variant="outline"
onClick={() => syncMutation.mutate()}
isLoading={syncMutation.isPending}
loadingLabel="Синхронизация…"
>
Синхронизировать
</LoadingButton>
<AppButton
variant="outline"
nativeButton={false}
render={
<Link
to="/domains/$domainId/dns"
params={{ domainId }}
search={{ host: undefined }}
/>
}
>
DNS-записи
</AppButton>
</>
}
/>
<QueryState
isLoading={isLoading}
isError={isError}
@@ -197,76 +286,83 @@ function DomainOverviewPage() {
skeleton={<DomainPageSkeleton />}
>
{domain && (
<>
<DomainHeader
domain={domain}
onCertMonitoringChange={(value) =>
updateDomainCertMonitoringMutation.mutate(value)
}
isCertMonitoringSaving={updateDomainCertMonitoringMutation.isPending}
/>
<DetailPanel>
<DetailPanel.Header
title={domain.zone_name}
description="Обзор домена и поддоменов"
actions={headerActions}
>
<div className="flex flex-col gap-1.5 sm:flex-row sm:items-center sm:gap-3">
<span className="text-muted-foreground flex items-center gap-2 text-sm">
<ShieldCheckIcon className="size-4" aria-hidden="true" />
Мониторинг SSL (apex):
</span>
<Select
items={certMonitoringItems}
value={domain.cert_monitoring}
onValueChange={(value) =>
updateDomainCertMonitoringMutation.mutate(
(value ?? 'auto') as CertMonitoring,
)
}
disabled={updateDomainCertMonitoringMutation.isPending}
>
<SelectTrigger className="w-full sm:w-56">
<SelectValue>
{certMonitoringLabel(domain.cert_monitoring)}
</SelectValue>
</SelectTrigger>
<SelectContent>
{certMonitoringOptions.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</DetailPanel.Header>
<div className="grid gap-4 md:grid-cols-2">
<AppCard>
<AppCardHeader>
<AppCardTitle>Сводка</AppCardTitle>
<AppCardDescription>Основные параметры зоны</AppCardDescription>
</AppCardHeader>
<AppCardContent className="flex flex-col gap-3 text-sm">
<div className="flex items-center justify-between gap-2">
<span className="text-muted-foreground">Статус</span>
<StatusBadge status={domain.status} />
</div>
<div className="flex items-center justify-between gap-2">
<span className="text-muted-foreground">Группа</span>
{domain.group_id ? (
<AppButton
variant="link"
className="h-auto p-0"
nativeButton={false}
render={
<Link
to="/groups/$groupId"
params={{ groupId: String(domain.group_id) }}
/>
}
>
Открыть группу
</AppButton>
) : (
<AppBadge variant="outline">Без группы</AppBadge>
)}
</div>
<div className="flex items-center justify-between gap-2">
<span className="text-muted-foreground">
Последняя синхронизация
</span>
<span className="font-medium">
{formatDate(domain.last_synced_at)}
</span>
</div>
</AppCardContent>
</AppCard>
<DetailPanel.Metrics cards={metricCards} />
<DetailPanel.Section
title="Привязки сервисов"
description="Сервисы, назначенные hostname в этой зоне"
>
<DomainBindingsCard bindings={bindings} />
</div>
</DetailPanel.Section>
<SubdomainsDataGrid
domainId={domainId}
rows={subdomainRows}
isDeleting={deleteSubdomainMutation.isPending}
isToggling={updateSubdomainMutation.isPending}
onEdit={openEditSheet}
onDelete={(row) =>
deleteSubdomainMutation.mutate(row.subdomain.id)
}
onToggleEnabled={(row) =>
updateSubdomainMutation.mutate({
id: row.subdomain.id,
enabled: !row.subdomain.enabled,
})
}
toolbar={<DomainActionsBar onCreateSubdomain={openCreateSheet} />}
/>
<DetailPanel.Section title="Поддомены">
<ResourcePage
title="Поддомены"
description={`Записи в зоне ${domain.zone_name}`}
hideHeader
tabs={SUBDOMAIN_TABS.map((tab) => ({ ...tab }))}
tabFilter={subdomainTabFilter}
filterFields={filterFields}
filters={filters}
onFiltersChange={setFilters}
onClearFilters={() => setFilters(createDefaultSubdomainFilters())}
getFilterFieldValue={subdomainFilterFieldValue}
columns={columns}
data={subdomainRows}
getRowId={(row) => String(row.subdomain.id)}
primaryAction={
<Button type="button" onClick={openCreateSheet}>
Добавить поддомен
</Button>
}
emptyState={{
title: 'Поддомены не созданы',
description: 'Добавьте поддомен для привязки сервисов',
action: (
<Button type="button" onClick={openCreateSheet}>
Добавить поддомен
</Button>
),
}}
/>
</DetailPanel.Section>
<SubdomainEditSheet
mode={sheetMode}
@@ -282,7 +378,7 @@ function DomainOverviewPage() {
onOpenChange={setSheetOpen}
onSubmit={handleSheetSubmit}
/>
</>
</DetailPanel>
)}
</QueryState>
</PageShell>
+78 -91
View File
@@ -3,8 +3,8 @@ 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 type { Filter } from '@/components/reui/filters'
import { api } from '@/lib/api-client'
import { createDomainSchema, type CreateDomainInput } from '@/lib/schemas'
import {
@@ -13,18 +13,22 @@ import {
groupsQueryOptions,
serviceBindingKeys,
} from '@/queries'
import { PageHeader } from '@/components/page-header'
import { PageShell } from '@/components/page-shell'
import { QueryState } from '@/components/query-state'
import { DomainsDataGrid, type DomainTableRow } from '@/components/domains-data-grid'
import { ResourcePage } from '@/components/reui-kit'
import {
DOMAIN_TABS,
createDefaultDomainFilters,
domainFilterFieldValue,
domainTabFilter,
useDomainColumns,
useDomainFilterFields,
} from '@/components/columns/domains-columns'
import { FormSheet } from '@/components/form-sheet'
import { FormFieldSimple } from '@/components/form-field'
import { LoadingButton } from '@/components/loading-button'
import { TableSkeleton } from '@/components/skeletons'
import { EmptyState } from '@/components/empty-state'
import { AppButton } from '@/components/app-button'
import { AppInput } from '@/components/app-input'
import { AppFieldGroup } from '@/components/app-field'
import { ConfirmDialog } from '@/components/confirm-dialog'
import { Button } from '@cfdm/ui/components/button'
import { Input } from '@cfdm/ui/components/input'
import {
Select,
SelectContent,
@@ -32,6 +36,8 @@ import {
SelectTrigger,
SelectValue,
} from '@cfdm/ui/components/select'
import { FieldGroup } from '@cfdm/ui/components/field'
import type { DomainListItem } from '@/lib/schemas'
export const Route = createFileRoute('/_auth/domains/')({
loader: ({ context: { queryClient } }) =>
@@ -45,18 +51,17 @@ export const Route = createFileRoute('/_auth/domains/')({
function DomainsPage() {
const [sheetOpen, setSheetOpen] = useState(false)
const [importGroupId, setImportGroupId] = useState('')
const [filterGroupId, setFilterGroupId] = useState('')
const [search, setSearch] = useState('')
const listGroupId =
filterGroupId && filterGroupId !== 'none' ? Number(filterGroupId) : undefined
const [deleteTarget, setDeleteTarget] = useState<DomainListItem | null>(null)
const [filters, setFilters] = useState<Filter[]>(createDefaultDomainFilters)
const queryClient = useQueryClient()
const {
data: domains,
isLoading,
isError,
error,
refetch,
} = useQuery(domainsListQueryOptions(listGroupId))
} = useQuery(domainsListQueryOptions())
const { data: groups } = useQuery(groupsQueryOptions())
const groupItems = useMemo(
@@ -67,6 +72,8 @@ function DomainsPage() {
[groups],
)
const filterFields = useDomainFilterFields(groupItems)
const form = useForm<CreateDomainInput>({
resolver: zodResolver(createDomainSchema),
defaultValues: { zone_name: '', group_id: '' },
@@ -91,6 +98,7 @@ function DomainsPage() {
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: domainKeys.all })
queryClient.invalidateQueries({ queryKey: serviceBindingKeys.all })
setDeleteTarget(null)
toast.success('Зона удалена')
},
onError: (err) => {
@@ -98,9 +106,10 @@ function DomainsPage() {
},
})
function handleDeleteDomain(domain: DomainTableRow) {
deleteMutation.mutate(domain.id)
}
const { columns } = useDomainColumns({
onRequestDelete: setDeleteTarget,
isDeleting: deleteMutation.isPending,
})
const handleCreate = (values: CreateDomainInput) => {
createMutation.mutate({
@@ -115,86 +124,47 @@ function DomainsPage() {
form.setValue('group_id', next)
}
const handleFilterGroupChange = (value: string | null) => {
if (!value || value === 'all') {
setFilterGroupId('')
return
}
setFilterGroupId(value === 'none' ? 'none' : value)
}
const filteredDomains = useMemo(() => {
if (filterGroupId === 'none') {
return domains?.filter((d) => d.group_id === null) ?? []
}
return domains ?? []
}, [domains, filterGroupId])
const tableData = useMemo(
() => filteredDomains as DomainTableRow[],
[filteredDomains],
)
const hasActiveFilter = filterGroupId !== ''
const isZeroResults = hasActiveFilter && tableData.length === 0 && (domains?.length ?? 0) > 0
const isTrulyEmpty = tableData.length === 0 && !hasActiveFilter
const emptyAction = isZeroResults ? (
<AppButton variant="outline" onClick={() => setFilterGroupId('')}>
Сбросить фильтр
</AppButton>
) : (
<AppButton onClick={() => setSheetOpen(true)}>Импортировать домен</AppButton>
const primaryAction = (
<>
<Button type="button" variant="outline" nativeButton={false} render={<Link to="/groups" />}>
Канбан групп
</Button>
<Button type="button" onClick={() => setSheetOpen(true)}>
Импортировать домен
</Button>
</>
)
return (
<PageShell>
<PageHeader
<ResourcePage
title="Домены"
description={`${tableData.length} зон · ${tableData.reduce((sum, d) => sum + d.service_count, 0)} привязок сервисов`}
actions={
<>
<AppButton variant="outline" nativeButton={false} render={<Link to="/groups" />}>
Канбан групп
</AppButton>
<AppButton onClick={() => setSheetOpen(true)}>Импортировать домен</AppButton>
</>
}
/>
<QueryState
description="Импортированные зоны Cloudflare"
tabs={DOMAIN_TABS.map((tab) => ({ ...tab }))}
tabFilter={domainTabFilter}
filterFields={filterFields}
filters={filters}
onFiltersChange={setFilters}
onClearFilters={() => setFilters(createDefaultDomainFilters())}
getFilterFieldValue={domainFilterFieldValue}
columns={columns}
data={domains ?? []}
getRowId={(row) => String(row.id)}
isLoading={isLoading}
isError={isError}
error={error}
onRetry={refetch}
skeleton={<TableSkeleton />}
>
{isTrulyEmpty || isZeroResults ? (
<EmptyState
icon={GlobeIcon}
title={isZeroResults ? 'Ничего не найдено' : 'Домены не импортированы'}
description={
isZeroResults
? 'По выбранному фильтру группы нет подходящих зон'
: 'Импортируйте зону из аккаунта Cloudflare'
}
action={emptyAction}
/>
) : (
<DomainsDataGrid
data={tableData}
totalCount={tableData.length}
groupFilterItems={groupItems}
groupFilterValue={filterGroupId || 'all'}
onGroupFilterChange={handleFilterGroupChange}
search={search}
onSearchChange={setSearch}
onDelete={handleDeleteDomain}
isDeleting={deleteMutation.isPending}
emptyAction={emptyAction}
/>
)}
</QueryState>
primaryAction={primaryAction}
emptyState={{
title: 'Домены не импортированы',
description: 'Импортируйте зону из аккаунта Cloudflare',
action: (
<Button type="button" onClick={() => setSheetOpen(true)}>
Импортировать домен
</Button>
),
}}
/>
<FormSheet
open={sheetOpen}
@@ -214,13 +184,13 @@ function DomainsPage() {
</LoadingButton>
}
>
<AppFieldGroup>
<FieldGroup>
<FormFieldSimple
label="Имя зоны"
htmlFor="zone_name"
error={form.formState.errors.zone_name}
>
<AppInput
<Input
id="zone_name"
placeholder="example.com"
{...form.register('zone_name')}
@@ -245,8 +215,25 @@ function DomainsPage() {
</SelectContent>
</Select>
</FormFieldSimple>
</AppFieldGroup>
</FieldGroup>
</FormSheet>
<ConfirmDialog
open={deleteTarget !== null}
onOpenChange={(open) => {
if (!open) setDeleteTarget(null)
}}
title="Удалить зону?"
description={
deleteTarget
? `Зона «${deleteTarget.zone_name}» будет удалена из менеджера вместе с DNS-записями и привязками. Зона в Cloudflare не затрагивается.`
: ''
}
onConfirm={() => {
if (deleteTarget) deleteMutation.mutate(deleteTarget.id)
}}
disabled={deleteMutation.isPending}
/>
</PageShell>
)
}
+95 -54
View File
@@ -13,16 +13,27 @@ import {
} from '@/queries'
import { api } from '@/lib/api-client'
import type { CreateGroupInput, Group } 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 { ConfirmDialog } from '@/components/confirm-dialog'
import { DomainGroupEditSheet } from '@/components/domain-group-edit-sheet'
import { GroupsBoard } from '@/components/groups-board/groups-board'
import { GroupsBoardSkeleton } from '@/components/groups-board/groups-board-skeleton'
import { useGroupsBoard } from '@/hooks/use-groups-board'
import { AppButton } from '@/components/app-button'
import { KanbanBoard } from '@/components/reui-kit'
import { DomainKanbanCard } from '@/components/kanban/domain-kanban-card'
import { useDomainGroupsKanban } from '@/hooks/use-domain-groups-kanban'
import { groupColumnId } from '@/components/board/column-ids'
import { UNGROUPED_COLUMN_ID } from '@/components/groups-board/column-ids'
import { Button } from '@cfdm/ui/components/button'
const GROUP_DOT_COLORS = [
'bg-chart-1',
'bg-chart-2',
'bg-chart-3',
'bg-chart-4',
'bg-success',
'bg-warning',
'bg-info',
'bg-destructive',
]
export const Route = createFileRoute('/_auth/groups')({
loader: ({ context: { queryClient } }) =>
@@ -50,12 +61,7 @@ function GroupsPage() {
const { data: domains } = useQuery(domainsListQueryOptions())
const { data: bindings } = useQuery(serviceBindingsQueryOptions())
const {
board,
activeDomain,
handleDragStart,
handleDragEnd,
} = useGroupsBoard({
const { kanbanValue, handleKanbanValueChange } = useDomainGroupsKanban({
groups,
domains,
})
@@ -72,10 +78,29 @@ function GroupsPage() {
return map
}, [bindings])
const columnConfigs = useMemo(() => {
const groupCols = (groups ?? []).map((group, index) => ({
id: groupColumnId(group.id),
title: group.name,
description: group.slug ?? group.name,
dotClassName: GROUP_DOT_COLORS[index % GROUP_DOT_COLORS.length],
addLabel: 'Настроить группу',
onAdd: () => setEditingGroup(group),
}))
return [
...groupCols,
{
id: UNGROUPED_COLUMN_ID,
title: 'Без группы',
description: 'Домены без назначенной группы',
dotClassName: 'bg-muted-foreground/50',
},
]
}, [groups])
const isEmpty = useMemo(() => {
if (!groups || !domains) return true
const hasDomains = domains.length > 0
if (hasDomains) return false
if (domains.length > 0) return false
return groups.length === 0
}, [groups, domains])
@@ -122,48 +147,64 @@ function GroupsPage() {
},
})
if (isLoading) {
return (
<PageShell>
<div className="text-muted-foreground text-sm">Загрузка доски…</div>
</PageShell>
)
}
if (isError) {
return (
<PageShell>
<EmptyState
title="Не удалось загрузить группы"
description={error?.message}
action={
<Button type="button" variant="outline" onClick={() => refetch()}>
Повторить
</Button>
}
/>
</PageShell>
)
}
return (
<PageShell>
<PageHeader
title="Группы доменов"
description="Перетащите домен в группу или создайте новую группу для организации зон"
actions={
<AppButton onClick={() => setCreateSheetOpen(true)}>
Добавить группу
</AppButton>
}
/>
<QueryState
isLoading={isLoading}
isError={isError}
error={error}
onRetry={refetch}
skeleton={<GroupsBoardSkeleton />}
>
{isEmpty ? (
<EmptyState
icon={FolderTreeIcon}
title="Группы не найдены"
description="Создайте группу и назначьте домены при импорте или перетаскиванием на доске."
action={
<AppButton onClick={() => setCreateSheetOpen(true)}>
Добавить группу
</AppButton>
}
/>
) : (
<GroupsBoard
board={board}
activeDomain={activeDomain}
onDragStart={handleDragStart}
onDragEnd={handleDragEnd}
onEditGroup={setEditingGroup}
onDeleteGroup={setDeletingGroup}
serviceLabelsByDomain={serviceLabelsByDomain}
/>
)}
</QueryState>
{isEmpty ? (
<EmptyState
icon={FolderTreeIcon}
title="Группы не найдены"
description="Создайте группу и назначьте домены при импорте или перетаскиванием на доске."
action={
<Button type="button" onClick={() => setCreateSheetOpen(true)}>
Добавить группу
</Button>
}
/>
) : (
<KanbanBoard
title="Группы доменов"
description="Перетащите домен в группу или создайте новую группу для организации зон"
columns={columnConfigs}
value={kanbanValue}
onValueChange={handleKanbanValueChange}
toolbarActions={
<Button type="button" onClick={() => setCreateSheetOpen(true)}>
Добавить группу
</Button>
}
renderCard={(domain, ctx) => (
<DomainKanbanCard
domain={domain}
serviceLabels={serviceLabelsByDomain.get(domain.id)}
isOverlay={ctx.isOverlay}
/>
)}
/>
)}
<DomainGroupEditSheet
mode="create"
+34 -129
View File
@@ -1,25 +1,20 @@
import { createFileRoute, Link } from '@tanstack/react-router'
import { useMemo, useState } from 'react'
import { useState } from 'react'
import { useQuery } from '@tanstack/react-query'
import { GlobeIcon } from 'lucide-react'
import type { Filter } from '@/components/reui/filters'
import {
domainsListQueryOptions,
groupDetailQueryOptions,
} 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 { ResourcePage } from '@/components/reui-kit'
import {
DataGridCard,
columnDefFromDataGrid,
} from '@/components/data-grid-card'
import type { DataGridColumn } from '@/components/data-grid-types'
import { ListFiltersBar } from '@/components/list-filters-bar'
import { EmptyState } from '@/components/empty-state'
import { TableSkeleton } from '@/components/skeletons'
createDefaultGroupDomainFilters,
groupDomainFilterFieldValue,
useGroupDomainColumns,
useGroupDomainFilterFields,
} from '@/components/columns/group-domains-columns'
import { Button } from '@cfdm/ui/components/button'
import type { DomainListItem } from '@/lib/schemas'
export const Route = createFileRoute('/_auth/groups/$groupId')({
loader: async ({ context: { queryClient }, params }) => {
@@ -32,7 +27,7 @@ export const Route = createFileRoute('/_auth/groups/$groupId')({
})
function GroupDetailPage() {
const [searchQuery, setSearchQuery] = useState('')
const [filters, setFilters] = useState<Filter[]>(createDefaultGroupDomainFilters)
const { groupId } = Route.useParams()
const id = Number(groupId)
const {
@@ -54,96 +49,32 @@ function GroupDetailPage() {
const isError = groupError || domainsError
const error = groupErr ?? domainsErr
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 filterFields = useGroupDomainFilterFields()
const columns = useGroupDomainColumns()
const isFilteredEmpty = (domains?.length ?? 0) > 0 && filteredDomains.length === 0
const isTrulyEmpty = filteredDomains.length === 0 && !searchQuery.trim()
const columns = useMemo<DataGridColumn<DomainListItem>[]>(
() => [
{
key: 'zone_name',
header: 'Зона',
icon: GlobeIcon,
sortable: true,
sortValue: (row) => row.zone_name,
cell: (row) => <span className="font-medium">{row.zone_name}</span>,
},
{
key: 'status',
header: 'Статус',
cell: (row) => <StatusBadge status={row.status} />,
},
{
key: 'service_count',
header: 'Сервисы',
sortable: true,
sortValue: (row) => row.service_count,
className: 'tabular-nums',
cell: (row) => (
<Button
variant="link"
className="h-auto p-0 tabular-nums"
nativeButton={false}
render={<Link to="/services" search={{ domainId: row.id }} />}
>
{row.service_count}
</Button>
),
},
{
key: 'actions',
header: '',
enableHiding: false,
className: 'text-right',
cell: (row) => (
<Button
variant="outline"
size="sm"
nativeButton={false}
render={
<Link to="/domains/$domainId" params={{ domainId: String(row.id) }} />
}
>
Обзор
</Button>
),
},
],
[],
)
const emptyAction = isFilteredEmpty ? (
<Button variant="outline" onClick={() => setSearchQuery('')}>
Сбросить фильтр
</Button>
) : (
const primaryAction = (
<Button variant="outline" nativeButton={false} render={<Link to="/groups" />}>
Открыть канбан групп
К группам
</Button>
)
return (
<PageShell>
<PageHeader
<ResourcePage
title={group?.name ?? 'Группа'}
description={
group
? `${group.domain_count} домен(ов) · slug: ${group.slug}`
: 'Домены в группе'
}
actions={
<Button variant="outline" nativeButton={false} render={<Link to="/groups" />}>
К группам
</Button>
}
/>
<QueryState
filterFields={filterFields}
filters={filters}
onFiltersChange={setFilters}
onClearFilters={() => setFilters(createDefaultGroupDomainFilters())}
getFilterFieldValue={groupDomainFilterFieldValue}
columns={columns}
data={domains ?? []}
getRowId={(row) => String(row.id)}
isLoading={isLoading}
isError={isError}
error={error}
@@ -151,44 +82,18 @@ function GroupDetailPage() {
void refetchGroup()
void refetchDomains()
}}
skeleton={<TableSkeleton rows={5} cols={4} />}
>
{isTrulyEmpty || isFilteredEmpty ? (
<EmptyState
icon={GlobeIcon}
title={isFilteredEmpty ? 'Ничего не найдено' : 'В группе нет доменов'}
description={
isFilteredEmpty
? 'Измените поисковый запрос'
: 'Перетащите домены на канбане групп или назначьте группу при импорте'
}
action={emptyAction}
/>
) : (
<DataGridCard
title="Домены группы"
description="Список доменов, назначенных этой группе"
columns={columnDefFromDataGrid(columns)}
data={filteredDomains}
rowId={(row) => String(row.id)}
emptyTitle="В группе нет доменов"
pinLastColumn
actions={
<ListFiltersBar
search={{
value: searchQuery,
onChange: setSearchQuery,
placeholder: 'Поиск по зоне…',
}}
shown={filteredDomains.length}
total={domains?.length ?? 0}
showReset={Boolean(searchQuery.trim())}
onReset={() => setSearchQuery('')}
/>
}
/>
)}
</QueryState>
primaryAction={primaryAction}
emptyState={{
title: 'В группе нет доменов',
description:
'Перетащите домены на канбане групп или назначьте группу при импорте',
action: (
<Button variant="outline" nativeButton={false} render={<Link to="/groups" />}>
Открыть канбан групп
</Button>
),
}}
/>
</PageShell>
)
}
+185 -127
View File
@@ -24,20 +24,16 @@ import {
groupsQueryOptions,
serviceGroupsQueryOptions,
} from '@/queries'
import { PageHeader } from '@/components/page-header'
import { PageShell } from '@/components/page-shell'
import { QueryState } from '@/components/query-state'
import { SectionCardsSkeleton } from '@/components/skeletons'
import { SectionCards } from '@/components/section-cards'
import { ChartCard } from '@/components/chart-card'
import { ExpiringCertsCard } from '@/components/expiring-certs-card'
import { StatusBadge } from '@/components/status-badge'
import { OpsDashboard, OpsDashboardHintLink } from '@/components/reui-kit'
import {
AppItem,
AppItemContent,
AppItemGroup,
AppItemTitle,
} from '@/components/app-item'
Frame,
FrameDescription,
FrameHeader,
FramePanel,
FrameTitle,
} from '@/components/reui/frame'
import { StatusBadge } from '@/components/status-badge'
import {
ChartContainer,
ChartLegend,
@@ -46,6 +42,13 @@ import {
ChartTooltipContent,
type ChartConfig,
} from '@cfdm/ui/components/chart'
import {
Item,
ItemContent,
ItemGroup,
ItemTitle,
} from '@cfdm/ui/components/item'
import { formatRelative } from '@/lib/format'
export const Route = createFileRoute('/_auth/')({
loader: ({ context: { queryClient } }) =>
@@ -74,21 +77,16 @@ const groupChartConfig = {
count: { label: 'Домены', color: 'var(--chart-1)' },
} satisfies ChartConfig
const KPI_ICON_BG = [
'bg-chart-1',
'bg-chart-2',
'bg-chart-3',
'bg-chart-4',
]
function DashboardPage() {
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: domains, isLoading: domainsLoading } = useQuery(domainsListQueryOptions())
const { data: summary, isLoading: summaryLoading } = useQuery(certSummaryQueryOptions())
const { data: certs } = useQuery(certificatesQueryOptions())
const { data: groups } = useQuery(groupsQueryOptions())
const { data: serviceData } = useQuery(serviceGroupsQueryOptions())
@@ -98,8 +96,6 @@ function DashboardPage() {
(serviceData?.ungrouped.length ?? 0)
const isLoading = domainsLoading || summaryLoading
const isError = domainsError || summaryError
const error = domainsErr ?? summaryErr
const statusChartData = useMemo(
() => (summary ?? []).map(([status, count]) => ({ status, count })),
@@ -111,81 +107,98 @@ function DashboardPage() {
return list
.map((g) => ({
name: g.name,
count:
domains?.filter((d) => d.group_id === g.id).length ?? 0,
count: domains?.filter((d) => d.group_id === g.id).length ?? 0,
}))
.sort((a, b) => b.count - a.count)
.slice(0, 6)
}, [groups, domains])
const expiringCerts = useMemo(() => {
const now = Date.now()
const warnMs = 14 * 24 * 60 * 60 * 1000
return (certs ?? [])
.filter((c) => c.expires_at)
.map((c) => ({ cert: c, ts: new Date(c.expires_at as string).getTime() }))
.filter((e) => !Number.isNaN(e.ts) && e.ts - now <= warnMs)
.sort((a, b) => a.ts - b.ts)
.slice(0, 8)
}, [certs])
const ungroupedDomains = useMemo(
() => (domains ?? []).filter((d) => d.group_id == null).slice(0, 5),
[domains],
)
const kpiCards = [
{
id: 'domains',
typeLabel: 'Инфраструктура',
title: 'Домены',
metricLabel: 'Импортированные зоны',
value: domains?.length ?? 0,
icon: <GlobeIcon aria-hidden="true" />,
iconBg: KPI_ICON_BG[0],
hint: <OpsDashboardHintLink to="/domains">Управление</OpsDashboardHintLink>,
},
{
id: 'groups',
typeLabel: 'Организация',
title: 'Группы',
metricLabel: 'Группы доменов',
value: groups?.length ?? 0,
icon: <FolderTreeIcon aria-hidden="true" />,
iconBg: KPI_ICON_BG[1],
hint: <OpsDashboardHintLink to="/groups">Канбан</OpsDashboardHintLink>,
},
{
id: 'services',
typeLabel: 'Операции',
title: 'Сервисы',
metricLabel: 'Привязки и сервисы',
value: serviceCount,
icon: <ServerIcon aria-hidden="true" />,
iconBg: KPI_ICON_BG[2],
hint: (
<OpsDashboardHintLink to="/services" search={{ domainId: undefined }}>
Управление
</OpsDashboardHintLink>
),
},
{
id: 'certs',
typeLabel: 'Безопасность',
title: 'Сертификаты',
metricLabel: 'Мониторинг TLS',
value: certs?.length ?? 0,
icon: <ShieldCheckIcon aria-hidden="true" />,
iconBg: KPI_ICON_BG[3],
hint: <OpsDashboardHintLink to="/certificates">Мониторинг</OpsDashboardHintLink>,
},
]
if (isLoading) {
return (
<PageShell>
<div className="text-muted-foreground text-sm">Загрузка панели…</div>
</PageShell>
)
}
return (
<PageShell>
<PageHeader
title="Панель управления"
description="Обзор доменов, групп, сервисов и сертификатов Cloudflare"
/>
<QueryState
isLoading={isLoading}
isError={isError}
error={error}
skeleton={<SectionCardsSkeleton />}
onRetry={() => {
void refetchDomains()
void refetchSummary()
}}
>
<div className="flex flex-col gap-4">
<SectionCards
items={[
{
label: 'Домены',
value: domains?.length ?? 0,
icon: <GlobeIcon className="size-4" />,
hint: (
<Link to="/domains" className="hover:underline">
Управление
</Link>
),
},
{
label: 'Группы доменов',
value: groups?.length ?? 0,
icon: <FolderTreeIcon className="size-4" />,
hint: (
<Link to="/groups" className="hover:underline">
Канбан
</Link>
),
},
{
label: 'Сервисы',
value: serviceCount,
icon: <ServerIcon className="size-4" />,
hint: (
<Link to="/services" search={{ domainId: undefined }} className="hover:underline">
Управление
</Link>
),
},
{
label: 'Сертификаты',
value: certs?.length ?? 0,
icon: <ShieldCheckIcon className="size-4" />,
hint: (
<Link to="/certificates" className="hover:underline">
Мониторинг
</Link>
),
},
]}
/>
<div className="grid gap-4 lg:grid-cols-2">
<ChartCard
title="Статусы сертификатов"
description="Распределение по последней проверке"
chart={
statusChartData.length === 0 ? null : (
<OpsDashboard
kpiCards={kpiCards}
charts={
<>
<Frame dense spacing="sm" className="h-full w-full">
<FrameHeader>
<FrameTitle>Статусы сертификатов</FrameTitle>
<FrameDescription>Распределение по последней проверке</FrameDescription>
</FrameHeader>
<FramePanel>
{statusChartData.length === 0 ? (
<p className="text-muted-foreground text-sm">Нет данных</p>
) : (
<ChartContainer
config={statusChartConfig}
className="mx-auto aspect-square h-64"
@@ -202,9 +215,14 @@ function DashboardPage() {
strokeWidth={2}
>
{statusChartData.map((entry) => {
const cfg = (statusChartConfig as Record<string, { color?: string }>)[entry.status]
const cfg = (statusChartConfig as Record<string, { color?: string }>)[
entry.status
]
return (
<Cell key={entry.status} fill={cfg?.color ?? 'var(--chart-1)'} />
<Cell
key={entry.status}
fill={cfg?.color ?? 'var(--chart-1)'}
/>
)
})}
<Label
@@ -231,28 +249,19 @@ function DashboardPage() {
<ChartLegend content={<ChartLegendContent nameKey="status" />} />
</PieChart>
</ChartContainer>
)
}
table={
<AppItemGroup className="gap-2">
{statusChartData.map((entry) => (
<AppItem key={entry.status} variant="outline" size="sm">
<AppItemContent className="flex flex-row items-center justify-between gap-2">
<StatusBadge status={entry.status} />
<AppItemTitle className="font-medium tabular-nums">
{entry.count}
</AppItemTitle>
</AppItemContent>
</AppItem>
))}
</AppItemGroup>
}
/>
<ChartCard
title="Домены по группам"
description="Топ-6 групп по количеству зон"
chart={
groupChartData.length === 0 ? null : (
)}
</FramePanel>
</Frame>
<Frame dense spacing="sm" className="h-full w-full">
<FrameHeader>
<FrameTitle>Домены по группам</FrameTitle>
<FrameDescription>Топ-6 групп по количеству зон</FrameDescription>
</FrameHeader>
<FramePanel>
{groupChartData.length === 0 ? (
<p className="text-muted-foreground text-sm">Нет групп</p>
) : (
<ChartContainer
config={groupChartConfig}
className="aspect-auto h-64 w-full"
@@ -274,14 +283,63 @@ function DashboardPage() {
<Bar dataKey="count" fill="var(--color-count)" radius={4} />
</BarChart>
</ChartContainer>
)
}
/>
)}
</FramePanel>
</Frame>
</>
}
queue={
<div className="grid gap-4 lg:grid-cols-2">
<div className="flex flex-col gap-2">
<h3 className="text-sm font-semibold">Истекающие сертификаты</h3>
{expiringCerts.length === 0 ? (
<p className="text-muted-foreground text-sm">В пределах 14 дней истечений нет</p>
) : (
<ItemGroup className="gap-2">
{expiringCerts.map(({ cert, ts }) => (
<Item key={cert.id} variant="outline" size="sm">
<ItemContent className="flex flex-row items-center justify-between gap-2">
<ItemTitle className="truncate font-medium">{cert.hostname}</ItemTitle>
<div className="flex shrink-0 items-center gap-2">
<StatusBadge status={cert.status} />
<span className="text-muted-foreground text-xs tabular-nums">
{formatRelative(new Date(ts).toISOString())}
</span>
</div>
</ItemContent>
</Item>
))}
</ItemGroup>
)}
</div>
<div className="flex flex-col gap-2">
<h3 className="text-sm font-semibold">Домены без группы</h3>
{ungroupedDomains.length === 0 ? (
<p className="text-muted-foreground text-sm">Все домены в группах</p>
) : (
<ItemGroup className="gap-2">
{ungroupedDomains.map((domain) => (
<Item key={domain.id} variant="outline" size="sm">
<ItemContent className="flex flex-row items-center justify-between gap-2">
<ItemTitle className="truncate font-medium">
{domain.zone_name}
</ItemTitle>
<Link
to="/domains/$domainId"
params={{ domainId: String(domain.id) }}
className="text-primary text-xs hover:underline"
>
Открыть
</Link>
</ItemContent>
</Item>
))}
</ItemGroup>
)}
</div>
</div>
<ExpiringCertsCard certificates={certs ?? []} />
</div>
</QueryState>
}
/>
</PageShell>
)
}
+91 -139
View File
@@ -20,19 +20,17 @@ import type {
ServiceView,
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 { ConfirmDialog } from '@/components/confirm-dialog'
import { ServiceEditSheet } from '@/components/service-edit-sheet'
import { ServiceGroupEditSheet } from '@/components/service-group-edit-sheet'
import { ServicesBoard } from '@/components/services-board/services-board'
import { ServicesBoardSkeleton } from '@/components/services-board/services-board-skeleton'
import { ServicesBulkToolbar } from '@/components/services-board/services-bulk-toolbar'
import { useServicesBoard } from '@/hooks/use-services-board'
import { useServicesKanban } from '@/hooks/use-services-kanban'
import { useServicesSelection } from '@/hooks/use-services-selection'
import { AppButton } from '@/components/app-button'
import { KanbanBoard } from '@/components/reui-kit'
import { ServiceKanbanCard } from '@/components/kanban/service-kanban-card'
import { Button } from '@cfdm/ui/components/button'
export const Route = createFileRoute('/_auth/services')({
validateSearch: (search: Record<string, unknown>) => ({
@@ -67,25 +65,16 @@ function setServiceEnabled(
}
}
function setGroupEnabled(
data: ServiceGroupsResponse,
groupId: number,
enabled: boolean,
): ServiceGroupsResponse {
return {
...data,
groups: data.groups.map((group) => {
if (group.id !== groupId) return group
return {
...group,
enabled,
services: enabled
? group.services
: group.services.map((service) => ({ ...service, enabled: false })),
}
}),
}
}
const GROUP_DOT_COLORS = [
'bg-chart-1',
'bg-chart-2',
'bg-chart-3',
'bg-chart-4',
'bg-success',
'bg-warning',
'bg-info',
'bg-destructive',
]
function ServicesPage() {
const { domainId } = Route.useSearch()
@@ -100,7 +89,6 @@ function ServicesPage() {
const [deletingId, setDeletingId] = useState<number | null>(null)
const [deletingGroupId, setDeletingGroupId] = useState<number | null>(null)
const [togglingServiceId, setTogglingServiceId] = useState<number | null>(null)
const [togglingGroupId, setTogglingGroupId] = useState<number | null>(null)
const [bulkToggling, setBulkToggling] = useState(false)
const queryClient = useQueryClient()
const selection = useServicesSelection()
@@ -116,11 +104,10 @@ function ServicesPage() {
const dragDisabled = domainId != null
const {
kanbanValue,
handleKanbanValueChange,
board,
activeService,
handleDragStart,
handleDragEnd,
} = useServicesBoard({
} = useServicesKanban({
data,
domainId,
dragDisabled,
@@ -287,43 +274,6 @@ function ServicesPage() {
},
})
const toggleGroupMutation = useMutation({
mutationFn: ({ id, enabled }: { id: number; enabled: boolean }) =>
api.patch<ServiceGroupsResponse>(`/api/v1/service-groups/${id}/toggle`, {
enabled,
}),
onMutate: async ({ id, enabled }) => {
await queryClient.cancelQueries({ queryKey: serviceGroupKeys.all })
const previous = queryClient.getQueryData<ServiceGroupsResponse>(
serviceGroupKeys.all,
)
if (previous) {
queryClient.setQueryData(
serviceGroupKeys.all,
setGroupEnabled(previous, id, enabled),
)
}
return { previous }
},
onError: (err, _vars, context) => {
if (context?.previous) {
queryClient.setQueryData(serviceGroupKeys.all, context.previous)
}
toast.error(err instanceof Error ? err.message : 'Не удалось переключить группу')
},
onSuccess: (_data, { enabled }) => {
toast.success(
enabled
? 'Группа включена, DNS включённых сервисов синхронизируется'
: 'Группа выключена, DNS-записи сервисов удалены',
)
},
onSettled: () => {
setTogglingGroupId(null)
invalidateAll()
},
})
function handleSave(id: number, body: UpdateServiceConfigInput) {
setSavingId(id)
updateServiceMutation.mutate({ id, body })
@@ -343,11 +293,6 @@ function ServicesPage() {
toggleServiceMutation.mutate({ id: serviceId, enabled })
}
function handleGroupToggle(groupId: number, enabled: boolean) {
setTogglingGroupId(groupId)
toggleGroupMutation.mutate({ id: groupId, enabled })
}
function handleOpenCreateService(groupId: number | null = null) {
setDefaultGroupId(groupId)
setCreateSheetOpen(true)
@@ -401,29 +346,25 @@ function ServicesPage() {
invalidateAll()
}
const columnConfigs = useMemo(
() =>
board.columns.map((column, index) => ({
id: column.id,
title: column.title,
description: column.domain ?? undefined,
dotClassName: GROUP_DOT_COLORS[index % GROUP_DOT_COLORS.length],
addLabel: column.groupId ? 'Добавить сервис' : 'Добавить сервис',
onAdd: () => handleOpenCreateService(column.groupId),
})),
[board.columns],
)
const boardHint = dragDisabled
? 'Перетаскивание отключено при фильтре по домену'
: 'Перетащите сервис между группами или измените порядок в списке'
return (
<PageShell>
<PageHeader
title="Сервисы"
description={
filteredDomain
? `Сервисы с привязками к домену ${filteredDomain.zone_name}. ${boardHint}`
: `Сервисы и группы — FQDN синхронизируются в Cloudflare при включении. ${boardHint}`
}
actions={
<div className="flex flex-wrap gap-2">
<AppButton variant="outline" onClick={() => setCreateGroupSheetOpen(true)}>
Добавить группу
</AppButton>
<AppButton onClick={() => handleOpenCreateService()}>Добавить сервис</AppButton>
</div>
}
/>
<ServicesBulkToolbar
count={selection.count}
isPending={bulkToggling}
@@ -432,57 +373,68 @@ function ServicesPage() {
onClear={selection.clear}
/>
<QueryState
isLoading={isLoading}
isError={isError}
error={error}
onRetry={refetch}
skeleton={<ServicesBoardSkeleton />}
>
{isEmpty ? (
<EmptyState
icon={ServerIcon}
title="Сервисы не найдены"
description={
filteredDomain
? `Нет сервисов с привязками к ${filteredDomain.zone_name}`
: 'Создайте группу и сервис — например VPN Panel или Home Assistant.'
}
action={
<div className="flex flex-wrap justify-center gap-2">
<AppButton variant="outline" onClick={() => setCreateGroupSheetOpen(true)}>
Добавить группу
</AppButton>
<AppButton onClick={() => handleOpenCreateService()}>Добавить сервис</AppButton>
</div>
}
/>
) : (
<ServicesBoard
board={board}
activeService={activeService}
{isLoading ? (
<div className="text-muted-foreground text-sm">Загрузка доски…</div>
) : isError ? (
<EmptyState
title="Не удалось загрузить сервисы"
description={error?.message}
action={
<Button type="button" variant="outline" onClick={() => refetch()}>
Повторить
</Button>
}
/>
) : 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={() => handleOpenCreateService()}>Добавить сервис</Button>
</div>
}
/>
) : (
<KanbanBoard
title="Сервисы"
description={
filteredDomain
? `Сервисы с привязками к домену ${filteredDomain.zone_name}. ${boardHint}`
: `Сервисы и группы — FQDN синхронизируются в Cloudflare при включении. ${boardHint}`
}
columns={columnConfigs}
value={kanbanValue}
onValueChange={dragDisabled ? () => {} : handleKanbanValueChange}
toolbarActions={
<div className="flex flex-wrap gap-2">
<Button variant="outline" onClick={() => setCreateGroupSheetOpen(true)}>
Добавить группу
</Button>
<Button onClick={() => handleOpenCreateService()}>Добавить сервис</Button>
</div>
}
renderCard={(service, ctx) => (
<ServiceKanbanCard
service={service}
isOverlay={ctx.isOverlay}
dragDisabled={dragDisabled}
onDragStart={handleDragStart}
onDragEnd={handleDragEnd}
onGroupToggle={handleGroupToggle}
onServiceToggle={handleServiceToggle}
onEditGroup={setEditingGroup}
onDeleteGroup={setDeletingGroup}
onAddService={handleOpenCreateService}
onEditService={setEditingService}
onDeleteService={setDeletingService}
togglingGroupId={togglingGroupId}
togglingServiceId={togglingServiceId}
showCheckbox={!dragDisabled}
isSelected={selection.isSelected}
onSelectedChange={selection.setSelected}
isAllSelected={selection.isAllSelected}
isSomeSelected={selection.isSomeSelected}
onSelectAllInGroup={selection.selectAll}
onDeselectAllInGroup={selection.deselectAll}
/>
)}
</QueryState>
isToggling={togglingServiceId === service.id}
onToggle={handleServiceToggle}
onEdit={setEditingService}
onDelete={setDeletingService}
/>
)}
/>
)}
<ServiceEditSheet
mode="edit"
@@ -12,6 +12,13 @@ import {
} from '@/components/integrations/vps-tracker-integration-card'
import { DEFAULT_APP_SWITCHER_CONFIG } from '@/lib/app-switcher-config'
import { appSwitcherQueryKey } from '@/queries/app-switcher'
import {
Frame,
FrameDescription,
FrameHeader,
FramePanel,
FrameTitle,
} from '@/components/reui/frame'
type SettingsResponse = AppSettingsView & {
id: string
@@ -48,25 +55,62 @@ function SettingsIntegrationsPage() {
onRetry={() => refetch()}
>
{data ? (
<div className="flex flex-col gap-4">
<AppSwitcherEditor
defaultValues={data.appSwitcher ?? DEFAULT_APP_SWITCHER_CONFIG}
isSaving={saveMut.isPending}
onSave={(appSwitcher) => saveMut.mutate({ appSwitcher })}
/>
<VpsTrackerIntegrationCard
settings={data}
isSaving={saveMut.isPending}
onSave={(values) => {
const patch: AppSettingsPatch = {
vpsTrackerUrl: values.vpsTrackerUrl,
vpsTrackerSyncEnabled: values.vpsTrackerSyncEnabled,
}
const token = values.vpsTrackerIntegrationToken?.trim()
if (token) patch.vpsTrackerIntegrationToken = token
saveMut.mutate(patch)
}}
/>
<div className="flex flex-col gap-5">
<section className="flex flex-col gap-3">
<header className="flex flex-col gap-1">
<h2 className="text-sm font-semibold">Подключённые приложения</h2>
<p className="text-muted-foreground text-sm">
Переключение между сервисами и интеграция с VPS Tracker
</p>
</header>
<Frame dense spacing="sm">
<FrameHeader>
<FrameTitle>App Switcher</FrameTitle>
<FrameDescription>
Быстрый переход между связанными приложениями
</FrameDescription>
</FrameHeader>
<FramePanel>
<AppSwitcherEditor
defaultValues={data.appSwitcher ?? DEFAULT_APP_SWITCHER_CONFIG}
isSaving={saveMut.isPending}
onSave={(appSwitcher) => saveMut.mutate({ appSwitcher })}
/>
</FramePanel>
</Frame>
</section>
<section className="flex flex-col gap-3">
<header className="flex flex-col gap-1">
<h2 className="text-sm font-semibold">Внешние интеграции</h2>
<p className="text-muted-foreground text-sm">
Синхронизация данных с VPS Tracker
</p>
</header>
<Frame dense spacing="sm">
<FrameHeader>
<FrameTitle>VPS Tracker</FrameTitle>
<FrameDescription>
URL, токен и расписание синхронизации
</FrameDescription>
</FrameHeader>
<FramePanel>
<VpsTrackerIntegrationCard
settings={data}
isSaving={saveMut.isPending}
onSave={(values) => {
const patch: AppSettingsPatch = {
vpsTrackerUrl: values.vpsTrackerUrl,
vpsTrackerSyncEnabled: values.vpsTrackerSyncEnabled,
}
const token = values.vpsTrackerIntegrationToken?.trim()
if (token) patch.vpsTrackerIntegrationToken = token
saveMut.mutate(patch)
}}
/>
</FramePanel>
</Frame>
</section>
</div>
) : null}
</QueryState>
+4 -30
View File
@@ -1,40 +1,14 @@
import { createFileRoute, Link, Outlet, useRouterState } from '@tanstack/react-router'
import { cn } from '@cfdm/ui/lib/utils'
import { PageShell } from '@/components/page-shell'
import { PageHeader } from '@/components/page-header'
import { createFileRoute, Outlet } from '@tanstack/react-router'
import { SettingsShell } from '@/components/reui-kit'
export const Route = createFileRoute('/_auth/settings')({
component: SettingsLayout,
})
const TABS = [{ to: '/settings/integrations', label: 'Интеграции' }] as const
function SettingsLayout() {
const pathname = useRouterState({ select: (s) => s.location.pathname })
return (
<PageShell>
<PageHeader title="Настройки" description="Интеграции с другими приложениями" />
<nav className="flex gap-1 border-b pb-0">
{TABS.map((tab) => {
const active = pathname.startsWith(tab.to)
return (
<Link
key={tab.to}
to={tab.to}
className={cn(
'rounded-t-md px-4 py-2 text-sm font-medium transition-colors',
active
? 'border border-b-0 bg-background text-foreground'
: 'text-muted-foreground hover:text-foreground',
)}
>
{tab.label}
</Link>
)
})}
</nav>
<SettingsShell>
<Outlet />
</PageShell>
</SettingsShell>
)
}
+46 -17
View File
@@ -1,5 +1,10 @@
import { createFileRoute } from '@tanstack/react-router'
import { createFileRoute, Link } from '@tanstack/react-router'
import { CloudIcon } from 'lucide-react'
import { Frame, FramePanel } from '@/components/reui/frame'
import {
AUTH13_SIDEBAR_IMAGE_DARK,
AUTH13_SIDEBAR_IMAGE_LIGHT,
} from '@/lib/auth-13-assets'
import { LoginForm } from '@/components/login-form'
export const Route = createFileRoute('/login')({
@@ -8,23 +13,47 @@ export const Route = createFileRoute('/login')({
function LoginPage() {
return (
<div className="flex min-h-svh flex-col items-center justify-center gap-6 bg-muted p-6 md:p-10">
<div className="flex w-full max-w-sm flex-col gap-6">
<a
href="/"
className="flex items-center gap-2 self-center font-medium"
aria-label="CF Domain Manager"
>
<div className="flex size-6 items-center justify-center rounded-md bg-primary text-primary-foreground">
<CloudIcon className="size-4" />
<div className="min-h-svh w-full lg:h-svh lg:overflow-hidden">
<div className="grid min-h-svh w-full gap-8 px-4 py-4 sm:px-6 sm:py-6 lg:h-svh lg:min-h-0 lg:grid-cols-[32rem_minmax(0,1fr)] lg:gap-8 lg:px-0 lg:py-0">
<section className="order-2 flex min-h-[calc(100svh-2rem)] items-center justify-center px-4 py-8 sm:px-8 lg:order-2 lg:h-full lg:min-h-0 lg:px-12 lg:py-0 xl:px-16 2xl:px-20">
<div className="mx-auto flex w-full max-w-sm flex-col gap-6">
<Link
to="/"
className="flex items-center gap-2 self-center font-medium"
aria-label="CF Domain Manager"
>
<div className="flex size-6 items-center justify-center rounded-md bg-primary text-primary-foreground">
<CloudIcon className="size-4" />
</div>
CF Domain Manager
</Link>
<LoginForm />
</div>
CF Domain Manager
</a>
<LoginForm />
<div className="text-balance text-center text-xs text-muted-foreground">
Войдите учётной записью администратора для доступа к управлению доменами,
сервисами и сертификатами Cloudflare.
</div>
</section>
<aside className="order-1 flex min-h-full items-stretch justify-start lg:order-1 lg:h-full lg:min-h-0 lg:w-full lg:self-stretch lg:justify-self-start lg:py-7 lg:pl-7">
<div className="w-full lg:sticky lg:top-7 lg:h-[calc(100svh-3.5rem)]">
<Frame
spacing="lg"
className="border-border/70 h-full w-full bg-transparent"
>
<FramePanel className="border-border/70 h-full min-h-[32rem] overflow-hidden p-0 shadow-none before:hidden">
<img
src={AUTH13_SIDEBAR_IMAGE_LIGHT}
alt=""
aria-hidden="true"
className="h-full w-full object-cover dark:hidden"
/>
<img
src={AUTH13_SIDEBAR_IMAGE_DARK}
alt=""
aria-hidden="true"
className="hidden h-full w-full object-cover dark:block"
/>
</FramePanel>
</Frame>
</div>
</aside>
</div>
</div>
)