Выравнивание 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

Синхронизированы тема и layout (ModeToggle, sticky header), списки переведены на DataGridCard, дашборд на компактные SectionCards.

Co-authored-by: Cursor <[email protected]>
This commit is contained in:
Denozordec
2026-06-30 14:48:24 +07:00
co-authored by Cursor
parent 09b32a5d62
commit 72a4b3403b
72 changed files with 10700 additions and 1901 deletions
+55 -90
View File
@@ -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}
+37 -37
View File
@@ -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
+112 -108
View File
@@ -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>
)
+49 -37
View File
@@ -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