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