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 <[email protected]>
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 { InboxIcon, type LucideIcon } from 'lucide-react'
import { IconStack } from '@/components/reui/icon-stack' import { IconStack } from '@/components/reui/icon-stack'
import { IconTile } from '@/components/reui/icon-tile'
import { import {
Empty, Empty,
EmptyContent, EmptyContent,
@@ -46,20 +47,29 @@ export function EmptyState({
!centered && className, !centered && className,
)} )}
> >
<EmptyHeader className="gap-5 text-center"> <EmptyHeader className={cn('text-center', stackedIcon ? 'gap-5' : 'gap-3')}>
<EmptyMedia className="mb-0"> <EmptyMedia className="mb-0">
{stackedIcon ? ( {stackedIcon ? (
<IconStack aria-hidden="true" className="h-14 w-12"> <IconStack aria-hidden="true" className="h-14 w-12">
<Icon strokeWidth={1.9} aria-hidden="true" className="size-5" /> <Icon strokeWidth={1.9} aria-hidden="true" className="size-5" />
</IconStack> </IconStack>
) : ( ) : (
<span className="bg-muted text-muted-foreground flex size-10 items-center justify-center rounded-lg [&_svg]:size-5"> <IconTile
<Icon aria-hidden="true" /> variant="elevated"
</span> className="size-10.5 text-muted-foreground"
aria-hidden="true"
>
<Icon />
</IconTile>
)} )}
</EmptyMedia> </EmptyMedia>
<div className="flex flex-col items-center gap-2"> <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} {title}
</EmptyTitle> </EmptyTitle>
{description ? ( {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, type OpsKpiCard,
} from './kpi-stat-grid' } from './kpi-stat-grid'
export { QuickActionGrid, type QuickActionItem } from './quick-action-grid' export { QuickActionGrid, type QuickActionItem } from './quick-action-grid'
export { AttentionQueue, type AttentionQueueColumn } from './attention-queue'
export { OpsDashboard } from './ops-dashboard' export { OpsDashboard } from './ops-dashboard'
export { KanbanBoard, KanbanBoardSkeleton, type KanbanBoardProps, type KanbanColumnConfig } from './kanban-board' export { KanbanBoard, KanbanBoardSkeleton, type KanbanBoardProps, type KanbanColumnConfig } from './kanban-board'
export { DetailPanel, type DetailMetricCard } from './detail-panel' export { DetailPanel, type DetailMetricCard } from './detail-panel'
@@ -1,11 +1,4 @@
import type { ReactNode } from 'react' import type { ReactNode } from 'react'
import {
Frame,
FrameDescription,
FrameHeader,
FramePanel,
FrameTitle,
} from '@/components/reui/frame'
import { Skeleton } from '@cfdm/ui/components/skeleton' import { Skeleton } from '@cfdm/ui/components/skeleton'
import { KpiStatGrid, type KpiStatCard } from './kpi-stat-grid' import { KpiStatGrid, type KpiStatCard } from './kpi-stat-grid'
@@ -17,10 +10,18 @@ interface OpsDashboardProps {
afterKpi?: ReactNode afterKpi?: ReactNode
charts: ReactNode charts: ReactNode
queue: ReactNode queue: ReactNode
queueTitle?: string
queueDescription?: string
isLoading?: boolean 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 = const rootClassName =
'text-foreground @container flex w-full flex-col gap-2 md:gap-3' '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" />
<Skeleton className="h-64 w-full rounded-xl" /> <Skeleton className="h-64 w-full rounded-xl" />
</div> </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> </div>
) )
} }
@@ -42,6 +47,8 @@ export function OpsDashboard({
afterKpi, afterKpi,
charts, charts,
queue, queue,
queueTitle = 'Требуют внимания',
queueDescription = 'Проблемы health-check, истекающие сертификаты и домены без группы',
isLoading = false, isLoading = false,
}: OpsDashboardProps) { }: OpsDashboardProps) {
if (isLoading) { if (isLoading) {
@@ -63,16 +70,16 @@ export function OpsDashboard({
{charts} {charts}
</section> </section>
<section aria-label="Требуют внимания"> <section aria-label={queueTitle} className="flex min-w-0 flex-col gap-2">
<Frame dense spacing="sm" className="w-full"> <div className="flex min-w-0 flex-col gap-1">
<FrameHeader> <h2 className="text-sm font-semibold tracking-tight">{queueTitle}</h2>
<FrameTitle>Требуют внимания</FrameTitle> {queueDescription ? (
<FrameDescription> <p className="text-muted-foreground max-w-prose text-sm">
Проблемы health-check, истекающие сертификаты и домены без группы {queueDescription}
</FrameDescription> </p>
</FrameHeader> ) : null}
<FramePanel>{queue}</FramePanel> </div>
</Frame> {queue}
</section> </section>
</div> </div>
) )
+157 -134
View File
@@ -19,12 +19,12 @@ import {
} from '@/queries' } from '@/queries'
import { PageShell } from '@/components/page-shell' import { PageShell } from '@/components/page-shell'
import { PageHeader } from '@/components/page-header' import { PageHeader } from '@/components/page-header'
import { EmptyState } from '@/components/empty-state'
import { import {
CertStatusChart, CertStatusChart,
GroupDomainsChart, GroupDomainsChart,
OpsDashboard, OpsDashboard,
QuickActionGrid, QuickActionGrid,
AttentionQueue,
type KpiStatCard, type KpiStatCard,
type QuickActionItem, type QuickActionItem,
} from '@/components/reui-kit' } from '@/components/reui-kit'
@@ -32,10 +32,12 @@ import { HealthCheckBadge } from '@/components/health-check-badge'
import { StatusBadge } from '@/components/status-badge' import { StatusBadge } from '@/components/status-badge'
import { import {
Item, Item,
ItemActions,
ItemContent, ItemContent,
ItemGroup, ItemGroup,
ItemTitle, ItemTitle,
} from '@cfdm/ui/components/item' } from '@cfdm/ui/components/item'
import { Button } from '@cfdm/ui/components/button'
import { formatRelative } from '@/lib/format' import { formatRelative } from '@/lib/format'
import { DEBUG_BUILD_STAMP, debugAgentLog } from '@/lib/debug-agent-log' import { DEBUG_BUILD_STAMP, debugAgentLog } from '@/lib/debug-agent-log'
import { api } from '@/lib/api-client' import { api } from '@/lib/api-client'
@@ -300,143 +302,164 @@ function DashboardPage() {
</> </>
} }
queue={ queue={
<div className="grid gap-3 lg:grid-cols-3"> <AttentionQueue
<div className="flex flex-col gap-2"> columns={[
<div className="flex items-center gap-2"> {
<ActivityIcon id: 'health',
className="text-destructive size-4 shrink-0" title: 'Проблемы health-check',
aria-hidden="true" icon: ActivityIcon,
/> iconClassName: 'text-destructive [&_svg]:text-current',
<h3 className="text-sm font-semibold">Проблемы health-check</h3> count: attentionCount,
{attentionCount > 0 ? ( countVariant: 'destructive-light',
<span className="text-muted-foreground text-xs tabular-nums"> emptyTitle: 'Нет проблем',
({attentionCount}) emptyDescription: 'Нет сервисов со статусом Down или Slow',
</span> emptyAction: (
) : null} <Button
</div> variant="outline"
{attentionServices.length === 0 ? ( size="sm"
<div className="flex min-h-24 items-center justify-center py-4"> nativeButton={false}
<EmptyState render={<Link to="/services" />}
title="Нет проблем" >
description="Нет сервисов со статусом Down или Slow" К сервисам
centered={false} </Button>
stackedIcon={false} ),
/> children: (
</div> <ItemGroup className="gap-2">
) : ( {attentionServices.map((service) => (
<ItemGroup className="gap-2"> <Item key={service.id} variant="outline" size="sm">
{attentionServices.map((service) => ( <ItemContent className="min-w-0 gap-1">
<Item key={service.id} variant="outline" size="sm"> <ItemTitle className="truncate font-medium">
<ItemContent className="flex flex-row items-center justify-between gap-2">
<ItemTitle className="truncate font-medium">
<Link
to="/services"
className="hover:underline"
>
{service.name} {service.name}
</Link> </ItemTitle>
</ItemTitle> </ItemContent>
<HealthCheckBadge <ItemActions>
status={ <HealthCheckBadge
(service.health_status as status={
| 'up' (service.health_status as
| 'down' | 'up'
| 'degraded' | 'down'
| 'unknown') ?? 'unknown' | 'degraded'
} | 'unknown') ?? 'unknown'
latencyMs={service.health_latency_ms} }
size="xs" latencyMs={service.health_latency_ms}
/> size="xs"
</ItemContent> />
</Item> <Button
))} variant="outline"
</ItemGroup> size="sm"
)} nativeButton={false}
</div> render={
<div className="flex flex-col gap-2"> <Link
<div className="flex items-center gap-2"> to="/services"
<AlertTriangleIcon search={{ serviceId: service.id }}
className="text-warning size-4 shrink-0" />
aria-hidden="true" }
/> >
<h3 className="text-sm font-semibold">Истекающие сертификаты</h3> Открыть
{expiringCerts.length > 0 ? ( </Button>
<span className="text-muted-foreground text-xs tabular-nums"> </ItemActions>
({expiringCerts.length}) </Item>
</span> ))}
) : null} </ItemGroup>
</div> ),
{expiringCerts.length === 0 ? ( },
<div className="flex min-h-24 items-center justify-center py-4"> {
<EmptyState id: 'certs',
title="Нет истечений" title: 'Истекающие сертификаты',
description="В пределах 14 дней истечений нет" icon: AlertTriangleIcon,
centered={false} iconClassName: 'text-warning [&_svg]:text-current',
stackedIcon={false} count: expiringCerts.length,
/> countVariant: 'warning-light',
</div> emptyTitle: 'Нет истечений',
) : ( emptyDescription: 'В пределах 14 дней истечений нет',
<ItemGroup className="gap-2"> emptyAction: (
{expiringCerts.map(({ cert, ts }) => ( <Button
<Item key={cert.id} variant="outline" size="sm"> variant="outline"
<ItemContent className="flex flex-row items-center justify-between gap-2"> size="sm"
<ItemTitle className="truncate font-medium">{cert.hostname}</ItemTitle> nativeButton={false}
<div className="flex shrink-0 items-center gap-2"> render={<Link to="/certificates" />}
<StatusBadge status={cert.status} /> >
К сертификатам
</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"> <span className="text-muted-foreground text-xs tabular-nums">
{formatRelative(new Date(ts).toISOString())} {formatRelative(new Date(ts).toISOString())}
</span> </span>
</div> </ItemContent>
</ItemContent> <ItemActions>
</Item> <StatusBadge status={cert.status} />
))} <Button
</ItemGroup> variant="outline"
)} size="sm"
</div> nativeButton={false}
<div className="flex flex-col gap-2"> render={<Link to="/certificates" />}
<div className="flex items-center gap-2"> >
<FolderTreeIcon Открыть
className="text-muted-foreground size-4 shrink-0" </Button>
aria-hidden="true" </ItemActions>
/> </Item>
<h3 className="text-sm font-semibold">Домены без группы</h3> ))}
{ungroupedCount > 0 ? ( </ItemGroup>
<span className="text-muted-foreground text-xs tabular-nums"> ),
({ungroupedCount}) },
</span> {
) : null} id: 'ungrouped',
</div> title: 'Домены без группы',
{ungroupedDomains.length === 0 ? ( icon: FolderTreeIcon,
<div className="flex min-h-24 items-center justify-center py-4"> iconClassName: 'text-info [&_svg]:text-current',
<EmptyState count: ungroupedCount,
title="Всё сгруппировано" countVariant: 'warning-light',
description="Все домены в группах" emptyTitle: 'Всё сгруппировано',
centered={false} emptyDescription: 'Все домены в группах',
stackedIcon={false} emptyAction: (
/> <Button
</div> variant="outline"
) : ( size="sm"
<ItemGroup className="gap-2"> nativeButton={false}
{ungroupedDomains.map((domain) => ( render={<Link to="/domains" />}
<Item key={domain.id} variant="outline" size="sm"> >
<ItemContent className="flex flex-row items-center justify-between gap-2"> К доменам
<ItemTitle className="truncate font-medium"> </Button>
{domain.zone_name} ),
</ItemTitle> children: (
<Link <ItemGroup className="gap-2">
to="/domains/$domainId" {ungroupedDomains.map((domain) => (
params={{ domainId: String(domain.id) }} <Item key={domain.id} variant="outline" size="sm">
className="text-primary text-xs font-medium hover:underline" <ItemContent className="min-w-0">
> <ItemTitle className="truncate font-medium">
Открыть {domain.zone_name}
</Link> </ItemTitle>
</ItemContent> </ItemContent>
</Item> <ItemActions>
))} <Button
</ItemGroup> variant="outline"
)} size="sm"
</div> nativeButton={false}
</div> render={
<Link
to="/domains/$domainId"
params={{ domainId: String(domain.id) }}
/>
}
>
Открыть
</Button>
</ItemActions>
</Item>
))}
</ItemGroup>
),
},
]}
/>
} }
/> />
</PageShell> </PageShell>