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