diff --git a/apps/web/src/components/empty-state.tsx b/apps/web/src/components/empty-state.tsx index a0cd703..66f59b2 100644 --- a/apps/web/src/components/empty-state.tsx +++ b/apps/web/src/components/empty-state.tsx @@ -1,5 +1,6 @@ import { InboxIcon, type LucideIcon } from 'lucide-react' import { IconStack } from '@/components/reui/icon-stack' +import { IconTile } from '@/components/reui/icon-tile' import { Empty, EmptyContent, @@ -46,20 +47,29 @@ export function EmptyState({ !centered && className, )} > - + {stackedIcon ? ( ) : ( - - + )}
- + {title} {description ? ( diff --git a/apps/web/src/components/reui-kit/attention-queue.tsx b/apps/web/src/components/reui-kit/attention-queue.tsx new file mode 100644 index 0000000..8a78484 --- /dev/null +++ b/apps/web/src/components/reui-kit/attention-queue.tsx @@ -0,0 +1,95 @@ +import type { ReactNode } from 'react' +import type { LucideIcon } from 'lucide-react' +import { EmptyState } from '@/components/empty-state' +import { Badge } from '@/components/reui/badge' +import { + Frame, + FrameHeader, + FramePanel, + FrameTitle, +} from '@/components/reui/frame' +import { IconTile } from '@/components/reui/icon-tile' +import { cn } from '@cfdm/ui/lib/utils' + +/** + * Sibling Frame columns for dashboard attention queue. + * Preview: https://reui.io/preview/base/dashboard-1 · https://reui.io/preview/base/stats-12 + * Docs: https://reui.io/docs/components/base/frame · https://reui.io/docs/components/base/icon-tile + */ +export interface AttentionQueueColumn { + id: string + title: string + icon: LucideIcon + iconClassName?: string + count: number + countVariant?: 'destructive' | 'warning' | 'secondary' | 'destructive-light' | 'warning-light' + emptyTitle: string + emptyDescription: string + emptyAction?: ReactNode + children: ReactNode +} + +interface AttentionQueueProps { + columns: AttentionQueueColumn[] + className?: string +} + +const DEFAULT_ICON_CLASS = 'text-muted-foreground [&_svg]:text-current' + +export function AttentionQueue({ columns, className }: AttentionQueueProps) { + return ( +
+ {columns.map((column) => { + const Icon = column.icon + const isEmpty = column.count === 0 + return ( + + +
+ + {column.title} + {column.count > 0 ? ( + + {column.count} + + ) : null} +
+
+ + {isEmpty ? ( +
+ +
+ ) : ( + column.children + )} +
+ + ) + })} +
+ ) +} diff --git a/apps/web/src/components/reui-kit/index.ts b/apps/web/src/components/reui-kit/index.ts index 5ed2012..d2febb4 100644 --- a/apps/web/src/components/reui-kit/index.ts +++ b/apps/web/src/components/reui-kit/index.ts @@ -13,6 +13,7 @@ export { type OpsKpiCard, } from './kpi-stat-grid' export { QuickActionGrid, type QuickActionItem } from './quick-action-grid' +export { AttentionQueue, type AttentionQueueColumn } from './attention-queue' export { OpsDashboard } from './ops-dashboard' export { KanbanBoard, KanbanBoardSkeleton, type KanbanBoardProps, type KanbanColumnConfig } from './kanban-board' export { DetailPanel, type DetailMetricCard } from './detail-panel' diff --git a/apps/web/src/components/reui-kit/ops-dashboard.tsx b/apps/web/src/components/reui-kit/ops-dashboard.tsx index b7817aa..8a5a030 100644 --- a/apps/web/src/components/reui-kit/ops-dashboard.tsx +++ b/apps/web/src/components/reui-kit/ops-dashboard.tsx @@ -1,11 +1,4 @@ import type { ReactNode } from 'react' -import { - Frame, - FrameDescription, - FrameHeader, - FramePanel, - FrameTitle, -} from '@/components/reui/frame' import { Skeleton } from '@cfdm/ui/components/skeleton' import { KpiStatGrid, type KpiStatCard } from './kpi-stat-grid' @@ -17,10 +10,18 @@ interface OpsDashboardProps { afterKpi?: ReactNode charts: ReactNode queue: ReactNode + queueTitle?: string + queueDescription?: string isLoading?: boolean } -/** Denser stack for KPI / charts / queue. PageHeader lives outside via PageShell. */ +/** + * Ops dashboard: KPI → optional Quick Actions → charts → queue. + * Queue is a sibling Frame grid — never wrap Frames inside another Frame. + * @see https://reui.io/preview/base/dashboard-1 + * @see https://reui.io/preview/base/stats-12 + * @see https://reui.io/docs/components/base/frame + */ const rootClassName = 'text-foreground @container flex w-full flex-col gap-2 md:gap-3' @@ -32,7 +33,11 @@ function OpsDashboardSkeleton() {
- +
+ + + +
) } @@ -42,6 +47,8 @@ export function OpsDashboard({ afterKpi, charts, queue, + queueTitle = 'Требуют внимания', + queueDescription = 'Проблемы health-check, истекающие сертификаты и домены без группы', isLoading = false, }: OpsDashboardProps) { if (isLoading) { @@ -63,16 +70,16 @@ export function OpsDashboard({ {charts} -
- - - Требуют внимания - - Проблемы health-check, истекающие сертификаты и домены без группы - - - {queue} - +
+
+

{queueTitle}

+ {queueDescription ? ( +

+ {queueDescription} +

+ ) : null} +
+ {queue}
) diff --git a/apps/web/src/routes/_auth/index.tsx b/apps/web/src/routes/_auth/index.tsx index 53a5fac..e38d2c0 100644 --- a/apps/web/src/routes/_auth/index.tsx +++ b/apps/web/src/routes/_auth/index.tsx @@ -19,12 +19,12 @@ import { } from '@/queries' import { PageShell } from '@/components/page-shell' import { PageHeader } from '@/components/page-header' -import { EmptyState } from '@/components/empty-state' import { CertStatusChart, GroupDomainsChart, OpsDashboard, QuickActionGrid, + AttentionQueue, type KpiStatCard, type QuickActionItem, } from '@/components/reui-kit' @@ -32,10 +32,12 @@ import { HealthCheckBadge } from '@/components/health-check-badge' import { StatusBadge } from '@/components/status-badge' import { Item, + ItemActions, ItemContent, ItemGroup, ItemTitle, } from '@cfdm/ui/components/item' +import { Button } from '@cfdm/ui/components/button' import { formatRelative } from '@/lib/format' import { DEBUG_BUILD_STAMP, debugAgentLog } from '@/lib/debug-agent-log' import { api } from '@/lib/api-client' @@ -300,143 +302,164 @@ function DashboardPage() { } queue={ -
-
-
-
- {attentionServices.length === 0 ? ( -
- -
- ) : ( - - {attentionServices.map((service) => ( - - - - + } + > + К сервисам + + ), + children: ( + + {attentionServices.map((service) => ( + + + {service.name} - - - - - - ))} - - )} -
-
-
-
- {expiringCerts.length === 0 ? ( -
- -
- ) : ( - - {expiringCerts.map(({ cert, ts }) => ( - - - {cert.hostname} -
- + + + + + + + + ))} + + ), + }, + { + id: 'certs', + title: 'Истекающие сертификаты', + icon: AlertTriangleIcon, + iconClassName: 'text-warning [&_svg]:text-current', + count: expiringCerts.length, + countVariant: 'warning-light', + emptyTitle: 'Нет истечений', + emptyDescription: 'В пределах 14 дней истечений нет', + emptyAction: ( + + ), + children: ( + + {expiringCerts.map(({ cert, ts }) => ( + + + + {cert.hostname} + {formatRelative(new Date(ts).toISOString())} -
-
-
- ))} -
- )} -
-
-
-
- {ungroupedDomains.length === 0 ? ( -
- -
- ) : ( - - {ungroupedDomains.map((domain) => ( - - - - {domain.zone_name} - - - Открыть - - - - ))} - - )} -
-
+ + + + + + + ))} + + ), + }, + { + id: 'ungrouped', + title: 'Домены без группы', + icon: FolderTreeIcon, + iconClassName: 'text-info [&_svg]:text-current', + count: ungroupedCount, + countVariant: 'warning-light', + emptyTitle: 'Всё сгруппировано', + emptyDescription: 'Все домены в группах', + emptyAction: ( + + ), + children: ( + + {ungroupedDomains.map((domain) => ( + + + + {domain.zone_name} + + + + + + + ))} + + ), + }, + ]} + /> } />