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",
|
"version": "0.9.9",
|
||||||
"socketPath": "\\\\.\\pipe\\codegraph-7bcc7a2b16d00925",
|
"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 type { ColumnDef } from '@tanstack/react-table'
|
||||||
import { GlobeIcon, SearchIcon } from 'lucide-react'
|
import { GlobeIcon, SearchIcon } from 'lucide-react'
|
||||||
|
|
||||||
|
import { Badge } from '@/components/reui/badge'
|
||||||
import { DataGridColumnHeader } from '@/components/reui/data-grid/data-grid-column-header'
|
import { DataGridColumnHeader } from '@/components/reui/data-grid/data-grid-column-header'
|
||||||
import { createFilter, type FilterFieldConfig } from '@/components/reui/filters'
|
import { createFilter, type FilterFieldConfig } from '@/components/reui/filters'
|
||||||
import { StatusBadge } from '@/components/status-badge'
|
import { StatusBadge } from '@/components/status-badge'
|
||||||
|
import { renderSingleSelectedLabel } from '@/components/reui-kit/filter-utils'
|
||||||
import type { Certificate } from '@/lib/schemas'
|
import type { Certificate } from '@/lib/schemas'
|
||||||
import { formatDate, formatRelative } from '@/lib/format'
|
import { formatDate, formatRelative } from '@/lib/format'
|
||||||
|
|
||||||
export const CERT_TABS = [
|
export const CERT_TABS = [
|
||||||
{ id: 'all', label: 'Все' },
|
{ id: 'all', label: 'Все' },
|
||||||
{ id: 'active', label: 'Активен' },
|
{ id: 'active', label: 'OK' },
|
||||||
{ id: 'warning', label: 'Предупреждение' },
|
{ id: 'warning', label: 'Предупреждение' },
|
||||||
{ id: 'expired', label: 'Истёк' },
|
{ id: 'expired', label: 'Истёк' },
|
||||||
] as const
|
] as const
|
||||||
@@ -18,6 +20,17 @@ export const CERT_TABS = [
|
|||||||
const ACTIVE_STATUSES = new Set(['active', 'ok', 'synced'])
|
const ACTIVE_STATUSES = new Set(['active', 'ok', 'synced'])
|
||||||
const EXPIRED_STATUSES = new Set(['expired', 'error', 'conflict'])
|
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) {
|
export function certTabFilter(item: Certificate, tabId: string) {
|
||||||
if (tabId === 'active') return ACTIVE_STATUSES.has(item.status)
|
if (tabId === 'active') return ACTIVE_STATUSES.has(item.status)
|
||||||
if (tabId === 'warning') return item.status === 'warning'
|
if (tabId === 'warning') return item.status === 'warning'
|
||||||
@@ -26,7 +39,10 @@ export function certTabFilter(item: Certificate, tabId: string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function createDefaultCertFilters() {
|
export function createDefaultCertFilters() {
|
||||||
return [createFilter('hostname', 'contains', [''])]
|
return [
|
||||||
|
createFilter('hostname', 'contains', ['']),
|
||||||
|
createFilter('status', 'is', ['']),
|
||||||
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useCertFilterFields() {
|
export function useCertFilterFields() {
|
||||||
@@ -40,16 +56,47 @@ export function useCertFilterFields() {
|
|||||||
className: 'w-52',
|
className: 'w-52',
|
||||||
placeholder: 'Поиск по хосту…',
|
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) {
|
export function certFilterFieldValue(item: Certificate, field: string) {
|
||||||
if (field === 'hostname') {
|
switch (field) {
|
||||||
return `${item.hostname} ${item.status}`.toLowerCase()
|
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() {
|
export function useCertificateColumns() {
|
||||||
@@ -85,11 +132,8 @@ export function useCertificateColumns() {
|
|||||||
{
|
{
|
||||||
id: 'relative',
|
id: 'relative',
|
||||||
header: 'Срок',
|
header: 'Срок',
|
||||||
cell: ({ row }) => (
|
cell: ({ row }) =>
|
||||||
<span className="text-muted-foreground tabular-nums">
|
certRelativeBadge(row.original.status, row.original.expires_at),
|
||||||
{formatRelative(row.original.expires_at)}
|
|
||||||
</span>
|
|
||||||
),
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'last_checked_at',
|
id: 'last_checked_at',
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
export { applyFiltersToData, getActiveFilters, renderSingleSelectedLabel } from './filter-utils'
|
export { applyFiltersToData, getActiveFilters, renderSingleSelectedLabel } from './filter-utils'
|
||||||
export { CertStatusChart, GroupDomainsChart } from './dashboard-analytics'
|
export { CertStatusChart, GroupDomainsChart } from './dashboard-analytics'
|
||||||
export { ResourcePage, type ResourcePageProps, type ResourcePageTab } from './resource-page'
|
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 { KanbanBoard, type KanbanBoardProps, type KanbanColumnConfig } from './kanban-board'
|
||||||
export { DetailPanel, type DetailMetricCard } from './detail-panel'
|
export { DetailPanel, type DetailMetricCard } from './detail-panel'
|
||||||
export { SettingsShell, type SettingsTabConfig } from './settings-shell'
|
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,
|
FramePanel,
|
||||||
FrameTitle,
|
FrameTitle,
|
||||||
} from '@/components/reui/frame'
|
} from '@/components/reui/frame'
|
||||||
import { cn } from '@cfdm/ui/lib/utils'
|
|
||||||
import { debugAgentLog } from '@/lib/debug-agent-log'
|
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 {
|
export type OpsKpiCard = KpiStatCard
|
||||||
id: string
|
|
||||||
typeLabel: string
|
|
||||||
title: string
|
|
||||||
metricLabel: string
|
|
||||||
value: string | number
|
|
||||||
hint?: ReactNode
|
|
||||||
detail?: ReactNode
|
|
||||||
icon: ReactNode
|
|
||||||
iconBg?: string
|
|
||||||
}
|
|
||||||
|
|
||||||
interface OpsDashboardProps {
|
interface OpsDashboardProps {
|
||||||
title?: string
|
title?: string
|
||||||
@@ -31,46 +20,6 @@ interface OpsDashboardProps {
|
|||||||
queue: ReactNode
|
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({
|
export function OpsDashboard({
|
||||||
title = 'Панель управления',
|
title = 'Панель управления',
|
||||||
description = 'Обзор доменов, групп, сервисов и сертификатов Cloudflare',
|
description = 'Обзор доменов, групп, сервисов и сертификатов Cloudflare',
|
||||||
@@ -108,13 +57,7 @@ export function OpsDashboard({
|
|||||||
</header>
|
</header>
|
||||||
|
|
||||||
<section aria-label="Ключевые метрики">
|
<section aria-label="Ключевые метрики">
|
||||||
<Frame className="@container w-full">
|
<KpiStatGrid cards={kpiCards} skeletonCount={4} />
|
||||||
<div className="grid gap-1 @2xl:grid-cols-2 @5xl:grid-cols-4">
|
|
||||||
{kpiCards.map((card) => (
|
|
||||||
<KpiCardItem key={card.id} card={card} />
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</Frame>
|
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section
|
<section
|
||||||
|
|||||||
@@ -1,13 +1,13 @@
|
|||||||
import { createFileRoute } from '@tanstack/react-router'
|
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 { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||||
import { ShieldCheckIcon } from 'lucide-react'
|
|
||||||
import { toast } from 'sonner'
|
import { toast } from 'sonner'
|
||||||
import type { Filter } from '@/components/reui/filters'
|
import type { Filter } from '@/components/reui/filters'
|
||||||
import { certificatesQueryOptions, certKeys, certSummaryQueryOptions } from '@/queries'
|
import { certificatesQueryOptions, certKeys, certSummaryQueryOptions } from '@/queries'
|
||||||
import { api } from '@/lib/api-client'
|
import { api } from '@/lib/api-client'
|
||||||
import { PageShell } from '@/components/page-shell'
|
import { PageShell } from '@/components/page-shell'
|
||||||
import { ResourcePage } from '@/components/reui-kit'
|
import { ResourcePage } from '@/components/reui-kit'
|
||||||
|
import { CertKpiCards } from '@/components/cert-kpi-cards'
|
||||||
import {
|
import {
|
||||||
CERT_TABS,
|
CERT_TABS,
|
||||||
certFilterFieldValue,
|
certFilterFieldValue,
|
||||||
@@ -16,14 +16,6 @@ import {
|
|||||||
useCertificateColumns,
|
useCertificateColumns,
|
||||||
useCertFilterFields,
|
useCertFilterFields,
|
||||||
} from '@/components/columns/certificates-columns'
|
} 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'
|
import { LoadingButton } from '@/components/loading-button'
|
||||||
|
|
||||||
export const Route = createFileRoute('/_auth/certificates')({
|
export const Route = createFileRoute('/_auth/certificates')({
|
||||||
@@ -37,6 +29,7 @@ export const Route = createFileRoute('/_auth/certificates')({
|
|||||||
|
|
||||||
function CertificatesPage() {
|
function CertificatesPage() {
|
||||||
const [filters, setFilters] = useState<Filter[]>(createDefaultCertFilters)
|
const [filters, setFilters] = useState<Filter[]>(createDefaultCertFilters)
|
||||||
|
const [activeTab, setActiveTab] = useState('all')
|
||||||
const queryClient = useQueryClient()
|
const queryClient = useQueryClient()
|
||||||
const {
|
const {
|
||||||
data: certs,
|
data: certs,
|
||||||
@@ -45,7 +38,7 @@ function CertificatesPage() {
|
|||||||
error,
|
error,
|
||||||
refetch,
|
refetch,
|
||||||
} = useQuery(certificatesQueryOptions())
|
} = useQuery(certificatesQueryOptions())
|
||||||
const { data: summary } = useQuery(certSummaryQueryOptions())
|
const { data: summary, isLoading: isSummaryLoading } = useQuery(certSummaryQueryOptions())
|
||||||
|
|
||||||
const checkMutation = useMutation({
|
const checkMutation = useMutation({
|
||||||
mutationFn: () => api.post('/api/v1/certificates/check'),
|
mutationFn: () => api.post('/api/v1/certificates/check'),
|
||||||
@@ -59,40 +52,13 @@ function CertificatesPage() {
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const handleSelectTab = useCallback((tabId: string) => {
|
||||||
|
setActiveTab(tabId)
|
||||||
|
}, [])
|
||||||
|
|
||||||
const filterFields = useCertFilterFields()
|
const filterFields = useCertFilterFields()
|
||||||
const columns = useCertificateColumns()
|
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 = (
|
const primaryAction = (
|
||||||
<LoadingButton
|
<LoadingButton
|
||||||
onClick={() => checkMutation.mutate()}
|
onClick={() => checkMutation.mutate()}
|
||||||
@@ -105,11 +71,19 @@ function CertificatesPage() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<PageShell className="gap-6">
|
<PageShell className="gap-6">
|
||||||
{kpiStrip}
|
<CertKpiCards
|
||||||
|
summary={summary}
|
||||||
|
total={certs?.length}
|
||||||
|
activeTab={activeTab}
|
||||||
|
isLoading={isSummaryLoading && !summary}
|
||||||
|
onSelectTab={handleSelectTab}
|
||||||
|
/>
|
||||||
<ResourcePage
|
<ResourcePage
|
||||||
title="Сертификаты"
|
title="Сертификаты"
|
||||||
description="Мониторинг SSL: хосты с активными сервисами или режимом «Обязательно»"
|
description="Мониторинг SSL: хосты с активными сервисами или режимом «Обязательно»"
|
||||||
tabs={CERT_TABS.map((tab) => ({ ...tab }))}
|
tabs={CERT_TABS.map((tab) => ({ ...tab }))}
|
||||||
|
activeTab={activeTab}
|
||||||
|
onTabChange={setActiveTab}
|
||||||
tabFilter={certTabFilter}
|
tabFilter={certTabFilter}
|
||||||
filterFields={filterFields}
|
filterFields={filterFields}
|
||||||
filters={filters}
|
filters={filters}
|
||||||
|
|||||||
Reference in New Issue
Block a user