fix(ui): выровнять очередь внимания дашборда под Frame и IconTile
quality / changes (push) Successful in 8s
quality / api (push) Skipped
quality / commitlint (push) Skipped
quality / docker-check (push) Skipped
CD / update-wiki (push) Successful in 5s
quality / web (push) Successful in 1m6s
CD / quality (push) Successful in 1m21s
CD / publish (push) Successful in 2m36s

Соседние Frame-колонки как в EvoBGP вместо вложенной оболочки; compact empty через IconTile elevated; счётчики ReUI Badge.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Denozordec
2026-08-19 01:11:36 +07:00
co-authored by Cursor
parent a185b3a2cb
commit 9c00b268dc
5 changed files with 294 additions and 158 deletions
+15 -5
View File
@@ -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,
)}
>
<EmptyHeader className="gap-5 text-center">
<EmptyHeader className={cn('text-center', stackedIcon ? 'gap-5' : 'gap-3')}>
<EmptyMedia className="mb-0">
{stackedIcon ? (
<IconStack aria-hidden="true" className="h-14 w-12">
<Icon strokeWidth={1.9} aria-hidden="true" className="size-5" />
</IconStack>
) : (
<span className="bg-muted text-muted-foreground flex size-10 items-center justify-center rounded-lg [&_svg]:size-5">
<Icon aria-hidden="true" />
</span>
<IconTile
variant="elevated"
className="size-10.5 text-muted-foreground"
aria-hidden="true"
>
<Icon />
</IconTile>
)}
</EmptyMedia>
<div className="flex flex-col items-center gap-2">
<EmptyTitle className="text-base font-semibold tracking-tight">
<EmptyTitle
className={cn(
'font-semibold tracking-tight',
stackedIcon ? 'text-base' : 'text-sm',
)}
>
{title}
</EmptyTitle>
{description ? (
@@ -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 (
<div
className={cn(
'grid min-w-0 items-start gap-2 @3xl:grid-cols-3',
className,
)}
>
{columns.map((column) => {
const Icon = column.icon
const isEmpty = column.count === 0
return (
<Frame key={column.id} dense spacing="sm" className="min-w-0 w-full">
<FrameHeader>
<div className="flex min-w-0 items-center gap-2">
<IconTile
variant="elevated"
size="sm"
className={cn(DEFAULT_ICON_CLASS, column.iconClassName)}
aria-hidden="true"
>
<Icon />
</IconTile>
<FrameTitle className="min-w-0 truncate">{column.title}</FrameTitle>
{column.count > 0 ? (
<Badge
size="sm"
variant={column.countVariant ?? 'secondary'}
className="tabular-nums"
>
{column.count}
</Badge>
) : null}
</div>
</FrameHeader>
<FramePanel className="min-w-0">
{isEmpty ? (
<div className="flex min-h-28 items-center justify-center py-4">
<EmptyState
icon={Icon}
title={column.emptyTitle}
description={column.emptyDescription}
action={column.emptyAction}
centered={false}
stackedIcon={false}
/>
</div>
) : (
column.children
)}
</FramePanel>
</Frame>
)
})}
</div>
)
}
@@ -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'
@@ -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() {
<Skeleton className="h-64 w-full rounded-xl" />
<Skeleton className="h-64 w-full rounded-xl" />
</div>
<Skeleton className="h-48 w-full rounded-xl" />
<div className="grid gap-2 @3xl:grid-cols-3">
<Skeleton className="h-40 w-full rounded-xl" />
<Skeleton className="h-40 w-full rounded-xl" />
<Skeleton className="h-40 w-full rounded-xl" />
</div>
</div>
)
}
@@ -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}
</section>
<section aria-label="Требуют внимания">
<Frame dense spacing="sm" className="w-full">
<FrameHeader>
<FrameTitle>Требуют внимания</FrameTitle>
<FrameDescription>
Проблемы health-check, истекающие сертификаты и домены без группы
</FrameDescription>
</FrameHeader>
<FramePanel>{queue}</FramePanel>
</Frame>
<section aria-label={queueTitle} className="flex min-w-0 flex-col gap-2">
<div className="flex min-w-0 flex-col gap-1">
<h2 className="text-sm font-semibold tracking-tight">{queueTitle}</h2>
{queueDescription ? (
<p className="text-muted-foreground max-w-prose text-sm">
{queueDescription}
</p>
) : null}
</div>
{queue}
</section>
</div>
)
+157 -134
View File
@@ -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={
<div className="grid gap-3 lg:grid-cols-3">
<div className="flex flex-col gap-2">
<div className="flex items-center gap-2">
<ActivityIcon
className="text-destructive size-4 shrink-0"
aria-hidden="true"
/>
<h3 className="text-sm font-semibold">Проблемы health-check</h3>
{attentionCount > 0 ? (
<span className="text-muted-foreground text-xs tabular-nums">
({attentionCount})
</span>
) : null}
</div>
{attentionServices.length === 0 ? (
<div className="flex min-h-24 items-center justify-center py-4">
<EmptyState
title="Нет проблем"
description="Нет сервисов со статусом Down или Slow"
centered={false}
stackedIcon={false}
/>
</div>
) : (
<ItemGroup className="gap-2">
{attentionServices.map((service) => (
<Item key={service.id} variant="outline" size="sm">
<ItemContent className="flex flex-row items-center justify-between gap-2">
<ItemTitle className="truncate font-medium">
<Link
to="/services"
className="hover:underline"
>
<AttentionQueue
columns={[
{
id: 'health',
title: 'Проблемы health-check',
icon: ActivityIcon,
iconClassName: 'text-destructive [&_svg]:text-current',
count: attentionCount,
countVariant: 'destructive-light',
emptyTitle: 'Нет проблем',
emptyDescription: 'Нет сервисов со статусом Down или Slow',
emptyAction: (
<Button
variant="outline"
size="sm"
nativeButton={false}
render={<Link to="/services" />}
>
К сервисам
</Button>
),
children: (
<ItemGroup className="gap-2">
{attentionServices.map((service) => (
<Item key={service.id} variant="outline" size="sm">
<ItemContent className="min-w-0 gap-1">
<ItemTitle className="truncate font-medium">
{service.name}
</Link>
</ItemTitle>
<HealthCheckBadge
status={
(service.health_status as
| 'up'
| 'down'
| 'degraded'
| 'unknown') ?? 'unknown'
}
latencyMs={service.health_latency_ms}
size="xs"
/>
</ItemContent>
</Item>
))}
</ItemGroup>
)}
</div>
<div className="flex flex-col gap-2">
<div className="flex items-center gap-2">
<AlertTriangleIcon
className="text-warning size-4 shrink-0"
aria-hidden="true"
/>
<h3 className="text-sm font-semibold">Истекающие сертификаты</h3>
{expiringCerts.length > 0 ? (
<span className="text-muted-foreground text-xs tabular-nums">
({expiringCerts.length})
</span>
) : null}
</div>
{expiringCerts.length === 0 ? (
<div className="flex min-h-24 items-center justify-center py-4">
<EmptyState
title="Нет истечений"
description="В пределах 14 дней истечений нет"
centered={false}
stackedIcon={false}
/>
</div>
) : (
<ItemGroup className="gap-2">
{expiringCerts.map(({ cert, ts }) => (
<Item key={cert.id} variant="outline" size="sm">
<ItemContent className="flex flex-row items-center justify-between gap-2">
<ItemTitle className="truncate font-medium">{cert.hostname}</ItemTitle>
<div className="flex shrink-0 items-center gap-2">
<StatusBadge status={cert.status} />
</ItemTitle>
</ItemContent>
<ItemActions>
<HealthCheckBadge
status={
(service.health_status as
| 'up'
| 'down'
| 'degraded'
| 'unknown') ?? 'unknown'
}
latencyMs={service.health_latency_ms}
size="xs"
/>
<Button
variant="outline"
size="sm"
nativeButton={false}
render={
<Link
to="/services"
search={{ serviceId: service.id }}
/>
}
>
Открыть
</Button>
</ItemActions>
</Item>
))}
</ItemGroup>
),
},
{
id: 'certs',
title: 'Истекающие сертификаты',
icon: AlertTriangleIcon,
iconClassName: 'text-warning [&_svg]:text-current',
count: expiringCerts.length,
countVariant: 'warning-light',
emptyTitle: 'Нет истечений',
emptyDescription: 'В пределах 14 дней истечений нет',
emptyAction: (
<Button
variant="outline"
size="sm"
nativeButton={false}
render={<Link to="/certificates" />}
>
К сертификатам
</Button>
),
children: (
<ItemGroup className="gap-2">
{expiringCerts.map(({ cert, ts }) => (
<Item key={cert.id} variant="outline" size="sm">
<ItemContent className="min-w-0 gap-1">
<ItemTitle className="truncate font-medium">
{cert.hostname}
</ItemTitle>
<span className="text-muted-foreground text-xs tabular-nums">
{formatRelative(new Date(ts).toISOString())}
</span>
</div>
</ItemContent>
</Item>
))}
</ItemGroup>
)}
</div>
<div className="flex flex-col gap-2">
<div className="flex items-center gap-2">
<FolderTreeIcon
className="text-muted-foreground size-4 shrink-0"
aria-hidden="true"
/>
<h3 className="text-sm font-semibold">Домены без группы</h3>
{ungroupedCount > 0 ? (
<span className="text-muted-foreground text-xs tabular-nums">
({ungroupedCount})
</span>
) : null}
</div>
{ungroupedDomains.length === 0 ? (
<div className="flex min-h-24 items-center justify-center py-4">
<EmptyState
title="Всё сгруппировано"
description="Все домены в группах"
centered={false}
stackedIcon={false}
/>
</div>
) : (
<ItemGroup className="gap-2">
{ungroupedDomains.map((domain) => (
<Item key={domain.id} variant="outline" size="sm">
<ItemContent className="flex flex-row items-center justify-between gap-2">
<ItemTitle className="truncate font-medium">
{domain.zone_name}
</ItemTitle>
<Link
to="/domains/$domainId"
params={{ domainId: String(domain.id) }}
className="text-primary text-xs font-medium hover:underline"
>
Открыть
</Link>
</ItemContent>
</Item>
))}
</ItemGroup>
)}
</div>
</div>
</ItemContent>
<ItemActions>
<StatusBadge status={cert.status} />
<Button
variant="outline"
size="sm"
nativeButton={false}
render={<Link to="/certificates" />}
>
Открыть
</Button>
</ItemActions>
</Item>
))}
</ItemGroup>
),
},
{
id: 'ungrouped',
title: 'Домены без группы',
icon: FolderTreeIcon,
iconClassName: 'text-info [&_svg]:text-current',
count: ungroupedCount,
countVariant: 'warning-light',
emptyTitle: 'Всё сгруппировано',
emptyDescription: 'Все домены в группах',
emptyAction: (
<Button
variant="outline"
size="sm"
nativeButton={false}
render={<Link to="/domains" />}
>
К доменам
</Button>
),
children: (
<ItemGroup className="gap-2">
{ungroupedDomains.map((domain) => (
<Item key={domain.id} variant="outline" size="sm">
<ItemContent className="min-w-0">
<ItemTitle className="truncate font-medium">
{domain.zone_name}
</ItemTitle>
</ItemContent>
<ItemActions>
<Button
variant="outline"
size="sm"
nativeButton={false}
render={
<Link
to="/domains/$domainId"
params={{ domainId: String(domain.id) }}
/>
}
>
Открыть
</Button>
</ItemActions>
</Item>
))}
</ItemGroup>
),
},
]}
/>
}
/>
</PageShell>