feat(web): enhance certificate management and UI components
Build, Test, and Push CFDM Docker Image / test (push) Successful in 4m0s
Build, Test, and Push CFDM Docker Image / build-and-push (push) Successful in 2m17s
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

- Updated the CertificatesPage to include a new CertKpiCards component for improved KPI visualization.
- Refactored filter handling and state management for better user experience.
- Introduced new status options and enhanced filtering capabilities in certificates columns.
- Updated the OpsDashboard to utilize KpiStatGrid for displaying key metrics.

Co-authored-by: Cursor <[email protected]>
This commit is contained in:
Denozordec
2026-07-16 22:53:01 +07:00
co-authored by Cursor
parent d44baab8d4
commit 3e326a4509
10 changed files with 436 additions and 116 deletions
+2 -2
View File
@@ -1,6 +1,6 @@
{
"pid": 12352,
"pid": 47044,
"version": "0.9.9",
"socketPath": "\\\\.\\pipe\\codegraph-7bcc7a2b16d00925",
"startedAt": 1783570286395
"startedAt": 1784215894915
}
@@ -0,0 +1,47 @@
"use client"
import { ReactNode } from "react"
import { Badge } from "@/components/reui/badge"
import { Headphones, CircleCheckIcon, SmileIcon } from "lucide-react"
// ── Types ──
export interface CardData {
icon: ReactNode
iconBg: string
value: string | number
label: string
info: ReactNode
}
// ── Data ──
export const cards: CardData[] = [
{
icon: (
<Headphones aria-hidden="true" />
),
iconBg: "text-blue-600 dark:text-blue-400",
value: 320,
label: "Support Tickets",
info: <Badge variant="info-light">12 Open, 308 Closed</Badge>,
},
{
icon: (
<CircleCheckIcon aria-hidden="true" />
),
iconBg: "text-emerald-600 dark:text-emerald-400",
value: "98%",
label: "Resolved",
info: <Badge variant="success-light">+2.1% this month</Badge>,
},
{
icon: (
<SmileIcon aria-hidden="true" />
),
iconBg: "text-amber-600 dark:text-amber-400",
value: "4.8",
label: "Satisfaction Rate",
info: <Badge variant="warning-light">Avg. (out of 5)</Badge>,
},
]
@@ -0,0 +1,43 @@
import { Frame, FramePanel } from "@/components/reui/frame"
import { cn } from "@cfdm/ui/lib/utils"
import { Item, ItemMedia } from "@cfdm/ui/components/item"
import { cards } from "./data"
export function Stats() {
return (
<div className="@container w-full grow">
{/* Grid */}
<div className="mx-auto grid max-w-5xl grow grid-cols-1 gap-5 @3xl:grid-cols-3">
{cards.map((card, i) => (
<Frame key={i}>
<FramePanel className="flex flex-col items-start gap-6">
<Item
className={cn(
"border-background bg-muted flex size-10.5 items-center justify-center border-2 p-0 shadow-[0_1px_3px_0_rgba(0,0,0,0.14)] dark:border [&_svg]:size-4",
card.iconBg
)}
>
<ItemMedia variant="icon" className="size-auto">
{card.icon}
</ItemMedia>
</Item>
<div className="space-y-0.5">
<div className="text-foreground text-2xl leading-none font-bold">
{card.value}
</div>
<div className="text-muted-foreground text-sm font-medium">
{card.label}
</div>
</div>
{card.info}
</FramePanel>
</Frame>
))}
</div>
</div>
)
}
@@ -0,0 +1,9 @@
import { Stats } from "./components/stats"
export function Page() {
return (
<div className="flex w-full items-center justify-center p-10 md:p-20">
<Stats />
</div>
)
}
+115
View File
@@ -0,0 +1,115 @@
import { useMemo } from 'react'
import {
AlertTriangleIcon,
CheckCircle2Icon,
ShieldAlertIcon,
ShieldCheckIcon,
} from 'lucide-react'
import { Badge } from '@/components/reui/badge'
import { KpiStatGrid, type KpiStatCard } from '@/components/reui-kit/kpi-stat-grid'
const KPI_ICON_BG = ['bg-chart-1', 'bg-chart-2', 'bg-chart-3', 'bg-chart-4'] as const
const ACTIVE_STATUSES = new Set(['active', 'ok', 'synced'])
const PROBLEM_STATUSES = new Set(['expired', 'error', 'conflict'])
function countFromSummary(
summary: [string, number][] | undefined,
statuses: Set<string>,
): number {
if (!summary) return 0
return summary
.filter(([status]) => statuses.has(status))
.reduce((sum, [, count]) => sum + count, 0)
}
function totalFromSummary(summary: [string, number][] | undefined): number {
if (!summary) return 0
return summary.reduce((sum, [, count]) => sum + count, 0)
}
interface CertKpiCardsProps {
summary?: [string, number][]
total?: number
activeTab: string
isLoading?: boolean
onSelectTab: (tabId: string) => void
}
export function CertKpiCards({
summary,
total,
activeTab,
isLoading,
onSelectTab,
}: CertKpiCardsProps) {
const cards = useMemo<KpiStatCard[]>(() => {
const allCount = total ?? totalFromSummary(summary)
const okCount = countFromSummary(summary, ACTIVE_STATUSES)
const warningCount = countFromSummary(summary, new Set(['warning']))
const problemCount = countFromSummary(summary, PROBLEM_STATUSES)
return [
{
id: 'all',
typeLabel: 'Мониторинг',
title: 'Всего',
metricLabel: 'Проверенные хосты',
value: allCount,
icon: <ShieldCheckIcon aria-hidden="true" />,
iconBg: KPI_ICON_BG[0],
badge: <Badge variant="info-light">Все статусы</Badge>,
onSelect: () => onSelectTab('all'),
selected: activeTab === 'all',
},
{
id: 'active',
typeLabel: 'Безопасность',
title: 'OK',
metricLabel: 'Активные сертификаты',
value: okCount,
icon: <CheckCircle2Icon aria-hidden="true" />,
iconBg: KPI_ICON_BG[1],
badge: <Badge variant="success-light">В норме</Badge>,
onSelect: () => onSelectTab('active'),
selected: activeTab === 'active',
},
{
id: 'warning',
typeLabel: 'Срок действия',
title: 'Предупреждение',
metricLabel: 'Истекают скоро',
value: warningCount,
icon: <AlertTriangleIcon aria-hidden="true" />,
iconBg: KPI_ICON_BG[2],
badge: <Badge variant="warning-light">Требуют внимания</Badge>,
onSelect: () => onSelectTab('warning'),
selected: activeTab === 'warning',
},
{
id: 'expired',
typeLabel: 'Риски',
title: 'Проблемы',
metricLabel: 'Ошибка или истёк',
value: problemCount,
icon: <ShieldAlertIcon aria-hidden="true" />,
iconBg: KPI_ICON_BG[3],
badge: <Badge variant="destructive-light">Критично</Badge>,
onSelect: () => onSelectTab('expired'),
selected: activeTab === 'expired',
},
]
}, [summary, total, activeTab, onSelectTab])
const hasData = (total ?? totalFromSummary(summary)) > 0
return (
<KpiStatGrid
cards={hasData ? cards : []}
isLoading={isLoading}
skeletonCount={4}
emptyIcon={<ShieldCheckIcon className="text-muted-foreground size-5" aria-hidden />}
emptyMessage="Нет данных — запустите проверку сертификатов"
/>
)
}
@@ -2,15 +2,17 @@ import { useMemo } from 'react'
import type { ColumnDef } from '@tanstack/react-table'
import { GlobeIcon, SearchIcon } from 'lucide-react'
import { Badge } from '@/components/reui/badge'
import { DataGridColumnHeader } from '@/components/reui/data-grid/data-grid-column-header'
import { createFilter, type FilterFieldConfig } from '@/components/reui/filters'
import { StatusBadge } from '@/components/status-badge'
import { renderSingleSelectedLabel } from '@/components/reui-kit/filter-utils'
import type { Certificate } from '@/lib/schemas'
import { formatDate, formatRelative } from '@/lib/format'
export const CERT_TABS = [
{ id: 'all', label: 'Все' },
{ id: 'active', label: 'Активен' },
{ id: 'active', label: 'OK' },
{ id: 'warning', label: 'Предупреждение' },
{ id: 'expired', label: 'Истёк' },
] as const
@@ -18,6 +20,17 @@ export const CERT_TABS = [
const ACTIVE_STATUSES = new Set(['active', 'ok', 'synced'])
const EXPIRED_STATUSES = new Set(['expired', 'error', 'conflict'])
const CERT_STATUS_OPTIONS = [
{ value: 'ok', label: 'OK' },
{ value: 'active', label: 'Активен' },
{ value: 'synced', label: 'Синхронизировано' },
{ value: 'warning', label: 'Предупреждение' },
{ value: 'expired', label: 'Истёк' },
{ value: 'error', label: 'Ошибка' },
{ value: 'conflict', label: 'Конфликт' },
{ value: 'unknown', label: 'Неизвестно' },
]
export function certTabFilter(item: Certificate, tabId: string) {
if (tabId === 'active') return ACTIVE_STATUSES.has(item.status)
if (tabId === 'warning') return item.status === 'warning'
@@ -26,7 +39,10 @@ export function certTabFilter(item: Certificate, tabId: string) {
}
export function createDefaultCertFilters() {
return [createFilter('hostname', 'contains', [''])]
return [
createFilter('hostname', 'contains', ['']),
createFilter('status', 'is', ['']),
]
}
export function useCertFilterFields() {
@@ -40,16 +56,47 @@ export function useCertFilterFields() {
className: 'w-52',
placeholder: 'Поиск по хосту…',
},
{
key: 'status',
label: 'Статус',
type: 'select',
searchable: true,
className: 'w-[168px]',
options: CERT_STATUS_OPTIONS,
customValueRenderer: (values) =>
renderSingleSelectedLabel(values, CERT_STATUS_OPTIONS),
},
],
[],
)
}
export function certFilterFieldValue(item: Certificate, field: string) {
if (field === 'hostname') {
return `${item.hostname} ${item.status}`.toLowerCase()
switch (field) {
case 'hostname':
return `${item.hostname} ${item.status}`.toLowerCase()
case 'status':
return item.status
default:
return ''
}
return ''
}
function certRelativeBadge(status: string, expiresAt: string | null) {
const relative = formatRelative(expiresAt)
if (!expiresAt) {
return <span className="text-muted-foreground tabular-nums"></span>
}
if (EXPIRED_STATUSES.has(status)) {
return <Badge variant="destructive-light">{relative}</Badge>
}
if (status === 'warning') {
return <Badge variant="warning-light">{relative}</Badge>
}
if (ACTIVE_STATUSES.has(status)) {
return <Badge variant="success-light">{relative}</Badge>
}
return <span className="text-muted-foreground tabular-nums">{relative}</span>
}
export function useCertificateColumns() {
@@ -85,11 +132,8 @@ export function useCertificateColumns() {
{
id: 'relative',
header: 'Срок',
cell: ({ row }) => (
<span className="text-muted-foreground tabular-nums">
{formatRelative(row.original.expires_at)}
</span>
),
cell: ({ row }) =>
certRelativeBadge(row.original.status, row.original.expires_at),
},
{
id: 'last_checked_at',
+2 -1
View File
@@ -1,7 +1,8 @@
export { applyFiltersToData, getActiveFilters, renderSingleSelectedLabel } from './filter-utils'
export { CertStatusChart, GroupDomainsChart } from './dashboard-analytics'
export { ResourcePage, type ResourcePageProps, type ResourcePageTab } from './resource-page'
export { OpsDashboard, OpsDashboardHintLink, type OpsKpiCard } from './ops-dashboard'
export { KpiStatGrid, type KpiStatCard, type OpsKpiCard } from './kpi-stat-grid'
export { OpsDashboard, OpsDashboardHintLink } from './ops-dashboard'
export { KanbanBoard, type KanbanBoardProps, type KanbanColumnConfig } from './kanban-board'
export { DetailPanel, type DetailMetricCard } from './detail-panel'
export { SettingsShell, type SettingsTabConfig } from './settings-shell'
@@ -0,0 +1,144 @@
import type { ReactNode } from 'react'
import { Frame, FramePanel } from '@/components/reui/frame'
import { cn } from '@cfdm/ui/lib/utils'
import { Item, ItemMedia } from '@cfdm/ui/components/item'
import { Skeleton } from '@cfdm/ui/components/skeleton'
export interface KpiStatCard {
id: string
typeLabel: string
title: string
metricLabel: string
value: string | number
icon: ReactNode
iconBg?: string
hint?: ReactNode
detail?: ReactNode
badge?: ReactNode
onSelect?: () => void
selected?: boolean
}
/** @deprecated Use KpiStatCard */
export type OpsKpiCard = KpiStatCard
interface KpiStatGridProps {
cards: KpiStatCard[]
isLoading?: boolean
emptyMessage?: ReactNode
emptyIcon?: ReactNode
className?: string
skeletonCount?: number
}
const ICON_TILE_CLASS =
'border-background flex size-10.5 items-center justify-center border-2 p-0 [background-image:radial-gradient(48.05%_48.05%_at_50%_5.95%,rgba(255,255,255,0.4)_0%,rgba(255,255,255,0)_100%)] shadow-[0_1px_3px_0_rgba(0,0,0,0.14)] dark:border [&_svg]:size-5 [&_svg]:text-white'
function KpiStatCardItem({ card }: { card: KpiStatCard }) {
const interactive = Boolean(card.onSelect)
const Panel = interactive ? 'button' : 'div'
return (
<Panel
type={interactive ? 'button' : undefined}
onClick={card.onSelect}
className={cn(
'text-start',
interactive &&
'hover:bg-muted/40 focus-visible:ring-ring rounded-[calc(var(--frame-radius)-2px)] transition-colors focus-visible:ring-2 focus-visible:outline-none',
card.selected && 'bg-muted/30 ring-primary/30 ring-1',
)}
>
<FramePanel className="flex h-full flex-col items-start gap-4">
<div className="flex w-full items-center gap-2.5">
<Item className={cn('p-0', ICON_TILE_CLASS, card.iconBg ?? 'bg-chart-1')}>
<ItemMedia variant="icon" className="size-auto">
{card.icon}
</ItemMedia>
</Item>
<p className="text-muted-foreground min-w-0 flex-1 text-xs leading-tight">
{card.typeLabel}
</p>
</div>
<div className="space-y-0.5">
<div className="text-foreground text-2xl leading-none font-bold tabular-nums">
{card.value}
</div>
<div className="text-sm leading-tight font-medium">{card.title}</div>
<div className="text-muted-foreground text-xs leading-snug">{card.metricLabel}</div>
</div>
{card.badge || card.hint ? (
<div className="mt-auto flex flex-wrap items-center gap-2">
{card.badge}
{card.hint}
</div>
) : null}
{card.detail ? (
<p className="text-muted-foreground text-xs leading-snug">{card.detail}</p>
) : null}
</FramePanel>
</Panel>
)
}
function KpiStatGridSkeleton({ count }: { count: number }) {
return (
<Frame className="@container w-full">
<div className="grid gap-1 @2xl:grid-cols-2 @5xl:grid-cols-4">
{Array.from({ length: count }).map((_, index) => (
<FramePanel key={index} className="flex flex-col gap-4">
<div className="flex items-center gap-2.5">
<Skeleton className="size-10.5 rounded-lg" />
<Skeleton className="h-3 w-20" />
</div>
<div className="space-y-2">
<Skeleton className="h-8 w-16" />
<Skeleton className="h-4 w-24" />
<Skeleton className="h-3 w-32" />
</div>
<Skeleton className="h-5 w-28" />
</FramePanel>
))}
</div>
</Frame>
)
}
export function KpiStatGrid({
cards,
isLoading = false,
emptyMessage,
emptyIcon,
className,
skeletonCount = 4,
}: KpiStatGridProps) {
if (isLoading) {
return <KpiStatGridSkeleton count={skeletonCount} />
}
if (cards.length === 0) {
return (
<Frame dense spacing="sm" className={cn('w-full', className)}>
<FramePanel className="flex items-center gap-3 p-4">
{emptyIcon}
{emptyMessage ? (
<p className="text-muted-foreground text-sm">{emptyMessage}</p>
) : null}
</FramePanel>
</Frame>
)
}
return (
<Frame className={cn('@container w-full', className)}>
<div className="grid gap-1 @2xl:grid-cols-2 @5xl:grid-cols-4">
{cards.map((card) => (
<KpiStatCardItem key={card.id} card={card} />
))}
</div>
</Frame>
)
}
@@ -7,21 +7,10 @@ import {
FramePanel,
FrameTitle,
} from '@/components/reui/frame'
import { cn } from '@cfdm/ui/lib/utils'
import { debugAgentLog } from '@/lib/debug-agent-log'
import { Item, ItemMedia } from '@cfdm/ui/components/item'
import { KpiStatGrid, type KpiStatCard } from './kpi-stat-grid'
export interface OpsKpiCard {
id: string
typeLabel: string
title: string
metricLabel: string
value: string | number
hint?: ReactNode
detail?: ReactNode
icon: ReactNode
iconBg?: string
}
export type OpsKpiCard = KpiStatCard
interface OpsDashboardProps {
title?: string
@@ -31,46 +20,6 @@ interface OpsDashboardProps {
queue: ReactNode
}
function KpiCardItem({ card }: { card: OpsKpiCard }) {
return (
<FramePanel>
<div className="flex items-center gap-2.5">
<Item
className={cn(
'p-0',
'border-background flex size-10 items-center justify-center border-2 [background-image:radial-gradient(48.05%_48.05%_at_50%_5.95%,rgba(255,255,255,0.4)_0%,rgba(255,255,255,0)_100%)] shadow-[0_1px_3px_0_rgba(0,0,0,0.14)] dark:border [&_svg]:size-5 [&_svg]:text-white',
card.iconBg ?? 'bg-chart-1',
)}
>
<ItemMedia variant="icon" className="size-auto">
{card.icon}
</ItemMedia>
</Item>
<div className="flex min-w-0 flex-col gap-1">
<p className="text-muted-foreground text-sm leading-tight">
{card.typeLabel}
</p>
<h3 className="truncate text-sm leading-tight font-medium">{card.title}</h3>
</div>
</div>
<div className="mt-5 flex flex-col gap-1.5">
<p className="text-muted-foreground text-sm leading-tight">
{card.metricLabel}
</p>
<div className="flex flex-wrap items-center gap-2">
<span className="text-xl font-medium tracking-tight tabular-nums">
{card.value}
</span>
{card.hint}
</div>
{card.detail ? (
<p className="text-muted-foreground text-xs leading-snug">{card.detail}</p>
) : null}
</div>
</FramePanel>
)
}
export function OpsDashboard({
title = 'Панель управления',
description = 'Обзор доменов, групп, сервисов и сертификатов Cloudflare',
@@ -108,13 +57,7 @@ export function OpsDashboard({
</header>
<section aria-label="Ключевые метрики">
<Frame className="@container w-full">
<div className="grid gap-1 @2xl:grid-cols-2 @5xl:grid-cols-4">
{kpiCards.map((card) => (
<KpiCardItem key={card.id} card={card} />
))}
</div>
</Frame>
<KpiStatGrid cards={kpiCards} skeletonCount={4} />
</section>
<section
+17 -43
View File
@@ -1,13 +1,13 @@
import { createFileRoute } from '@tanstack/react-router'
import { useMemo, useState } from 'react'
import { useCallback, useState } from 'react'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { ShieldCheckIcon } from 'lucide-react'
import { toast } from 'sonner'
import type { Filter } from '@/components/reui/filters'
import { certificatesQueryOptions, certKeys, certSummaryQueryOptions } from '@/queries'
import { api } from '@/lib/api-client'
import { PageShell } from '@/components/page-shell'
import { ResourcePage } from '@/components/reui-kit'
import { CertKpiCards } from '@/components/cert-kpi-cards'
import {
CERT_TABS,
certFilterFieldValue,
@@ -16,14 +16,6 @@ import {
useCertificateColumns,
useCertFilterFields,
} from '@/components/columns/certificates-columns'
import { StatusBadge } from '@/components/status-badge'
import {
Frame,
FrameDescription,
FrameHeader,
FramePanel,
FrameTitle,
} from '@/components/reui/frame'
import { LoadingButton } from '@/components/loading-button'
export const Route = createFileRoute('/_auth/certificates')({
@@ -37,6 +29,7 @@ export const Route = createFileRoute('/_auth/certificates')({
function CertificatesPage() {
const [filters, setFilters] = useState<Filter[]>(createDefaultCertFilters)
const [activeTab, setActiveTab] = useState('all')
const queryClient = useQueryClient()
const {
data: certs,
@@ -45,7 +38,7 @@ function CertificatesPage() {
error,
refetch,
} = useQuery(certificatesQueryOptions())
const { data: summary } = useQuery(certSummaryQueryOptions())
const { data: summary, isLoading: isSummaryLoading } = useQuery(certSummaryQueryOptions())
const checkMutation = useMutation({
mutationFn: () => api.post('/api/v1/certificates/check'),
@@ -59,40 +52,13 @@ function CertificatesPage() {
},
})
const handleSelectTab = useCallback((tabId: string) => {
setActiveTab(tabId)
}, [])
const filterFields = useCertFilterFields()
const columns = useCertificateColumns()
const kpiStrip = useMemo(() => {
const chartData = summary?.map(([status, count]) => ({ status, count })) ?? []
return (
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
{chartData.length === 0 ? (
<Frame dense spacing="sm" className="sm:col-span-2 lg:col-span-4">
<FramePanel className="flex items-center gap-3 p-4">
<ShieldCheckIcon className="text-muted-foreground size-5" />
<p className="text-muted-foreground text-sm">
Нет данных запустите проверку сертификатов
</p>
</FramePanel>
</Frame>
) : (
chartData.map((entry) => (
<Frame key={entry.status} dense spacing="sm">
<FrameHeader className="px-3! py-2!">
<FrameTitle className="text-sm font-medium">
<StatusBadge status={entry.status} />
</FrameTitle>
<FrameDescription className="text-2xl font-semibold tabular-nums">
{entry.count}
</FrameDescription>
</FrameHeader>
</Frame>
))
)}
</div>
)
}, [summary])
const primaryAction = (
<LoadingButton
onClick={() => checkMutation.mutate()}
@@ -105,11 +71,19 @@ function CertificatesPage() {
return (
<PageShell className="gap-6">
{kpiStrip}
<CertKpiCards
summary={summary}
total={certs?.length}
activeTab={activeTab}
isLoading={isSummaryLoading && !summary}
onSelectTab={handleSelectTab}
/>
<ResourcePage
title="Сертификаты"
description="Мониторинг SSL: хосты с активными сервисами или режимом «Обязательно»"
tabs={CERT_TABS.map((tab) => ({ ...tab }))}
activeTab={activeTab}
onTabChange={setActiveTab}
tabFilter={certTabFilter}
filterFields={filterFields}
filters={filters}