Compare commits

...
1 Commits
Author SHA1 Message Date
Denozordec 940f8892f3 refactor: enhance UI components with PanelCard integration and improved layout consistency
CI / changes (push) Successful in 13s
CI / commitlint (push) Has been skipped
CI / openapi (push) Has been skipped
CI / web (push) Successful in 1m6s
CI / go (push) Has been skipped
CI / bird2 (push) Has been skipped
CI / release (push) Successful in 4m17s
Updated multiple components to utilize the new PanelCard for better organization and presentation of content. Refactored DataGridShell, DataGridCard, and various analytics components to streamline layouts and enhance user experience. Adjusted styles for consistency across components, including pagination and toolbar elements, ensuring a cohesive interface throughout the application.
2026-07-09 17:23:17 +07:00
46 changed files with 552 additions and 370 deletions
@@ -1,14 +1,6 @@
import type { ReactNode } from 'react' import type { ReactNode } from 'react'
import { Info } from 'lucide-react' import { Info } from 'lucide-react'
import {
Card,
CardContent,
CardDescription,
CardFooter,
CardHeader,
CardTitle,
} from '@evobgp/ui/components/card'
import { cn } from '@evobgp/ui/lib/utils' import { cn } from '@evobgp/ui/lib/utils'
import { import {
Tooltip, Tooltip,
@@ -16,6 +8,8 @@ import {
TooltipTrigger, TooltipTrigger,
} from '@evobgp/ui/components/tooltip' } from '@evobgp/ui/components/tooltip'
import { PanelCard } from '@/components/panel-card'
export function AnalyticsCardShell({ export function AnalyticsCardShell({
title, title,
description, description,
@@ -33,32 +27,36 @@ export function AnalyticsCardShell({
className?: string className?: string
children: ReactNode children: ReactNode
}) { }) {
const titleNode = (
<span className="flex items-center gap-2">
{title}
{info ? (
<Tooltip>
<TooltipTrigger
className="inline-flex text-muted-foreground transition-colors hover:text-foreground"
aria-label="Подробнее"
>
<Info className="size-3.5" />
</TooltipTrigger>
<TooltipContent side="top" className="max-w-xs text-xs">
{info}
</TooltipContent>
</Tooltip>
) : null}
</span>
)
return ( return (
<Card className={cn('flex flex-col gap-0 overflow-hidden', className)}> <PanelCard
<CardHeader className="flex flex-row items-start justify-between gap-3 border-b py-4"> title={titleNode}
<div className="min-w-0 space-y-1"> description={description}
<CardTitle className="flex items-center gap-2 text-base"> actions={actions}
{title} footer={footer}
{info ? ( className={cn('overflow-hidden', className)}
<Tooltip> contentClassName="flex flex-col gap-4 py-4"
<TooltipTrigger footerClassName={footer ? 'gap-2 p-3' : undefined}
className="inline-flex text-muted-foreground transition-colors hover:text-foreground" >
aria-label="Подробнее" {children}
> </PanelCard>
<Info className="size-3.5" />
</TooltipTrigger>
<TooltipContent side="top" className="max-w-xs text-xs">
{info}
</TooltipContent>
</Tooltip>
) : null}
</CardTitle>
{description ? <CardDescription>{description}</CardDescription> : null}
</div>
{actions ? <div className="shrink-0">{actions}</div> : null}
</CardHeader>
<CardContent className="flex flex-col gap-5 p-5">{children}</CardContent>
{footer ? <CardFooter className="gap-2 border-t p-4">{footer}</CardFooter> : null}
</Card>
) )
} }
@@ -7,10 +7,12 @@ import { cn } from '@evobgp/ui/lib/utils'
export function AnalyticsProgress({ export function AnalyticsProgress({
label, label,
hint,
value, value,
className, className,
}: { }: {
label: string label: string
hint?: string
value: number value: number
className?: string className?: string
}) { }) {
@@ -21,9 +23,10 @@ export function AnalyticsProgress({
<span className="text-muted-foreground">{label}</span> <span className="text-muted-foreground">{label}</span>
<span className="font-medium tabular-nums">{clamped}%</span> <span className="font-medium tabular-nums">{clamped}%</span>
</div> </div>
<Progress value={clamped} className="gap-0"> {hint ? <p className="text-xs leading-snug text-muted-foreground">{hint}</p> : null}
<Progress value={clamped} className="w-full gap-0">
<ProgressTrack className="h-2"> <ProgressTrack className="h-2">
<ProgressIndicator className="bg-foreground" /> <ProgressIndicator />
</ProgressTrack> </ProgressTrack>
</Progress> </Progress>
</div> </div>
@@ -50,14 +50,14 @@ export function DashboardNetworkCapacityCard({
const deltaLabel = const deltaLabel =
mode === 'peers' mode === 'peers'
? `${peers.filter((p) => p.enabled !== false && p.session_state === 'Established').length} Established` ? `${peers.filter((p) => p.enabled !== false && p.session_state === 'Established').length} установлено`
: `${speakers.filter((s) => s.live?.agent_ok).length} online` : `${speakers.filter((s) => s.live?.agent_ok).length} в сети`
return ( return (
<AnalyticsCardShell <AnalyticsCardShell
title="Загрузка BGP" title="Загрузка BGP"
description="Текущая утилизация сессий по пирам и спикерам" description="Текущая утилизация сессий по пирам и спикерам"
info="Каждый столбец — enabled peer или speaker. Высота отражает Established/online." info="Каждый столбец — включённый пир или спикер. Высота отражает установленную сессию или доступность."
actions={ actions={
<AnalyticsSegmentControl <AnalyticsSegmentControl
value={mode} value={mode}
@@ -9,6 +9,7 @@ import { AnalyticsKpiRow } from '@/components/analytics/analytics-kpi-row'
import { AnalyticsProgress } from '@/components/analytics/analytics-progress' import { AnalyticsProgress } from '@/components/analytics/analytics-progress'
import { import {
deploymentProgress, deploymentProgress,
deploymentProgressMeta,
recentPlatformActivity, recentPlatformActivity,
} from '@/lib/metrics' } from '@/lib/metrics'
import { runningJobCount } from '@/queries/overview' import { runningJobCount } from '@/queries/overview'
@@ -48,6 +49,7 @@ export function DashboardPlatformCard({
peersEnabled > 0 ? Math.round((peersEstablished / peersEnabled) * 100) : null peersEnabled > 0 ? Math.round((peersEstablished / peersEnabled) * 100) : null
const deploy = useMemo(() => deploymentProgress(speakers), [speakers]) const deploy = useMemo(() => deploymentProgress(speakers), [speakers])
const deployMeta = useMemo(() => deploymentProgressMeta(deploy), [deploy])
const activity = useMemo( const activity = useMemo(
() => recentPlatformActivity(jobs, revisions, peers, speakers), () => recentPlatformActivity(jobs, revisions, peers, speakers),
[jobs, revisions, peers, speakers], [jobs, revisions, peers, speakers],
@@ -74,7 +76,7 @@ export function DashboardPlatformCard({
label: label:
bgpPct === null bgpPct === null
? 'нет включённых пиров' ? 'нет включённых пиров'
: `${peersEstablished} Established`, : `${peersEstablished} установлено`,
tone: (bgpPct !== null && bgpPct >= 90 tone: (bgpPct !== null && bgpPct >= 90
? 'success' ? 'success'
: bgpPct !== null && bgpPct < 70 : bgpPct !== null && bgpPct < 70
@@ -87,16 +89,14 @@ export function DashboardPlatformCard({
value: loading ? '—' : String(riskCount), value: loading ? '—' : String(riskCount),
delta: { delta: {
direction: (riskCount > 0 ? 'down' : 'up') as 'up' | 'down', direction: (riskCount > 0 ? 'down' : 'up') as 'up' | 'down',
label: riskCount > 0 ? `${failedJobs} задач, ${peersMismatch} mismatch` : 'в норме', label: riskCount > 0 ? `${failedJobs} задач, ${peersMismatch} расхождений` : 'в норме',
tone: (riskCount > 0 ? 'destructive' : 'success') as 'destructive' | 'success', tone: (riskCount > 0 ? 'destructive' : 'success') as 'destructive' | 'success',
}, },
}, },
] ]
const progressLabel = const progressLabel = deployMeta.label
deploy.mode === 'revision' const progressHint = deployMeta.hint
? `Синхронизация ревизий (${deploy.synced}/${deploy.total})`
: `Спикеры online (${deploy.synced}/${deploy.total})`
return ( return (
<AnalyticsCardShell <AnalyticsCardShell
@@ -122,7 +122,11 @@ export function DashboardPlatformCard({
} }
> >
<AnalyticsKpiRow items={kpis} /> <AnalyticsKpiRow items={kpis} />
<AnalyticsProgress label={progressLabel} value={loading ? 0 : deploy.percent} /> <AnalyticsProgress
label={progressLabel}
hint={progressHint}
value={loading ? 0 : deploy.percent}
/>
<div className="space-y-3"> <div className="space-y-3">
<div className="flex items-center justify-between text-sm"> <div className="flex items-center justify-between text-sm">
<span className="text-muted-foreground">Недавняя активность</span> <span className="text-muted-foreground">Недавняя активность</span>
@@ -18,15 +18,15 @@ export function MonitoringHealthCard({
return ( return (
<AnalyticsCardShell <AnalyticsCardShell
title="Доступность системы" title="Доступность системы"
description="Health и readiness checks" description="Проверки живучести и готовности"
info="Donut отражает результат GET /v1/health и checks из GET /v1/ready." info="Диаграмма отражает результат GET /v1/health и проверок из GET /v1/ready."
> >
{loading ? ( {loading ? (
<div className="flex h-48 items-center justify-center text-sm text-muted-foreground"> <div className="flex h-48 items-center justify-center text-sm text-muted-foreground">
Загрузка Загрузка
</div> </div>
) : ( ) : (
<ChartDonutMetric slices={slices} centerLabel="Checks" centerValue={total} /> <ChartDonutMetric slices={slices} centerLabel="Проверки" centerValue={total} />
)} )}
</AnalyticsCardShell> </AnalyticsCardShell>
) )
@@ -21,29 +21,29 @@ export function NetworkOverviewAnalyticsCard({
return ( return (
<AnalyticsCardShell <AnalyticsCardShell
title="Сводка BGP" title="Сводка BGP"
description="Established, online и mismatch по live-данным" description="Установленные сессии, доступность спикеров и расхождения по live-данным"
info="Снимок текущего состояния пиров и спикеров." info="Снимок текущего состояния пиров и спикеров."
> >
<AnalyticsKpiRow <AnalyticsKpiRow
items={[ items={[
{ {
label: 'Пиры Established', label: 'Пиры с установленной сессией',
value: loading ? '—' : `${net.peersEstablished}/${net.peersEnabled}`, value: loading ? '—' : `${net.peersEstablished}/${net.peersEnabled}`,
delta: { delta: {
direction: net.peersMismatch > 0 ? 'down' : 'up', direction: net.peersMismatch > 0 ? 'down' : 'up',
label: net.peersMismatch > 0 ? `${net.peersMismatch} mismatch` : 'сессии в норме', label: net.peersMismatch > 0 ? `${net.peersMismatch} расхождений` : 'сессии в норме',
tone: net.peersMismatch > 0 ? 'warning' : 'success', tone: net.peersMismatch > 0 ? 'warning' : 'success',
}, },
}, },
{ {
label: 'Спикеры online', label: 'Спикеры в сети',
value: loading ? '—' : `${net.speakersOnline}/${net.speakersTotal}`, value: loading ? '—' : `${net.speakersOnline}/${net.speakersTotal}`,
delta: { delta: {
direction: net.speakersOnline < net.speakersTotal ? 'down' : 'up', direction: net.speakersOnline < net.speakersTotal ? 'down' : 'up',
label: label:
net.speakersOnline < net.speakersTotal net.speakersOnline < net.speakersTotal
? `${net.speakersTotal - net.speakersOnline} offline` ? `${net.speakersTotal - net.speakersOnline} не в сети`
: 'все online', : 'все в сети',
tone: net.speakersOnline < net.speakersTotal ? 'warning' : 'success', tone: net.speakersOnline < net.speakersTotal ? 'warning' : 'success',
}, },
}, },
@@ -28,10 +28,10 @@ export function DashboardNetworkPanel({
const m = aggregateNetworkMetrics(peers, speakers) const m = aggregateNetworkMetrics(peers, speakers)
return ( return (
<div className="flex flex-col gap-2 p-3"> <div className="flex flex-col gap-2 p-3">
<Row label="Пиры Established" value={`${m.peersEstablished} / ${m.peersEnabled}`} /> <Row label="Пиры с установленной сессией" value={`${m.peersEstablished} / ${m.peersEnabled}`} />
<Row label="Спикеры online" value={`${m.speakersOnline} / ${m.speakersTotal}`} /> <Row label="Спикеры в сети" value={`${m.speakersOnline} / ${m.speakersTotal}`} />
{m.peersMismatch > 0 ? ( {m.peersMismatch > 0 ? (
<Row label="Mismatches" value={String(m.peersMismatch)} variant="warning" /> <Row label="Расхождения сессий" value={String(m.peersMismatch)} variant="warning" />
) : null} ) : null}
</div> </div>
) )
@@ -2,18 +2,17 @@ import { Link } from '@tanstack/react-router'
import { Gauge, Network, Play, Plus, Share2, Tags } from 'lucide-react' import { Gauge, Network, Play, Plus, Share2, Tags } from 'lucide-react'
import { Button } from '@evobgp/ui/components/button' import { Button } from '@evobgp/ui/components/button'
import { CardFooter } from '@evobgp/ui/components/card'
export function DashboardQuickActions() { export function DashboardQuickActions() {
return ( return (
<CardFooter className="flex flex-wrap gap-2 border-t-0 bg-transparent"> <div className="flex flex-wrap gap-2">
<Button variant="outline" size="sm" type="button" render={<Link to="/modules" />}> <Button variant="outline" size="sm" type="button" render={<Link to="/modules" />}>
<Plus className="size-4" /> <Plus className="size-4" />
Создать модуль Создать модуль
</Button> </Button>
<Button variant="outline" size="sm" type="button" render={<Link to="/directories" />}> <Button variant="outline" size="sm" type="button" render={<Link to="/directories" />}>
<Tags className="size-4" /> <Tags className="size-4" />
Добавить community Добавить BGP-сообщество
</Button> </Button>
<Button variant="outline" size="sm" type="button" render={<Link to="/network" search={{ tab: 'overview' }} />}> <Button variant="outline" size="sm" type="button" render={<Link to="/network" search={{ tab: 'overview' }} />}>
<Network className="size-4" /> <Network className="size-4" />
@@ -30,12 +29,12 @@ export function DashboardQuickActions() {
render={<Link to="/operations" search={{ tab: 'revisions' }} />} render={<Link to="/operations" search={{ tab: 'revisions' }} />}
> >
<Play className="size-4" /> <Play className="size-4" />
Деплой (Apply) Деплой
</Button> </Button>
<Button variant="outline" size="sm" type="button" render={<Link to="/monitoring" search={{ tab: 'system' }} />}> <Button variant="outline" size="sm" type="button" render={<Link to="/monitoring" search={{ tab: 'system' }} />}>
<Gauge className="size-4" /> <Gauge className="size-4" />
Мониторинг Мониторинг
</Button> </Button>
</CardFooter> </div>
) )
} }
@@ -6,6 +6,7 @@ import { DataGridSection } from '@/components/data-grid-shell'
import { StatusBadge } from '@/components/status-badge' import { StatusBadge } from '@/components/status-badge'
import { DataGridColumnHeader } from '@/components/reui/data-grid/data-grid-column-header' import { DataGridColumnHeader } from '@/components/reui/data-grid/data-grid-column-header'
import { DATA_GRID_DENSE_LAYOUT } from '@/lib/data-grid-defaults' import { DATA_GRID_DENSE_LAYOUT } from '@/lib/data-grid-defaults'
import { jobKindRu } from '@/lib/ui-labels'
import { useClientDataGrid } from '@/hooks/use-client-data-grid' import { useClientDataGrid } from '@/hooks/use-client-data-grid'
import type { JobRow } from '@/types/api' import type { JobRow } from '@/types/api'
@@ -27,7 +28,7 @@ export function DashboardRecentJobsGrid({
header: ({ column }) => <DataGridColumnHeader column={column} title="Вид" />, header: ({ column }) => <DataGridColumnHeader column={column} title="Вид" />,
cell: ({ row }) => ( cell: ({ row }) => (
<DataGridPrimaryCell <DataGridPrimaryCell
title={row.original.kind} title={jobKindRu(row.original.kind)}
accent="mono" accent="mono"
subtitle={ subtitle={
row.original.meta?.module_id row.original.meta?.module_id
@@ -55,7 +56,7 @@ export function DashboardRecentJobsGrid({
const moduleName = row.meta?.module_id const moduleName = row.meta?.module_id
? (nameById.get(String(row.meta.module_id)) ?? '') ? (nameById.get(String(row.meta.module_id)) ?? '')
: '' : ''
return `${row.kind} ${row.status} ${moduleName}` return `${jobKindRu(row.kind)} ${row.status} ${moduleName}`
}, },
getRowId: (row) => row.job_id, getRowId: (row) => row.job_id,
pageSize: 8, pageSize: 8,
+15 -16
View File
@@ -1,13 +1,15 @@
import type { ReactNode } from 'react' import type { ReactNode } from 'react'
import type { Table } from '@tanstack/react-table' import type { Table } from '@tanstack/react-table'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@evobgp/ui/components/card' import { cn } from '@evobgp/ui/lib/utils'
import { DataGridToolbar } from '@/components/data-grid-toolbar' import { DataGridToolbar } from '@/components/data-grid-toolbar'
import { PanelCard, panelCardContentFlushClassName, panelCardFooterClassName } from '@/components/panel-card'
import { DataGridPagination } from '@/components/reui/data-grid/data-grid-pagination' import { DataGridPagination } from '@/components/reui/data-grid/data-grid-pagination'
import { DataGrid, DataGridContainer } from '@/components/reui/data-grid/data-grid' import { DataGrid, DataGridContainer } from '@/components/reui/data-grid/data-grid'
import { DataGridTable } from '@/components/reui/data-grid/data-grid-table' import { DataGridTable } from '@/components/reui/data-grid/data-grid-table'
import { import {
DATA_GRID_MESSAGES_RU,
DATA_GRID_PAGINATION_RU, DATA_GRID_PAGINATION_RU,
DATA_GRID_TABLE_LAYOUT, DATA_GRID_TABLE_LAYOUT,
} from '@/lib/data-grid-defaults' } from '@/lib/data-grid-defaults'
@@ -38,7 +40,8 @@ export function DataGridShell<TData extends object>({
table={table} table={table}
recordCount={recordCount} recordCount={recordCount}
isLoading={isLoading} isLoading={isLoading}
emptyMessage={emptyMessage} emptyMessage={emptyMessage ?? DATA_GRID_MESSAGES_RU.emptyMessage}
loadingMessage={DATA_GRID_MESSAGES_RU.loadingMessage}
tableLayout={tableLayout} tableLayout={tableLayout}
className={className} className={className}
onRowClick={onRowClick} onRowClick={onRowClick}
@@ -47,7 +50,7 @@ export function DataGridShell<TData extends object>({
<DataGridTable /> <DataGridTable />
</DataGridContainer> </DataGridContainer>
{showPagination ? ( {showPagination ? (
<div className="border-t px-4 py-3"> <div className={cn(panelCardFooterClassName, 'px-3 py-2')}>
<DataGridPagination {...DATA_GRID_PAGINATION_RU} /> <DataGridPagination {...DATA_GRID_PAGINATION_RU} />
</div> </div>
) : null} ) : null}
@@ -64,20 +67,16 @@ interface DataGridCardProps {
} }
export function DataGridCard({ title, description, actions, children, className }: DataGridCardProps) { export function DataGridCard({ title, description, actions, children, className }: DataGridCardProps) {
const hasHeader = Boolean(title || description || actions)
return ( return (
<Card className={className ?? 'gap-0'}> <PanelCard
{hasHeader ? ( title={title}
<CardHeader className="flex flex-row items-center justify-between border-b py-3"> description={description}
<div className="flex flex-col gap-0.5"> actions={actions}
{title ? <CardTitle className="text-base">{title}</CardTitle> : null} className={className}
{description ? <CardDescription>{description}</CardDescription> : null} contentClassName={panelCardContentFlushClassName}
</div> >
{actions ? <div className="flex shrink-0 items-center gap-2">{actions}</div> : null} {children}
</CardHeader> </PanelCard>
) : null}
<CardContent className="p-0">{children}</CardContent>
</Card>
) )
} }
@@ -28,7 +28,7 @@ export function DataGridToolbar({
className, className,
}: DataGridToolbarProps) { }: DataGridToolbarProps) {
return ( return (
<div className={`flex flex-wrap items-center gap-2 border-b px-4 py-3 ${className ?? ''}`}> <div className={`flex flex-wrap items-center gap-2 border-b px-3 py-2 ${className ?? ''}`}>
<Field className="min-w-[200px] flex-1"> <Field className="min-w-[200px] flex-1">
<InputGroup> <InputGroup>
<InputGroupAddon align="inline-start"> <InputGroupAddon align="inline-start">
@@ -60,7 +60,7 @@ export function FirewallClientsGrid({
{ {
id: 'last_seen_at', id: 'last_seen_at',
accessorFn: (row) => row.last_seen_at ?? '', accessorFn: (row) => row.last_seen_at ?? '',
header: ({ column }) => <DataGridColumnHeader column={column} title="Last seen" />, header: ({ column }) => <DataGridColumnHeader column={column} title="Последняя активность" />,
cell: ({ row }) => ( cell: ({ row }) => (
<DataGridMutedCell>{row.original.last_seen_at?.slice(0, 19) ?? '—'}</DataGridMutedCell> <DataGridMutedCell>{row.original.last_seen_at?.slice(0, 19) ?? '—'}</DataGridMutedCell>
), ),
@@ -69,12 +69,12 @@ export function FirewallClientsGrid({
const bv = b.original.last_seen_at ?? '' const bv = b.original.last_seen_at ?? ''
return av.localeCompare(bv) return av.localeCompare(bv)
}, },
meta: { headerTitle: 'Last seen' }, meta: { headerTitle: 'Последняя активность' },
}, },
{ {
id: 'apply', id: 'apply',
enableSorting: false, enableSorting: false,
header: 'Apply', header: 'Применение',
cell: ({ row }) => { cell: ({ row }) => {
const c = row.original const c = row.original
return ( return (
@@ -84,7 +84,7 @@ export function FirewallClientsGrid({
</span> </span>
) )
}, },
meta: { headerTitle: 'Apply' }, meta: { headerTitle: 'Применение' },
}, },
{ {
id: 'packets', id: 'packets',
+3 -3
View File
@@ -56,7 +56,7 @@ interface NavGroup {
const NAV_GROUPS: NavGroup[] = [ const NAV_GROUPS: NavGroup[] = [
{ {
label: 'Обзор', label: 'Обзор',
items: [{ to: '/dashboard', label: 'Dashboard', icon: LayoutDashboard }], items: [{ to: '/dashboard', label: 'Панель', icon: LayoutDashboard }],
}, },
{ {
label: 'Маршрутизация', label: 'Маршрутизация',
@@ -70,7 +70,7 @@ const NAV_GROUPS: NavGroup[] = [
label: 'Операции', label: 'Операции',
items: [ items: [
{ to: '/operations', label: 'Операции', icon: Cog }, { to: '/operations', label: 'Операции', icon: Cog },
{ to: '/firewall', label: 'Firewall', icon: Shield }, { to: '/firewall', label: 'Файрвол', icon: Shield },
{ to: '/schedule', label: 'Задачи', icon: ListChecks }, { to: '/schedule', label: 'Задачи', icon: ListChecks },
{ to: '/monitoring', label: 'Мониторинг', icon: Activity }, { to: '/monitoring', label: 'Мониторинг', icon: Activity },
], ],
@@ -111,7 +111,7 @@ export function AppShell({ children }: { children: ReactNode }) {
</div> </div>
<div className="flex flex-col overflow-hidden group-data-[collapsible=icon]:hidden"> <div className="flex flex-col overflow-hidden group-data-[collapsible=icon]:hidden">
<span className="truncate text-sm font-semibold">EvoBGP</span> <span className="truncate text-sm font-semibold">EvoBGP</span>
<span className="truncate text-xs text-muted-foreground">Control Plane</span> <span className="truncate text-xs text-muted-foreground">Плоскость управления</span>
</div> </div>
</div> </div>
</SidebarHeader> </SidebarHeader>
@@ -8,6 +8,7 @@ import { DataGridMutedCell, DataGridPrimaryCell } from '@/components/data-grid-c
import { DataGridSection } from '@/components/data-grid-shell' import { DataGridSection } from '@/components/data-grid-shell'
import { DataGridColumnHeader } from '@/components/reui/data-grid/data-grid-column-header' import { DataGridColumnHeader } from '@/components/reui/data-grid/data-grid-column-header'
import { useClientDataGrid } from '@/hooks/use-client-data-grid' import { useClientDataGrid } from '@/hooks/use-client-data-grid'
import { moduleTypeRu } from '@/lib/ui-labels'
import type { ModuleRow } from '@/types/api' import type { ModuleRow } from '@/types/api'
export function ModulesListGrid({ export function ModulesListGrid({
@@ -35,7 +36,7 @@ export function ModulesListGrid({
{ {
accessorKey: 'type', accessorKey: 'type',
header: ({ column }) => <DataGridColumnHeader column={column} title="Тип" />, header: ({ column }) => <DataGridColumnHeader column={column} title="Тип" />,
cell: ({ row }) => <CategoryBadge>{row.original.type}</CategoryBadge>, cell: ({ row }) => <CategoryBadge>{moduleTypeRu(row.original.type)}</CategoryBadge>,
meta: { headerTitle: 'Тип' }, meta: { headerTitle: 'Тип' },
}, },
{ {
@@ -7,6 +7,7 @@ import { DataGridSection } from '@/components/data-grid-shell'
import { StatusBadge } from '@/components/status-badge' import { StatusBadge } from '@/components/status-badge'
import { DataGridColumnHeader } from '@/components/reui/data-grid/data-grid-column-header' import { DataGridColumnHeader } from '@/components/reui/data-grid/data-grid-column-header'
import { useClientDataGrid } from '@/hooks/use-client-data-grid' import { useClientDataGrid } from '@/hooks/use-client-data-grid'
import { jobStatusRu, readyCheckRu } from '@/lib/ui-labels'
import type { ReadyStatus } from '@/queries/monitoring' import type { ReadyStatus } from '@/queries/monitoring'
const READY_CHECK_ICONS: Record<string, typeof Database> = { const READY_CHECK_ICONS: Record<string, typeof Database> = {
@@ -36,19 +37,19 @@ export function MonitoringReadyGrid({
const rows: ReadyCheckRow[] = [ const rows: ReadyCheckRow[] = [
{ {
id: 'liveness', id: 'liveness',
label: 'Liveness', label: 'Живучесть',
subtitle: '/v1/health', subtitle: '/v1/health',
icon: HeartPulse, icon: HeartPulse,
status: health?.ok ? 'ok' : 'error', status: health?.ok ? 'ok' : 'error',
statusLabel: health?.ok ? 'OK' : 'Ошибка', statusLabel: health?.ok ? 'В норме' : 'Ошибка',
}, },
{ {
id: 'readiness', id: 'readiness',
label: 'Readiness', label: 'Готовность',
subtitle: '/v1/ready', subtitle: '/v1/ready',
icon: ShieldCheck, icon: ShieldCheck,
status: ready.status === 'ok' ? 'ok' : 'warning', status: ready.status === 'ok' ? 'ok' : 'warning',
statusLabel: ready.status ?? '—', statusLabel: ready.status === 'ok' ? 'Готов' : jobStatusRu(ready.status ?? 'pending'),
}, },
] ]
for (const key of Object.keys(checks)) { for (const key of Object.keys(checks)) {
@@ -56,10 +57,10 @@ export function MonitoringReadyGrid({
const ok = typeof value === 'boolean' ? value : value?.ok !== false const ok = typeof value === 'boolean' ? value : value?.ok !== false
rows.push({ rows.push({
id: key, id: key,
label: key, label: readyCheckRu(key),
icon: READY_CHECK_ICONS[key] ?? ListTodo, icon: READY_CHECK_ICONS[key] ?? ListTodo,
status: ok ? 'ok' : 'error', status: ok ? 'ok' : 'error',
statusLabel: ok ? 'OK' : 'Ошибка', statusLabel: ok ? 'В норме' : 'Ошибка',
}) })
} }
return rows return rows
@@ -7,6 +7,7 @@ import { DataGridSection } from '@/components/data-grid-shell'
import { StatusBadge } from '@/components/status-badge' import { StatusBadge } from '@/components/status-badge'
import { DataGridColumnHeader } from '@/components/reui/data-grid/data-grid-column-header' import { DataGridColumnHeader } from '@/components/reui/data-grid/data-grid-column-header'
import { useClientDataGrid } from '@/hooks/use-client-data-grid' import { useClientDataGrid } from '@/hooks/use-client-data-grid'
import { bgpSessionStateRu } from '@/lib/ui-labels'
import type { PeerRow } from '@/types/api' import type { PeerRow } from '@/types/api'
export function NetworkPeersGrid({ export function NetworkPeersGrid({
@@ -33,11 +34,11 @@ export function NetworkPeersGrid({
}, },
{ {
accessorKey: 'neighbor', accessorKey: 'neighbor',
header: ({ column }) => <DataGridColumnHeader column={column} title="Neighbor" />, header: ({ column }) => <DataGridColumnHeader column={column} title="Адрес соседа" />,
cell: ({ row }) => ( cell: ({ row }) => (
<DataGridPrimaryCell title={row.original.neighbor} accent="mono" /> <DataGridPrimaryCell title={row.original.neighbor} accent="mono" />
), ),
meta: { headerTitle: 'Neighbor' }, meta: { headerTitle: 'Адрес соседа' },
}, },
{ {
accessorKey: 'remote_asn', accessorKey: 'remote_asn',
@@ -52,9 +53,12 @@ export function NetworkPeersGrid({
header: ({ column }) => <DataGridColumnHeader column={column} title="Состояние" />, header: ({ column }) => <DataGridColumnHeader column={column} title="Состояние" />,
cell: ({ row }) => ( cell: ({ row }) => (
<div className="flex items-center gap-1"> <div className="flex items-center gap-1">
<StatusBadge status={row.original.session_state} /> <StatusBadge
status={row.original.session_state ?? '—'}
label={bgpSessionStateRu(row.original.session_state)}
/>
{row.original.session_mismatch ? ( {row.original.session_mismatch ? (
<CategoryBadge tone="warning">mismatch</CategoryBadge> <CategoryBadge tone="warning">расхождение</CategoryBadge>
) : null} ) : null}
</div> </div>
), ),
@@ -8,6 +8,7 @@ import { StatusBadge } from '@/components/status-badge'
import { Badge } from '@/components/reui/badge' 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 { useClientDataGrid } from '@/hooks/use-client-data-grid' import { useClientDataGrid } from '@/hooks/use-client-data-grid'
import { speakerOnlineLabel } from '@/lib/ui-labels'
import type { SpeakerRow } from '@/types/api' import type { SpeakerRow } from '@/types/api'
export function NetworkSpeakersGrid({ export function NetworkSpeakersGrid({
@@ -21,11 +22,11 @@ export function NetworkSpeakersGrid({
() => [ () => [
{ {
accessorKey: 'endpoint', accessorKey: 'endpoint',
header: ({ column }) => <DataGridColumnHeader column={column} title="Endpoint" />, header: ({ column }) => <DataGridColumnHeader column={column} title="Конечная точка" />,
cell: ({ row }) => ( cell: ({ row }) => (
<DataGridPrimaryCell title={row.original.endpoint} accent="mono" /> <DataGridPrimaryCell title={row.original.endpoint} accent="mono" />
), ),
meta: { headerTitle: 'Endpoint' }, meta: { headerTitle: 'Конечная точка' },
}, },
{ {
accessorKey: 'role', accessorKey: 'role',
@@ -36,14 +37,14 @@ export function NetworkSpeakersGrid({
{ {
id: 'agent', id: 'agent',
enableSorting: false, enableSorting: false,
header: 'Agent', header: 'Агент',
cell: ({ row }) => { cell: ({ row }) => {
const live = row.original.live const live = row.original.live
if (live?.agent_ok === true) return <StatusBadge status="ok" label="online" /> if (live?.agent_ok === true) return <StatusBadge status="ok" label={speakerOnlineLabel(true)} />
if (live?.agent_ok === false) return <StatusBadge status="error" label="offline" /> if (live?.agent_ok === false) return <StatusBadge status="error" label={speakerOnlineLabel(false)} />
return <Badge variant="outline" size="sm" radius="full"></Badge> return <Badge variant="outline" size="sm" radius="full"></Badge>
}, },
meta: { headerTitle: 'Agent' }, meta: { headerTitle: 'Агент' },
}, },
{ {
id: 'bgp', id: 'bgp',
@@ -109,7 +109,7 @@ export function SpeakerFormDialog({ open, onOpenChange }: SpeakerFormDialogProps
} }
> >
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
<Label htmlFor="speaker-endpoint">Endpoint</Label> <Label htmlFor="speaker-endpoint">Конечная точка</Label>
<Input <Input
id="speaker-endpoint" id="speaker-endpoint"
placeholder="https://node.example.com:8443" placeholder="https://node.example.com:8443"
@@ -121,14 +121,14 @@ export function SpeakerFormDialog({ open, onOpenChange }: SpeakerFormDialogProps
id="speaker-role" id="speaker-role"
label="Роль" label="Роль"
items={[ items={[
{ value: 'replica', label: 'replica' }, { value: 'replica', label: 'Реплика' },
{ value: 'master', label: 'master (CP)' }, { value: 'master', label: 'Мастер (CP)' },
]} ]}
value={role} value={role}
onValueChange={(v) => setRole(v ?? 'replica')} onValueChange={(v) => setRole(v ?? 'replica')}
/> />
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
<Label htmlFor="speaker-agent-domain">Agent domain</Label> <Label htmlFor="speaker-agent-domain">Домен агента</Label>
<Input <Input
id="speaker-agent-domain" id="speaker-agent-domain"
placeholder="bird-agent.example.com" placeholder="bird-agent.example.com"
@@ -137,7 +137,7 @@ export function SpeakerFormDialog({ open, onOpenChange }: SpeakerFormDialogProps
/> />
</div> </div>
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
<Label htmlFor="speaker-node-ipv4">Node IPv4</Label> <Label htmlFor="speaker-node-ipv4">IPv4 ноды</Label>
<Input <Input
id="speaker-node-ipv4" id="speaker-node-ipv4"
placeholder="203.0.113.10" placeholder="203.0.113.10"
@@ -11,6 +11,7 @@ import { StatusBadge } from '@/components/status-badge'
import { DataGridColumnHeader } from '@/components/reui/data-grid/data-grid-column-header' import { DataGridColumnHeader } from '@/components/reui/data-grid/data-grid-column-header'
import { useClientDataGrid } from '@/hooks/use-client-data-grid' import { useClientDataGrid } from '@/hooks/use-client-data-grid'
import { apiMutate } from '@/lib/api-client' import { apiMutate } from '@/lib/api-client'
import { jobKindRu } from '@/lib/ui-labels'
import type { JobRow } from '@/types/api' import type { JobRow } from '@/types/api'
import type { QueryClient } from '@tanstack/react-query' import type { QueryClient } from '@tanstack/react-query'
@@ -41,7 +42,7 @@ export function OperationsJobsGrid({
header: ({ column }) => <DataGridColumnHeader column={column} title="Вид" />, header: ({ column }) => <DataGridColumnHeader column={column} title="Вид" />,
cell: ({ row }) => ( cell: ({ row }) => (
<DataGridPrimaryCell <DataGridPrimaryCell
title={row.original.kind} title={jobKindRu(row.original.kind)}
accent="mono" accent="mono"
subtitle={ subtitle={
row.original.meta?.module_id row.original.meta?.module_id
@@ -119,7 +120,7 @@ export function OperationsJobsGrid({
const moduleName = row.meta?.module_id const moduleName = row.meta?.module_id
? (nameById.get(String(row.meta.module_id)) ?? String(row.meta.module_id)) ? (nameById.get(String(row.meta.module_id)) ?? String(row.meta.module_id))
: '' : ''
return `${row.kind} ${row.status} ${row.job_id} ${moduleName}` return `${jobKindRu(row.kind)} ${row.status} ${row.job_id} ${moduleName}`
}, },
getRowId: (row) => row.job_id, getRowId: (row) => row.job_id,
}) })
+64
View File
@@ -0,0 +1,64 @@
import type { ReactNode } from 'react'
import {
Card,
CardAction,
CardContent,
CardDescription,
CardFooter,
CardHeader,
CardTitle,
} from '@evobgp/ui/components/card'
import { cn } from '@evobgp/ui/lib/utils'
/** REUI c-data-grid-19: flush card shell with compact header spacing. */
export const panelCardClassName = 'gap-0 py-0'
export const panelCardHeaderClassName = 'border-b [.border-b]:pb-3'
export const panelCardContentFlushClassName = 'px-0'
export const panelCardFooterClassName = 'border-t bg-transparent py-0'
interface PanelCardProps {
title?: ReactNode
description?: ReactNode
actions?: ReactNode
footer?: ReactNode
children?: ReactNode
className?: string
headerClassName?: string
contentClassName?: string
footerClassName?: string
size?: 'default' | 'sm'
}
export function PanelCard({
title,
description,
actions,
footer,
children,
className,
headerClassName,
contentClassName,
footerClassName,
size = 'sm',
}: PanelCardProps) {
const hasHeader = Boolean(title || description || actions)
return (
<Card size={size} className={cn(panelCardClassName, className)}>
{hasHeader ? (
<CardHeader className={cn(panelCardHeaderClassName, headerClassName)}>
{title ? <CardTitle>{title}</CardTitle> : null}
{description ? <CardDescription>{description}</CardDescription> : null}
{actions ? <CardAction>{actions}</CardAction> : null}
</CardHeader>
) : null}
{children != null && children !== false ? (
<CardContent className={contentClassName}>{children}</CardContent>
) : null}
{footer ? (
<CardFooter className={cn(panelCardFooterClassName, footerClassName)}>{footer}</CardFooter>
) : null}
</Card>
)
}
@@ -140,12 +140,12 @@ function DataGridPagination(props: DataGridPaginationProps): React.JSX.Element {
mergedProps?.className mergedProps?.className
)} )}
> >
<div className="order-2 flex flex-wrap items-center space-x-2.5 pb-2.5 sm:order-1 sm:pb-0"> <div className="order-2 flex flex-wrap items-center gap-2 pb-2.5 sm:order-1 sm:pb-0">
{isLoading ? ( {isLoading ? (
mergedProps?.sizesSkeleton mergedProps?.sizesSkeleton
) : ( ) : (
<> <>
<div className="text-muted-foreground text-sm"> <div className="shrink-0 text-sm text-muted-foreground whitespace-nowrap">
{mergedProps.rowsPerPageLabel} {mergedProps.rowsPerPageLabel}
</div> </div>
<SelectMenu <SelectMenu
@@ -156,10 +156,10 @@ function DataGridPagination(props: DataGridPaginationProps): React.JSX.Element {
})) ?? [] })) ?? []
} }
value={`${pageSize}`} value={`${pageSize}`}
triggerClassName="w-14" triggerClassName="min-w-20 w-auto tabular-nums"
size="sm" size="sm"
side="top" side="top"
contentClassName="min-w-18" contentClassName="min-w-20"
onValueChange={(value) => { onValueChange={(value) => {
if (!value) return if (!value) return
table.setPageSize(Number(value)) table.setPageSize(Number(value))
@@ -21,6 +21,7 @@ import {
DataGridTableViewport, DataGridTableViewport,
getDataGridTableRowSections, getDataGridTableRowSections,
} from "@/components/reui/data-grid/data-grid-table" } from "@/components/reui/data-grid/data-grid-table"
import { DATA_GRID_MESSAGES_RU } from "@/lib/data-grid-defaults"
import { flexRender, HeaderGroup, Row, Table } from "@tanstack/react-table" import { flexRender, HeaderGroup, Row, Table } from "@tanstack/react-table"
import { import {
useVirtualizer, useVirtualizer,
@@ -304,9 +305,9 @@ function DataGridTableVirtual<TData>({
const isVirtualizationEnabled = virtualizerOptions?.enabled !== false const isVirtualizationEnabled = virtualizerOptions?.enabled !== false
const loadingMoreMessage = const loadingMoreMessage =
props.fetchingMoreMessage || props.loadingMessage || "Loading..." props.fetchingMoreMessage || props.loadingMessage || DATA_GRID_MESSAGES_RU.loadingMessage
const allRowsLoadedMessage = const allRowsLoadedMessage =
props.allRowsLoadedMessage || "All records loaded" props.allRowsLoadedMessage || DATA_GRID_MESSAGES_RU.allRecordsLoadedMessage
const handleViewportRef = useCallback((node: HTMLDivElement | null) => { const handleViewportRef = useCallback((node: HTMLDivElement | null) => {
setViewportElements({ setViewportElements({
@@ -28,6 +28,7 @@ import { cva } from "class-variance-authority"
import { cn } from "@evobgp/ui/lib/utils" import { cn } from "@evobgp/ui/lib/utils"
import { Checkbox } from "@evobgp/ui/components/checkbox" import { Checkbox } from "@evobgp/ui/components/checkbox"
import { Spinner } from "@evobgp/ui/components/spinner" import { Spinner } from "@evobgp/ui/components/spinner"
import { DATA_GRID_MESSAGES_RU } from "@/lib/data-grid-defaults"
const headerCellSpacingVariants = cva("", { const headerCellSpacingVariants = cva("", {
variants: { variants: {
@@ -1098,7 +1099,7 @@ function DataGridTableEmpty() {
colSpan={Math.max(visibleColumnCount, 1)} colSpan={Math.max(visibleColumnCount, 1)}
className="text-muted-foreground text-sm py-6 text-center" className="text-muted-foreground text-sm py-6 text-center"
> >
{props.emptyMessage || "No data available"} {props.emptyMessage || DATA_GRID_MESSAGES_RU.emptyMessage}
</td> </td>
</tr> </tr>
) )
@@ -1111,7 +1112,7 @@ function DataGridTableLoader() {
<div className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2"> <div className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2">
<div className="text-muted-foreground bg-card rounded-lg text-sm flex items-center gap-2 border px-4 py-2 leading-none font-medium"> <div className="text-muted-foreground bg-card rounded-lg text-sm flex items-center gap-2 border px-4 py-2 leading-none font-medium">
<Spinner className="size-5 opacity-60" /> <Spinner className="size-5 opacity-60" />
{props.loadingMessage || "Loading..."} {props.loadingMessage || DATA_GRID_MESSAGES_RU.loadingMessage}
</div> </div>
</div> </div>
) )
@@ -1123,7 +1124,7 @@ function DataGridTableRowPin<TData>({ row }: { row: Row<TData> }) {
return ( return (
<button <button
type="button" type="button"
aria-label={isPinned ? "Unpin row" : "Pin row"} aria-label={isPinned ? DATA_GRID_MESSAGES_RU.unpinRowLabel : DATA_GRID_MESSAGES_RU.pinRowLabel}
onClick={() => { onClick={() => {
if (isPinned) { if (isPinned) {
row.pin(false) row.pin(false)
@@ -1179,7 +1180,7 @@ function DataGridTableRowSelect<TData>({ row }: { row: Row<TData> }) {
<Checkbox <Checkbox
checked={row.getIsSelected()} checked={row.getIsSelected()}
onCheckedChange={(value) => row.toggleSelected(!!value)} onCheckedChange={(value) => row.toggleSelected(!!value)}
aria-label="Select row" aria-label={DATA_GRID_MESSAGES_RU.selectRowLabel}
className="align-[inherit]" className="align-[inherit]"
/> />
</> </>
@@ -1198,7 +1199,7 @@ function DataGridTableRowSelectAll() {
indeterminate={isSomeSelected && !isAllSelected} indeterminate={isSomeSelected && !isAllSelected}
disabled={isLoading || recordCount === 0} disabled={isLoading || recordCount === 0}
onCheckedChange={(value) => table.toggleAllPageRowsSelected(!!value)} onCheckedChange={(value) => table.toggleAllPageRowsSelected(!!value)}
aria-label="Select all" aria-label={DATA_GRID_MESSAGES_RU.selectAllLabel}
className="align-[inherit]" className="align-[inherit]"
/> />
) )
@@ -1249,7 +1250,7 @@ function DataGridTableBodyRows<TData>({ table }: { table: Table<TData> }) {
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
></path> ></path>
</svg> </svg>
{props.loadingMessage || "Loading..."} {props.loadingMessage || DATA_GRID_MESSAGES_RU.loadingMessage}
</div> </div>
</td> </td>
</tr> </tr>
@@ -6,6 +6,7 @@ import { DataGridSection } from '@/components/data-grid-shell'
import { StatusBadge } from '@/components/status-badge' import { StatusBadge } from '@/components/status-badge'
import { DataGridColumnHeader } from '@/components/reui/data-grid/data-grid-column-header' import { DataGridColumnHeader } from '@/components/reui/data-grid/data-grid-column-header'
import { useClientDataGrid } from '@/hooks/use-client-data-grid' import { useClientDataGrid } from '@/hooks/use-client-data-grid'
import { jobKindRu } from '@/lib/ui-labels'
import type { JobRow } from '@/types/api' import type { JobRow } from '@/types/api'
export function ScheduleJobsGrid({ export function ScheduleJobsGrid({
@@ -20,7 +21,7 @@ export function ScheduleJobsGrid({
{ {
accessorKey: 'kind', accessorKey: 'kind',
header: ({ column }) => <DataGridColumnHeader column={column} title="Вид" />, header: ({ column }) => <DataGridColumnHeader column={column} title="Вид" />,
cell: ({ row }) => <DataGridPrimaryCell title={row.original.kind} accent="mono" />, cell: ({ row }) => <DataGridPrimaryCell title={jobKindRu(row.original.kind)} accent="mono" />,
meta: { headerTitle: 'Вид' }, meta: { headerTitle: 'Вид' },
}, },
{ {
@@ -79,7 +80,7 @@ export function ScheduleJobsGrid({
const { table, globalFilter, setGlobalFilter, filteredCount } = useClientDataGrid({ const { table, globalFilter, setGlobalFilter, filteredCount } = useClientDataGrid({
data: items, data: items,
columns, columns,
getSearchText: (row) => `${row.kind} ${row.status} ${row.error ?? ''}`, getSearchText: (row) => `${jobKindRu(row.kind)} ${row.status} ${row.error ?? ''}`,
getRowId: (row) => row.job_id, getRowId: (row) => row.job_id,
}) })
@@ -8,6 +8,7 @@ import { DataGridSection } from '@/components/data-grid-shell'
import { LoadingButton } from '@/components/loading-button' import { LoadingButton } from '@/components/loading-button'
import { DataGridColumnHeader } from '@/components/reui/data-grid/data-grid-column-header' import { DataGridColumnHeader } from '@/components/reui/data-grid/data-grid-column-header'
import { useClientDataGrid } from '@/hooks/use-client-data-grid' import { useClientDataGrid } from '@/hooks/use-client-data-grid'
import { moduleTypeRu } from '@/lib/ui-labels'
import type { ModuleRow } from '@/types/api' import type { ModuleRow } from '@/types/api'
export function ScheduleModulesGrid({ export function ScheduleModulesGrid({
@@ -32,7 +33,7 @@ export function ScheduleModulesGrid({
{ {
accessorKey: 'type', accessorKey: 'type',
header: ({ column }) => <DataGridColumnHeader column={column} title="Тип" />, header: ({ column }) => <DataGridColumnHeader column={column} title="Тип" />,
cell: ({ row }) => <CategoryBadge>{row.original.type}</CategoryBadge>, cell: ({ row }) => <CategoryBadge>{moduleTypeRu(row.original.type)}</CategoryBadge>,
meta: { headerTitle: 'Тип' }, meta: { headerTitle: 'Тип' },
}, },
{ {
+3 -2
View File
@@ -42,7 +42,7 @@ export function SectionCards({ items, className }: { items: SectionCardItem[]; c
{items.map((item, idx) => { {items.map((item, idx) => {
const clickable = Boolean(item.onClick) const clickable = Boolean(item.onClick)
const content = ( const content = (
<CardContent className="flex items-start gap-2.5 px-3 py-2.5"> <CardContent className="flex items-start gap-2.5 px-3 py-2">
{item.icon ? ( {item.icon ? (
<span className="flex size-7 shrink-0 items-center justify-center rounded-md bg-muted/60 text-muted-foreground"> <span className="flex size-7 shrink-0 items-center justify-center rounded-md bg-muted/60 text-muted-foreground">
{item.icon} {item.icon}
@@ -80,8 +80,9 @@ export function SectionCards({ items, className }: { items: SectionCardItem[]; c
return ( return (
<Card <Card
key={typeof item.label === 'string' ? item.label : idx} key={typeof item.label === 'string' ? item.label : idx}
size="sm"
className={cn( className={cn(
'gap-0', 'gap-0 py-0',
VARIANT_CLASS[item.variant ?? 'default'], VARIANT_CLASS[item.variant ?? 'default'],
item.active && 'border-primary ring-1 ring-primary/30', item.active && 'border-primary ring-1 ring-primary/30',
clickable && 'cursor-pointer transition-colors hover:bg-muted/40', clickable && 'cursor-pointer transition-colors hover:bg-muted/40',
+9 -9
View File
@@ -18,8 +18,8 @@ export function SectionCardsSkeleton({ count = 4 }: { count?: number }) {
export function AnalyticsDashboardSkeleton() { export function AnalyticsDashboardSkeleton() {
return ( return (
<div className="grid gap-4 lg:grid-cols-3 lg:items-start"> <div className="grid gap-4 lg:grid-cols-3 lg:items-start">
<Card className="gap-0"> <Card size="sm" className="gap-0 py-0">
<CardContent className="space-y-4 p-5"> <CardContent className="space-y-4 py-4">
<Skeleton className="h-4 w-40" /> <Skeleton className="h-4 w-40" />
<div className="grid gap-4 sm:grid-cols-3"> <div className="grid gap-4 sm:grid-cols-3">
{Array.from({ length: 3 }).map((_, i) => ( {Array.from({ length: 3 }).map((_, i) => (
@@ -30,15 +30,15 @@ export function AnalyticsDashboardSkeleton() {
<Skeleton className="h-32 w-full" /> <Skeleton className="h-32 w-full" />
</CardContent> </CardContent>
</Card> </Card>
<Card className="gap-0"> <Card size="sm" className="gap-0 py-0">
<CardContent className="space-y-4 p-5"> <CardContent className="space-y-4 py-4">
<Skeleton className="h-4 w-32" /> <Skeleton className="h-4 w-32" />
<Skeleton className="h-10 w-24" /> <Skeleton className="h-10 w-24" />
<Skeleton className="h-36 w-full" /> <Skeleton className="h-36 w-full" />
</CardContent> </CardContent>
</Card> </Card>
<Card className="gap-0"> <Card size="sm" className="gap-0 py-0">
<CardContent className="space-y-4 p-5"> <CardContent className="space-y-4 py-4">
<Skeleton className="h-4 w-32" /> <Skeleton className="h-4 w-32" />
<Skeleton className="h-44 w-full" /> <Skeleton className="h-44 w-full" />
</CardContent> </CardContent>
@@ -49,16 +49,16 @@ export function AnalyticsDashboardSkeleton() {
export function TableSkeleton({ rows = 6, cols = 4 }: { rows?: number; cols?: number }) { export function TableSkeleton({ rows = 6, cols = 4 }: { rows?: number; cols?: number }) {
return ( return (
<Card className="gap-0"> <Card size="sm" className="gap-0 py-0">
<CardContent className="p-0"> <CardContent className="p-0">
<div className="flex flex-col"> <div className="flex flex-col">
<div className="flex gap-2 border-b p-3"> <div className="flex gap-2 border-b px-3 py-2">
{Array.from({ length: cols }).map((_, i) => ( {Array.from({ length: cols }).map((_, i) => (
<Skeleton className="h-4 flex-1" key={`h-${i}`} /> <Skeleton className="h-4 flex-1" key={`h-${i}`} />
))} ))}
</div> </div>
{Array.from({ length: rows }).map((_, r) => ( {Array.from({ length: rows }).map((_, r) => (
<div className="flex gap-2 border-b p-3" key={`r-${r}`}> <div className="flex gap-2 border-b px-3 py-2" key={`r-${r}`}>
{Array.from({ length: cols }).map((_, c) => ( {Array.from({ length: cols }).map((_, c) => (
<Skeleton className="h-4 flex-1" key={`c-${r}-${c}`} /> <Skeleton className="h-4 flex-1" key={`c-${r}-${c}`} />
))} ))}
+2 -1
View File
@@ -3,6 +3,7 @@ import type { ComponentProps } from 'react'
import { cn } from '@evobgp/ui/lib/utils' import { cn } from '@evobgp/ui/lib/utils'
import { Badge } from '@/components/reui/badge' import { Badge } from '@/components/reui/badge'
import { jobStatusRu } from '@/lib/ui-labels'
type BadgeVariant = NonNullable<ComponentProps<typeof Badge>['variant']> type BadgeVariant = NonNullable<ComponentProps<typeof Badge>['variant']>
@@ -63,7 +64,7 @@ export function StatusBadge({
<div className={cn('flex flex-col gap-0.5', className)}> <div className={cn('flex flex-col gap-0.5', className)}>
<Badge variant={variant} size="sm" radius="full" className="gap-1.5"> <Badge variant={variant} size="sm" radius="full" className="gap-1.5">
<span className={cn('size-1.5 shrink-0 rounded-full', dotColor)} aria-hidden /> <span className={cn('size-1.5 shrink-0 rounded-full', dotColor)} aria-hidden />
{label ?? status} {label ?? jobStatusRu(status)}
</Badge> </Badge>
{hint ? <span className="text-xs text-muted-foreground">{hint}</span> : null} {hint ? <span className="text-xs text-muted-foreground">{hint}</span> : null}
</div> </div>
+6 -4
View File
@@ -1,10 +1,12 @@
import type { ApiKeyRole } from '@/types/api' import type { ApiKeyRole } from '@/types/api'
import { apiKeyRoleRu } from '@/lib/ui-labels'
export const API_KEY_ROLE_ITEMS: ReadonlyArray<{ value: ApiKeyRole; label: string }> = [ export const API_KEY_ROLE_ITEMS: ReadonlyArray<{ value: ApiKeyRole; label: string }> = [
{ value: 'viewer', label: 'viewer — только чтение' }, { value: 'viewer', label: `${apiKeyRoleRu('viewer')} — только чтение` },
{ value: 'editor', label: 'editor — CRUD без apply' }, { value: 'editor', label: `${apiKeyRoleRu('editor')} — CRUD без применения` },
{ value: 'operator', label: 'operator — полный доступ' }, { value: 'operator', label: `${apiKeyRoleRu('operator')} — полный доступ` },
{ value: 'node', label: 'node — только API ноды' }, { value: 'node', label: `${apiKeyRoleRu('node')} — только API ноды` },
] ]
export function apiKeyRoleLabel(role: ApiKeyRole): string { export function apiKeyRoleLabel(role: ApiKeyRole): string {
+11
View File
@@ -25,6 +25,17 @@ export const DATA_GRID_PAGINATION_RU = {
nextPageLabel: 'Следующая страница', nextPageLabel: 'Следующая страница',
} }
export const DATA_GRID_MESSAGES_RU = {
emptyMessage: 'Нет данных',
loadingMessage: 'Загрузка…',
fetchingMoreMessage: 'Загрузка…',
selectAllLabel: 'Выбрать все',
selectRowLabel: 'Выбрать строку',
pinRowLabel: 'Закрепить строку',
unpinRowLabel: 'Открепить строку',
allRecordsLoadedMessage: 'Все записи загружены',
}
export const DATA_GRID_DENSE_LAYOUT: NonNullable<DataGridProps<object>['tableLayout']> = { export const DATA_GRID_DENSE_LAYOUT: NonNullable<DataGridProps<object>['tableLayout']> = {
...DATA_GRID_TABLE_LAYOUT, ...DATA_GRID_TABLE_LAYOUT,
dense: true, dense: true,
@@ -30,3 +30,25 @@ export function deploymentProgress(speakers: SpeakerRow[]): DeploymentProgress {
mode: 'online', mode: 'online',
} }
} }
export function deploymentProgressMeta(deploy: DeploymentProgress): {
label: string
hint: string
} {
if (deploy.total === 0) {
return {
label: 'Деплой на спикерах',
hint: 'Нет зарегистрированных BIRD-спикеров',
}
}
if (deploy.mode === 'revision') {
return {
label: `Применена ревизия (${deploy.synced} из ${deploy.total} спикеров)`,
hint: 'Доля спикеров, на которых последняя опубликованная ревизия уже применена',
}
}
return {
label: `Спикеры в сети (${deploy.synced} из ${deploy.total})`,
hint: 'Ревизии ещё не публиковались — показана доступность агента на нодах',
}
}
@@ -14,7 +14,7 @@ export function peerSessionBreakdown(peers: PeerRow[]): BreakdownSlice[] {
if (established > 0) { if (established > 0) {
slices.push({ slices.push({
key: 'established', key: 'established',
label: 'Established', label: 'Установлена',
count: established, count: established,
color: 'var(--color-chart-2)', color: 'var(--color-chart-2)',
}) })
@@ -22,7 +22,7 @@ export function peerSessionBreakdown(peers: PeerRow[]): BreakdownSlice[] {
if (pending > 0) { if (pending > 0) {
slices.push({ slices.push({
key: 'pending', key: 'pending',
label: 'Не Established', label: 'Не установлена',
count: pending, count: pending,
color: 'var(--color-warning)', color: 'var(--color-warning)',
}) })
@@ -35,7 +35,7 @@ export function readinessBreakdown(
const slices: BreakdownSlice[] = [ const slices: BreakdownSlice[] = [
{ {
key: 'health', key: 'health',
label: 'Health OK', label: 'API доступен',
count: 1, count: 1,
color: 'var(--color-chart-2)', color: 'var(--color-chart-2)',
}, },
@@ -44,7 +44,7 @@ export function readinessBreakdown(
if (okCount > 0) { if (okCount > 0) {
slices.push({ slices.push({
key: 'checks-ok', key: 'checks-ok',
label: 'Checks OK', label: 'Проверки в норме',
count: okCount, count: okCount,
color: 'var(--color-chart-1)', color: 'var(--color-chart-1)',
}) })
@@ -52,7 +52,7 @@ export function readinessBreakdown(
if (failCount > 0) { if (failCount > 0) {
slices.push({ slices.push({
key: 'checks-fail', key: 'checks-fail',
label: 'Checks fail', label: 'Ошибки проверок',
count: failCount, count: failCount,
color: 'var(--color-warning)', color: 'var(--color-warning)',
}) })
@@ -61,7 +61,7 @@ export function readinessBreakdown(
if (slices.length === 1 && okCount === 0 && failCount === 0) { if (slices.length === 1 && okCount === 0 && failCount === 0) {
slices.push({ slices.push({
key: 'ready', key: 'ready',
label: ready?.status === 'ok' ? 'Ready' : 'Ready pending', label: ready?.status === 'ok' ? 'Готов' : 'Ожидает готовности',
count: 1, count: 1,
color: 'var(--color-chart-4)', color: 'var(--color-chart-4)',
}) })
@@ -1,17 +1,11 @@
import type { JobRow, PeerRow, RevisionRow, SpeakerRow } from '@/types/api' import type { JobRow, PeerRow, RevisionRow, SpeakerRow } from '@/types/api'
import { jobKindRu, jobStatusRu } from '@/lib/ui-labels'
import type { PlatformActivityItem } from './types' import type { PlatformActivityItem } from './types'
const JOB_KIND_RU: Record<string, string> = {
module_refresh: 'Обновление модуля',
apply: 'Применение конфигурации',
rollback: 'Откат ревизии',
bird_reload: 'Перезагрузка BIRD',
}
function jobMessage(job: JobRow): string { function jobMessage(job: JobRow): string {
const kind = JOB_KIND_RU[job.kind] ?? job.kind return `${jobKindRu(job.kind)} · ${jobStatusRu(job.status)}`
return `${kind} · ${job.status}`
} }
export function recentPlatformActivity( export function recentPlatformActivity(
@@ -45,7 +39,7 @@ export function recentPlatformActivity(
for (const peer of peers.filter((p) => p.session_mismatch).slice(0, 2)) { for (const peer of peers.filter((p) => p.session_mismatch).slice(0, 2)) {
items.push({ items.push({
id: `peer-${peer.id}`, id: `peer-${peer.id}`,
message: `Mismatch сессии: ${peer.name ?? peer.neighbor}`, message: `Расхождение сессии: ${peer.name ?? peer.neighbor}`,
status: 'mismatch', status: 'mismatch',
kind: 'network', kind: 'network',
}) })
+94
View File
@@ -27,3 +27,97 @@ export function moduleTypeRu(type: string): string {
return type return type
} }
} }
const JOB_KIND_RU: Record<string, string> = {
module_refresh: 'Обновление модуля',
apply: 'Применение конфигурации',
rollback: 'Откат ревизии',
bird_reload: 'Перезагрузка BIRD',
}
export function jobKindRu(kind: string): string {
return JOB_KIND_RU[kind] ?? kind
}
const JOB_STATUS_RU: Record<string, string> = {
queued: 'В очереди',
running: 'Выполняется',
succeeded: 'Успешно',
failed: 'Ошибка',
error: 'Ошибка',
cancelled: 'Отменена',
canceled: 'Отменена',
pending: 'Ожидает',
approved: 'Одобрен',
revoked: 'Отозван',
active: 'Активен',
ok: 'В норме',
mismatch: 'Расхождение',
established: 'Установлена',
healthy: 'В норме',
warning: 'Предупреждение',
stale: 'Устарело',
overdue: 'Просрочено',
paused: 'Приостановлен',
disabled: 'Выключен',
archived: 'В архиве',
block: 'block',
accept: 'accept',
}
export function jobStatusRu(status: string): string {
return JOB_STATUS_RU[status.toLowerCase()] ?? status
}
export function bgpSessionStateRu(state: string | null | undefined): string {
if (!state) return '—'
if (state === 'Established') return 'Установлена'
return state
}
export function speakerOnlineLabel(agentOk: boolean | undefined): string {
if (agentOk === true) return 'В сети'
if (agentOk === false) return 'Не в сети'
return '—'
}
export function firewallClientStatusRu(status: string): string {
switch (status) {
case 'pending':
return 'Ожидает'
case 'approved':
return 'Одобрен'
case 'revoked':
return 'Отозван'
default:
return status
}
}
export function apiKeyRoleRu(role: string): string {
switch (role) {
case 'viewer':
return 'Наблюдатель'
case 'editor':
return 'Редактор'
case 'operator':
return 'Оператор'
case 'node':
return 'Нода'
default:
return role
}
}
export function readyCheckRu(key: string): string {
switch (key) {
case 'postgres':
return 'PostgreSQL'
case 'store':
return 'Хранилище'
case 'jobs':
return 'Очередь задач'
default:
return key
}
}
+15 -21
View File
@@ -4,7 +4,7 @@ import { KeyRound, RefreshCw, ShieldCheck, ShieldOff } from 'lucide-react'
import { useMemo } from 'react' import { useMemo } from 'react'
import { Button } from '@evobgp/ui/components/button' import { Button } from '@evobgp/ui/components/button'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@evobgp/ui/components/card' import { PanelCard } from '@/components/panel-card'
import { AccessApiKeysCard } from '@/components/access/access-api-keys-card' import { AccessApiKeysCard } from '@/components/access/access-api-keys-card'
import { PageHeader } from '@/components/page-header' import { PageHeader } from '@/components/page-header'
@@ -79,12 +79,11 @@ function AccessComponent() {
/> />
{session ? ( {session ? (
<Card> <PanelCard
<CardHeader className="border-b py-3"> title="Текущая сессия"
<CardTitle className="text-base">Текущая сессия</CardTitle> description="Tenant и роль ключа, с которым открыта панель."
<CardDescription>Tenant и роль ключа, с которым открыта панель.</CardDescription> contentClassName="grid gap-3 py-4 text-sm sm:grid-cols-2"
</CardHeader> >
<CardContent className="grid gap-3 p-4 text-sm sm:grid-cols-2">
<div> <div>
<p className="text-muted-foreground">Tenant</p> <p className="text-muted-foreground">Tenant</p>
<p className="break-all font-mono text-xs">{session.tenant_id}</p> <p className="break-all font-mono text-xs">{session.tenant_id}</p>
@@ -93,11 +92,9 @@ function AccessComponent() {
<p className="text-muted-foreground">Роль</p> <p className="text-muted-foreground">Роль</p>
<p className="font-mono">{session.role}</p> <p className="font-mono">{session.role}</p>
</div> </div>
</CardContent> </PanelCard>
</Card>
) : ( ) : (
<Card> <PanelCard contentClassName="py-4 text-sm text-muted-foreground">
<CardContent className="py-6 text-sm text-muted-foreground">
Не удалось определить сессию. Укажите токен в{' '} Не удалось определить сессию. Укажите токен в{' '}
<Link to="/settings" className="text-primary underline-offset-4 hover:underline"> <Link to="/settings" className="text-primary underline-offset-4 hover:underline">
настройках настройках
@@ -106,8 +103,7 @@ function AccessComponent() {
{sessionQuery.isError && sessionQuery.error instanceof Error ? ( {sessionQuery.isError && sessionQuery.error instanceof Error ? (
<span className="mt-2 block text-destructive">{sessionQuery.error.message}</span> <span className="mt-2 block text-destructive">{sessionQuery.error.message}</span>
) : null} ) : null}
</CardContent> </PanelCard>
</Card>
)} )}
{isOperator ? ( {isOperator ? (
@@ -126,14 +122,12 @@ function AccessComponent() {
/> />
</> </>
) : session ? ( ) : session ? (
<Card> <PanelCard contentClassName="py-4 text-sm text-muted-foreground">
<CardContent className="py-6 text-sm text-muted-foreground"> Управление API-ключами доступно только роли <strong>operator</strong>. Текущая роль:{' '}
Управление API-ключами доступно только роли <strong>operator</strong>. Текущая роль:{' '} <span className="font-mono">{session.role}</span>. Для выдачи ключей войдите с
<span className="font-mono">{session.role}</span>. Для выдачи ключей войдите с operator-ключом или создайте ключ через API / переменную{' '}
operator-ключом или создайте ключ через API / переменную{' '} <code className="text-xs">EVOBGP_API_KEYS</code>.
<code className="text-xs">EVOBGP_API_KEYS</code>. </PanelCard>
</CardContent>
</Card>
) : null} ) : null}
</div> </div>
) )
+7 -13
View File
@@ -4,12 +4,7 @@ import { RefreshCw } from 'lucide-react'
import { useState } from 'react' import { useState } from 'react'
import { Button } from '@evobgp/ui/components/button' import { Button } from '@evobgp/ui/components/button'
import { import { PanelCard } from '@/components/panel-card'
Card,
CardDescription,
CardHeader,
CardTitle,
} from '@evobgp/ui/components/card'
import { Skeleton } from '@evobgp/ui/components/skeleton' import { Skeleton } from '@evobgp/ui/components/skeleton'
import { import {
@@ -133,13 +128,12 @@ function DashboardComponent() {
</DataGridCard> </DataGridCard>
</div> </div>
<Card className="gap-0"> <PanelCard
<CardHeader className="border-b py-3"> title="Быстрые действия"
<CardTitle className="text-base">Быстрые действия</CardTitle> description="Частые переходы к настройке и деплою"
<CardDescription>Частые переходы к настройке и деплою</CardDescription> footer={<DashboardQuickActions />}
</CardHeader> footerClassName="gap-2 p-3"
<DashboardQuickActions /> />
</Card>
</div> </div>
) )
} }
+19 -19
View File
@@ -5,7 +5,7 @@ import { useEffect, useMemo, useState } from 'react'
import { toast } from 'sonner' import { toast } from 'sonner'
import { Button } from '@evobgp/ui/components/button' import { Button } from '@evobgp/ui/components/button'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@evobgp/ui/components/card' import { PanelCard } from '@/components/panel-card'
import { Input } from '@evobgp/ui/components/input' import { Input } from '@evobgp/ui/components/input'
import { Label } from '@evobgp/ui/components/label' import { Label } from '@evobgp/ui/components/label'
import { BadgeTabs, TabsContent } from '@/components/badge-tabs' import { BadgeTabs, TabsContent } from '@/components/badge-tabs'
@@ -92,7 +92,7 @@ function FirewallPage() {
toast.error( toast.error(
installCtx?.bundle_seed_configured === false installCtx?.bundle_seed_configured === false
? 'На CP не задан EVOBGP_BUNDLE_SEED_HEX' ? 'На CP не задан EVOBGP_BUNDLE_SEED_HEX'
: 'Bundle seed недоступен (нужна роль operator)', : 'Seed бандла недоступен (нужна роль оператора)',
) )
return return
} }
@@ -107,7 +107,7 @@ function FirewallPage() {
return ( return (
<div className="flex flex-col gap-6"> <div className="flex flex-col gap-6">
<PageHeader <PageHeader
title="Firewall blocklist" title="Файрвол: blocklist"
description="Linux-серверы: синхронизация CIDR по policy block/accept" description="Linux-серверы: синхронизация CIDR по policy block/accept"
actions={ actions={
<Button <Button
@@ -125,15 +125,16 @@ function FirewallPage() {
} }
/> />
<Card> <PanelCard
<CardHeader> title={
<CardTitle className="flex items-center gap-2"> <span className="flex items-center gap-2">
<Shield className="size-5" /> <Shield className="size-4" />
Установка на сервер Установка на сервер
</CardTitle> </span>
<CardDescription>One-liner для root на целевом Linux (bash, curl). После enroll approve в «Запросы».</CardDescription> }
</CardHeader> description="Команда для root на целевом Linux (bash, curl). После регистрации — одобрите клиента во вкладке «Запросы»."
<CardContent className="flex flex-col gap-4"> contentClassName="flex flex-col gap-4 py-4"
>
<div className="grid gap-4 sm:grid-cols-3"> <div className="grid gap-4 sm:grid-cols-3">
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="fw-name">Имя сервера</Label> <Label htmlFor="fw-name">Имя сервера</Label>
@@ -144,7 +145,7 @@ function FirewallPage() {
<Input id="fw-url" value={cpUrl} onChange={(e) => setCpUrl(e.target.value)} /> <Input id="fw-url" value={cpUrl} onChange={(e) => setCpUrl(e.target.value)} />
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="fw-seed">Bundle seed</Label> <Label htmlFor="fw-seed">Seed бандла</Label>
<Input <Input
id="fw-seed" id="fw-seed"
type="password" type="password"
@@ -155,10 +156,10 @@ function FirewallPage() {
/> />
<p className="text-muted-foreground text-xs"> <p className="text-muted-foreground text-xs">
{installCtxQ.isLoading {installCtxQ.isLoading
? 'Загрузка из control plane…' ? 'Загрузка с плоскости управления…'
: installCtx?.bundle_seed_configured : installCtx?.bundle_seed_configured
? 'Из переменной EVOBGP_BUNDLE_SEED_HEX на CP (docker compose / .env)' ? 'Из переменной EVOBGP_BUNDLE_SEED_HEX на CP (docker compose / .env)'
: 'На CP не задан EVOBGP_BUNDLE_SEED_HEX — enroll невозможен'} : 'На CP не задан EVOBGP_BUNDLE_SEED_HEX — регистрация невозможна'}
</p> </p>
</div> </div>
</div> </div>
@@ -167,8 +168,7 @@ function FirewallPage() {
<Copy /> <Copy />
Копировать команду Копировать команду
</Button> </Button>
</CardContent> </PanelCard>
</Card>
<BadgeTabs <BadgeTabs
defaultValue="clients" defaultValue="clients"
@@ -184,7 +184,7 @@ function FirewallPage() {
]} ]}
> >
<TabsContent value="clients" className="mt-0"> <TabsContent value="clients" className="mt-0">
<DataGridCard title="Клиенты" description="Активные Linux-серверы с синхронизацией blocklist"> <DataGridCard title="Клиенты" description="Активные Linux-серверы с синхронизацией списка блокировок">
<QueryState <QueryState
data={clientsQ.data} data={clientsQ.data}
isLoading={clientsQ.isLoading} isLoading={clientsQ.isLoading}
@@ -246,7 +246,7 @@ function FirewallPage() {
<TabsContent value="requests" className="mt-0"> <TabsContent value="requests" className="mt-0">
<DataGridCard <DataGridCard
title="Запросы" title="Запросы"
description="Pending enroll — одобрите или отклоните новые клиенты" description="Запросы на регистрацию — одобрите или отклоните новые клиенты"
> >
<QueryState <QueryState
data={clientsQ.data} data={clientsQ.data}
@@ -264,7 +264,7 @@ function FirewallPage() {
onReject={(id) => deleteClient.mutate(id)} onReject={(id) => deleteClient.mutate(id)}
approvePending={approve.isPending} approvePending={approve.isPending}
rejectPending={deleteClient.isPending} rejectPending={deleteClient.isPending}
emptyTitle="Нет pending-запросов" emptyTitle="Нет ожидающих запросов"
/> />
)} )}
</QueryState> </QueryState>
+50 -47
View File
@@ -3,11 +3,12 @@ import { useQuery } from '@tanstack/react-query'
import { Activity, AlertTriangle, Bird, RefreshCw } from 'lucide-react' import { Activity, AlertTriangle, Bird, RefreshCw } from 'lucide-react'
import { Button } from '@evobgp/ui/components/button' import { Button } from '@evobgp/ui/components/button'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@evobgp/ui/components/card' import { PanelCard } from '@/components/panel-card'
import { Separator } from '@evobgp/ui/components/separator' import { Separator } from '@evobgp/ui/components/separator'
import { BadgeTabs, TabsContent } from '@/components/badge-tabs' import { BadgeTabs, TabsContent } from '@/components/badge-tabs'
import { DataGridCard } from '@/components/data-grid-shell' import { DataGridCard } from '@/components/data-grid-shell'
import { StatusBadge } from '@/components/status-badge' import { StatusBadge } from '@/components/status-badge'
import { jobKindRu } from '@/lib/ui-labels'
import { import {
DashboardOperationsFlowCard, DashboardOperationsFlowCard,
MonitoringHealthCard, MonitoringHealthCard,
@@ -139,15 +140,16 @@ function MonitoringComponent() {
</QueryState> </QueryState>
</DataGridCard> </DataGridCard>
<Card> <PanelCard
<CardHeader> title={
<CardTitle className="flex items-center gap-2 text-base"> <span className="flex items-center gap-2">
<Bird className="size-4" /> <Bird className="size-4" />
BGP на API-хосте BGP на API-хосте
</CardTitle> </span>
<CardDescription>GET /v1/bird/status</CardDescription> }
</CardHeader> description="GET /v1/bird/status"
<CardContent> contentClassName="py-4"
>
<QueryState <QueryState
data={birdQ.data} data={birdQ.data}
isLoading={birdQ.isLoading} isLoading={birdQ.isLoading}
@@ -158,20 +160,20 @@ function MonitoringComponent() {
> >
{(bird) => <BirdSummary bird={bird} />} {(bird) => <BirdSummary bird={bird} />}
</QueryState> </QueryState>
</CardContent> </PanelCard>
</Card>
</div> </div>
<div className="grid gap-4 lg:grid-cols-2"> <div className="grid gap-4 lg:grid-cols-2">
<Card> <PanelCard
<CardHeader> title={
<CardTitle className="flex items-center gap-2 text-base"> <span className="flex items-center gap-2">
<Activity className="size-4" /> <Activity className="size-4" />
Задачи Задачи
</CardTitle> </span>
<CardDescription>Последние 100 задач · GET /v1/jobs</CardDescription> }
</CardHeader> description="Последние 100 задач · GET /v1/jobs"
<CardContent className="space-y-4"> contentClassName="space-y-4 py-4"
>
<div className="flex flex-wrap gap-4 text-sm"> <div className="flex flex-wrap gap-4 text-sm">
<Metric label="Активных" value={jobs.filter((j) => j.status === 'running' || j.status === 'queued').length} /> <Metric label="Активных" value={jobs.filter((j) => j.status === 'running' || j.status === 'queued').length} />
<Metric <Metric
@@ -189,7 +191,7 @@ function MonitoringComponent() {
{failedJobs.map((job) => ( {failedJobs.map((job) => (
<li key={job.job_id} className="rounded-lg border px-3 py-2 text-sm"> <li key={job.job_id} className="rounded-lg border px-3 py-2 text-sm">
<div className="flex items-start justify-between gap-2"> <div className="flex items-start justify-between gap-2">
<p className="font-medium">{job.kind}</p> <p className="font-medium">{jobKindRu(job.kind)}</p>
<StatusBadge status={job.status} /> <StatusBadge status={job.status} />
</div> </div>
{job.error ? ( {job.error ? (
@@ -207,18 +209,18 @@ function MonitoringComponent() {
Критичных сбоев в последних 100 задачах нет. Критичных сбоев в последних 100 задачах нет.
</p> </p>
)} )}
</CardContent> </PanelCard>
</Card>
<Card> <PanelCard
<CardHeader> title={
<CardTitle className="flex items-center gap-2 text-base"> <span className="flex items-center gap-2">
<AlertTriangle className="size-4 text-muted-foreground" /> <AlertTriangle className="size-4 text-muted-foreground" />
Что проверять при деградации Что проверять при деградации
</CardTitle> </span>
<CardDescription>Короткая шпаргалка для triage</CardDescription> }
</CardHeader> description="Краткая шпаргалка для первичной диагностики"
<CardContent> contentClassName="py-4"
>
<ul className="space-y-3 text-sm text-muted-foreground"> <ul className="space-y-3 text-sm text-muted-foreground">
<li> <li>
<span className="font-medium text-foreground">API недоступен.</span> Если{' '} <span className="font-medium text-foreground">API недоступен.</span> Если{' '}
@@ -226,9 +228,9 @@ function MonitoringComponent() {
его логи. его логи.
</li> </li>
<li> <li>
<span className="font-medium text-foreground">Readiness не «Готов».</span> Сначала{' '} <span className="font-medium text-foreground">Готовность не «Готов».</span> Сначала{' '}
<code className="text-xs">postgres</code>, затем <code className="text-xs">store</code>{' '} <code className="text-xs">postgres</code>, затем <code className="text-xs">store</code>{' '}
и <code className="text-xs">jobs</code> в checks. и <code className="text-xs">jobs</code> в проверках.
</li> </li>
<li> <li>
<span className="font-medium text-foreground">Низкий ratio BGP.</span> Проверьте{' '} <span className="font-medium text-foreground">Низкий ratio BGP.</span> Проверьте{' '}
@@ -236,36 +238,37 @@ function MonitoringComponent() {
</li> </li>
<li> <li>
<span className="font-medium text-foreground">Ошибки задач.</span> Откройте Операции и <span className="font-medium text-foreground">Ошибки задач.</span> Откройте Операции и
проверьте последние неуспешные jobs. проверьте последние неуспешные задачи.
</li> </li>
</ul> </ul>
</CardContent> </PanelCard>
</Card>
</div> </div>
</TabsContent> </TabsContent>
<TabsContent value="postgres" className="mt-0"> <TabsContent value="postgres" className="mt-0">
<Card> <PanelCard
<CardHeader> title="PostgreSQL"
<CardTitle className="text-base">PostgreSQL</CardTitle> description={
<CardDescription> <>
Статус соединения и пул. PostgreSQL отображается в readiness-проверке на вкладке «Система» Статус соединения и пул. PostgreSQL отображается в readiness-проверке на вкладке «Система»
(check <code className="text-xs">postgres</code>). (check <code className="text-xs">postgres</code>).
</CardDescription> </>
</CardHeader> }
</Card> contentClassName="py-4"
/>
</TabsContent> </TabsContent>
<TabsContent value="runtime-logs" className="mt-0"> <TabsContent value="runtime-logs" className="mt-0">
<Card> <PanelCard
<CardHeader> title="Файловые логи"
<CardTitle className="text-base">Файловые логи</CardTitle> description={
<CardDescription> <>
Логи API и pipeline настраиваются переменной <code className="text-xs">EVOBGP_LOG_*</code> и Логи API и pipeline настраиваются переменной <code className="text-xs">EVOBGP_LOG_*</code> и
управляются в tenant-settings. управляются в tenant-settings.
</CardDescription> </>
</CardHeader> }
</Card> contentClassName="py-4"
/>
</TabsContent> </TabsContent>
</BadgeTabs> </BadgeTabs>
</div> </div>
@@ -301,7 +304,7 @@ function BirdSummary({ bird }: { bird: import('@/types/api').BirdStatus }) {
return ( return (
<div className="space-y-2"> <div className="space-y-2">
<div className="flex items-center justify-between text-sm"> <div className="flex items-center justify-between text-sm">
<span className="text-muted-foreground">Established / total</span> <span className="text-muted-foreground">Установлено / всего</span>
<span className="font-medium tabular-nums"> <span className="font-medium tabular-nums">
{bird.bgp_established} / {bird.bgp_sessions_total} {bird.bgp_established} / {bird.bgp_sessions_total}
{ratio !== null ? <span className="text-muted-foreground"> ({ratio}%)</span> : null} {ratio !== null ? <span className="text-muted-foreground"> ({ratio}%)</span> : null}
+16 -19
View File
@@ -1,7 +1,7 @@
import { createFileRoute, useSearch } from '@tanstack/react-router' import { createFileRoute, useSearch } from '@tanstack/react-router'
import { useQuery } from '@tanstack/react-query' import { useQuery } from '@tanstack/react-query'
import { Button } from '@evobgp/ui/components/button' import { Button } from '@evobgp/ui/components/button'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@evobgp/ui/components/card' import { PanelCard } from '@/components/panel-card'
import { RefreshCw } from 'lucide-react' import { RefreshCw } from 'lucide-react'
import { import {
@@ -72,7 +72,7 @@ function NetworkComponent() {
{ value: 'overview', label: 'Обзор' }, { value: 'overview', label: 'Обзор' },
{ value: 'peers', label: 'Пиры', count: peers.length }, { value: 'peers', label: 'Пиры', count: peers.length },
{ value: 'speakers', label: 'Спикеры', count: speakers.length, badgeVariant: 'info-light' }, { value: 'speakers', label: 'Спикеры', count: speakers.length, badgeVariant: 'info-light' },
{ value: 'control-plane', label: 'Control plane' }, { value: 'control-plane', label: 'Плоскость управления' },
]} ]}
> >
<TabsContent value="overview" className="mt-0"> <TabsContent value="overview" className="mt-0">
@@ -89,12 +89,12 @@ function NetworkComponent() {
loading={overviewLoading} loading={overviewLoading}
/> />
</div> </div>
<Card className="mt-4"> <PanelCard
<CardHeader className="border-b py-3"> className="mt-4"
<CardTitle className="text-base">BIRD (control plane)</CardTitle> title="BIRD (control plane)"
<CardDescription>Статус birdc на хосте API</CardDescription> description="Статус birdc на хосте API"
</CardHeader> contentClassName="py-4"
<CardContent className="p-4"> >
<QueryState <QueryState
data={birdQ.data} data={birdQ.data}
isLoading={birdQ.isLoading} isLoading={birdQ.isLoading}
@@ -105,8 +105,7 @@ function NetworkComponent() {
> >
{(bird) => <BirdSummary bird={bird} />} {(bird) => <BirdSummary bird={bird} />}
</QueryState> </QueryState>
</CardContent> </PanelCard>
</Card>
</TabsContent> </TabsContent>
<TabsContent value="peers" className="mt-0"> <TabsContent value="peers" className="mt-0">
@@ -131,15 +130,13 @@ function NetworkComponent() {
</TabsContent> </TabsContent>
<TabsContent value="control-plane" className="mt-0"> <TabsContent value="control-plane" className="mt-0">
<Card> <PanelCard
<CardHeader className="border-b py-3"> title="Настройки Control Plane (BIRD)"
<CardTitle className="text-base">Настройки Control Plane (BIRD)</CardTitle> description="Конфигурация tenant-level — в разделе «Настройки BIRD»"
<CardDescription>Конфигурация tenant-level в разделе «Настройки BIRD»</CardDescription> contentClassName="py-4 text-sm text-muted-foreground"
</CardHeader> >
<CardContent className="p-4 text-sm text-muted-foreground"> См. раздел «Настройки BIRD».
См. раздел «Настройки BIRD». </PanelCard>
</CardContent>
</Card>
</TabsContent> </TabsContent>
</BadgeTabs> </BadgeTabs>
</div> </div>
+7 -12
View File
@@ -5,7 +5,7 @@ import { toast } from 'sonner'
import { useState, useMemo } from 'react' import { useState, useMemo } from 'react'
import { Button } from '@evobgp/ui/components/button' import { Button } from '@evobgp/ui/components/button'
import { Card, CardContent, CardHeader, CardTitle } from '@evobgp/ui/components/card' import { PanelCard } from '@/components/panel-card'
import { BadgeTabs, TabsContent } from '@/components/badge-tabs' import { BadgeTabs, TabsContent } from '@/components/badge-tabs'
import { DataGridCard } from '@/components/data-grid-shell' import { DataGridCard } from '@/components/data-grid-shell'
import { OperationsAnalyticsCard } from '@/components/analytics' import { OperationsAnalyticsCard } from '@/components/analytics'
@@ -97,22 +97,22 @@ function OperationsComponent() {
<ConfirmDialog <ConfirmDialog
trigger={ trigger={
<Button variant="default" size="sm" disabled={applyMutation.isPending}> <Button variant="default" size="sm" disabled={applyMutation.isPending}>
Apply Применить
</Button> </Button>
} }
title="Применить конфигурацию на всех спикерах?" title="Применить конфигурацию на всех спикерах?"
description="Текущая конфигурация будет применена на всех BIRD-агентах. Требуется роль operator." description="Текущая конфигурация будет применена на всех BIRD-агентах. Требуется роль оператора."
confirmLabel="Применить" confirmLabel="Применить"
onConfirm={() => applyMutation.mutate()} onConfirm={() => applyMutation.mutate()}
/> />
<ConfirmDialog <ConfirmDialog
trigger={ trigger={
<Button variant="outline" size="sm" disabled={birdReloadMutation.isPending}> <Button variant="outline" size="sm" disabled={birdReloadMutation.isPending}>
BIRD reload Перезагрузка BIRD
</Button> </Button>
} }
title="Перезагрузить BIRD?" title="Перезагрузить BIRD?"
description="BIRD перезагрузит конфигурацию. Требуется роль operator." description="BIRD перезагрузит конфигурацию. Требуется роль оператора."
confirmLabel="Перезагрузить" confirmLabel="Перезагрузить"
onConfirm={() => birdReloadMutation.mutate()} onConfirm={() => birdReloadMutation.mutate()}
/> />
@@ -202,11 +202,7 @@ function DiffTab({ revisions }: { revisions: import('@/types/api').RevisionRow[]
) )
return ( return (
<Card> <PanelCard title="Сравнение ревизий" contentClassName="flex flex-col gap-4 py-4">
<CardHeader className="border-b py-3">
<CardTitle className="text-base">Сравнение ревизий</CardTitle>
</CardHeader>
<CardContent className="flex flex-col gap-4 p-4">
<div className="flex flex-wrap items-end gap-3"> <div className="flex flex-wrap items-end gap-3">
<div className="flex w-full max-w-xs flex-col gap-1"> <div className="flex w-full max-w-xs flex-col gap-1">
<span className="text-xs text-muted-foreground">Ревизия A</span> <span className="text-xs text-muted-foreground">Ревизия A</span>
@@ -242,8 +238,7 @@ function DiffTab({ revisions }: { revisions: import('@/types/api').RevisionRow[]
> >
{(diff) => <DiffView diff={diff} />} {(diff) => <DiffView diff={diff} />}
</QueryState> </QueryState>
</CardContent> </PanelCard>
</Card>
) )
} }
+2 -2
View File
@@ -39,7 +39,7 @@ function ScheduleComponent() {
const items: SectionCardItem[] = [ const items: SectionCardItem[] = [
{ label: 'Всего задач', value: jobs.length, icon: <ListTodo className="size-4" />, hint: 'в выборке' }, { label: 'Всего задач', value: jobs.length, icon: <ListTodo className="size-4" />, hint: 'в выборке' },
{ label: 'В работе', value: running, icon: <Clock className="size-4" />, hint: 'queued и running' }, { label: 'В работе', value: running, icon: <Clock className="size-4" />, hint: 'в очереди и выполняются' },
{ {
label: 'С ошибкой', label: 'С ошибкой',
value: failed, value: failed,
@@ -90,7 +90,7 @@ function ScheduleComponent() {
<DataGridCard <DataGridCard
title="Модули" title="Модули"
description="Расписание обновления и ручной запуск ingest" description="Расписание обновления и ручной запуск обновления"
> >
<QueryState <QueryState
data={modules} data={modules}
+13 -22
View File
@@ -2,7 +2,7 @@ import { createFileRoute, useNavigate } from '@tanstack/react-router'
import { useQuery, useQueryClient } from '@tanstack/react-query' import { useQuery, useQueryClient } from '@tanstack/react-query'
import { Button } from '@evobgp/ui/components/button' import { Button } from '@evobgp/ui/components/button'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@evobgp/ui/components/card' import { PanelCard } from '@/components/panel-card'
import { Input } from '@evobgp/ui/components/input' import { Input } from '@evobgp/ui/components/input'
import { Label } from '@evobgp/ui/components/label' import { Label } from '@evobgp/ui/components/label'
@@ -69,15 +69,11 @@ function SettingsComponent() {
description="Параметры интерфейса и подключения браузера к API." description="Параметры интерфейса и подключения браузера к API."
/> />
<Card> <PanelCard
<CardHeader> title="Подключение к API"
<CardTitle>Подключение к API</CardTitle> description="Токен хранится только в этом браузере (localStorage). Управление ключами tenant — в разделе «Права доступа»."
<CardDescription> contentClassName="flex flex-col gap-4 py-4"
Токен хранится только в этом браузере (localStorage). Управление ключами tenant в >
разделе «Права доступа».
</CardDescription>
</CardHeader>
<CardContent className="flex flex-col gap-4">
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
<Label htmlFor="token">Токен для запросов</Label> <Label htmlFor="token">Токен для запросов</Label>
<Input <Input
@@ -113,17 +109,13 @@ function SettingsComponent() {
<code className="text-xs">EVOBGP_SEED_DEMO</code> 0) и запущенный API. <code className="text-xs">EVOBGP_SEED_DEMO</code> 0) и запущенный API.
</p> </p>
) : null} ) : null}
</CardContent> </PanelCard>
</Card>
<Card> <PanelCard
<CardHeader> title="Оформление"
<CardTitle>Оформление</CardTitle> description="Тема интерфейса. Быстрый переключатель также доступен в боковой панели."
<CardDescription> contentClassName="flex flex-col gap-2 py-4"
Тема интерфейса. Быстрый переключатель также доступен в боковой панели. >
</CardDescription>
</CardHeader>
<CardContent className="flex flex-col gap-2">
<SelectField <SelectField
id="theme-select" id="theme-select"
label="Тема" label="Тема"
@@ -133,8 +125,7 @@ function SettingsComponent() {
triggerClassName="max-w-xs" triggerClassName="max-w-xs"
onValueChange={(v) => v && setTheme(v)} onValueChange={(v) => v && setTheme(v)}
/> />
</CardContent> </PanelCard>
</Card>
</div> </div>
) )
} }
+27 -31
View File
@@ -4,7 +4,7 @@ import { Save } from 'lucide-react'
import { useEffect, useState } from 'react' import { useEffect, useState } from 'react'
import { toast } from 'sonner' import { toast } from 'sonner'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@evobgp/ui/components/card' import { PanelCard } from '@/components/panel-card'
import { Input } from '@evobgp/ui/components/input' import { Input } from '@evobgp/ui/components/input'
import { Label } from '@evobgp/ui/components/label' import { Label } from '@evobgp/ui/components/label'
import { BadgeTabs, TabsContent } from '@/components/badge-tabs' import { BadgeTabs, TabsContent } from '@/components/badge-tabs'
@@ -42,8 +42,8 @@ const RUNTIME_LOGS_ENABLED_ITEMS = [
] as const ] as const
const RUNTIME_LOGS_MODE_ITEMS = [ const RUNTIME_LOGS_MODE_ITEMS = [
{ value: 'truncate', label: 'truncate — обнулить' }, { value: 'truncate', label: 'обнулить (truncate)' },
{ value: 'delete', label: 'delete — удалить файл' }, { value: 'delete', label: 'удалить файл (delete)' },
] as const ] as const
const BIRD_LABELS: Record<BirdSettingKey, string> = { const BIRD_LABELS: Record<BirdSettingKey, string> = {
@@ -99,8 +99,8 @@ function TenantSettingsComponent() {
return ( return (
<div className="flex flex-col gap-6"> <div className="flex flex-col gap-6">
<PageHeader <PageHeader
title="Параметры tenant" title="Параметры арендатора"
description="Параметры control plane для текущего tenant (API /v1/settings)" description="Параметры плоскости управления для текущего арендатора (API /v1/settings)"
/> />
<BadgeTabs <BadgeTabs
@@ -118,15 +118,16 @@ function TenantSettingsComponent() {
]} ]}
> >
<TabsContent value="bird" className="mt-0"> <TabsContent value="bird" className="mt-0">
<Card> <PanelCard
<CardHeader> title="BIRD control plane"
<CardTitle>BIRD control plane</CardTitle> description={
<CardDescription> <>
Глобальные параметры BIRD для pipeline refresh/apply. Сохранение через{' '} Глобальные параметры BIRD для pipeline refresh/apply. Сохранение через{' '}
<code className="text-xs">PATCH /v1/settings</code> (роль operator). <code className="text-xs">PATCH /v1/settings</code> (роль operator).
</CardDescription> </>
</CardHeader> }
<CardContent className="space-y-4"> contentClassName="space-y-4 py-4"
>
<QueryState <QueryState
data={partitioned} data={partitioned}
isLoading={settingsQ.isLoading} isLoading={settingsQ.isLoading}
@@ -158,17 +159,15 @@ function TenantSettingsComponent() {
</div> </div>
)} )}
</QueryState> </QueryState>
</CardContent> </PanelCard>
</Card>
</TabsContent> </TabsContent>
<TabsContent value="revision" className="mt-0"> <TabsContent value="revision" className="mt-0">
<Card> <PanelCard
<CardHeader> title="Ревизии"
<CardTitle>Ревизии</CardTitle> description="Время хранения ревизий в БД"
<CardDescription>Время хранения ревизий в БД</CardDescription> contentClassName="space-y-4 py-4"
</CardHeader> >
<CardContent className="space-y-4">
<QueryState <QueryState
data={partitioned} data={partitioned}
isLoading={settingsQ.isLoading} isLoading={settingsQ.isLoading}
@@ -205,17 +204,15 @@ function TenantSettingsComponent() {
</div> </div>
)} )}
</QueryState> </QueryState>
</CardContent> </PanelCard>
</Card>
</TabsContent> </TabsContent>
<TabsContent value="runtime-logs" className="mt-0"> <TabsContent value="runtime-logs" className="mt-0">
<Card> <PanelCard
<CardHeader> title="Файловые логи"
<CardTitle>Файловые логи</CardTitle> description="Автоматическая очистка логов"
<CardDescription>Автоматическая очистка логов</CardDescription> contentClassName="space-y-4 py-4"
</CardHeader> >
<CardContent className="space-y-4">
<QueryState <QueryState
data={partitioned} data={partitioned}
isLoading={settingsQ.isLoading} isLoading={settingsQ.isLoading}
@@ -296,14 +293,13 @@ function TenantSettingsComponent() {
</div> </div>
)} )}
</QueryState> </QueryState>
</CardContent> </PanelCard>
</Card>
</TabsContent> </TabsContent>
<TabsContent value="additional" className="mt-0"> <TabsContent value="additional" className="mt-0">
<DataGridCard <DataGridCard
title="Дополнительные параметры" title="Дополнительные параметры"
description="Параметры вне стандартных групп (readonly — изменяются только через API)" description="Параметры вне стандартных групп (только чтение — изменяются через API)"
> >
<QueryState <QueryState
data={partitioned?.additional ?? []} data={partitioned?.additional ?? []}
File diff suppressed because one or more lines are too long
+30 -23
View File
@@ -1,33 +1,13 @@
import * as React from "react"
import { Progress as ProgressPrimitive } from "@base-ui/react/progress" import { Progress as ProgressPrimitive } from "@base-ui/react/progress"
import { cn } from "@evobgp/ui/lib/utils" import { cn } from "@evobgp/ui/lib/utils"
function Progress({
className,
children,
value,
...props
}: ProgressPrimitive.Root.Props) {
return (
<ProgressPrimitive.Root
value={value}
data-slot="progress"
className={cn("flex flex-wrap gap-3", className)}
{...props}
>
{children}
<ProgressTrack>
<ProgressIndicator />
</ProgressTrack>
</ProgressPrimitive.Root>
)
}
function ProgressTrack({ className, ...props }: ProgressPrimitive.Track.Props) { function ProgressTrack({ className, ...props }: ProgressPrimitive.Track.Props) {
return ( return (
<ProgressPrimitive.Track <ProgressPrimitive.Track
className={cn( className={cn(
"relative flex h-1 w-full items-center overflow-x-hidden rounded-full bg-muted", "relative flex h-1 w-full items-center overflow-hidden rounded-full bg-muted",
className className
)} )}
data-slot="progress-track" data-slot="progress-track"
@@ -43,12 +23,39 @@ function ProgressIndicator({
return ( return (
<ProgressPrimitive.Indicator <ProgressPrimitive.Indicator
data-slot="progress-indicator" data-slot="progress-indicator"
className={cn("h-full bg-primary transition-all", className)} className={cn("h-full rounded-full bg-primary transition-all", className)}
{...props} {...props}
/> />
) )
} }
function Progress({
className,
children,
value,
...props
}: ProgressPrimitive.Root.Props) {
const hasCustomTrack = React.Children.toArray(children).some(
(child) => React.isValidElement(child) && child.type === ProgressTrack,
)
return (
<ProgressPrimitive.Root
value={value}
data-slot="progress"
className={cn("flex flex-wrap gap-3", className)}
{...props}
>
{children}
{!hasCustomTrack ? (
<ProgressTrack>
<ProgressIndicator />
</ProgressTrack>
) : null}
</ProgressPrimitive.Root>
)
}
function ProgressLabel({ className, ...props }: ProgressPrimitive.Label.Props) { function ProgressLabel({ className, ...props }: ProgressPrimitive.Label.Props) {
return ( return (
<ProgressPrimitive.Label <ProgressPrimitive.Label