refactor: Enhance UI components in various files by implementing new layouts, improving accessibility, and optimizing data handling for better performance and user experience
Build, Test, and Push CFDM Docker Image / test (push) Failing after 45s
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

This commit is contained in:
Denozordec
2026-06-25 17:27:21 +07:00
parent 2147782ba6
commit 4e536c3b7e
16 changed files with 1236 additions and 358 deletions
+132 -80
View File
@@ -1,41 +1,44 @@
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 { Bar, BarChart, CartesianGrid, XAxis } from 'recharts'
import { ShieldCheckIcon } from 'lucide-react'
import { toast } from 'sonner'
import { certificatesQueryOptions, certKeys, certSummaryQueryOptions } from '@/queries'
import { api } from '@/lib/api-client'
import { formatDate } from '@/lib/format'
import { 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 { StatusBadge } from '@/components/status-badge'
import { DataTableCard } from '@/components/data-table-card'
import { TableToolbar } from '@/components/table-toolbar'
import { DataTableView } from '@/components/data-table-view'
import type { Density } from '@/components/data-table-toolbar'
import { ChartCard } from '@/components/chart-card'
import { AppButton } from '@/components/app-button'
import {
AppCard,
AppCardContent,
AppCardDescription,
AppCardHeader,
AppCardTitle,
} from '@/components/app-card'
AppItem,
AppItemContent,
AppItemGroup,
AppItemTitle,
} from '@/components/app-item'
import {
ChartContainer,
ChartTooltip,
ChartTooltipContent,
type ChartConfig,
} from '@cfdm/ui/components/chart'
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@cfdm/ui/components/table'
import { LoadingButton } from '@/components/loading-button'
import { TableSkeleton } from '@/components/table-skeleton'
@@ -57,6 +60,8 @@ 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,
@@ -94,6 +99,80 @@ function CertificatesPage() {
const isFilteredEmpty = (certs?.length ?? 0) > 0 && filteredCerts.length === 0
const columns = useMemo<ColumnDef<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>
),
},
{
accessorKey: 'status',
header: 'Статус',
cell: ({ row }) => <StatusBadge status={row.original.status} />,
},
{
accessorKey: 'expires_at',
header: 'Истекает',
cell: ({ row }) => (
<span className="tabular-nums text-muted-foreground">
{formatDate(row.original.expires_at)}
</span>
),
},
{
id: 'relative',
header: 'Срок',
cell: ({ row }) => (
<span className="tabular-nums text-muted-foreground">
{formatRelative(row.original.expires_at)}
</span>
),
enableHiding: true,
},
{
accessorKey: 'last_checked_at',
header: 'Проверка',
cell: ({ row }) => (
<span className="tabular-nums text-muted-foreground">
{formatDate(row.original.last_checked_at)}
</span>
),
enableHiding: true,
},
],
[],
)
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
@@ -116,13 +195,11 @@ function CertificatesPage() {
onRetry={refetch}
skeleton={<TableSkeleton rows={5} columns={4} />}
>
<AppCard>
<AppCardHeader>
<AppCardTitle>Обзор статусов</AppCardTitle>
<AppCardDescription>Распределение сертификатов по статусам</AppCardDescription>
</AppCardHeader>
<AppCardContent>
{chartData.length > 0 ? (
<ChartCard
title="Обзор статусов"
description="Распределение сертификатов по статусам"
chart={
chartData.length > 0 ? (
<ChartContainer config={chartConfig} className="aspect-auto h-64 w-full">
<BarChart data={chartData}>
<CartesianGrid vertical={false} />
@@ -142,67 +219,42 @@ function CertificatesPage() {
title="Нет данных для графика"
description="Запустите проверку сертификатов"
/>
)}
</AppCardContent>
</AppCard>
)
}
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>
}
/>
<DataTableCard
title="Сертификаты"
description="Хосты с активными сервисами или ручным мониторингом"
isEmpty={!filteredCerts.length}
emptyTitle={
isFilteredEmpty ? 'Ничего не найдено' : 'Сертификаты не найдены'
}
emptyDescription={
isFilteredEmpty
? 'Измените поисковый запрос'
: 'Сертификаты появятся после проверки доменов'
}
emptyAction={
isFilteredEmpty ? (
<AppButton variant="outline" onClick={() => setSearchQuery('')}>
Сбросить фильтр
</AppButton>
) : (
<LoadingButton
onClick={() => checkMutation.mutate()}
isLoading={checkMutation.isPending}
loadingLabel="Проверка…"
>
Запустить проверку
</LoadingButton>
)
}
emptyIcon={ShieldCheckIcon}
toolbar={
<TableToolbar
value={searchQuery}
onChange={setSearchQuery}
placeholder="Поиск по хосту или статусу…"
/>
}
isEmpty={false}
>
<Table>
<TableHeader>
<TableRow>
<TableHead>Хост</TableHead>
<TableHead>Статус</TableHead>
<TableHead>Истекает</TableHead>
<TableHead>Последняя проверка</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{filteredCerts.map((c) => (
<TableRow key={c.id}>
<TableCell className="font-medium">{c.hostname}</TableCell>
<TableCell>
<StatusBadge status={c.status} />
</TableCell>
<TableCell>{formatDate(c.expires_at)}</TableCell>
<TableCell>{formatDate(c.last_checked_at)}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
<DataTableView
table={table}
density={density}
onDensityChange={setDensity}
searchPlaceholder="Поиск по хосту или статусу…"
searchValue={searchQuery}
onSearchChange={setSearchQuery}
emptyTitle={isFilteredEmpty ? 'Ничего не найдено' : 'Сертификаты не найдены'}
emptyDescription={
isFilteredEmpty
? 'Измените поисковый запрос'
: 'Сертификаты появятся после проверки доменов'
}
/>
</DataTableCard>
</QueryState>
</PageShell>
+211 -10
View File
@@ -1,11 +1,51 @@
import { createFileRoute } from '@tanstack/react-router'
import { createFileRoute, Link } from '@tanstack/react-router'
import { useQuery } from '@tanstack/react-query'
import { certificatesQueryOptions, certSummaryQueryOptions, domainsListQueryOptions, groupsQueryOptions, serviceGroupsQueryOptions } from '@/queries'
import { useMemo } from 'react'
import {
Bar,
BarChart,
CartesianGrid,
Cell,
Label,
Pie,
PieChart,
XAxis,
} from 'recharts'
import {
FolderTreeIcon,
GlobeIcon,
ServerIcon,
ShieldCheckIcon,
} from 'lucide-react'
import {
certificatesQueryOptions,
certSummaryQueryOptions,
domainsListQueryOptions,
groupsQueryOptions,
serviceGroupsQueryOptions,
} from '@/queries'
import { PageHeader } from '@/components/page-header'
import { PageShell } from '@/components/page-shell'
import { QueryState } from '@/components/query-state'
import { SectionCards } from '@/components/section-cards'
import { SectionCardsSkeleton } from '@/components/section-cards-skeleton'
import { KpiCard } from '@/components/kpi-card'
import { ChartCard } from '@/components/chart-card'
import { ExpiringCertsCard } from '@/components/expiring-certs-card'
import { StatusBadge } from '@/components/status-badge'
import {
AppItem,
AppItemContent,
AppItemGroup,
AppItemTitle,
} from '@/components/app-item'
import {
ChartContainer,
ChartLegend,
ChartLegendContent,
ChartTooltip,
ChartTooltipContent,
type ChartConfig,
} from '@cfdm/ui/components/chart'
export const Route = createFileRoute('/_auth/')({
loader: ({ context: { queryClient } }) =>
@@ -14,10 +54,26 @@ export const Route = createFileRoute('/_auth/')({
queryClient.ensureQueryData(certSummaryQueryOptions()),
queryClient.ensureQueryData(groupsQueryOptions()),
queryClient.ensureQueryData(serviceGroupsQueryOptions()),
queryClient.ensureQueryData(certificatesQueryOptions()),
]),
component: DashboardPage,
})
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)' },
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)' },
} satisfies ChartConfig
function DashboardPage() {
const {
data: domains,
@@ -45,6 +101,23 @@ function DashboardPage() {
const isError = domainsError || summaryError
const error = domainsErr ?? summaryErr
const statusChartData = useMemo(
() => (summary ?? []).map(([status, count]) => ({ status, count })),
[summary],
)
const groupChartData = useMemo(() => {
const list = groups ?? []
return list
.map((g) => ({
name: g.name,
count:
domains?.filter((d) => d.group_id === g.id).length ?? 0,
}))
.sort((a, b) => b.count - a.count)
.slice(0, 6)
}, [groups, domains])
return (
<PageShell>
<PageHeader
@@ -61,13 +134,141 @@ function DashboardPage() {
void refetchSummary()
}}
>
<SectionCards
domainCount={domains?.length ?? 0}
certCount={certs?.length ?? 0}
groupCount={groups?.length ?? 0}
serviceCount={serviceCount}
certSummary={summary}
/>
<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>
<div className="grid gap-4 lg:grid-cols-2">
<ChartCard
title="Статусы сертификатов"
description="Распределение по последней проверке"
chart={
statusChartData.length === 0 ? null : (
<ChartContainer
config={statusChartConfig}
className="mx-auto aspect-square h-64"
>
<PieChart>
<ChartTooltip
content={<ChartTooltipContent nameKey="status" hideLabel />}
/>
<Pie
data={statusChartData}
dataKey="count"
nameKey="status"
innerRadius={60}
strokeWidth={2}
>
{statusChartData.map((entry) => {
const cfg = (statusChartConfig as Record<string, { color?: string }>)[entry.status]
return (
<Cell key={entry.status} fill={cfg?.color ?? 'var(--chart-1)'} />
)
})}
<Label
content={({ viewBox }) => {
if (!viewBox || !('cx' in viewBox)) return null
const total = statusChartData.reduce(
(sum, entry) => sum + entry.count,
0,
)
return (
<text
x={viewBox.cx}
y={viewBox.cy}
textAnchor="middle"
dominantBaseline="middle"
className="fill-foreground text-2xl font-semibold tabular-nums"
>
{total}
</text>
)
}}
/>
</Pie>
<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 : (
<ChartContainer
config={groupChartConfig}
className="aspect-auto h-64 w-full"
>
<BarChart data={groupChartData}>
<CartesianGrid vertical={false} />
<XAxis
dataKey="name"
tickLine={false}
axisLine={false}
tickMargin={8}
interval={0}
height={36}
tickFormatter={(value: string) =>
value.length > 10 ? `${value.slice(0, 9)}…` : value
}
/>
<ChartTooltip content={<ChartTooltipContent nameKey="count" />} />
<Bar dataKey="count" fill="var(--color-count)" radius={4} />
</BarChart>
</ChartContainer>
)
}
/>
</div>
<ExpiringCertsCard certificates={certs ?? []} />
</div>
</QueryState>
</PageShell>
)
+10 -2
View File
@@ -10,13 +10,21 @@ 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">
<div className="flex items-center gap-2 self-center font-medium">
<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>
CF Domain Manager
</div>
</a>
<LoginForm />
<div className="text-balance text-center text-xs text-muted-foreground">
Войдите учётной записью администратора для доступа к управлению доменами,
сервисами и сертификатами Cloudflare.
</div>
</div>
</div>
)