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