diff --git a/.codegraph/daemon.pid b/.codegraph/daemon.pid
index 12cd382..c1132e1 100644
--- a/.codegraph/daemon.pid
+++ b/.codegraph/daemon.pid
@@ -1,6 +1,6 @@
{
- "pid": 12352,
+ "pid": 47044,
"version": "0.9.9",
"socketPath": "\\\\.\\pipe\\codegraph-7bcc7a2b16d00925",
- "startedAt": 1783570286395
+ "startedAt": 1784215894915
}
diff --git a/apps/web/src/components/blocks/stats-12/components/data.tsx b/apps/web/src/components/blocks/stats-12/components/data.tsx
new file mode 100644
index 0000000..a13dbf4
--- /dev/null
+++ b/apps/web/src/components/blocks/stats-12/components/data.tsx
@@ -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: (
+
+ ),
+ iconBg: "text-blue-600 dark:text-blue-400",
+ value: 320,
+ label: "Support Tickets",
+ info: 12 Open, 308 Closed,
+ },
+ {
+ icon: (
+
+ ),
+ iconBg: "text-emerald-600 dark:text-emerald-400",
+ value: "98%",
+ label: "Resolved",
+ info: +2.1% this month,
+ },
+ {
+ icon: (
+
+ ),
+ iconBg: "text-amber-600 dark:text-amber-400",
+ value: "4.8",
+ label: "Satisfaction Rate",
+ info: Avg. (out of 5),
+ },
+]
\ No newline at end of file
diff --git a/apps/web/src/components/blocks/stats-12/components/stats.tsx b/apps/web/src/components/blocks/stats-12/components/stats.tsx
new file mode 100644
index 0000000..93b5a25
--- /dev/null
+++ b/apps/web/src/components/blocks/stats-12/components/stats.tsx
@@ -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 (
+
+ {/* Grid */}
+
+ {cards.map((card, i) => (
+
+
+ -
+
+ {card.icon}
+
+
+
+
+
+ {card.value}
+
+
+ {card.label}
+
+
+
+ {card.info}
+
+
+ ))}
+
+
+ )
+}
\ No newline at end of file
diff --git a/apps/web/src/components/blocks/stats-12/page.tsx b/apps/web/src/components/blocks/stats-12/page.tsx
new file mode 100644
index 0000000..2261885
--- /dev/null
+++ b/apps/web/src/components/blocks/stats-12/page.tsx
@@ -0,0 +1,9 @@
+import { Stats } from "./components/stats"
+
+export function Page() {
+ return (
+
+
+
+ )
+}
\ No newline at end of file
diff --git a/apps/web/src/components/cert-kpi-cards.tsx b/apps/web/src/components/cert-kpi-cards.tsx
new file mode 100644
index 0000000..0b3f7cc
--- /dev/null
+++ b/apps/web/src/components/cert-kpi-cards.tsx
@@ -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,
+): 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(() => {
+ 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: ,
+ iconBg: KPI_ICON_BG[0],
+ badge: Все статусы,
+ onSelect: () => onSelectTab('all'),
+ selected: activeTab === 'all',
+ },
+ {
+ id: 'active',
+ typeLabel: 'Безопасность',
+ title: 'OK',
+ metricLabel: 'Активные сертификаты',
+ value: okCount,
+ icon: ,
+ iconBg: KPI_ICON_BG[1],
+ badge: В норме,
+ onSelect: () => onSelectTab('active'),
+ selected: activeTab === 'active',
+ },
+ {
+ id: 'warning',
+ typeLabel: 'Срок действия',
+ title: 'Предупреждение',
+ metricLabel: 'Истекают скоро',
+ value: warningCount,
+ icon: ,
+ iconBg: KPI_ICON_BG[2],
+ badge: Требуют внимания,
+ onSelect: () => onSelectTab('warning'),
+ selected: activeTab === 'warning',
+ },
+ {
+ id: 'expired',
+ typeLabel: 'Риски',
+ title: 'Проблемы',
+ metricLabel: 'Ошибка или истёк',
+ value: problemCount,
+ icon: ,
+ iconBg: KPI_ICON_BG[3],
+ badge: Критично,
+ onSelect: () => onSelectTab('expired'),
+ selected: activeTab === 'expired',
+ },
+ ]
+ }, [summary, total, activeTab, onSelectTab])
+
+ const hasData = (total ?? totalFromSummary(summary)) > 0
+
+ return (
+ }
+ emptyMessage="Нет данных — запустите проверку сертификатов"
+ />
+ )
+}
diff --git a/apps/web/src/components/columns/certificates-columns.tsx b/apps/web/src/components/columns/certificates-columns.tsx
index ece736f..c1f1516 100644
--- a/apps/web/src/components/columns/certificates-columns.tsx
+++ b/apps/web/src/components/columns/certificates-columns.tsx
@@ -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 —
+ }
+ if (EXPIRED_STATUSES.has(status)) {
+ return {relative}
+ }
+ if (status === 'warning') {
+ return {relative}
+ }
+ if (ACTIVE_STATUSES.has(status)) {
+ return {relative}
+ }
+ return {relative}
}
export function useCertificateColumns() {
@@ -85,11 +132,8 @@ export function useCertificateColumns() {
{
id: 'relative',
header: 'Срок',
- cell: ({ row }) => (
-
- {formatRelative(row.original.expires_at)}
-
- ),
+ cell: ({ row }) =>
+ certRelativeBadge(row.original.status, row.original.expires_at),
},
{
id: 'last_checked_at',
diff --git a/apps/web/src/components/reui-kit/index.ts b/apps/web/src/components/reui-kit/index.ts
index d8a73f0..dbfa3de 100644
--- a/apps/web/src/components/reui-kit/index.ts
+++ b/apps/web/src/components/reui-kit/index.ts
@@ -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'
diff --git a/apps/web/src/components/reui-kit/kpi-stat-grid.tsx b/apps/web/src/components/reui-kit/kpi-stat-grid.tsx
new file mode 100644
index 0000000..c16b34c
--- /dev/null
+++ b/apps/web/src/components/reui-kit/kpi-stat-grid.tsx
@@ -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 (
+
+
+
+
-
+
+ {card.icon}
+
+
+
+ {card.typeLabel}
+
+
+
+
+
+ {card.value}
+
+
{card.title}
+
{card.metricLabel}
+
+
+ {card.badge || card.hint ? (
+
+ {card.badge}
+ {card.hint}
+
+ ) : null}
+
+ {card.detail ? (
+ {card.detail}
+ ) : null}
+
+
+ )
+}
+
+function KpiStatGridSkeleton({ count }: { count: number }) {
+ return (
+
+
+ {Array.from({ length: count }).map((_, index) => (
+
+
+
+
+
+
+
+
+
+
+
+
+ ))}
+
+
+ )
+}
+
+export function KpiStatGrid({
+ cards,
+ isLoading = false,
+ emptyMessage,
+ emptyIcon,
+ className,
+ skeletonCount = 4,
+}: KpiStatGridProps) {
+ if (isLoading) {
+ return
+ }
+
+ if (cards.length === 0) {
+ return (
+
+
+ {emptyIcon}
+ {emptyMessage ? (
+ {emptyMessage}
+ ) : null}
+
+
+ )
+ }
+
+ return (
+
+
+ {cards.map((card) => (
+
+ ))}
+
+
+ )
+}
diff --git a/apps/web/src/components/reui-kit/ops-dashboard.tsx b/apps/web/src/components/reui-kit/ops-dashboard.tsx
index 5dff125..516cd04 100644
--- a/apps/web/src/components/reui-kit/ops-dashboard.tsx
+++ b/apps/web/src/components/reui-kit/ops-dashboard.tsx
@@ -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 (
-
-
-
-
-
- {card.icon}
-
-
-
-
- {card.typeLabel}
-
-
{card.title}
-
-
-
-
- {card.metricLabel}
-
-
-
- {card.value}
-
- {card.hint}
-
- {card.detail ? (
-
{card.detail}
- ) : null}
-
-
- )
-}
-
export function OpsDashboard({
title = 'Панель управления',
description = 'Обзор доменов, групп, сервисов и сертификатов Cloudflare',
@@ -108,13 +57,7 @@ export function OpsDashboard({
-
-
- {kpiCards.map((card) => (
-
- ))}
-
-
+
(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 (
-
- {chartData.length === 0 ? (
-
-
-
-
- Нет данных — запустите проверку сертификатов
-
-
-
- ) : (
- chartData.map((entry) => (
-
-
-
-
-
-
- {entry.count}
-
-
-
- ))
- )}
-
- )
- }, [summary])
-
const primaryAction = (
checkMutation.mutate()}
@@ -105,11 +71,19 @@ function CertificatesPage() {
return (
- {kpiStrip}
+
({ ...tab }))}
+ activeTab={activeTab}
+ onTabChange={setActiveTab}
tabFilter={certTabFilter}
filterFields={filterFields}
filters={filters}