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