Выравнивание UI с vps-tracker: тема b2fA, ReUI DataGrid и shell
Build, Test, and Push CFDM Docker Image / test (push) Successful in 3m14s
Build, Test, and Push CFDM Docker Image / build-and-push (push) Successful in 2m5s
Build, Test, and Push CFDM Docker Image / update-wiki (push) Successful in 6s
Build, Test, and Push CFDM Docker Image / create-release (push) Has been skipped
Build, Test, and Push CFDM Docker Image / test (push) Successful in 3m14s
Build, Test, and Push CFDM Docker Image / build-and-push (push) Successful in 2m5s
Build, Test, and Push CFDM Docker Image / update-wiki (push) Successful in 6s
Build, Test, and Push CFDM Docker Image / create-release (push) Has been skipped
Синхронизированы тема и layout (ModeToggle, sticky header), списки переведены на DataGridCard, дашборд на компактные SectionCards. Co-authored-by: Cursor <[email protected]>
This commit is contained in:
@@ -1,17 +1,8 @@
|
||||
import { createFileRoute } from '@tanstack/react-router'
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { useMemo, useState } from 'react'
|
||||
import {
|
||||
getCoreRowModel,
|
||||
getFilteredRowModel,
|
||||
getPaginationRowModel,
|
||||
getSortedRowModel,
|
||||
useReactTable,
|
||||
type ColumnDef,
|
||||
type SortingState,
|
||||
} from '@tanstack/react-table'
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { Bar, BarChart, CartesianGrid, XAxis } from 'recharts'
|
||||
import { ShieldCheckIcon } from 'lucide-react'
|
||||
import { GlobeIcon, ShieldCheckIcon } from 'lucide-react'
|
||||
import { toast } from 'sonner'
|
||||
import { certificatesQueryOptions, certKeys, certSummaryQueryOptions } from '@/queries'
|
||||
import { api } from '@/lib/api-client'
|
||||
@@ -22,11 +13,13 @@ import { PageShell } from '@/components/page-shell'
|
||||
import { QueryState } from '@/components/query-state'
|
||||
import { EmptyState } from '@/components/empty-state'
|
||||
import { StatusBadge } from '@/components/status-badge'
|
||||
import { DataTableCard } from '@/components/data-table-card'
|
||||
import { DataTableView } from '@/components/data-table-view'
|
||||
import type { Density } from '@/components/data-table-density'
|
||||
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 { AppButton } from '@/components/app-button'
|
||||
import {
|
||||
AppItem,
|
||||
AppItemContent,
|
||||
@@ -40,7 +33,7 @@ import {
|
||||
type ChartConfig,
|
||||
} from '@cfdm/ui/components/chart'
|
||||
import { LoadingButton } from '@/components/loading-button'
|
||||
import { TableSkeleton } from '@/components/table-skeleton'
|
||||
import { TableSkeleton } from '@/components/skeletons'
|
||||
|
||||
const chartConfig = {
|
||||
count: {
|
||||
@@ -60,8 +53,6 @@ export const Route = createFileRoute('/_auth/certificates')({
|
||||
|
||||
function CertificatesPage() {
|
||||
const [searchQuery, setSearchQuery] = useState('')
|
||||
const [sorting, setSorting] = useState<SortingState>([{ id: 'expires_at', desc: false }])
|
||||
const [density, setDensity] = useState<Density>('comfortable')
|
||||
const queryClient = useQueryClient()
|
||||
const {
|
||||
data: certs,
|
||||
@@ -97,82 +88,52 @@ function CertificatesPage() {
|
||||
)
|
||||
}, [certs, searchQuery])
|
||||
|
||||
const isFilteredEmpty = (certs?.length ?? 0) > 0 && filteredCerts.length === 0
|
||||
|
||||
const columns = useMemo<ColumnDef<Certificate>[]>(
|
||||
const columns = useMemo<DataGridColumn<Certificate>[]>(
|
||||
() => [
|
||||
{
|
||||
accessorKey: 'hostname',
|
||||
header: ({ column }) => (
|
||||
<AppButton
|
||||
variant="ghost"
|
||||
className="-ml-3 h-8"
|
||||
onClick={() => column.toggleSorting(column.getIsSorted() === 'asc')}
|
||||
>
|
||||
Хост
|
||||
</AppButton>
|
||||
),
|
||||
cell: ({ row }) => (
|
||||
<span className="truncate font-medium">{row.original.hostname}</span>
|
||||
),
|
||||
key: 'hostname',
|
||||
header: 'Хост',
|
||||
icon: GlobeIcon,
|
||||
sortable: true,
|
||||
sortValue: (row) => row.hostname,
|
||||
cell: (row) => <span className="truncate font-medium">{row.hostname}</span>,
|
||||
},
|
||||
{
|
||||
accessorKey: 'status',
|
||||
key: 'status',
|
||||
header: 'Статус',
|
||||
cell: ({ row }) => <StatusBadge status={row.original.status} />,
|
||||
cell: (row) => <StatusBadge status={row.status} />,
|
||||
},
|
||||
{
|
||||
accessorKey: 'expires_at',
|
||||
key: 'expires_at',
|
||||
header: 'Истекает',
|
||||
cell: ({ row }) => (
|
||||
<span className="tabular-nums text-muted-foreground">
|
||||
{formatDate(row.original.expires_at)}
|
||||
</span>
|
||||
),
|
||||
sortable: true,
|
||||
sortValue: (row) => row.expires_at ?? '',
|
||||
className: 'tabular-nums text-muted-foreground',
|
||||
cell: (row) => formatDate(row.expires_at),
|
||||
},
|
||||
{
|
||||
id: 'relative',
|
||||
key: 'relative',
|
||||
header: 'Срок',
|
||||
cell: ({ row }) => (
|
||||
enableHiding: true,
|
||||
cell: (row) => (
|
||||
<span className="tabular-nums text-muted-foreground">
|
||||
{formatRelative(row.original.expires_at)}
|
||||
{formatRelative(row.expires_at)}
|
||||
</span>
|
||||
),
|
||||
enableHiding: true,
|
||||
},
|
||||
{
|
||||
accessorKey: 'last_checked_at',
|
||||
key: 'last_checked_at',
|
||||
header: 'Проверка',
|
||||
cell: ({ row }) => (
|
||||
<span className="tabular-nums text-muted-foreground">
|
||||
{formatDate(row.original.last_checked_at)}
|
||||
</span>
|
||||
),
|
||||
enableHiding: true,
|
||||
sortable: true,
|
||||
sortValue: (row) => row.last_checked_at ?? '',
|
||||
className: 'tabular-nums text-muted-foreground',
|
||||
cell: (row) => formatDate(row.last_checked_at),
|
||||
},
|
||||
],
|
||||
[],
|
||||
)
|
||||
|
||||
const table = useReactTable({
|
||||
data: filteredCerts,
|
||||
columns,
|
||||
state: { sorting, globalFilter: searchQuery },
|
||||
onSortingChange: setSorting,
|
||||
globalFilterFn: (row, _columnId, value: string) => {
|
||||
const q = value.toLowerCase()
|
||||
return (
|
||||
row.original.hostname.toLowerCase().includes(q) ||
|
||||
row.original.status.toLowerCase().includes(q)
|
||||
)
|
||||
},
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
getSortedRowModel: getSortedRowModel(),
|
||||
getFilteredRowModel: getFilteredRowModel(),
|
||||
getPaginationRowModel: getPaginationRowModel(),
|
||||
initialState: { pagination: { pageSize: 10 } },
|
||||
})
|
||||
|
||||
return (
|
||||
<PageShell>
|
||||
<PageHeader
|
||||
@@ -193,7 +154,7 @@ function CertificatesPage() {
|
||||
isError={isError}
|
||||
error={error}
|
||||
onRetry={refetch}
|
||||
skeleton={<TableSkeleton rows={5} columns={4} />}
|
||||
skeleton={<TableSkeleton rows={5} cols={4} />}
|
||||
>
|
||||
<ChartCard
|
||||
title="Обзор статусов"
|
||||
@@ -236,26 +197,30 @@ function CertificatesPage() {
|
||||
</AppItemGroup>
|
||||
}
|
||||
/>
|
||||
<DataTableCard
|
||||
<DataGridCard
|
||||
title="Сертификаты"
|
||||
description="Хосты с активными сервисами или ручным мониторингом"
|
||||
isEmpty={false}
|
||||
>
|
||||
<DataTableView
|
||||
table={table}
|
||||
density={density}
|
||||
onDensityChange={setDensity}
|
||||
searchPlaceholder="Поиск по хосту или статусу…"
|
||||
searchValue={searchQuery}
|
||||
onSearchChange={setSearchQuery}
|
||||
emptyTitle={isFilteredEmpty ? 'Ничего не найдено' : 'Сертификаты не найдены'}
|
||||
emptyDescription={
|
||||
isFilteredEmpty
|
||||
? 'Измените поисковый запрос'
|
||||
: 'Сертификаты появятся после проверки доменов'
|
||||
}
|
||||
/>
|
||||
</DataTableCard>
|
||||
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>
|
||||
</PageShell>
|
||||
)
|
||||
|
||||
@@ -3,21 +3,18 @@ import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { useMemo, useState } from 'react'
|
||||
import { useForm, Controller } from 'react-hook-form'
|
||||
import { zodResolver } from '@hookform/resolvers/zod'
|
||||
import { NetworkIcon } from 'lucide-react'
|
||||
import { toast } from 'sonner'
|
||||
import { domainDetailQueryOptions, dnsKeys, dnsListQueryOptions, subdomainKeys } from '@/queries'
|
||||
import { api } from '@/lib/api-client'
|
||||
import { createDnsRecordSchema, type CreateDnsRecordInput } from '@/lib/schemas'
|
||||
import { 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 { DataTableCard } from '@/components/data-table-card'
|
||||
import { DnsRecordsDataGrid } from '@/components/dns-records-data-grid'
|
||||
import { FormSheet } from '@/components/form-sheet'
|
||||
import { FormFieldSimple } from '@/components/form-field'
|
||||
import { LoadingButton } from '@/components/loading-button'
|
||||
import { TableSkeleton } from '@/components/table-skeleton'
|
||||
import { TableToolbar } from '@/components/table-toolbar'
|
||||
import { DnsRecordsTable } from '@/components/dns-records-table'
|
||||
import { TableSkeleton } from '@/components/skeletons'
|
||||
import { AppButton } from '@/components/app-button'
|
||||
import { AppInput } from '@/components/app-input'
|
||||
import { AppField, AppFieldGroup, AppFieldLabel } from '@/components/app-field'
|
||||
@@ -144,17 +141,12 @@ function DnsPage() {
|
||||
)
|
||||
}, [records, host, searchQuery])
|
||||
|
||||
const hasRecords = filteredRecords.length > 0
|
||||
const isFilteredEmpty = (records?.length ?? 0) > 0 && filteredRecords.length === 0
|
||||
|
||||
return (
|
||||
<PageShell>
|
||||
<PageHeader
|
||||
title={`${domain?.zone_name ?? ''} — DNS`}
|
||||
description={
|
||||
host
|
||||
? `Фильтр по хосту: ${host}`
|
||||
: 'Управление DNS-записями зоны'
|
||||
host ? `Фильтр по хосту: ${host}` : 'Управление DNS-записями зоны'
|
||||
}
|
||||
actions={
|
||||
<>
|
||||
@@ -180,50 +172,16 @@ function DnsPage() {
|
||||
void refetchDomain()
|
||||
void refetchRecords()
|
||||
}}
|
||||
skeleton={<TableSkeleton rows={6} columns={6} />}
|
||||
skeleton={<TableSkeleton rows={6} cols={6} />}
|
||||
>
|
||||
<DataTableCard
|
||||
title="DNS-записи"
|
||||
description="Записи в зоне. Несколько A/AAAA с одним именем выделены фоном и разделителем"
|
||||
emptyTitle={
|
||||
isFilteredEmpty ? 'Ничего не найдено' : 'DNS-записи не найдены'
|
||||
}
|
||||
emptyDescription={
|
||||
isFilteredEmpty
|
||||
? 'Измените поисковый запрос'
|
||||
: 'Создайте запись или синхронизируйте зону с Cloudflare'
|
||||
}
|
||||
emptyIcon={NetworkIcon}
|
||||
isEmpty={!hasRecords}
|
||||
emptyAction={
|
||||
isFilteredEmpty ? (
|
||||
<AppButton variant="outline" onClick={() => setSearchQuery('')}>
|
||||
Сбросить фильтр
|
||||
</AppButton>
|
||||
) : (
|
||||
<AppButton onClick={() => setSheetOpen(true)}>Новая запись</AppButton>
|
||||
)
|
||||
}
|
||||
toolbar={
|
||||
!host ? (
|
||||
<TableToolbar
|
||||
value={searchQuery}
|
||||
onChange={setSearchQuery}
|
||||
placeholder="Поиск по имени или значению…"
|
||||
/>
|
||||
) : undefined
|
||||
}
|
||||
>
|
||||
{hasRecords && (
|
||||
<div className="p-4">
|
||||
<DnsRecordsTable
|
||||
records={filteredRecords}
|
||||
onDelete={(recordId) => deleteMutation.mutate(recordId)}
|
||||
isDeleting={deleteMutation.isPending}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</DataTableCard>
|
||||
<DnsRecordsDataGrid
|
||||
records={filteredRecords}
|
||||
onDelete={(recordId) => deleteMutation.mutate(recordId)}
|
||||
isDeleting={deleteMutation.isPending}
|
||||
search={host ?? searchQuery}
|
||||
onSearchChange={setSearchQuery}
|
||||
hideSearch={Boolean(host)}
|
||||
/>
|
||||
</QueryState>
|
||||
|
||||
<FormSheet
|
||||
@@ -259,9 +217,9 @@ function DnsPage() {
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{dnsTypeItems.map((item) => (
|
||||
<SelectItem key={item.value} value={item.value}>
|
||||
{item.label}
|
||||
{DNS_TYPES.map((type) => (
|
||||
<SelectItem key={type} value={type}>
|
||||
{type}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
@@ -269,53 +227,30 @@ function DnsPage() {
|
||||
)}
|
||||
/>
|
||||
</FormFieldSimple>
|
||||
<FormFieldSimple
|
||||
label="Имя"
|
||||
htmlFor="name"
|
||||
error={form.formState.errors.name}
|
||||
>
|
||||
<AppInput
|
||||
id="name"
|
||||
placeholder="@"
|
||||
{...form.register('name')}
|
||||
aria-invalid={!!form.formState.errors.name}
|
||||
/>
|
||||
<FormFieldSimple label="Имя" htmlFor="name" error={form.formState.errors.name}>
|
||||
<AppInput id="name" placeholder="@" {...form.register('name')} />
|
||||
</FormFieldSimple>
|
||||
<FormFieldSimple
|
||||
label="Значение"
|
||||
htmlFor="content"
|
||||
error={form.formState.errors.content}
|
||||
>
|
||||
<AppInput
|
||||
id="content"
|
||||
placeholder="192.168.1.1"
|
||||
{...form.register('content')}
|
||||
aria-invalid={!!form.formState.errors.content}
|
||||
/>
|
||||
<AppInput id="content" {...form.register('content')} />
|
||||
</FormFieldSimple>
|
||||
<FormFieldSimple
|
||||
label="TTL"
|
||||
htmlFor="ttl"
|
||||
error={form.formState.errors.ttl}
|
||||
>
|
||||
<FormFieldSimple label="TTL" htmlFor="ttl" error={form.formState.errors.ttl}>
|
||||
<AppInput
|
||||
id="ttl"
|
||||
type="number"
|
||||
{...form.register('ttl', { valueAsNumber: true })}
|
||||
aria-invalid={!!form.formState.errors.ttl}
|
||||
/>
|
||||
</FormFieldSimple>
|
||||
<AppField className="flex flex-row items-center justify-between gap-4">
|
||||
<AppField orientation="horizontal">
|
||||
<AppFieldLabel htmlFor="proxied">Прокси Cloudflare</AppFieldLabel>
|
||||
<Controller
|
||||
control={form.control}
|
||||
name="proxied"
|
||||
render={({ field }) => (
|
||||
<AppSwitch
|
||||
id="proxied"
|
||||
checked={field.value}
|
||||
onCheckedChange={field.onChange}
|
||||
/>
|
||||
<AppSwitch id="proxied" checked={field.value} onCheckedChange={field.onChange} />
|
||||
)}
|
||||
/>
|
||||
</AppField>
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { createFileRoute, Link } from '@tanstack/react-router'
|
||||
import { useState } from 'react'
|
||||
import { GlobeIcon } from 'lucide-react'
|
||||
import {
|
||||
domainDetailQueryOptions,
|
||||
domainServiceBindingsQueryOptions,
|
||||
@@ -16,8 +15,7 @@ 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 { DataTableCard } from '@/components/data-table-card'
|
||||
import { SubdomainsTable } from '@/components/subdomains-table'
|
||||
import { SubdomainsDataGrid } from '@/components/subdomains-data-grid'
|
||||
import {
|
||||
SubdomainEditSheet,
|
||||
type SubdomainEditValues,
|
||||
@@ -34,7 +32,7 @@ import {
|
||||
AppCardTitle,
|
||||
} from '@/components/app-card'
|
||||
import { LoadingButton } from '@/components/loading-button'
|
||||
import { TableSkeleton } from '@/components/table-skeleton'
|
||||
import { TableSkeleton } from '@/components/skeletons'
|
||||
|
||||
export const Route = createFileRoute('/_auth/domains/$domainId/')({
|
||||
loader: async ({ context: { queryClient }, params }) => {
|
||||
@@ -54,12 +52,12 @@ export const Route = createFileRoute('/_auth/domains/$domainId/')({
|
||||
function DomainPageSkeleton() {
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
<TableSkeleton withCard={false} rows={1} columns={2} />
|
||||
<TableSkeleton rows={1} cols={2} />
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<TableSkeleton withCard rows={3} columns={2} />
|
||||
<TableSkeleton withCard rows={3} columns={2} />
|
||||
<TableSkeleton rows={3} cols={2} />
|
||||
<TableSkeleton rows={3} cols={2} />
|
||||
</div>
|
||||
<TableSkeleton rows={5} columns={5} />
|
||||
<TableSkeleton rows={5} cols={5} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -252,44 +250,23 @@ function DomainOverviewPage() {
|
||||
<DomainBindingsCard bindings={bindings} />
|
||||
</div>
|
||||
|
||||
<DataTableCard
|
||||
title="Поддомены"
|
||||
description="Управление поддоменами и привязками сервисов"
|
||||
isEmpty={subdomainRows.length === 0}
|
||||
emptyTitle="Поддомены не найдены"
|
||||
emptyDescription="Создайте поддомен вручную или синхронизируйте зону с Cloudflare"
|
||||
emptyIcon={GlobeIcon}
|
||||
emptyAction={
|
||||
<div className="flex flex-wrap justify-center gap-2">
|
||||
<AppButton onClick={openCreateSheet}>Создать поддомен</AppButton>
|
||||
<AppButton
|
||||
variant="outline"
|
||||
onClick={() => syncMutation.mutate()}
|
||||
disabled={syncMutation.isPending}
|
||||
>
|
||||
Синхронизировать
|
||||
</AppButton>
|
||||
</div>
|
||||
<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} />}
|
||||
>
|
||||
<SubdomainsTable
|
||||
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,
|
||||
})
|
||||
}
|
||||
/>
|
||||
</DataTableCard>
|
||||
/>
|
||||
|
||||
<SubdomainEditSheet
|
||||
mode={sheetMode}
|
||||
|
||||
@@ -16,12 +16,12 @@ import {
|
||||
import { PageHeader } from '@/components/page-header'
|
||||
import { PageShell } from '@/components/page-shell'
|
||||
import { QueryState } from '@/components/query-state'
|
||||
import { DataTableCard } from '@/components/data-table-card'
|
||||
import { DomainsDataTable, type DomainTableRow } from '@/components/domains-data-table'
|
||||
import { DomainsDataGrid, type DomainTableRow } from '@/components/domains-data-grid'
|
||||
import { FormSheet } from '@/components/form-sheet'
|
||||
import { FormFieldSimple } from '@/components/form-field'
|
||||
import { LoadingButton } from '@/components/loading-button'
|
||||
import { TableSkeleton } from '@/components/table-skeleton'
|
||||
import { 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'
|
||||
@@ -46,6 +46,7 @@ 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 queryClient = useQueryClient()
|
||||
@@ -138,6 +139,14 @@ function DomainsPage() {
|
||||
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>
|
||||
)
|
||||
|
||||
return (
|
||||
<PageShell>
|
||||
<PageHeader
|
||||
@@ -160,40 +169,31 @@ function DomainsPage() {
|
||||
onRetry={refetch}
|
||||
skeleton={<TableSkeleton />}
|
||||
>
|
||||
<DataTableCard
|
||||
title="Список доменов"
|
||||
description="Импортированные зоны Cloudflare"
|
||||
isEmpty={isTrulyEmpty || isZeroResults}
|
||||
emptyTitle={
|
||||
isZeroResults ? 'Ничего не найдено' : 'Домены не импортированы'
|
||||
}
|
||||
emptyDescription={
|
||||
isZeroResults
|
||||
? 'По выбранному фильтру группы нет подходящих зон'
|
||||
: 'Импортируйте зону из аккаунта Cloudflare'
|
||||
}
|
||||
emptyIcon={GlobeIcon}
|
||||
emptyAction={
|
||||
isZeroResults ? (
|
||||
<AppButton variant="outline" onClick={() => setFilterGroupId('')}>
|
||||
Сбросить фильтр
|
||||
</AppButton>
|
||||
) : (
|
||||
<AppButton onClick={() => setSheetOpen(true)}>Импортировать домен</AppButton>
|
||||
)
|
||||
}
|
||||
>
|
||||
{!isTrulyEmpty && !isZeroResults && (
|
||||
<DomainsDataTable
|
||||
data={tableData}
|
||||
groupFilterItems={groupItems}
|
||||
groupFilterValue={filterGroupId || 'all'}
|
||||
onGroupFilterChange={handleFilterGroupChange}
|
||||
onDelete={handleDeleteDomain}
|
||||
isDeleting={deleteMutation.isPending}
|
||||
/>
|
||||
)}
|
||||
</DataTableCard>
|
||||
{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>
|
||||
|
||||
<FormSheet
|
||||
|
||||
@@ -10,18 +10,16 @@ import { PageHeader } from '@/components/page-header'
|
||||
import { PageShell } from '@/components/page-shell'
|
||||
import { QueryState } from '@/components/query-state'
|
||||
import { StatusBadge } from '@/components/status-badge'
|
||||
import { DataTableCard } from '@/components/data-table-card'
|
||||
import { TableSkeleton } from '@/components/table-skeleton'
|
||||
import { TableToolbar } from '@/components/table-toolbar'
|
||||
import { AppButton } from '@/components/app-button'
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@cfdm/ui/components/table'
|
||||
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'
|
||||
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 }) => {
|
||||
@@ -64,7 +62,71 @@ function GroupDetailPage() {
|
||||
}, [domains, searchQuery])
|
||||
|
||||
const isFilteredEmpty = (domains?.length ?? 0) > 0 && filteredDomains.length === 0
|
||||
const hasRecords = 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>
|
||||
) : (
|
||||
<Button variant="outline" nativeButton={false} render={<Link to="/groups" />}>
|
||||
Открыть канбан групп
|
||||
</Button>
|
||||
)
|
||||
|
||||
return (
|
||||
<PageShell>
|
||||
@@ -76,13 +138,9 @@ function GroupDetailPage() {
|
||||
: 'Домены в группе'
|
||||
}
|
||||
actions={
|
||||
<AppButton
|
||||
variant="outline"
|
||||
nativeButton={false}
|
||||
render={<Link to="/groups" />}
|
||||
>
|
||||
<Button variant="outline" nativeButton={false} render={<Link to="/groups" />}>
|
||||
К группам
|
||||
</AppButton>
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
<QueryState
|
||||
@@ -93,97 +151,43 @@ function GroupDetailPage() {
|
||||
void refetchGroup()
|
||||
void refetchDomains()
|
||||
}}
|
||||
skeleton={<TableSkeleton rows={5} columns={4} />}
|
||||
skeleton={<TableSkeleton rows={5} cols={4} />}
|
||||
>
|
||||
<DataTableCard
|
||||
title="Домены группы"
|
||||
description="Список доменов, назначенных этой группе"
|
||||
emptyTitle={
|
||||
isFilteredEmpty ? 'Ничего не найдено' : 'В группе нет доменов'
|
||||
}
|
||||
emptyDescription={
|
||||
isFilteredEmpty
|
||||
? 'Измените поисковый запрос'
|
||||
: 'Перетащите домены на канбане групп или назначьте группу при импорте'
|
||||
}
|
||||
emptyIcon={GlobeIcon}
|
||||
isEmpty={!hasRecords}
|
||||
emptyAction={
|
||||
isFilteredEmpty ? (
|
||||
<AppButton variant="outline" onClick={() => setSearchQuery('')}>
|
||||
Сбросить фильтр
|
||||
</AppButton>
|
||||
) : (
|
||||
<AppButton
|
||||
variant="outline"
|
||||
nativeButton={false}
|
||||
render={<Link to="/groups" />}
|
||||
>
|
||||
Открыть канбан групп
|
||||
</AppButton>
|
||||
)
|
||||
}
|
||||
toolbar={
|
||||
<TableToolbar
|
||||
value={searchQuery}
|
||||
onChange={setSearchQuery}
|
||||
placeholder="Поиск по зоне…"
|
||||
/>
|
||||
}
|
||||
>
|
||||
{hasRecords && (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow className="h-10">
|
||||
<TableHead>Зона</TableHead>
|
||||
<TableHead>Статус</TableHead>
|
||||
<TableHead>Сервисы</TableHead>
|
||||
<TableHead className="text-right">Действия</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{filteredDomains.map((domain) => (
|
||||
<TableRow key={domain.id} className="h-10 text-sm">
|
||||
<TableCell className="font-medium">{domain.zone_name}</TableCell>
|
||||
<TableCell>
|
||||
<StatusBadge status={domain.status} />
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<AppButton
|
||||
variant="link"
|
||||
className="h-auto p-0 tabular-nums"
|
||||
nativeButton={false}
|
||||
render={
|
||||
<Link
|
||||
to="/services"
|
||||
search={{ domainId: domain.id }}
|
||||
/>
|
||||
}
|
||||
>
|
||||
{domain.service_count}
|
||||
</AppButton>
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<AppButton
|
||||
variant="outline"
|
||||
size="sm"
|
||||
nativeButton={false}
|
||||
render={
|
||||
<Link
|
||||
to="/domains/$domainId"
|
||||
params={{ domainId: String(domain.id) }}
|
||||
/>
|
||||
}
|
||||
>
|
||||
Обзор
|
||||
</AppButton>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)}
|
||||
</DataTableCard>
|
||||
{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>
|
||||
</PageShell>
|
||||
)
|
||||
|
||||
@@ -27,8 +27,8 @@ import {
|
||||
import { PageHeader } from '@/components/page-header'
|
||||
import { PageShell } from '@/components/page-shell'
|
||||
import { QueryState } from '@/components/query-state'
|
||||
import { SectionCardsSkeleton } from '@/components/section-cards-skeleton'
|
||||
import { KpiCard } from '@/components/kpi-card'
|
||||
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'
|
||||
@@ -63,15 +63,15 @@ const statusChartConfig = {
|
||||
count: { label: 'Сертификаты' },
|
||||
active: { label: 'Активен', color: 'var(--success)' },
|
||||
ok: { label: 'OK', color: 'var(--success)' },
|
||||
warning: { label: 'Предупреждение', color: 'var(--chart-3)' },
|
||||
pending_push: { label: 'Ожидает', color: 'var(--chart-2)' },
|
||||
warning: { label: 'Предупреждение', color: 'var(--warning)' },
|
||||
pending_push: { label: 'Ожидает', color: 'var(--info)' },
|
||||
expired: { label: 'Истёк', color: 'var(--destructive)' },
|
||||
error: { label: 'Ошибка', color: 'var(--destructive)' },
|
||||
unknown: { label: 'Неизвестно', color: 'var(--muted-foreground)' },
|
||||
} satisfies ChartConfig
|
||||
|
||||
const groupChartConfig = {
|
||||
count: { label: 'Домены', color: 'var(--chart-2)' },
|
||||
count: { label: 'Домены', color: 'var(--chart-1)' },
|
||||
} satisfies ChartConfig
|
||||
|
||||
function DashboardPage() {
|
||||
@@ -135,38 +135,50 @@ function DashboardPage() {
|
||||
}}
|
||||
>
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
|
||||
<KpiCard
|
||||
label="Домены"
|
||||
value={domains?.length ?? 0}
|
||||
icon={GlobeIcon}
|
||||
actionLabel="Управление"
|
||||
actionRender={<Link to="/domains" />}
|
||||
/>
|
||||
<KpiCard
|
||||
label="Группы доменов"
|
||||
value={groups?.length ?? 0}
|
||||
icon={FolderTreeIcon}
|
||||
actionLabel="Канбан"
|
||||
actionRender={<Link to="/groups" />}
|
||||
/>
|
||||
<KpiCard
|
||||
label="Сервисы"
|
||||
value={serviceCount}
|
||||
icon={ServerIcon}
|
||||
actionLabel="Управление"
|
||||
actionRender={
|
||||
<Link to="/services" search={{ domainId: undefined }} />
|
||||
}
|
||||
/>
|
||||
<KpiCard
|
||||
label="Сертификаты"
|
||||
value={certs?.length ?? 0}
|
||||
icon={ShieldCheckIcon}
|
||||
actionLabel="Мониторинг"
|
||||
actionRender={<Link to="/certificates" />}
|
||||
/>
|
||||
</div>
|
||||
<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
|
||||
|
||||
Reference in New Issue
Block a user