Compare commits

...
5 Commits
Author SHA1 Message Date
Denozordec d0bd4d661d feat: refactor components to enhance UI consistency and functionality
CI / changes (push) Successful in 12s
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 4m27s
Updated multiple components to improve user interface consistency by replacing traditional badge implementations with the new CategoryBadge and DataGridPrimaryCell components. Enhanced the StatusBadge component to support additional status variants and integrated it across various grids, including AccessApiKeysGrid, DashboardRecentJobsGrid, and OperationsJobsGrid. This refactor streamlines the presentation of data and improves the overall user experience across the application.
2026-07-09 13:21:09 +07:00
Denozordec 642db1a83a feat: enhance BadgeTabs and Tabs components with utility class integration
Refactored the BadgeTabs component to utilize the `cn` utility for class name management, improving layout consistency. Updated the Tabs component to conditionally apply flex direction based on orientation, enhancing responsiveness. These changes streamline the styling process and ensure a more cohesive user interface across tabbed components.
2026-07-09 13:04:46 +07:00
Denozordec f66d68d1c7 feat: refactor data grid components to utilize DataGridSection for enhanced functionality
CI / changes (push) Successful in 9s
CI / commitlint (push) Has been skipped
CI / openapi (push) Has been skipped
CI / web (push) Successful in 52s
CI / go (push) Has been skipped
CI / bird2 (push) Has been skipped
CI / release (push) Successful in 4m16s
Updated multiple components to replace DataGridShell with DataGridSection, integrating search functionality and improved data handling. This change enhances user experience by providing a consistent interface across various grids, including AccessApiKeysGrid, DashboardRecentJobsGrid, and others. Additionally, introduced global filtering capabilities to streamline data retrieval and presentation, ensuring a more efficient user interaction with the data grids.
2026-07-09 12:48:52 +07:00
Denozordec e04fea657c feat: enhance dashboard and monitoring components with new analytics skeletons
CI / changes (push) Successful in 11s
CI / commitlint (push) Has been skipped
CI / openapi (push) Has been skipped
CI / web (push) Successful in 49s
CI / go (push) Successful in 1m2s
CI / bird2 (push) Successful in 16s
CI / release (push) Successful in 3m57s
Added the AnalyticsDashboardSkeleton component to improve loading states in the dashboard and monitoring pages. Refactored the dashboard to utilize the new skeleton for initial loading, replacing the previous SectionCardsSkeleton. Updated the overview queries to increase job limit from 10 to 100 for better data handling. Enhanced the network and operations components to incorporate new analytics cards, streamlining the user experience and improving data presentation.
2026-07-09 12:09:08 +07:00
Denozordec 452f6b2db0 feat: refactor components to utilize SelectField for improved UI consistency
Updated various components to replace traditional select implementations with the new SelectField component. This change enhances the user interface by providing a more consistent layout and improved accessibility. Additionally, refactored the dashboard and operations pages to utilize DataGridCard for better organization of content, streamlining the overall user experience.
2026-07-09 11:53:43 +07:00
83 changed files with 3524 additions and 921 deletions
+2 -2
View File
@@ -1,6 +1,6 @@
{ {
"pid": 39884, "pid": 43636,
"version": "0.9.9", "version": "0.9.9",
"socketPath": "\\\\.\\pipe\\codegraph-97b92efdcc5351da", "socketPath": "\\\\.\\pipe\\codegraph-97b92efdcc5351da",
"startedAt": 1783489774882 "startedAt": 1783570299043
} }
@@ -1,16 +1,17 @@
import { ColumnDef, useReactTable } from '@tanstack/react-table' import { ColumnDef } from '@tanstack/react-table'
import { RefreshCw, Trash2 } from 'lucide-react' import { RefreshCw, Trash2 } 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 { DataGridShell } from '@/components/data-grid-shell' import { CategoryBadge } from '@/components/category-badge'
import { Badge } from '@/components/reui/badge' import { DataGridMutedCell, DataGridPrimaryCell } from '@/components/data-grid-cell'
import { DataGridSection } from '@/components/data-grid-shell'
import { ConfirmDialog } from '@/components/confirm-dialog' import { ConfirmDialog } from '@/components/confirm-dialog'
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 { formatApiKeyDate } from '@/lib/access/api-key-labels' import { formatApiKeyDate } from '@/lib/access/api-key-labels'
import { createClientDataGridOptions } from '@/lib/data-grid-defaults' import { useClientDataGrid } from '@/hooks/use-client-data-grid'
import type { ApiKey } from '@/types/api' import type { ApiKey } from '@/types/api'
export function AccessApiKeysGrid({ export function AccessApiKeysGrid({
@@ -33,16 +34,14 @@ export function AccessApiKeysGrid({
{ {
accessorKey: 'name', accessorKey: 'name',
header: ({ column }) => <DataGridColumnHeader column={column} title="Имя" />, header: ({ column }) => <DataGridColumnHeader column={column} title="Имя" />,
cell: ({ row }) => <span className="font-medium">{row.original.name}</span>, cell: ({ row }) => <DataGridPrimaryCell title={row.original.name} accent="primary" />,
meta: { headerTitle: 'Имя' }, meta: { headerTitle: 'Имя' },
}, },
{ {
accessorKey: 'role', accessorKey: 'role',
header: ({ column }) => <DataGridColumnHeader column={column} title="Роль" />, header: ({ column }) => <DataGridColumnHeader column={column} title="Роль" />,
cell: ({ row }) => ( cell: ({ row }) => (
<Badge variant="outline" className="font-mono text-xs"> <CategoryBadge className="font-mono text-xs">{row.original.role}</CategoryBadge>
{row.original.role}
</Badge>
), ),
meta: { headerTitle: 'Роль' }, meta: { headerTitle: 'Роль' },
}, },
@@ -71,9 +70,7 @@ export function AccessApiKeysGrid({
accessorFn: (row) => row.expires_at ?? '', accessorFn: (row) => row.expires_at ?? '',
header: ({ column }) => <DataGridColumnHeader column={column} title="Истекает" />, header: ({ column }) => <DataGridColumnHeader column={column} title="Истекает" />,
cell: ({ row }) => ( cell: ({ row }) => (
<span className="text-sm text-muted-foreground"> <DataGridMutedCell>{formatApiKeyDate(row.original.expires_at)}</DataGridMutedCell>
{formatApiKeyDate(row.original.expires_at)}
</span>
), ),
meta: { headerTitle: 'Истекает' }, meta: { headerTitle: 'Истекает' },
}, },
@@ -82,9 +79,7 @@ export function AccessApiKeysGrid({
accessorFn: (row) => row.last_used_at ?? '', accessorFn: (row) => row.last_used_at ?? '',
header: ({ column }) => <DataGridColumnHeader column={column} title="Последнее использование" />, header: ({ column }) => <DataGridColumnHeader column={column} title="Последнее использование" />,
cell: ({ row }) => ( cell: ({ row }) => (
<span className="text-sm text-muted-foreground"> <DataGridMutedCell>{formatApiKeyDate(row.original.last_used_at)}</DataGridMutedCell>
{formatApiKeyDate(row.original.last_used_at)}
</span>
), ),
meta: { headerTitle: 'Последнее использование' }, meta: { headerTitle: 'Последнее использование' },
}, },
@@ -140,19 +135,23 @@ export function AccessApiKeysGrid({
[onRevoke, onRotate, revokePending, rotatePending], [onRevoke, onRotate, revokePending, rotatePending],
) )
const table = useReactTable({ const { table, globalFilter, setGlobalFilter, filteredCount } = useClientDataGrid({
data: items, data: items,
columns, columns,
...createClientDataGridOptions<ApiKey>(), getSearchText: (row) =>
`${row.name} ${row.role} ${row.prefix} ${row.revoked_at ? 'отозван' : 'активен'}`,
getRowId: (row) => row.id, getRowId: (row) => row.id,
}) })
return ( return (
<DataGridShell <DataGridSection
table={table} table={table}
recordCount={items.length} recordCount={filteredCount}
isLoading={isLoading} isLoading={isLoading}
emptyMessage="Нет ключей" emptyMessage="Нет ключей"
searchValue={globalFilter}
onSearchChange={setGlobalFilter}
searchPlaceholder="Поиск API-ключей…"
/> />
) )
} }
@@ -11,15 +11,9 @@ import {
} from '@evobgp/ui/components/dialog' } from '@evobgp/ui/components/dialog'
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 {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@evobgp/ui/components/select'
import { LoadingButton } from '@/components/loading-button' import { LoadingButton } from '@/components/loading-button'
import { SelectField } from '@/components/select-field'
import { API_KEY_ROLE_ITEMS } from '@/lib/access/api-key-labels' import { API_KEY_ROLE_ITEMS } from '@/lib/access/api-key-labels'
import { useCreateApiKeyMutation } from '@/queries/api-keys' import { useCreateApiKeyMutation } from '@/queries/api-keys'
import type { ApiKeyCreate, ApiKeyCreated, ApiKeyRole } from '@/types/api' import type { ApiKeyCreate, ApiKeyCreated, ApiKeyRole } from '@/types/api'
@@ -89,25 +83,14 @@ export function ApiKeyCreateDialog({ open, onOpenChange, onCreated }: ApiKeyCrea
placeholder="CI / оператор UI" placeholder="CI / оператор UI"
/> />
</div> </div>
<div className="flex flex-col gap-2"> <SelectField
<Label htmlFor="key-role">Роль</Label> id="key-role"
<Select label="Роль"
items={[...API_KEY_ROLE_ITEMS]} items={[...API_KEY_ROLE_ITEMS]}
value={role} value={role}
onValueChange={(v) => v && setRole(v as ApiKeyRole)} placeholder="Выберите роль"
> onValueChange={(v) => v && setRole(v as ApiKeyRole)}
<SelectTrigger id="key-role" className="w-full"> />
<SelectValue placeholder="Выберите роль" />
</SelectTrigger>
<SelectContent>
{API_KEY_ROLE_ITEMS.map((opt) => (
<SelectItem key={opt.value} value={opt.value}>
{opt.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
<Label htmlFor="key-expires">Истекает (опционально)</Label> <Label htmlFor="key-expires">Истекает (опционально)</Label>
<Input <Input
@@ -0,0 +1,54 @@
import { AlertTriangle, CheckCircle, Info } from 'lucide-react'
import { cn } from '@evobgp/ui/lib/utils'
import { StatusBadge } from '@/components/status-badge'
import type { PlatformActivityItem } from '@/lib/metrics'
const KIND_ICON = {
job: Info,
revision: CheckCircle,
network: AlertTriangle,
} as const
const KIND_ICON_CLASS = {
job: 'text-info',
revision: 'text-success',
network: 'text-warning',
} as const
export function AnalyticsActivityList({
items,
className,
}: {
items: PlatformActivityItem[]
className?: string
}) {
if (items.length === 0) {
return <p className="text-sm text-muted-foreground">Нет недавних событий</p>
}
return (
<ul className={cn('space-y-3', className)}>
{items.map((item) => {
const Icon = KIND_ICON[item.kind]
return (
<li key={item.id} className="flex items-start justify-between gap-3">
<div className="flex min-w-0 items-start gap-2.5">
<span
className={cn(
'mt-0.5 flex size-7 shrink-0 items-center justify-center rounded-full bg-muted/60',
KIND_ICON_CLASS[item.kind],
)}
>
<Icon className="size-3.5" />
</span>
<p className="text-sm leading-snug">{item.message}</p>
</div>
<StatusBadge status={item.status} label={item.statusLabel} />
</li>
)
})}
</ul>
)
}
@@ -0,0 +1,64 @@
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,
TooltipContent,
TooltipTrigger,
} from '@evobgp/ui/components/tooltip'
export function AnalyticsCardShell({
title,
description,
info,
actions,
footer,
className,
children,
}: {
title: string
description?: string
info?: string
actions?: ReactNode
footer?: ReactNode
className?: string
children: ReactNode
}) {
return (
<Card className={cn('flex h-full 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-1 flex-col gap-5 p-5">{children}</CardContent>
{footer ? <CardFooter className="gap-2 border-t p-4">{footer}</CardFooter> : null}
</Card>
)
}
@@ -0,0 +1,50 @@
import { Minus, TrendingDown, TrendingUp } from 'lucide-react'
import { cn } from '@evobgp/ui/lib/utils'
export type AnalyticsKpiItem = {
label: string
value: string
delta?: {
direction: 'up' | 'down' | 'neutral'
label: string
tone?: 'success' | 'warning' | 'destructive' | 'muted'
}
}
const TONE_CLASS = {
success: 'text-success',
warning: 'text-warning',
destructive: 'text-destructive',
muted: 'text-muted-foreground',
} as const
function DeltaIcon({ direction }: { direction: AnalyticsKpiItem['delta'] extends infer D ? D extends { direction: infer Dir } ? Dir : never : never }) {
if (direction === 'up') return <TrendingUp className="size-3" />
if (direction === 'down') return <TrendingDown className="size-3" />
return <Minus className="size-3" />
}
export function AnalyticsKpiRow({ items, className }: { items: AnalyticsKpiItem[]; className?: string }) {
return (
<div className={cn('grid gap-4 sm:grid-cols-3', className)}>
{items.map((item) => (
<div key={item.label} className="min-w-0 space-y-1">
<p className="text-xs text-muted-foreground">{item.label}</p>
<p className="text-2xl font-semibold tracking-tight tabular-nums">{item.value}</p>
{item.delta ? (
<p
className={cn(
'flex items-center gap-1 text-xs',
TONE_CLASS[item.delta.tone ?? 'muted'],
)}
>
<DeltaIcon direction={item.delta.direction} />
{item.delta.label}
</p>
) : null}
</div>
))}
</div>
)
}
@@ -0,0 +1,31 @@
import {
Progress,
ProgressIndicator,
ProgressTrack,
} from '@evobgp/ui/components/progress'
import { cn } from '@evobgp/ui/lib/utils'
export function AnalyticsProgress({
label,
value,
className,
}: {
label: string
value: number
className?: string
}) {
const clamped = Math.max(0, Math.min(100, value))
return (
<div className={cn('space-y-2', className)}>
<div className="flex items-center justify-between gap-2 text-sm">
<span className="text-muted-foreground">{label}</span>
<span className="font-medium tabular-nums">{clamped}%</span>
</div>
<Progress value={clamped} className="gap-0">
<ProgressTrack className="h-2">
<ProgressIndicator className="bg-foreground" />
</ProgressTrack>
</Progress>
</div>
)
}
@@ -0,0 +1,35 @@
import { Button } from '@evobgp/ui/components/button'
import { ButtonGroup } from '@evobgp/ui/components/button-group'
import { cn } from '@evobgp/ui/lib/utils'
export function AnalyticsSegmentControl<T extends string>({
value,
onChange,
options,
className,
}: {
value: T
onChange: (value: T) => void
options: { value: T; label: string }[]
className?: string
}) {
return (
<ButtonGroup className={cn('rounded-lg bg-muted/50 p-0.5', className)}>
{options.map((option) => (
<Button
key={option.value}
type="button"
size="sm"
variant={value === option.value ? 'secondary' : 'ghost'}
className={cn(
'h-7 rounded-md px-2.5 text-xs',
value === option.value && 'bg-background shadow-sm',
)}
onClick={() => onChange(option.value)}
>
{option.label}
</Button>
))}
</ButtonGroup>
)
}
@@ -0,0 +1,41 @@
import { Bar, BarChart, XAxis } from 'recharts'
import { ChartContainer, type ChartConfig } from '@evobgp/ui/components/chart'
import { cn } from '@evobgp/ui/lib/utils'
import type { CapacityBar } from '@/lib/metrics'
const chartConfig = {
value: { label: 'Загрузка', color: 'var(--color-chart-2)' },
} satisfies ChartConfig
export function ChartBarStrip({
bars,
className,
}: {
bars: CapacityBar[]
className?: string
}) {
if (bars.length === 0) {
return (
<div className={cn('flex h-36 items-center justify-center text-sm text-muted-foreground', className)}>
Нет данных для графика
</div>
)
}
const data = bars.map((bar, index) => ({
...bar,
slot: index + 1,
fill: bar.value >= 80 ? 'var(--color-chart-2)' : 'var(--color-chart-3)',
}))
return (
<ChartContainer config={chartConfig} className={cn('aspect-auto h-36 w-full', className)}>
<BarChart data={data} margin={{ top: 4, right: 0, left: 0, bottom: 0 }}>
<XAxis dataKey="slot" hide />
<Bar dataKey="value" radius={[3, 3, 0, 0]} maxBarSize={10} />
</BarChart>
</ChartContainer>
)
}
@@ -0,0 +1,100 @@
import { Cell, Label, Pie, PieChart } from 'recharts'
import {
ChartContainer,
type ChartConfig,
} from '@evobgp/ui/components/chart'
import { cn } from '@evobgp/ui/lib/utils'
import type { BreakdownSlice } from '@/lib/metrics'
export function ChartDonutMetric({
slices,
centerLabel,
centerValue,
className,
}: {
slices: BreakdownSlice[]
centerLabel: string
centerValue: string | number
className?: string
}) {
const chartConfig = slices.reduce<ChartConfig>((acc, slice) => {
acc[slice.key] = { label: slice.label, color: slice.color }
return acc
}, {})
const data = slices.map((slice) => ({
...slice,
fill: slice.color,
}))
const total = slices.reduce((sum, slice) => sum + slice.count, 0)
if (total === 0) {
return (
<div className={cn('flex h-48 items-center justify-center text-sm text-muted-foreground', className)}>
Нет данных
</div>
)
}
return (
<div className={cn('flex items-center gap-6', className)}>
<ChartContainer config={chartConfig} className="mx-0 aspect-square h-44 w-44 shrink-0">
<PieChart>
<Pie
data={data}
dataKey="count"
nameKey="label"
innerRadius={52}
outerRadius={72}
strokeWidth={2}
stroke="var(--color-card)"
>
{data.map((entry) => (
<Cell key={entry.key} fill={entry.fill} />
))}
<Label
content={({ viewBox }) => {
if (!viewBox || !('cx' in viewBox) || !('cy' in viewBox)) return null
const { cx, cy } = viewBox
return (
<text x={cx} y={cy} textAnchor="middle" dominantBaseline="middle">
<tspan x={cx} y={(cy ?? 0) - 6} className="fill-muted-foreground text-xs">
{centerLabel}
</tspan>
<tspan x={cx} y={(cy ?? 0) + 14} className="fill-foreground text-xl font-semibold">
{centerValue}
</tspan>
</text>
)
}}
/>
</Pie>
</PieChart>
</ChartContainer>
<ul className="min-w-0 flex-1 space-y-3">
{slices.map((slice) => {
const pct = total > 0 ? ((slice.count / total) * 100).toFixed(1) : '0'
return (
<li key={slice.key} className="flex items-center justify-between gap-3 text-sm">
<div className="flex min-w-0 items-center gap-2">
<span
className="size-2 shrink-0 rounded-full"
style={{ backgroundColor: slice.color }}
/>
<span className="truncate text-muted-foreground">{slice.label}</span>
</div>
<div className="shrink-0 text-right tabular-nums">
<span className="font-semibold">{slice.count}</span>
<span className="ml-2 text-muted-foreground">{pct}%</span>
</div>
</li>
)
})}
</ul>
</div>
)
}
@@ -0,0 +1,107 @@
import { useMemo, useState } from 'react'
import {
Avatar,
AvatarFallback,
AvatarGroup,
AvatarGroupCount,
} from '@evobgp/ui/components/avatar'
import { AnalyticsCardShell } from '@/components/analytics/analytics-card-shell'
import { AnalyticsSegmentControl } from '@/components/analytics/analytics-segment-control'
import { ChartBarStrip } from '@/components/analytics/chart-bar-strip'
import {
capacityUtilization,
peerCapacityBars,
speakerCapacityBars,
} from '@/lib/metrics'
import { runningJobCount } from '@/queries/overview'
import type { JobRow, PeerRow, SpeakerRow } from '@/types/api'
type CapacityMode = 'peers' | 'speakers'
function speakerInitials(speaker: SpeakerRow): string {
const label = speaker.live?.label ?? speaker.agent_domain ?? speaker.endpoint ?? speaker.id
const parts = label.split(/[.\-_@/]/).filter(Boolean)
if (parts.length >= 2) return (parts[0][0] + parts[1][0]).toUpperCase()
return label.slice(0, 2).toUpperCase()
}
export function DashboardNetworkCapacityCard({
peers,
speakers,
jobs,
loading,
}: {
peers: PeerRow[]
speakers: SpeakerRow[]
jobs: JobRow[]
loading?: boolean
}) {
const [mode, setMode] = useState<CapacityMode>('peers')
const bars = useMemo(
() => (mode === 'peers' ? peerCapacityBars(peers) : speakerCapacityBars(speakers)),
[mode, peers, speakers],
)
const utilization = capacityUtilization(bars)
const queued = runningJobCount(jobs)
const previewSpeakers = speakers.slice(0, 3)
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`
return (
<AnalyticsCardShell
title="Загрузка BGP"
description="Текущая утилизация сессий по пирам и спикерам"
info="Каждый столбец — enabled peer или speaker. Высота отражает Established/online."
actions={
<AnalyticsSegmentControl
value={mode}
onChange={setMode}
options={[
{ value: 'peers', label: 'Пиры' },
{ value: 'speakers', label: 'Спикеры' },
]}
/>
}
>
<div className="space-y-1">
<p className="text-3xl font-semibold tracking-tight tabular-nums">
{loading ? '—' : `${utilization}%`}
</p>
<p className="text-sm text-success">{loading ? '…' : `${deltaLabel} · снимок live`}</p>
</div>
{loading ? (
<div className="flex h-36 items-center justify-center text-sm text-muted-foreground">
Загрузка
</div>
) : (
<ChartBarStrip bars={bars} />
)}
<div className="flex items-center justify-between gap-3 text-sm">
<p className="text-muted-foreground">
Активных задач: <span className="font-medium text-foreground">{loading ? '—' : queued}</span>
</p>
<div className="flex items-center gap-2">
<AvatarGroup>
{previewSpeakers.map((speaker) => (
<Avatar key={speaker.id} size="sm">
<AvatarFallback>{speakerInitials(speaker)}</AvatarFallback>
</Avatar>
))}
{speakers.length > 3 ? (
<AvatarGroupCount>+{speakers.length - 3}</AvatarGroupCount>
) : null}
</AvatarGroup>
<span className="text-muted-foreground">{speakers.length} спикеров</span>
</div>
</div>
</AnalyticsCardShell>
)
}
@@ -0,0 +1,59 @@
import { useMemo, useState } from 'react'
import { AnalyticsCardShell } from '@/components/analytics/analytics-card-shell'
import { AnalyticsSegmentControl } from '@/components/analytics/analytics-segment-control'
import { ChartDonutMetric } from '@/components/analytics/chart-donut-metric'
import { jobStatusBreakdown, moduleTypeBreakdown } from '@/lib/metrics'
import type { JobRow, ModuleRow } from '@/types/api'
type FlowMode = 'jobs' | 'modules'
export function DashboardOperationsFlowCard({
jobs,
modules,
loading,
}: {
jobs: JobRow[]
modules: ModuleRow[]
loading?: boolean
}) {
const [mode, setMode] = useState<FlowMode>('jobs')
const slices = useMemo(
() => (mode === 'jobs' ? jobStatusBreakdown(jobs) : moduleTypeBreakdown(modules)),
[mode, jobs, modules],
)
const total = slices.reduce((sum, slice) => sum + slice.count, 0)
const centerLabel = mode === 'jobs' ? 'Задачи' : 'Модули'
return (
<AnalyticsCardShell
title="Поток операций"
description="Распределение фоновых задач и типов модулей"
info="Donut строится по текущей выборке API (до 100 последних задач)."
actions={
<AnalyticsSegmentControl
value={mode}
onChange={setMode}
options={[
{ value: 'jobs', label: 'Задачи' },
{ value: 'modules', label: 'Модули' },
]}
/>
}
>
{loading ? (
<div className="flex h-48 items-center justify-center text-sm text-muted-foreground">
Загрузка
</div>
) : (
<ChartDonutMetric
slices={slices}
centerLabel={centerLabel}
centerValue={total}
/>
)}
</AnalyticsCardShell>
)
}
@@ -0,0 +1,141 @@
import { useNavigate } from '@tanstack/react-router'
import { useMemo } from 'react'
import { Button } from '@evobgp/ui/components/button'
import { AnalyticsActivityList } from '@/components/analytics/analytics-activity-list'
import { AnalyticsCardShell } from '@/components/analytics/analytics-card-shell'
import { AnalyticsKpiRow } from '@/components/analytics/analytics-kpi-row'
import { AnalyticsProgress } from '@/components/analytics/analytics-progress'
import {
deploymentProgress,
recentPlatformActivity,
} from '@/lib/metrics'
import { runningJobCount } from '@/queries/overview'
import type { JobRow, ModuleRow, PeerRow, RevisionRow, SpeakerRow } from '@/types/api'
export function DashboardPlatformCard({
modules,
peers,
speakers,
jobs,
revisions,
loading,
}: {
modules: ModuleRow[]
peers: PeerRow[]
speakers: SpeakerRow[]
jobs: JobRow[]
revisions: RevisionRow[]
loading?: boolean
}) {
const navigate = useNavigate()
const enabledModules = modules.filter((m) => m.enabled !== false).length
const peersEnabled = peers.filter((p) => p.enabled !== false).length
const peersEstablished = peers.filter(
(p) => p.enabled !== false && p.session_state === 'Established',
).length
const peersMismatch = peers.filter((p) => p.session_mismatch).length
const speakersOnline = speakers.filter((s) => s.live?.agent_ok).length
const failedJobs = jobs.filter((j) =>
['failed', 'error', 'cancelled'].includes(j.status.toLowerCase()),
).length
const running = runningJobCount(jobs)
const riskCount = peersMismatch + failedJobs + Math.max(0, speakers.length - speakersOnline)
const bgpPct =
peersEnabled > 0 ? Math.round((peersEstablished / peersEnabled) * 100) : null
const deploy = useMemo(() => deploymentProgress(speakers), [speakers])
const activity = useMemo(
() => recentPlatformActivity(jobs, revisions, peers, speakers),
[jobs, revisions, peers, speakers],
)
const kpis = [
{
label: 'Модули активны',
value: loading ? '—' : `${enabledModules}/${modules.length || 0}`,
delta: {
direction: 'neutral' as const,
label: `${modules.length} всего`,
tone: 'muted' as const,
},
},
{
label: 'BGP готовность',
value: loading || bgpPct === null ? '—' : `${bgpPct}%`,
delta: {
direction: (bgpPct !== null && bgpPct >= 90 ? 'up' : bgpPct !== null && bgpPct < 70 ? 'down' : 'neutral') as
| 'up'
| 'down'
| 'neutral',
label:
bgpPct === null
? 'нет включённых пиров'
: `${peersEstablished} Established`,
tone: (bgpPct !== null && bgpPct >= 90
? 'success'
: bgpPct !== null && bgpPct < 70
? 'warning'
: 'muted') as 'success' | 'warning' | 'muted',
},
},
{
label: 'Риски',
value: loading ? '—' : String(riskCount),
delta: {
direction: (riskCount > 0 ? 'down' : 'up') as 'up' | 'down',
label: riskCount > 0 ? `${failedJobs} задач, ${peersMismatch} mismatch` : 'в норме',
tone: (riskCount > 0 ? 'destructive' : 'success') as 'destructive' | 'success',
},
},
]
const progressLabel =
deploy.mode === 'revision'
? `Синхронизация ревизий (${deploy.synced}/${deploy.total})`
: `Спикеры online (${deploy.synced}/${deploy.total})`
return (
<AnalyticsCardShell
title="Состояние платформы"
description="Сводка модулей, BGP и фоновых задач"
info="Актуальный снимок без исторических трендов. Обновите данные кнопкой «Обновить» на странице."
footer={
<>
<Button
variant="outline"
className="flex-1"
onClick={() => navigate({ to: '/schedule' })}
>
Расписание
</Button>
<Button
className="flex-1"
onClick={() => navigate({ to: '/monitoring', search: { tab: 'system' } })}
>
Мониторинг
</Button>
</>
}
>
<AnalyticsKpiRow items={kpis} />
<AnalyticsProgress label={progressLabel} 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>
{!loading ? (
<span className="text-xs text-muted-foreground">{running} активных задач</span>
) : null}
</div>
{loading ? (
<p className="text-sm text-muted-foreground">Загрузка</p>
) : (
<AnalyticsActivityList items={activity} />
)}
</div>
</AnalyticsCardShell>
)
}
@@ -0,0 +1,13 @@
export { AnalyticsActivityList } from './analytics-activity-list'
export { AnalyticsCardShell } from './analytics-card-shell'
export { AnalyticsKpiRow, type AnalyticsKpiItem } from './analytics-kpi-row'
export { AnalyticsProgress } from './analytics-progress'
export { AnalyticsSegmentControl } from './analytics-segment-control'
export { ChartBarStrip } from './chart-bar-strip'
export { ChartDonutMetric } from './chart-donut-metric'
export { DashboardNetworkCapacityCard } from './dashboard-network-capacity-card'
export { DashboardOperationsFlowCard } from './dashboard-operations-flow-card'
export { DashboardPlatformCard } from './dashboard-platform-card'
export { MonitoringHealthCard } from './monitoring-health-card'
export { NetworkOverviewAnalyticsCard } from './network-overview-analytics-card'
export { OperationsAnalyticsCard } from './operations-analytics-card'
@@ -0,0 +1,33 @@
import { AnalyticsCardShell } from '@/components/analytics/analytics-card-shell'
import { ChartDonutMetric } from '@/components/analytics/chart-donut-metric'
import { readinessBreakdown } from '@/lib/metrics'
import type { ReadyStatus } from '@/queries/monitoring'
export function MonitoringHealthCard({
healthOk,
ready,
loading,
}: {
healthOk: boolean
ready: ReadyStatus | null | undefined
loading?: boolean
}) {
const slices = readinessBreakdown(ready, healthOk)
const total = slices.reduce((sum, slice) => sum + slice.count, 0)
return (
<AnalyticsCardShell
title="Доступность системы"
description="Health и readiness checks"
info="Donut отражает результат GET /v1/health и checks из 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} />
)}
</AnalyticsCardShell>
)
}
@@ -0,0 +1,66 @@
import { AnalyticsCardShell } from '@/components/analytics/analytics-card-shell'
import { AnalyticsKpiRow } from '@/components/analytics/analytics-kpi-row'
import { ChartDonutMetric } from '@/components/analytics/chart-donut-metric'
import { peerSessionBreakdown } from '@/lib/metrics'
import { aggregateNetworkMetrics } from '@/queries/overview'
import type { PeerRow, SpeakerRow } from '@/types/api'
export function NetworkOverviewAnalyticsCard({
peers,
speakers,
loading,
}: {
peers: PeerRow[]
speakers: SpeakerRow[]
loading?: boolean
}) {
const net = aggregateNetworkMetrics(peers, speakers)
const slices = peerSessionBreakdown(peers)
const total = slices.reduce((sum, slice) => sum + slice.count, 0)
return (
<AnalyticsCardShell
title="Сводка BGP"
description="Established, online и mismatch по live-данным"
info="Снимок текущего состояния пиров и спикеров."
>
<AnalyticsKpiRow
items={[
{
label: 'Пиры Established',
value: loading ? '—' : `${net.peersEstablished}/${net.peersEnabled}`,
delta: {
direction: net.peersMismatch > 0 ? 'down' : 'up',
label: net.peersMismatch > 0 ? `${net.peersMismatch} mismatch` : 'сессии в норме',
tone: net.peersMismatch > 0 ? 'warning' : 'success',
},
},
{
label: 'Спикеры online',
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',
tone: net.speakersOnline < net.speakersTotal ? 'warning' : 'success',
},
},
{
label: 'Пиры всего',
value: loading ? '—' : String(net.peersTotal),
delta: { direction: 'neutral', label: 'в каталоге', tone: 'muted' },
},
]}
/>
{loading ? (
<div className="flex h-48 items-center justify-center text-sm text-muted-foreground">
Загрузка
</div>
) : (
<ChartDonutMetric slices={slices} centerLabel="Пиры" centerValue={total} />
)}
</AnalyticsCardShell>
)
}
@@ -0,0 +1,65 @@
import { AnalyticsCardShell } from '@/components/analytics/analytics-card-shell'
import { AnalyticsKpiRow } from '@/components/analytics/analytics-kpi-row'
import { ChartDonutMetric } from '@/components/analytics/chart-donut-metric'
import { jobStatusBreakdown } from '@/lib/metrics'
import type { JobRow, RevisionRow } from '@/types/api'
export function OperationsAnalyticsCard({
jobs,
revisions,
loading,
}: {
jobs: JobRow[]
revisions: RevisionRow[]
loading?: boolean
}) {
const running = jobs.filter((j) => ['running', 'queued'].includes(j.status.toLowerCase())).length
const failed = jobs.filter((j) =>
['failed', 'error', 'cancelled'].includes(j.status.toLowerCase()),
).length
const slices = jobStatusBreakdown(jobs)
const total = slices.reduce((sum, slice) => sum + slice.count, 0)
return (
<AnalyticsCardShell
title="Операции и задачи"
description="Статистика ревизий и фоновых jobs"
info="Данные из GET /v1/jobs и /v1/revisions."
>
<AnalyticsKpiRow
items={[
{
label: 'Ревизий',
value: loading ? '—' : String(revisions.length),
delta: { direction: 'neutral', label: 'в выборке', tone: 'muted' },
},
{
label: 'Активных задач',
value: loading ? '—' : String(running),
delta: {
direction: running > 0 ? 'up' : 'neutral',
label: running > 0 ? 'выполняются' : 'очередь пуста',
tone: (running > 0 ? 'warning' : 'muted') as 'warning' | 'muted',
},
},
{
label: 'С ошибкой',
value: loading ? '—' : String(failed),
delta: {
direction: failed > 0 ? 'down' : 'up',
label: failed > 0 ? 'требуют внимания' : 'в норме',
tone: failed > 0 ? 'destructive' : 'success',
},
},
]}
/>
{loading ? (
<div className="flex h-48 items-center justify-center text-sm text-muted-foreground">
Загрузка
</div>
) : (
<ChartDonutMetric slices={slices} centerLabel="Задачи" centerValue={total} />
)}
</AnalyticsCardShell>
)
}
+69
View File
@@ -0,0 +1,69 @@
import type { ComponentProps, ReactNode } from 'react'
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@evobgp/ui/components/tabs'
import { cn } from '@evobgp/ui/lib/utils'
import { Badge } from '@/components/reui/badge'
export type BadgeTabItem = {
value: string
label: string
count?: number
badgeVariant?: ComponentProps<typeof Badge>['variant']
icon?: ReactNode
}
interface BadgeTabsProps {
items: BadgeTabItem[]
value?: string
defaultValue?: string
onValueChange?: (value: string) => void
children: ReactNode
className?: string
listClassName?: string
contentClassName?: string
}
/** Underline tabs with optional badge counts (ReUI c-tabs-7 pattern). */
export function BadgeTabs({
items,
value,
defaultValue,
onValueChange,
children,
className,
listClassName,
contentClassName,
}: BadgeTabsProps) {
return (
<Tabs
value={value}
defaultValue={defaultValue}
onValueChange={onValueChange}
className={cn('w-full', className)}
>
<TabsList
variant="line"
className={cn(
'mb-4 w-full justify-start gap-6',
listClassName,
)}
>
{items.map((item) => (
<TabsTrigger key={item.value} value={item.value} className="gap-2">
{item.icon}
{item.label}
{item.count !== undefined ? (
<Badge variant={item.badgeVariant ?? 'primary-light'} size="sm">
{item.count}
</Badge>
) : null}
</TabsTrigger>
))}
</TabsList>
<div className={cn('w-full min-w-0', contentClassName)}>{children}</div>
</Tabs>
)
}
export { TabsContent }
@@ -0,0 +1,50 @@
import type { ComponentProps, ReactNode } from 'react'
import { Badge } from '@/components/reui/badge'
type BadgeVariant = NonNullable<ComponentProps<typeof Badge>['variant']>
const TONE_VARIANT: Record<string, BadgeVariant> = {
neutral: 'outline',
info: 'info-light',
warning: 'warning-light',
success: 'success-light',
}
export function ModeBadge({
enabled,
onLabel = 'включён',
offLabel = 'выключен',
className,
}: {
enabled: boolean
onLabel?: string
offLabel?: string
className?: string
}) {
return enabled ? (
<Badge variant="success-light" size="sm" radius="full" className={className}>
{onLabel}
</Badge>
) : (
<Badge variant="secondary" size="sm" radius="full" className={className}>
{offLabel}
</Badge>
)
}
export function CategoryBadge({
children,
tone = 'neutral',
className,
}: {
children: ReactNode
tone?: keyof typeof TONE_VARIANT
className?: string
}) {
return (
<Badge variant={TONE_VARIANT[tone]} size="sm" radius="full" className={className}>
{children}
</Badge>
)
}
@@ -2,11 +2,11 @@ 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 { FrameFooter } from '@/components/reui/frame' import { CardFooter } from '@evobgp/ui/components/card'
export function DashboardQuickActions() { export function DashboardQuickActions() {
return ( return (
<FrameFooter className="flex flex-wrap gap-2"> <CardFooter className="flex flex-wrap gap-2 border-t-0 bg-transparent">
<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" />
Создать модуль Создать модуль
@@ -36,6 +36,6 @@ export function DashboardQuickActions() {
<Gauge className="size-4" /> <Gauge className="size-4" />
Мониторинг Мониторинг
</Button> </Button>
</FrameFooter> </CardFooter>
) )
} }
@@ -1,21 +1,14 @@
import { ColumnDef, getCoreRowModel, useReactTable } from '@tanstack/react-table' import { ColumnDef } from '@tanstack/react-table'
import { useMemo } from 'react' import { useMemo } from 'react'
import { DataGridShell } from '@/components/data-grid-shell' import { DataGridPrimaryCell } from '@/components/data-grid-cell'
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 { 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 { useClientDataGrid } from '@/hooks/use-client-data-grid'
import type { JobRow } from '@/types/api' import type { JobRow } from '@/types/api'
function StatusText({ status }: { status: string }) {
const cls =
status === 'succeeded'
? 'text-success'
: status === 'failed' || status === 'cancelled'
? 'text-destructive'
: 'text-muted-foreground'
return <span className={`text-xs font-medium ${cls}`}>{status}</span>
}
export function DashboardRecentJobsGrid({ export function DashboardRecentJobsGrid({
jobs, jobs,
nameById, nameById,
@@ -33,42 +26,52 @@ export function DashboardRecentJobsGrid({
accessorKey: 'kind', accessorKey: 'kind',
header: ({ column }) => <DataGridColumnHeader column={column} title="Вид" />, header: ({ column }) => <DataGridColumnHeader column={column} title="Вид" />,
cell: ({ row }) => ( cell: ({ row }) => (
<div className="min-w-0"> <DataGridPrimaryCell
<div className="truncate font-mono text-xs text-muted-foreground">{row.original.kind}</div> title={row.original.kind}
{row.original.meta?.module_id ? ( accent="mono"
<div className="truncate text-xs"> subtitle={
{nameById.get(String(row.original.meta.module_id)) ?? ''} row.original.meta?.module_id
</div> ? (nameById.get(String(row.original.meta.module_id)) ?? undefined)
) : null} : undefined
</div> }
/>
), ),
meta: { headerTitle: 'Вид' }, meta: { headerTitle: 'Вид' },
}, },
{ {
accessorKey: 'status', accessorKey: 'status',
header: ({ column }) => <DataGridColumnHeader column={column} title="Статус" />, header: ({ column }) => <DataGridColumnHeader column={column} title="Статус" />,
cell: ({ row }) => <StatusText status={row.original.status} />, cell: ({ row }) => <StatusBadge status={row.original.status} />,
meta: { headerTitle: 'Статус' }, meta: { headerTitle: 'Статус' },
}, },
], ],
[nameById], [nameById],
) )
const table = useReactTable({ const { table, globalFilter, setGlobalFilter, filteredCount } = useClientDataGrid({
data, data,
columns, columns,
getCoreRowModel: getCoreRowModel(), getSearchText: (row) => {
const moduleName = row.meta?.module_id
? (nameById.get(String(row.meta.module_id)) ?? '')
: ''
return `${row.kind} ${row.status} ${moduleName}`
},
getRowId: (row) => row.job_id, getRowId: (row) => row.job_id,
pageSize: 8,
}) })
return ( return (
<DataGridShell <DataGridSection
table={table} table={table}
recordCount={data.length} recordCount={filteredCount}
isLoading={isLoading} isLoading={isLoading}
emptyMessage="Нет задач" emptyMessage="Нет задач"
showPagination={false} showPagination={false}
tableLayout={DATA_GRID_DENSE_LAYOUT} tableLayout={DATA_GRID_DENSE_LAYOUT}
searchValue={globalFilter}
onSearchChange={setGlobalFilter}
searchPlaceholder="Поиск задач…"
/> />
) )
} }
@@ -1,9 +1,11 @@
import { ColumnDef, getCoreRowModel, useReactTable } from '@tanstack/react-table' import { ColumnDef } from '@tanstack/react-table'
import { useMemo } from 'react' import { useMemo } from 'react'
import { DataGridShell } from '@/components/data-grid-shell' import { DataGridMutedCell, DataGridPrimaryCell } from '@/components/data-grid-cell'
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 { DATA_GRID_DENSE_LAYOUT } from '@/lib/data-grid-defaults' import { DATA_GRID_DENSE_LAYOUT } from '@/lib/data-grid-defaults'
import { useClientDataGrid } from '@/hooks/use-client-data-grid'
import type { RevisionRow } from '@/types/api' import type { RevisionRow } from '@/types/api'
export function DashboardRecentRevisionsGrid({ export function DashboardRecentRevisionsGrid({
@@ -22,9 +24,7 @@ export function DashboardRecentRevisionsGrid({
accessorFn: (row) => row.id, accessorFn: (row) => row.id,
header: ({ column }) => <DataGridColumnHeader column={column} title="ID" />, header: ({ column }) => <DataGridColumnHeader column={column} title="ID" />,
cell: ({ row }) => ( cell: ({ row }) => (
<span className="truncate font-mono text-xs text-muted-foreground"> <DataGridPrimaryCell title={`${row.original.id.slice(0, 10)}`} accent="mono" />
{row.original.id.slice(0, 10)}
</span>
), ),
meta: { headerTitle: 'ID' }, meta: { headerTitle: 'ID' },
}, },
@@ -32,9 +32,9 @@ export function DashboardRecentRevisionsGrid({
accessorKey: 'created_at', accessorKey: 'created_at',
header: ({ column }) => <DataGridColumnHeader column={column} title="Создана" />, header: ({ column }) => <DataGridColumnHeader column={column} title="Создана" />,
cell: ({ row }) => ( cell: ({ row }) => (
<span className="text-xs text-muted-foreground"> <DataGridMutedCell>
{new Date(row.original.created_at).toLocaleString('ru-RU')} {new Date(row.original.created_at).toLocaleString('ru-RU')}
</span> </DataGridMutedCell>
), ),
meta: { headerTitle: 'Создана' }, meta: { headerTitle: 'Создана' },
}, },
@@ -42,21 +42,25 @@ export function DashboardRecentRevisionsGrid({
[], [],
) )
const table = useReactTable({ const { table, globalFilter, setGlobalFilter, filteredCount } = useClientDataGrid({
data, data,
columns, columns,
getCoreRowModel: getCoreRowModel(), getSearchText: (row) => row.id,
getRowId: (row) => row.id, getRowId: (row) => row.id,
pageSize: 8,
}) })
return ( return (
<DataGridShell <DataGridSection
table={table} table={table}
recordCount={data.length} recordCount={filteredCount}
isLoading={isLoading} isLoading={isLoading}
emptyMessage="Нет ревизий" emptyMessage="Нет ревизий"
showPagination={false} showPagination={false}
tableLayout={DATA_GRID_DENSE_LAYOUT} tableLayout={DATA_GRID_DENSE_LAYOUT}
searchValue={globalFilter}
onSearchChange={setGlobalFilter}
searchPlaceholder="Поиск ревизий…"
/> />
) )
} }
@@ -0,0 +1,44 @@
import type { ReactNode } from 'react'
import { cn } from '@evobgp/ui/lib/utils'
const ACCENT_CLASS = {
primary: 'font-medium text-primary',
default: 'font-medium text-foreground',
mono: 'font-mono text-sm text-primary',
} as const
export function DataGridPrimaryCell({
title,
subtitle,
accent = 'default',
className,
}: {
title: ReactNode
subtitle?: ReactNode
accent?: keyof typeof ACCENT_CLASS
className?: string
}) {
return (
<div className={cn('flex min-w-0 flex-col gap-0.5', className)}>
<span className={cn('truncate', ACCENT_CLASS[accent])}>{title}</span>
{subtitle ? (
<span className="truncate text-xs text-muted-foreground">{subtitle}</span>
) : null}
</div>
)
}
export function DataGridMutedCell({
children,
className,
}: {
children: ReactNode
className?: string
}) {
return (
<span className={cn('whitespace-nowrap text-xs text-muted-foreground', className)}>
{children}
</span>
)
}
@@ -3,6 +3,7 @@ import type { Table } from '@tanstack/react-table'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@evobgp/ui/components/card' import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@evobgp/ui/components/card'
import { DataGridToolbar } from '@/components/data-grid-toolbar'
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'
@@ -75,3 +76,36 @@ export function DataGridCard({ title, description, actions, children, className
</Card> </Card>
) )
} }
interface DataGridSectionProps<TData extends object> extends DataGridShellProps<TData> {
searchValue: string
onSearchChange: (value: string) => void
searchPlaceholder?: string
toolbarFilters?: ReactNode
toolbarActions?: ReactNode
beforeGrid?: ReactNode
}
export function DataGridSection<TData extends object>({
searchValue,
onSearchChange,
searchPlaceholder,
toolbarFilters,
toolbarActions,
beforeGrid,
...shellProps
}: DataGridSectionProps<TData>) {
return (
<>
<DataGridToolbar
searchValue={searchValue}
onSearchChange={onSearchChange}
searchPlaceholder={searchPlaceholder}
filters={toolbarFilters}
actions={toolbarActions}
/>
{beforeGrid}
<DataGridShell {...shellProps} />
</>
)
}
@@ -0,0 +1,73 @@
import type { ReactNode } from 'react'
import { Field } from '@evobgp/ui/components/field'
import {
InputGroup,
InputGroupAddon,
InputGroupButton,
InputGroupInput,
} from '@evobgp/ui/components/input-group'
import { ListFilterIcon, SearchIcon, XIcon } from 'lucide-react'
interface DataGridToolbarProps {
searchValue: string
onSearchChange: (value: string) => void
searchPlaceholder?: string
filters?: ReactNode
actions?: ReactNode
className?: string
}
/** Search row for data grids (ReUI c-input-group-37 pattern). */
export function DataGridToolbar({
searchValue,
onSearchChange,
searchPlaceholder = 'Поиск…',
filters,
actions,
className,
}: DataGridToolbarProps) {
return (
<div className={`flex flex-wrap items-center gap-2 border-b px-3 py-3 ${className ?? ''}`}>
<Field className="min-w-[200px] flex-1">
<InputGroup>
<InputGroupAddon align="inline-start">
<SearchIcon aria-hidden="true" />
</InputGroupAddon>
<InputGroupInput
placeholder={searchPlaceholder}
value={searchValue}
onChange={(event) => onSearchChange(event.target.value)}
aria-label={searchPlaceholder}
/>
<InputGroupAddon align="inline-end" className="gap-1">
{searchValue.length > 0 ? (
<InputGroupButton
aria-label="Очистить поиск"
size="icon-xs"
variant="ghost"
onClick={() => onSearchChange('')}
>
<XIcon aria-hidden="true" />
</InputGroupButton>
) : null}
{filters ? (
filters
) : (
<InputGroupButton
variant="ghost"
size="xs"
className="pointer-events-none gap-1.5 opacity-0"
aria-hidden="true"
tabIndex={-1}
>
<ListFilterIcon className="size-3.5" />
</InputGroupButton>
)}
</InputGroupAddon>
</InputGroup>
</Field>
{actions ? <div className="flex shrink-0 items-center gap-2">{actions}</div> : null}
</div>
)
}
@@ -1,11 +1,11 @@
import { ColumnDef, useReactTable } from '@tanstack/react-table' import { ColumnDef } from '@tanstack/react-table'
import { useMemo } from 'react' import { useMemo } from 'react'
import { Badge } from '@evobgp/ui/components/badge' import { CategoryBadge } from '@/components/category-badge'
import { DataGridPrimaryCell } from '@/components/data-grid-cell'
import { DataGridShell } 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 { createClientDataGridOptions } from '@/lib/data-grid-defaults' import { useClientDataGrid } from '@/hooks/use-client-data-grid'
import type { BgpCommunity } from '@/types/api' import type { BgpCommunity } from '@/types/api'
export function DirectoriesCommunitiesGrid({ export function DirectoriesCommunitiesGrid({
@@ -20,39 +20,44 @@ export function DirectoriesCommunitiesGrid({
{ {
accessorKey: 'title', accessorKey: 'title',
header: ({ column }) => <DataGridColumnHeader column={column} title="Название" />, header: ({ column }) => <DataGridColumnHeader column={column} title="Название" />,
cell: ({ row }) => <span className="font-medium">{row.original.title}</span>, cell: ({ row }) => <DataGridPrimaryCell title={row.original.title} accent="primary" />,
meta: { headerTitle: 'Название' }, meta: { headerTitle: 'Название' },
}, },
{ {
accessorKey: 'community', accessorKey: 'community',
header: ({ column }) => <DataGridColumnHeader column={column} title="Значение" />, header: ({ column }) => <DataGridColumnHeader column={column} title="Значение" />,
cell: ({ row }) => <span className="font-mono text-xs">{row.original.community}</span>, cell: ({ row }) => (
<DataGridPrimaryCell title={row.original.community} accent="mono" />
),
meta: { headerTitle: 'Значение' }, meta: { headerTitle: 'Значение' },
}, },
{ {
id: 'type', id: 'type',
enableSorting: false, enableSorting: false,
header: 'Тип', header: 'Тип',
cell: () => <Badge variant="outline">community</Badge>, cell: () => <CategoryBadge>community</CategoryBadge>,
meta: { headerTitle: 'Тип' }, meta: { headerTitle: 'Тип' },
}, },
], ],
[], [],
) )
const table = useReactTable({ const { table, globalFilter, setGlobalFilter, filteredCount } = useClientDataGrid({
data: items, data: items,
columns, columns,
...createClientDataGridOptions<BgpCommunity>(), getSearchText: (row) => `${row.title} ${row.community}`,
getRowId: (row) => row.id, getRowId: (row) => row.id,
}) })
return ( return (
<DataGridShell <DataGridSection
table={table} table={table}
recordCount={items.length} recordCount={filteredCount}
isLoading={isLoading} isLoading={isLoading}
emptyMessage="Нет сообществ" emptyMessage="Нет сообществ"
searchValue={globalFilter}
onSearchChange={setGlobalFilter}
searchPlaceholder="Поиск сообществ…"
/> />
) )
} }
@@ -1,11 +1,11 @@
import { ColumnDef, useReactTable } from '@tanstack/react-table' import { ColumnDef } from '@tanstack/react-table'
import { useMemo } from 'react' import { useMemo } from 'react'
import { Badge } from '@evobgp/ui/components/badge' import { CategoryBadge } from '@/components/category-badge'
import { DataGridPrimaryCell } from '@/components/data-grid-cell'
import { DataGridShell } 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 { createClientDataGridOptions } from '@/lib/data-grid-defaults' import { useClientDataGrid } from '@/hooks/use-client-data-grid'
import type { DohProfile } from '@/types/api' import type { DohProfile } from '@/types/api'
export function DirectoriesDohGrid({ export function DirectoriesDohGrid({
@@ -21,39 +21,48 @@ export function DirectoriesDohGrid({
id: 'name', id: 'name',
accessorFn: (row) => row.name ?? row.url, accessorFn: (row) => row.name ?? row.url,
header: ({ column }) => <DataGridColumnHeader column={column} title="Название" />, header: ({ column }) => <DataGridColumnHeader column={column} title="Название" />,
cell: ({ row }) => <span className="font-medium">{row.original.name ?? row.original.url}</span>, cell: ({ row }) => (
<DataGridPrimaryCell
title={row.original.name ?? row.original.url}
subtitle={row.original.name ? row.original.url : undefined}
accent="primary"
/>
),
meta: { headerTitle: 'Название' }, meta: { headerTitle: 'Название' },
}, },
{ {
accessorKey: 'url', accessorKey: 'url',
header: ({ column }) => <DataGridColumnHeader column={column} title="URL" />, header: ({ column }) => <DataGridColumnHeader column={column} title="URL" />,
cell: ({ row }) => <span className="font-mono text-xs">{row.original.url}</span>, cell: ({ row }) => <DataGridPrimaryCell title={row.original.url} accent="mono" />,
meta: { headerTitle: 'URL' }, meta: { headerTitle: 'URL' },
}, },
{ {
id: 'default', id: 'default',
enableSorting: false, enableSorting: false,
header: 'По умолчанию', header: 'По умолчанию',
cell: () => <Badge variant="outline"></Badge>, cell: () => <CategoryBadge></CategoryBadge>,
meta: { headerTitle: 'По умолчанию' }, meta: { headerTitle: 'По умолчанию' },
}, },
], ],
[], [],
) )
const table = useReactTable({ const { table, globalFilter, setGlobalFilter, filteredCount } = useClientDataGrid({
data: items, data: items,
columns, columns,
...createClientDataGridOptions<DohProfile>(), getSearchText: (row) => `${row.name ?? ''} ${row.url}`,
getRowId: (row) => row.id, getRowId: (row) => row.id,
}) })
return ( return (
<DataGridShell <DataGridSection
table={table} table={table}
recordCount={items.length} recordCount={filteredCount}
isLoading={isLoading} isLoading={isLoading}
emptyMessage="Нет DoH профилей" emptyMessage="Нет DoH профилей"
searchValue={globalFilter}
onSearchChange={setGlobalFilter}
searchPlaceholder="Поиск DoH профилей…"
/> />
) )
} }
@@ -0,0 +1,107 @@
"use client"
import { useState } from "react"
import { Checkbox } from "@evobgp/ui/components/checkbox"
import { Field } from "@evobgp/ui/components/field"
import {
InputGroup,
InputGroupAddon,
InputGroupButton,
InputGroupInput,
} from "@evobgp/ui/components/input-group"
import { Label } from "@evobgp/ui/components/label"
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@evobgp/ui/components/popover"
import { SearchIcon, XIcon, ListFilterIcon } from "lucide-react"
const statuses = ["Pending", "Shipped", "Cancelled"] as const
type Status = (typeof statuses)[number]
function toggleStatus(values: Status[], value: Status) {
return values.includes(value)
? values.filter((item) => item !== value)
: [...values, value]
}
export function Pattern() {
const [searchQuery, setSearchQuery] = useState("")
const [selectedStatuses, setSelectedStatuses] = useState<Status[]>([])
return (
<Field className="max-w-sm">
<InputGroup>
<InputGroupAddon align="inline-start">
<SearchIcon aria-hidden="true" />
</InputGroupAddon>
<InputGroupInput
placeholder="Search orders..."
value={searchQuery}
onChange={(event) => setSearchQuery(event.target.value)}
/>
<InputGroupAddon align="inline-end" className="gap-1">
{searchQuery.length > 0 ? (
<InputGroupButton
aria-label="Clear search"
size="icon-xs"
variant="ghost"
onClick={() => setSearchQuery("")}
>
<XIcon aria-hidden="true" />
</InputGroupButton>
) : null}
<Popover>
<PopoverTrigger
render={
<InputGroupButton
variant="ghost"
size="xs"
className="gap-1.5"
aria-label="Filter order status"
/>
}
>
<ListFilterIcon className="size-3.5" aria-hidden="true" />
Status
{selectedStatuses.length > 0 ? (
<span className="text-muted-foreground tabular-nums">
{selectedStatuses.length}
</span>
) : null}
</PopoverTrigger>
<PopoverContent align="end" className="w-40 p-3">
<div className="flex flex-col gap-2">
{statuses.map((status) => (
<div key={status} className="flex items-center gap-2.5">
<Checkbox
id={`order-status-${status}`}
checked={selectedStatuses.includes(status)}
onCheckedChange={() =>
setSelectedStatuses((previous) =>
toggleStatus(previous, status)
)
}
/>
<Label
htmlFor={`order-status-${status}`}
className="text-sm font-normal"
>
{status}
</Label>
</div>
))}
</div>
</PopoverContent>
</Popover>
</InputGroupAddon>
</InputGroup>
</Field>
)
}
@@ -0,0 +1,19 @@
import { Field } from '@evobgp/ui/components/field'
import { SelectMenu } from '@/components/select-field'
const items = [
{ label: 'Select an item', value: 'placeholder' as const },
...Array.from({ length: 100 }).map((_, i) => ({
label: `Item ${i}`,
value: `item-${i}` as const,
})),
]
export function Pattern() {
return (
<Field className="max-w-xs">
<SelectMenu items={items} placeholder="Select an item" />
</Field>
)
}
@@ -0,0 +1,46 @@
import { Card, CardContent } from "@evobgp/ui/components/card"
import {
Tabs,
TabsContent,
TabsList,
TabsTrigger,
} from "@evobgp/ui/components/tabs"
import { LayoutDashboardIcon, BarChart3Icon, SettingsIcon } from "lucide-react"
export function Pattern() {
return (
<div className="flex w-full max-w-md flex-col gap-6">
<Tabs defaultValue="overview">
<TabsList className="w-full">
<TabsTrigger value="overview">
<LayoutDashboardIcon className="size-4" />
Overview
</TabsTrigger>
<TabsTrigger value="analytics">
<BarChart3Icon className="size-4" />
Analytics
</TabsTrigger>
<TabsTrigger value="settings">
<SettingsIcon className="size-4" />
Settings
</TabsTrigger>
</TabsList>
<TabsContent value="overview">
<Card>
<CardContent>Overview dashboard content goes here.</CardContent>
</Card>
</TabsContent>
<TabsContent value="analytics">
<Card>
<CardContent>Analytics charts and metrics.</CardContent>
</Card>
</TabsContent>
<TabsContent value="settings">
<Card>
<CardContent>Application settings and preferences.</CardContent>
</Card>
</TabsContent>
</Tabs>
</div>
)
}
@@ -0,0 +1,61 @@
import { Badge } from "@/components/reui/badge"
import { Card, CardContent } from "@evobgp/ui/components/card"
import {
Tabs,
TabsContent,
TabsList,
TabsTrigger,
} from "@evobgp/ui/components/tabs"
export function Pattern() {
return (
<div className="flex w-full max-w-md flex-col gap-6">
<Tabs defaultValue="inbox">
<TabsList variant="line" className="mb-3.5 w-full">
<TabsTrigger value="inbox" className="gap-2">
Inbox
<Badge variant="primary-light" size="sm">
12
</Badge>
</TabsTrigger>
<TabsTrigger value="drafts" className="gap-2">
Drafts
<Badge variant="info-light" size="sm">
3
</Badge>
</TabsTrigger>
<TabsTrigger value="sent" className="gap-2">
Sent
</TabsTrigger>
<TabsTrigger value="spam" className="gap-2">
Spam
<Badge variant="destructive-light" size="sm">
24
</Badge>
</TabsTrigger>
</TabsList>
<TabsContent value="inbox">
<Card>
<CardContent>12 unread messages in your inbox.</CardContent>
</Card>
</TabsContent>
<TabsContent value="drafts">
<Card>
<CardContent>3 drafts waiting to be sent.</CardContent>
</Card>
</TabsContent>
<TabsContent value="sent">
<Card>
<CardContent>All sent messages appear here.</CardContent>
</Card>
</TabsContent>
<TabsContent value="spam">
<Card>
<CardContent>24 spam messages detected.</CardContent>
</Card>
</TabsContent>
</Tabs>
</div>
)
}
@@ -1,13 +1,14 @@
import { ColumnDef, useReactTable } from '@tanstack/react-table' import { ColumnDef } from '@tanstack/react-table'
import { useMemo } from 'react' import { useMemo } from 'react'
import { Button } from '@evobgp/ui/components/button' import { Button } from '@evobgp/ui/components/button'
import { DataGridShell } from '@/components/data-grid-shell' import { DataGridMutedCell, DataGridPrimaryCell } from '@/components/data-grid-cell'
import { DataGridSection } from '@/components/data-grid-shell'
import { ConfirmDialog } from '@/components/confirm-dialog' import { ConfirmDialog } from '@/components/confirm-dialog'
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 { createClientDataGridOptions } from '@/lib/data-grid-defaults' import { useClientDataGrid } from '@/hooks/use-client-data-grid'
import type { FirewallClient } from '@/types/api' import type { FirewallClient } from '@/types/api'
function formatPacketCount(value?: number | null): string | null { function formatPacketCount(value?: number | null): string | null {
@@ -42,12 +43,11 @@ export function FirewallClientsGrid({
accessorKey: 'name', accessorKey: 'name',
header: ({ column }) => <DataGridColumnHeader column={column} title="Имя" />, header: ({ column }) => <DataGridColumnHeader column={column} title="Имя" />,
cell: ({ row }) => ( cell: ({ row }) => (
<div> <DataGridPrimaryCell
<div className="font-medium">{row.original.name}</div> title={row.original.name}
<div className="text-muted-foreground text-xs"> subtitle={row.original.hostname || row.original.token_prefix}
{row.original.hostname || row.original.token_prefix} accent="primary"
</div> />
</div>
), ),
meta: { headerTitle: 'Имя' }, meta: { headerTitle: 'Имя' },
}, },
@@ -62,7 +62,7 @@ export function FirewallClientsGrid({
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="Last seen" />,
cell: ({ row }) => ( cell: ({ row }) => (
<span className="text-xs">{row.original.last_seen_at?.slice(0, 19) ?? '—'}</span> <DataGridMutedCell>{row.original.last_seen_at?.slice(0, 19) ?? '—'}</DataGridMutedCell>
), ),
sortingFn: (a, b) => { sortingFn: (a, b) => {
const av = a.original.last_seen_at ?? '' const av = a.original.last_seen_at ?? ''
@@ -173,19 +173,23 @@ export function FirewallClientsGrid({
[approvePending, onApprove, onReject, rejectPending], [approvePending, onApprove, onReject, rejectPending],
) )
const table = useReactTable({ const { table, globalFilter, setGlobalFilter, filteredCount } = useClientDataGrid({
data: clients, data: clients,
columns, columns,
...createClientDataGridOptions<FirewallClient>(), getSearchText: (row) =>
`${row.name} ${row.hostname ?? ''} ${row.token_prefix} ${row.status ?? ''}`,
getRowId: (row) => row.id, getRowId: (row) => row.id,
}) })
return ( return (
<DataGridShell <DataGridSection
table={table} table={table}
recordCount={clients.length} recordCount={filteredCount}
isLoading={isLoading} isLoading={isLoading}
emptyMessage={emptyTitle} emptyMessage={emptyTitle}
searchValue={globalFilter}
onSearchChange={setGlobalFilter}
searchPlaceholder="Поиск клиентов…"
/> />
) )
} }
@@ -1,13 +1,13 @@
import { ColumnDef, useReactTable } from '@tanstack/react-table' import { ColumnDef } from '@tanstack/react-table'
import { useMemo } from 'react' import { useMemo } from 'react'
import { Button } from '@evobgp/ui/components/button' import { Button } from '@evobgp/ui/components/button'
import { DataGridShell } from '@/components/data-grid-shell' import { DataGridSection } from '@/components/data-grid-shell'
import { ConfirmDialog } from '@/components/confirm-dialog' import { ConfirmDialog } from '@/components/confirm-dialog'
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 { createClientDataGridOptions } from '@/lib/data-grid-defaults' import { useClientDataGrid } from '@/hooks/use-client-data-grid'
import { communityLabel } from '@/lib/modules/helpers' import { communityLabel } from '@/lib/modules/helpers'
import type { BgpCommunity, FirewallRule } from '@/types/api' import type { BgpCommunity, FirewallRule } from '@/types/api'
@@ -100,19 +100,23 @@ export function FirewallRulesGrid({
[communities, deletePending, onDelete], [communities, deletePending, onDelete],
) )
const table = useReactTable({ const { table, globalFilter, setGlobalFilter, filteredCount } = useClientDataGrid({
data: rules, data: rules,
columns, columns,
...createClientDataGridOptions<FirewallRule>(), getSearchText: (row) =>
`${row.priority} ${row.action} ${row.comment ?? ''} ${communityLabel(row.community_id, communities)}`,
getRowId: (row) => row.id, getRowId: (row) => row.id,
}) })
return ( return (
<DataGridShell <DataGridSection
table={table} table={table}
recordCount={rules.length} recordCount={filteredCount}
isLoading={isLoading} isLoading={isLoading}
emptyMessage={emptyTitle} emptyMessage={emptyTitle}
searchValue={globalFilter}
onSearchChange={setGlobalFilter}
searchPlaceholder="Поиск правил…"
/> />
) )
} }
@@ -1,14 +1,8 @@
import { useMemo } from 'react' import { useMemo } from 'react'
import { Label } from '@evobgp/ui/components/label' import { Field, FieldLabel } from '@evobgp/ui/components/field'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@evobgp/ui/components/select'
import { SelectMenu } from '@/components/select-field'
import { import {
NONE_OPTION, NONE_OPTION,
communityOptionLabel, communityOptionLabel,
@@ -49,28 +43,27 @@ export function CommunitySelect({
const selectValue = nullable ? nullableSelectValue(value) : (value ?? '') const selectValue = nullable ? nullableSelectValue(value) : (value ?? '')
const select = (
<SelectMenu
id={id}
items={items}
value={selectValue}
placeholder={placeholder}
onValueChange={(v) => {
if (!v) return
onValueChange(nullable ? fromNullableSelect(v) : v)
}}
/>
)
if (!label) {
return select
}
return ( return (
<div className="flex flex-col gap-1.5"> <Field>
{label ? <Label htmlFor={id}>{label}</Label> : null} <FieldLabel htmlFor={id}>{label}</FieldLabel>
<Select {select}
items={items} </Field>
value={selectValue}
onValueChange={(v) => {
if (!v) return
onValueChange(nullable ? fromNullableSelect(v) : v)
}}
>
<SelectTrigger id={id} className="w-full">
<SelectValue placeholder={placeholder} />
</SelectTrigger>
<SelectContent>
{items.map((item) => (
<SelectItem key={item.value} value={item.value}>
{item.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
) )
} }
@@ -10,13 +10,8 @@ import {
} from '@evobgp/ui/components/dialog' } from '@evobgp/ui/components/dialog'
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 {
Select, import { SelectField } from '@/components/select-field'
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@evobgp/ui/components/select'
import { Button } from '@evobgp/ui/components/button' import { Button } from '@evobgp/ui/components/button'
import { LoadingButton } from '@/components/loading-button' import { LoadingButton } from '@/components/loading-button'
@@ -174,25 +169,13 @@ export function ModuleCdnSourceDialog({
onChange={(e) => setForm((s) => ({ ...s, url: e.target.value }))} onChange={(e) => setForm((s) => ({ ...s, url: e.target.value }))}
/> />
</div> </div>
<div className="flex flex-col gap-1.5"> <SelectField
<Label htmlFor="cdn-kind">Тип источника</Label> id="cdn-kind"
<Select label="Тип источника"
items={kindItems} items={kindItems}
value={form.source_kind} value={form.source_kind}
onValueChange={(v) => v && setForm((s) => ({ ...s, source_kind: v }))} onValueChange={(v) => v && setForm((s) => ({ ...s, source_kind: v }))}
> />
<SelectTrigger id="cdn-kind" className="w-full">
<SelectValue />
</SelectTrigger>
<SelectContent>
{kindItems.map((item) => (
<SelectItem key={item.value} value={item.value}>
{item.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="flex flex-col gap-1.5"> <div className="flex flex-col gap-1.5">
<Label htmlFor="cdn-prefix-path">JSON path (prefix_path)</Label> <Label htmlFor="cdn-prefix-path">JSON path (prefix_path)</Label>
<Input <Input
@@ -1,14 +1,16 @@
import { ColumnDef, useReactTable } from '@tanstack/react-table' import { ColumnDef } from '@tanstack/react-table'
import { Pencil, Trash2 } from 'lucide-react' import { Pencil, Trash2 } 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 { DataGridShell } from '@/components/data-grid-shell' import { CategoryBadge } from '@/components/category-badge'
import { DataGridMutedCell, DataGridPrimaryCell } from '@/components/data-grid-cell'
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 { formatDateTime } from '@/lib/modules/display' import { formatDateTime } from '@/lib/modules/display'
import { communityLabel } from '@/lib/modules/helpers' import { communityLabel } from '@/lib/modules/helpers'
import { createClientDataGridOptions } from '@/lib/data-grid-defaults' import { useClientDataGrid } from '@/hooks/use-client-data-grid'
import type { import type {
AsEntry, AsEntry,
BgpCommunity, BgpCommunity,
@@ -68,7 +70,7 @@ export function ModuleEntriesGrid({
<DataGridColumnHeader column={column as never} title="FQDN" /> <DataGridColumnHeader column={column as never} title="FQDN" />
), ),
cell: ({ row }: { row: { original: DomainEntry } }) => ( cell: ({ row }: { row: { original: DomainEntry } }) => (
<span className="font-mono text-sm">{row.original.fqdn}</span> <DataGridPrimaryCell title={row.original.fqdn} accent="mono" />
), ),
}, },
{ {
@@ -102,7 +104,7 @@ export function ModuleEntriesGrid({
<DataGridColumnHeader column={column as never} title="Префикс (CIDR)" /> <DataGridColumnHeader column={column as never} title="Префикс (CIDR)" />
), ),
cell: ({ row }: { row: { original: IpRangeEntry } }) => ( cell: ({ row }: { row: { original: IpRangeEntry } }) => (
<span className="font-mono text-sm">{row.original.prefix}</span> <DataGridPrimaryCell title={row.original.prefix} accent="mono" />
), ),
}, },
{ {
@@ -136,14 +138,14 @@ export function ModuleEntriesGrid({
<DataGridColumnHeader column={column as never} title="URL" /> <DataGridColumnHeader column={column as never} title="URL" />
), ),
cell: ({ row }: { row: { original: CdnSource } }) => ( cell: ({ row }: { row: { original: CdnSource } }) => (
<span className="max-w-xs truncate font-mono text-xs">{row.original.url}</span> <DataGridPrimaryCell title={row.original.url} accent="mono" className="max-w-xs" />
), ),
}, },
{ {
accessorKey: 'source_kind', accessorKey: 'source_kind',
header: 'Тип', header: 'Тип',
cell: ({ row }: { row: { original: CdnSource } }) => ( cell: ({ row }: { row: { original: CdnSource } }) => (
<span className="text-sm">{row.original.source_kind}</span> <CategoryBadge>{row.original.source_kind}</CategoryBadge>
), ),
}, },
{ {
@@ -162,9 +164,7 @@ export function ModuleEntriesGrid({
<DataGridColumnHeader column={column as never} title="Обновлено" /> <DataGridColumnHeader column={column as never} title="Обновлено" />
), ),
cell: ({ row }: { row: { original: CdnSource } }) => ( cell: ({ row }: { row: { original: CdnSource } }) => (
<span className="text-xs text-muted-foreground"> <DataGridMutedCell>{formatDateTime(row.original.last_refreshed_at)}</DataGridMutedCell>
{formatDateTime(row.original.last_refreshed_at)}
</span>
), ),
}, },
{ {
@@ -231,19 +231,27 @@ export function ModuleEntriesGrid({
type RowType = DomainEntry | IpRangeEntry | CdnSource | AsEntry type RowType = DomainEntry | IpRangeEntry | CdnSource | AsEntry
const data = rows as unknown as RowType[] const data = rows as unknown as RowType[]
const table = useReactTable({ const { table, globalFilter, setGlobalFilter, filteredCount } = useClientDataGrid({
data, data,
columns: columns as ColumnDef<RowType>[], columns: columns as ColumnDef<RowType>[],
...createClientDataGridOptions<RowType>(), getSearchText: (row) => {
const r = row as Record<string, unknown>
return Object.values(r)
.filter((v) => typeof v === 'string' || typeof v === 'number')
.join(' ')
},
getRowId: (row) => row.id, getRowId: (row) => row.id,
}) })
return ( return (
<DataGridShell <DataGridSection
table={table} table={table}
recordCount={data.length} recordCount={filteredCount}
isLoading={isLoading} isLoading={isLoading}
emptyMessage="Нет записей" emptyMessage="Нет записей"
searchValue={globalFilter}
onSearchChange={setGlobalFilter}
searchPlaceholder="Поиск записей…"
/> />
) )
} }
@@ -1,13 +1,13 @@
import { ColumnDef, useReactTable } from '@tanstack/react-table' import { ColumnDef } from '@tanstack/react-table'
import { useNavigate } from '@tanstack/react-router' import { useNavigate } from '@tanstack/react-router'
import { Boxes } from 'lucide-react' import { Boxes } from 'lucide-react'
import { useMemo } from 'react' import { useMemo } from 'react'
import { DataGridShell } from '@/components/data-grid-shell' import { CategoryBadge, ModeBadge } from '@/components/category-badge'
import { Badge } from '@/components/reui/badge' import { DataGridMutedCell, DataGridPrimaryCell } from '@/components/data-grid-cell'
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 { TruncatedText } from '@/components/truncated-text' import { useClientDataGrid } from '@/hooks/use-client-data-grid'
import { createClientDataGridOptions } from '@/lib/data-grid-defaults'
import type { ModuleRow } from '@/types/api' import type { ModuleRow } from '@/types/api'
export function ModulesListGrid({ export function ModulesListGrid({
@@ -26,8 +26,8 @@ export function ModulesListGrid({
header: ({ column }) => <DataGridColumnHeader column={column} title="Название" />, header: ({ column }) => <DataGridColumnHeader column={column} title="Название" />,
cell: ({ row }) => ( cell: ({ row }) => (
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Boxes className="size-4 text-muted-foreground" /> <Boxes className="size-4 shrink-0 text-muted-foreground" />
<TruncatedText className="max-w-[280px] font-medium">{row.original.name}</TruncatedText> <DataGridPrimaryCell title={row.original.name} accent="primary" className="max-w-[280px]" />
</div> </div>
), ),
meta: { headerTitle: 'Название' }, meta: { headerTitle: 'Название' },
@@ -35,7 +35,7 @@ export function ModulesListGrid({
{ {
accessorKey: 'type', accessorKey: 'type',
header: ({ column }) => <DataGridColumnHeader column={column} title="Тип" />, header: ({ column }) => <DataGridColumnHeader column={column} title="Тип" />,
cell: ({ row }) => <Badge variant="outline">{row.original.type}</Badge>, cell: ({ row }) => <CategoryBadge>{row.original.type}</CategoryBadge>,
meta: { headerTitle: 'Тип' }, meta: { headerTitle: 'Тип' },
}, },
{ {
@@ -50,12 +50,7 @@ export function ModulesListGrid({
id: 'enabled', id: 'enabled',
accessorFn: (row) => (row.enabled ? 'enabled' : 'disabled'), accessorFn: (row) => (row.enabled ? 'enabled' : 'disabled'),
header: ({ column }) => <DataGridColumnHeader column={column} title="Состояние" />, header: ({ column }) => <DataGridColumnHeader column={column} title="Состояние" />,
cell: ({ row }) => cell: ({ row }) => <ModeBadge enabled={row.original.enabled} />,
row.original.enabled ? (
<Badge variant="success">включён</Badge>
) : (
<Badge variant="secondary">выключен</Badge>
),
meta: { headerTitle: 'Состояние' }, meta: { headerTitle: 'Состояние' },
}, },
{ {
@@ -63,11 +58,11 @@ export function ModulesListGrid({
accessorFn: (row) => row.last_refreshed_at ?? '', accessorFn: (row) => row.last_refreshed_at ?? '',
header: ({ column }) => <DataGridColumnHeader column={column} title="Обновлено" />, header: ({ column }) => <DataGridColumnHeader column={column} title="Обновлено" />,
cell: ({ row }) => ( cell: ({ row }) => (
<span className="text-xs text-muted-foreground"> <DataGridMutedCell>
{row.original.last_refreshed_at {row.original.last_refreshed_at
? new Date(row.original.last_refreshed_at).toLocaleString('ru-RU') ? new Date(row.original.last_refreshed_at).toLocaleString('ru-RU')
: '—'} : '—'}
</span> </DataGridMutedCell>
), ),
sortingFn: (a, b) => { sortingFn: (a, b) => {
const av = a.original.last_refreshed_at ?? '' const av = a.original.last_refreshed_at ?? ''
@@ -80,19 +75,22 @@ export function ModulesListGrid({
[], [],
) )
const table = useReactTable({ const { table, globalFilter, setGlobalFilter, filteredCount } = useClientDataGrid({
data: items, data: items,
columns, columns,
...createClientDataGridOptions<ModuleRow>(), getSearchText: (row) => `${row.name} ${row.type} ${row.enabled ? 'включён' : 'выключен'}`,
getRowId: (row) => row.id, getRowId: (row) => row.id,
}) })
return ( return (
<DataGridShell <DataGridSection
table={table} table={table}
recordCount={items.length} recordCount={filteredCount}
isLoading={isLoading} isLoading={isLoading}
emptyMessage="Нет модулей" emptyMessage="Нет модулей"
searchValue={globalFilter}
onSearchChange={setGlobalFilter}
searchPlaceholder="Поиск модулей…"
onRowClick={(row) => void navigate({ to: '/modules/$moduleId', params: { moduleId: row.id } })} onRowClick={(row) => void navigate({ to: '/modules/$moduleId', params: { moduleId: row.id } })}
/> />
) )
@@ -1,12 +1,12 @@
import { ColumnDef, useReactTable } from '@tanstack/react-table' import { ColumnDef } from '@tanstack/react-table'
import { Database, HardDrive, HeartPulse, ListTodo, ShieldCheck } from 'lucide-react' import { Database, HardDrive, HeartPulse, ListTodo, ShieldCheck } from 'lucide-react'
import { useMemo } from 'react' import { useMemo } from 'react'
import { Badge } from '@evobgp/ui/components/badge' import { DataGridPrimaryCell } from '@/components/data-grid-cell'
import { DataGridSection } from '@/components/data-grid-shell'
import { DataGridShell } from '@/components/data-grid-shell' 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 { createClientDataGridOptions } from '@/lib/data-grid-defaults' import { useClientDataGrid } from '@/hooks/use-client-data-grid'
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> = {
@@ -20,9 +20,8 @@ interface ReadyCheckRow {
label: string label: string
subtitle?: string subtitle?: string
icon: typeof Database icon: typeof Database
ok: boolean status: string
statusLabel: string statusLabel: string
variant: 'default' | 'destructive' | 'secondary'
} }
export function MonitoringReadyGrid({ export function MonitoringReadyGrid({
@@ -40,18 +39,16 @@ export function MonitoringReadyGrid({
label: 'Liveness', label: 'Liveness',
subtitle: '/v1/health', subtitle: '/v1/health',
icon: HeartPulse, icon: HeartPulse,
ok: health?.ok === true, status: health?.ok ? 'ok' : 'error',
statusLabel: health?.ok ? 'OK' : 'Ошибка', statusLabel: health?.ok ? 'OK' : 'Ошибка',
variant: health?.ok ? 'default' : 'destructive',
}, },
{ {
id: 'readiness', id: 'readiness',
label: 'Readiness', label: 'Readiness',
subtitle: '/v1/ready', subtitle: '/v1/ready',
icon: ShieldCheck, icon: ShieldCheck,
ok: ready.status === 'ok', status: ready.status === 'ok' ? 'ok' : 'warning',
statusLabel: ready.status ?? '—', statusLabel: ready.status ?? '—',
variant: ready.status === 'ok' ? 'default' : 'secondary',
}, },
] ]
for (const key of Object.keys(checks)) { for (const key of Object.keys(checks)) {
@@ -61,9 +58,8 @@ export function MonitoringReadyGrid({
id: key, id: key,
label: key, label: key,
icon: READY_CHECK_ICONS[key] ?? ListTodo, icon: READY_CHECK_ICONS[key] ?? ListTodo,
ok, status: ok ? 'ok' : 'error',
statusLabel: ok ? 'OK' : 'Ошибка', statusLabel: ok ? 'OK' : 'Ошибка',
variant: ok ? 'default' : 'destructive',
}) })
} }
return rows return rows
@@ -79,12 +75,10 @@ export function MonitoringReadyGrid({
return ( return (
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Icon className="size-4 shrink-0 text-muted-foreground" /> <Icon className="size-4 shrink-0 text-muted-foreground" />
<div> <DataGridPrimaryCell
<p className="text-sm font-medium">{row.original.label}</p> title={row.original.label}
{row.original.subtitle ? ( subtitle={row.original.subtitle}
<p className="text-xs text-muted-foreground">{row.original.subtitle}</p> />
) : null}
</div>
</div> </div>
) )
}, },
@@ -95,7 +89,7 @@ export function MonitoringReadyGrid({
enableSorting: false, enableSorting: false,
header: 'Статус', header: 'Статус',
cell: ({ row }) => ( cell: ({ row }) => (
<Badge variant={row.original.variant}>{row.original.statusLabel}</Badge> <StatusBadge status={row.original.status} label={row.original.statusLabel} />
), ),
meta: { headerTitle: 'Статус' }, meta: { headerTitle: 'Статус' },
}, },
@@ -103,21 +97,23 @@ export function MonitoringReadyGrid({
[], [],
) )
const table = useReactTable({ const { table, globalFilter, setGlobalFilter, filteredCount } = useClientDataGrid({
data, data,
columns, columns,
...createClientDataGridOptions<ReadyCheckRow>({ getSearchText: (row) => `${row.label} ${row.subtitle ?? ''} ${row.statusLabel}`,
initialState: { pagination: { pageSize: 20 } },
}),
getRowId: (row) => row.id, getRowId: (row) => row.id,
pageSize: 20,
}) })
return ( return (
<DataGridShell <DataGridSection
table={table} table={table}
recordCount={data.length} recordCount={filteredCount}
showPagination={false} showPagination={false}
emptyMessage="Нет проверок" emptyMessage="Нет проверок"
searchValue={globalFilter}
onSearchChange={setGlobalFilter}
searchPlaceholder="Поиск проверок…"
/> />
) )
} }
@@ -0,0 +1,68 @@
import { useState } from 'react'
import { Plus } from 'lucide-react'
import { Button } from '@evobgp/ui/components/button'
import { DataGridCard } from '@/components/data-grid-shell'
import { NetworkPeersGrid } from '@/components/network/network-peers-grid'
import { PeerFormDialog } from '@/components/network/peer-form-dialog'
import { QueryState } from '@/components/query-state'
import { TableSkeleton } from '@/components/skeletons'
import type { PeerRow, SpeakerRow } from '@/types/api'
interface NetworkPeersCardProps {
items: PeerRow[]
speakers: SpeakerRow[]
isLoading: boolean
isError: boolean
error: unknown
onRetry: () => void
}
export function NetworkPeersCard({
items,
speakers,
isLoading,
isError,
error,
onRetry,
}: NetworkPeersCardProps) {
const [dialogOpen, setDialogOpen] = useState(false)
return (
<>
<DataGridCard
title="Пиры"
description="BGP-соседи и привязка к спикерам"
actions={
<Button size="sm" type="button" onClick={() => setDialogOpen(true)}>
<Plus />
Добавить пира
</Button>
}
>
<QueryState
data={items}
isLoading={isLoading}
isError={isError}
error={error}
empty={items.length === 0}
emptyTitle="Нет пиров"
emptyDescription="Добавьте первого BGP-соседа для установки сессии."
skeleton={<TableSkeleton rows={6} cols={4} />}
onRetry={onRetry}
>
{(data) => (
<NetworkPeersGrid items={data} isLoading={isLoading && data.length > 0} />
)}
</QueryState>
</DataGridCard>
<PeerFormDialog
open={dialogOpen}
onOpenChange={setDialogOpen}
speakers={speakers}
/>
</>
)
}
@@ -1,11 +1,12 @@
import { ColumnDef, useReactTable } from '@tanstack/react-table' import { ColumnDef } from '@tanstack/react-table'
import { useMemo } from 'react' import { useMemo } from 'react'
import { DataGridShell } from '@/components/data-grid-shell' import { CategoryBadge } from '@/components/category-badge'
import { DataGridPrimaryCell } from '@/components/data-grid-cell'
import { DataGridSection } from '@/components/data-grid-shell'
import { StatusBadge } from '@/components/status-badge' import { StatusBadge } from '@/components/status-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 { createClientDataGridOptions } from '@/lib/data-grid-defaults' import { useClientDataGrid } from '@/hooks/use-client-data-grid'
import type { PeerRow } from '@/types/api' import type { PeerRow } from '@/types/api'
export function NetworkPeersGrid({ export function NetworkPeersGrid({
@@ -22,14 +23,20 @@ export function NetworkPeersGrid({
accessorFn: (row) => row.name ?? row.neighbor, accessorFn: (row) => row.name ?? row.neighbor,
header: ({ column }) => <DataGridColumnHeader column={column} title="Имя" />, header: ({ column }) => <DataGridColumnHeader column={column} title="Имя" />,
cell: ({ row }) => ( cell: ({ row }) => (
<span className="font-medium">{row.original.name ?? row.original.neighbor}</span> <DataGridPrimaryCell
title={row.original.name ?? row.original.neighbor}
subtitle={row.original.name ? row.original.neighbor : undefined}
accent="primary"
/>
), ),
meta: { headerTitle: 'Имя' }, meta: { headerTitle: 'Имя' },
}, },
{ {
accessorKey: 'neighbor', accessorKey: 'neighbor',
header: ({ column }) => <DataGridColumnHeader column={column} title="Neighbor" />, header: ({ column }) => <DataGridColumnHeader column={column} title="Neighbor" />,
cell: ({ row }) => <span className="font-mono text-xs">{row.original.neighbor}</span>, cell: ({ row }) => (
<DataGridPrimaryCell title={row.original.neighbor} accent="mono" />
),
meta: { headerTitle: 'Neighbor' }, meta: { headerTitle: 'Neighbor' },
}, },
{ {
@@ -47,9 +54,7 @@ export function NetworkPeersGrid({
<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} />
{row.original.session_mismatch ? ( {row.original.session_mismatch ? (
<Badge variant="warning" className="ml-1"> <CategoryBadge tone="warning">mismatch</CategoryBadge>
mismatch
</Badge>
) : null} ) : null}
</div> </div>
), ),
@@ -59,19 +64,23 @@ export function NetworkPeersGrid({
[], [],
) )
const table = useReactTable({ const { table, globalFilter, setGlobalFilter, filteredCount } = useClientDataGrid({
data: items, data: items,
columns, columns,
...createClientDataGridOptions<PeerRow>(), getSearchText: (row) =>
`${row.name ?? ''} ${row.neighbor} ${row.remote_asn ?? ''} ${row.session_state ?? ''}`,
getRowId: (row) => row.id, getRowId: (row) => row.id,
}) })
return ( return (
<DataGridShell <DataGridSection
table={table} table={table}
recordCount={items.length} recordCount={filteredCount}
isLoading={isLoading} isLoading={isLoading}
emptyMessage="Нет пиров" emptyMessage="Нет пиров"
searchValue={globalFilter}
onSearchChange={setGlobalFilter}
searchPlaceholder="Поиск пиров…"
/> />
) )
} }
@@ -0,0 +1,62 @@
import { useState } from 'react'
import { Plus } from 'lucide-react'
import { Button } from '@evobgp/ui/components/button'
import { DataGridCard } from '@/components/data-grid-shell'
import { NetworkSpeakersGrid } from '@/components/network/network-speakers-grid'
import { SpeakerFormDialog } from '@/components/network/speaker-form-dialog'
import { QueryState } from '@/components/query-state'
import { TableSkeleton } from '@/components/skeletons'
import type { SpeakerRow } from '@/types/api'
interface NetworkSpeakersCardProps {
items: SpeakerRow[]
isLoading: boolean
isError: boolean
error: unknown
onRetry: () => void
}
export function NetworkSpeakersCard({
items,
isLoading,
isError,
error,
onRetry,
}: NetworkSpeakersCardProps) {
const [dialogOpen, setDialogOpen] = useState(false)
return (
<>
<DataGridCard
title="Спикеры"
description="BIRD-агенты на нодах tenant"
actions={
<Button size="sm" type="button" onClick={() => setDialogOpen(true)}>
<Plus />
Добавить спикера
</Button>
}
>
<QueryState
data={items}
isLoading={isLoading}
isError={isError}
error={error}
empty={items.length === 0}
emptyTitle="Нет спикеров"
emptyDescription="Добавьте первого BIRD-агента на ноде."
skeleton={<TableSkeleton rows={6} cols={4} />}
onRetry={onRetry}
>
{(data) => (
<NetworkSpeakersGrid items={data} isLoading={isLoading && data.length > 0} />
)}
</QueryState>
</DataGridCard>
<SpeakerFormDialog open={dialogOpen} onOpenChange={setDialogOpen} />
</>
)
}
@@ -1,11 +1,13 @@
import { ColumnDef, useReactTable } from '@tanstack/react-table' import { ColumnDef } from '@tanstack/react-table'
import { useMemo } from 'react' import { useMemo } from 'react'
import { DataGridShell } from '@/components/data-grid-shell' import { CategoryBadge } from '@/components/category-badge'
import { DataGridPrimaryCell } from '@/components/data-grid-cell'
import { DataGridSection } from '@/components/data-grid-shell'
import { StatusBadge } from '@/components/status-badge' 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 { createClientDataGridOptions } from '@/lib/data-grid-defaults' import { useClientDataGrid } from '@/hooks/use-client-data-grid'
import type { SpeakerRow } from '@/types/api' import type { SpeakerRow } from '@/types/api'
export function NetworkSpeakersGrid({ export function NetworkSpeakersGrid({
@@ -20,13 +22,15 @@ export function NetworkSpeakersGrid({
{ {
accessorKey: 'endpoint', accessorKey: 'endpoint',
header: ({ column }) => <DataGridColumnHeader column={column} title="Endpoint" />, header: ({ column }) => <DataGridColumnHeader column={column} title="Endpoint" />,
cell: ({ row }) => <span className="font-mono text-xs">{row.original.endpoint}</span>, cell: ({ row }) => (
<DataGridPrimaryCell title={row.original.endpoint} accent="mono" />
),
meta: { headerTitle: 'Endpoint' }, meta: { headerTitle: 'Endpoint' },
}, },
{ {
accessorKey: 'role', accessorKey: 'role',
header: ({ column }) => <DataGridColumnHeader column={column} title="Роль" />, header: ({ column }) => <DataGridColumnHeader column={column} title="Роль" />,
cell: ({ row }) => <Badge variant="outline">{row.original.role}</Badge>, cell: ({ row }) => <CategoryBadge>{row.original.role}</CategoryBadge>,
meta: { headerTitle: 'Роль' }, meta: { headerTitle: 'Роль' },
}, },
{ {
@@ -37,7 +41,7 @@ export function NetworkSpeakersGrid({
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="online" />
if (live?.agent_ok === false) return <StatusBadge status="error" label="offline" /> if (live?.agent_ok === false) return <StatusBadge status="error" label="offline" />
return <Badge variant="outline"></Badge> return <Badge variant="outline" size="sm" radius="full"></Badge>
}, },
meta: { headerTitle: 'Agent' }, meta: { headerTitle: 'Agent' },
}, },
@@ -60,19 +64,23 @@ export function NetworkSpeakersGrid({
[], [],
) )
const table = useReactTable({ const { table, globalFilter, setGlobalFilter, filteredCount } = useClientDataGrid({
data: items, data: items,
columns, columns,
...createClientDataGridOptions<SpeakerRow>(), getSearchText: (row) =>
`${row.endpoint} ${row.role} ${row.agent_domain ?? ''} ${row.node_ipv4 ?? ''}`,
getRowId: (row) => row.id, getRowId: (row) => row.id,
}) })
return ( return (
<DataGridShell <DataGridSection
table={table} table={table}
recordCount={items.length} recordCount={filteredCount}
isLoading={isLoading} isLoading={isLoading}
emptyMessage="Нет спикеров" emptyMessage="Нет спикеров"
searchValue={globalFilter}
onSearchChange={setGlobalFilter}
searchPlaceholder="Поиск спикеров…"
/> />
) )
} }
@@ -0,0 +1,175 @@
import { useEffect, useState } from 'react'
import { toast } from 'sonner'
import { Button } from '@evobgp/ui/components/button'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@evobgp/ui/components/dialog'
import { Input } from '@evobgp/ui/components/input'
import { Label } from '@evobgp/ui/components/label'
import { Checkbox } from '@evobgp/ui/components/checkbox'
import { LoadingButton } from '@/components/loading-button'
import { SelectField } from '@/components/select-field'
import { useCreatePeerMutation, useUpdatePeerMutation } from '@/queries/network'
import type { BgpPeerCreate, PeerRow, SpeakerRow } from '@/types/api'
interface PeerFormDialogProps {
open: boolean
onOpenChange: (open: boolean) => void
speakers: SpeakerRow[]
editTarget?: PeerRow | null
}
function speakerLabel(s: SpeakerRow): string {
if (s.role === 'master') {
const host = s.agent_domain ?? s.endpoint
return host ? `CP · ${host}` : 'CP (master)'
}
return s.agent_domain ?? s.endpoint ?? `${s.id.slice(0, 8)}`
}
export function PeerFormDialog({
open,
onOpenChange,
speakers,
editTarget = null,
}: PeerFormDialogProps) {
const createMutation = useCreatePeerMutation()
const updateMutation = useUpdatePeerMutation()
const saving = createMutation.isPending || updateMutation.isPending
const [name, setName] = useState('')
const [neighbor, setNeighbor] = useState('')
const [remoteAsn, setRemoteAsn] = useState('')
const [bgpSpeakerId, setBgpSpeakerId] = useState<string | null>(null)
const [enabled, setEnabled] = useState(true)
useEffect(() => {
if (!open) return
if (editTarget) {
setName(editTarget.name ?? '')
setNeighbor(editTarget.neighbor)
setRemoteAsn(String(editTarget.remote_asn ?? ''))
setBgpSpeakerId(editTarget.bgp_speaker_id ?? null)
setEnabled(editTarget.enabled !== false)
} else {
setName('')
setNeighbor('')
setRemoteAsn('')
setBgpSpeakerId(null)
setEnabled(true)
}
}, [editTarget, open])
const speakerItems = [
{ value: '', label: 'Все спикеры' },
...speakers.map((s) => ({ value: s.id, label: speakerLabel(s) })),
]
async function save() {
if (!neighbor.trim()) {
toast.error('Укажите адрес соседа')
return
}
const asn = Number(remoteAsn)
if (!asn || asn <= 0) {
toast.error('Remote ASN должен быть больше 0')
return
}
const body: BgpPeerCreate = {
name: name.trim() || undefined,
neighbor: neighbor.trim(),
remote_asn: asn,
bgp_speaker_id: bgpSpeakerId || null,
enabled,
}
try {
if (editTarget) {
await updateMutation.mutateAsync({ id: editTarget.id, body })
} else {
await createMutation.mutateAsync(body)
}
onOpenChange(false)
} catch {
// toast in mutation
}
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-sm">
<DialogHeader>
<DialogTitle>{editTarget ? 'Редактировать пира' : 'Новый пир'}</DialogTitle>
<DialogDescription>BGP-сосед для установки сессии</DialogDescription>
</DialogHeader>
<div className="flex flex-col gap-4 py-2">
<div className="flex flex-col gap-2">
<Label htmlFor="peer-name">Имя пира (опционально)</Label>
<Input
id="peer-name"
placeholder="Core-RTR-1"
value={name}
onChange={(e) => setName(e.target.value)}
/>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="peer-neighbor">Адрес соседа</Label>
<Input
id="peer-neighbor"
placeholder="192.0.2.1"
value={neighbor}
onChange={(e) => setNeighbor(e.target.value)}
required
/>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="peer-asn">Remote ASN</Label>
<Input
id="peer-asn"
type="number"
placeholder="65000"
value={remoteAsn}
onChange={(e) => setRemoteAsn(e.target.value)}
required
/>
</div>
<SelectField
id="peer-speaker"
label="Спикер (опционально)"
items={speakerItems}
value={bgpSpeakerId ?? ''}
onValueChange={(v) => setBgpSpeakerId(v || null)}
placeholder="Все спикеры"
/>
<div className="flex flex-row items-center justify-between gap-4 rounded-lg border border-border bg-muted/30 p-3">
<div className="grid min-w-0 flex-1 gap-1 pr-2">
<Label htmlFor="peer-enabled">Включён</Label>
<p className="text-xs text-muted-foreground">
Выключенный пир не попадает в конфиг BIRD до следующей ревизии.
</p>
</div>
<Checkbox
id="peer-enabled"
checked={enabled}
onCheckedChange={(v) => setEnabled(v === true)}
/>
</div>
</div>
<DialogFooter>
<Button variant="outline" type="button" onClick={() => onOpenChange(false)}>
Отмена
</Button>
<LoadingButton type="button" loading={saving} onClick={save}>
{editTarget ? 'Сохранить' : 'Создать'}
</LoadingButton>
</DialogFooter>
</DialogContent>
</Dialog>
)
}
@@ -0,0 +1,169 @@
import { useEffect, useState } from 'react'
import { toast } from 'sonner'
import { Button } from '@evobgp/ui/components/button'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@evobgp/ui/components/dialog'
import { Input } from '@evobgp/ui/components/input'
import { Label } from '@evobgp/ui/components/label'
import { LoadingButton } from '@/components/loading-button'
import { SelectField } from '@/components/select-field'
import { useCreateSpeakerMutation } from '@/queries/network'
import type { BgpSpeakerCreate } from '@/types/api'
interface SpeakerFormDialogProps {
open: boolean
onOpenChange: (open: boolean) => void
}
function parseIpv4FromEndpoint(ep: string): string {
try {
const u = ep.includes('://') ? new URL(ep) : new URL(`https://${ep}`)
const host = u.hostname
if (/^\d{1,3}(\.\d{1,3}){3}$/.test(host)) return host
} catch {
/* ignore */
}
return ''
}
function buildMetaJson(agentDomain: string, nodeIpv4: string, bgpSource: string): string {
const meta: Record<string, string> = {}
if (agentDomain.trim()) meta.agent_domain = agentDomain.trim()
if (nodeIpv4.trim()) meta.node_ipv4 = nodeIpv4.trim()
if (bgpSource.trim()) meta.bird_bgp_source_ipv4 = bgpSource.trim()
return JSON.stringify(meta)
}
export function SpeakerFormDialog({ open, onOpenChange }: SpeakerFormDialogProps) {
const createMutation = useCreateSpeakerMutation()
const [endpoint, setEndpoint] = useState('')
const [role, setRole] = useState('replica')
const [agentDomain, setAgentDomain] = useState('')
const [nodeIpv4, setNodeIpv4] = useState('')
const [bgpSourceIpv4, setBgpSourceIpv4] = useState('')
const [bgpSourceManual, setBgpSourceManual] = useState(false)
useEffect(() => {
if (!open) return
setEndpoint('')
setRole('replica')
setAgentDomain('')
setNodeIpv4('')
setBgpSourceIpv4('')
setBgpSourceManual(false)
}, [open])
function handleEndpointChange(value: string) {
setEndpoint(value)
const ip = parseIpv4FromEndpoint(value)
if (ip && !nodeIpv4) {
handleNodeIpv4Change(ip)
}
}
function handleNodeIpv4Change(value: string) {
setNodeIpv4(value)
if (!bgpSourceManual) {
setBgpSourceIpv4(value)
}
}
async function save() {
const ep =
endpoint.trim() || (agentDomain.trim() ? `https://${agentDomain.trim()}` : '')
if (!ep) {
toast.error('Укажите endpoint или agent domain')
return
}
const body: BgpSpeakerCreate = {
endpoint: ep,
role: role.trim() || 'replica',
meta_json: buildMetaJson(agentDomain, nodeIpv4, bgpSourceIpv4),
}
try {
await createMutation.mutateAsync(body)
onOpenChange(false)
} catch {
// toast in mutation
}
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>Новый спикер</DialogTitle>
<DialogDescription>BIRD-агент на ноде реплики или control plane</DialogDescription>
</DialogHeader>
<div className="flex flex-col gap-4 py-2">
<div className="flex flex-col gap-2">
<Label htmlFor="speaker-endpoint">Endpoint</Label>
<Input
id="speaker-endpoint"
placeholder="https://node.example.com:8443"
value={endpoint}
onChange={(e) => handleEndpointChange(e.target.value)}
/>
</div>
<SelectField
id="speaker-role"
label="Роль"
items={[
{ value: 'replica', label: 'replica' },
{ value: 'master', label: 'master (CP)' },
]}
value={role}
onValueChange={(v) => setRole(v ?? 'replica')}
/>
<div className="flex flex-col gap-2">
<Label htmlFor="speaker-agent-domain">Agent domain</Label>
<Input
id="speaker-agent-domain"
placeholder="bird-agent.example.com"
value={agentDomain}
onChange={(e) => setAgentDomain(e.target.value)}
/>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="speaker-node-ipv4">Node IPv4</Label>
<Input
id="speaker-node-ipv4"
placeholder="203.0.113.10"
value={nodeIpv4}
onChange={(e) => handleNodeIpv4Change(e.target.value)}
/>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="speaker-bgp-source">BGP source IPv4</Label>
<Input
id="speaker-bgp-source"
placeholder="203.0.113.10"
value={bgpSourceIpv4}
onChange={(e) => {
setBgpSourceManual(true)
setBgpSourceIpv4(e.target.value)
}}
/>
</div>
</div>
<DialogFooter>
<Button variant="outline" type="button" onClick={() => onOpenChange(false)}>
Отмена
</Button>
<LoadingButton type="button" loading={createMutation.isPending} onClick={save}>
Создать
</LoadingButton>
</DialogFooter>
</DialogContent>
</Dialog>
)
}
@@ -1,27 +1,19 @@
import { ColumnDef, useReactTable } from '@tanstack/react-table' import { ColumnDef } from '@tanstack/react-table'
import { useMutation } from '@tanstack/react-query' import { useMutation } from '@tanstack/react-query'
import { useMemo } from 'react' import { useMemo } 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 { DataGridShell } from '@/components/data-grid-shell' import { DataGridMutedCell, DataGridPrimaryCell } from '@/components/data-grid-cell'
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 { 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 { apiMutate } from '@/lib/api-client'
import { createClientDataGridOptions } from '@/lib/data-grid-defaults'
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'
function StatusBadgeColored({ status }: { status: string }) {
const cls =
status === 'succeeded'
? 'text-success'
: status === 'failed' || status === 'cancelled'
? 'text-destructive'
: 'text-info'
return <span className={`text-sm font-medium ${cls}`}>{status}</span>
}
export function OperationsJobsGrid({ export function OperationsJobsGrid({
items, items,
nameById, nameById,
@@ -48,22 +40,29 @@ export function OperationsJobsGrid({
accessorKey: 'kind', accessorKey: 'kind',
header: ({ column }) => <DataGridColumnHeader column={column} title="Вид" />, header: ({ column }) => <DataGridColumnHeader column={column} title="Вид" />,
cell: ({ row }) => ( cell: ({ row }) => (
<div className="flex flex-col gap-0.5"> <DataGridPrimaryCell
<span className="font-medium">{row.original.kind}</span> title={row.original.kind}
{row.original.meta?.module_id ? ( accent="mono"
<span className="text-xs text-muted-foreground"> subtitle={
{nameById.get(String(row.original.meta.module_id)) ?? row.original.meta?.module_id
String(row.original.meta.module_id)} ? (nameById.get(String(row.original.meta.module_id)) ??
</span> String(row.original.meta.module_id))
) : null} : undefined
</div> }
/>
), ),
meta: { headerTitle: 'Вид' }, meta: { headerTitle: 'Вид' },
}, },
{ {
accessorKey: 'status', accessorKey: 'status',
header: ({ column }) => <DataGridColumnHeader column={column} title="Статус" />, header: ({ column }) => <DataGridColumnHeader column={column} title="Статус" />,
cell: ({ row }) => <StatusBadgeColored status={row.original.status} />, cell: ({ row }) => {
const finished = row.original.finished_at
const hint = finished
? new Date(finished).toLocaleTimeString('ru-RU', { hour: '2-digit', minute: '2-digit' })
: undefined
return <StatusBadge status={row.original.status} hint={hint} />
},
meta: { headerTitle: 'Статус' }, meta: { headerTitle: 'Статус' },
}, },
{ {
@@ -71,11 +70,11 @@ export function OperationsJobsGrid({
accessorFn: (row) => row.created_at ?? '', accessorFn: (row) => row.created_at ?? '',
header: ({ column }) => <DataGridColumnHeader column={column} title="Создана" />, header: ({ column }) => <DataGridColumnHeader column={column} title="Создана" />,
cell: ({ row }) => ( cell: ({ row }) => (
<span className="whitespace-nowrap text-xs text-muted-foreground"> <DataGridMutedCell>
{row.original.created_at {row.original.created_at
? new Date(row.original.created_at).toLocaleString('ru-RU') ? new Date(row.original.created_at).toLocaleString('ru-RU')
: '—'} : '—'}
</span> </DataGridMutedCell>
), ),
meta: { headerTitle: 'Создана' }, meta: { headerTitle: 'Создана' },
}, },
@@ -84,11 +83,11 @@ export function OperationsJobsGrid({
accessorFn: (row) => row.finished_at ?? '', accessorFn: (row) => row.finished_at ?? '',
header: ({ column }) => <DataGridColumnHeader column={column} title="Завершена" />, header: ({ column }) => <DataGridColumnHeader column={column} title="Завершена" />,
cell: ({ row }) => ( cell: ({ row }) => (
<span className="whitespace-nowrap text-xs text-muted-foreground"> <DataGridMutedCell>
{row.original.finished_at {row.original.finished_at
? new Date(row.original.finished_at).toLocaleString('ru-RU') ? new Date(row.original.finished_at).toLocaleString('ru-RU')
: '—'} : '—'}
</span> </DataGridMutedCell>
), ),
meta: { headerTitle: 'Завершена' }, meta: { headerTitle: 'Завершена' },
}, },
@@ -113,19 +112,27 @@ export function OperationsJobsGrid({
[cancelMutation, nameById], [cancelMutation, nameById],
) )
const table = useReactTable({ const { table, globalFilter, setGlobalFilter, filteredCount } = useClientDataGrid({
data: items, data: items,
columns, columns,
...createClientDataGridOptions<JobRow>(), getSearchText: (row) => {
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}`
},
getRowId: (row) => row.job_id, getRowId: (row) => row.job_id,
}) })
return ( return (
<DataGridShell <DataGridSection
table={table} table={table}
recordCount={items.length} recordCount={filteredCount}
isLoading={isLoading} isLoading={isLoading}
emptyMessage="Нет задач" emptyMessage="Нет задач"
searchValue={globalFilter}
onSearchChange={setGlobalFilter}
searchPlaceholder="Поиск задач…"
/> />
) )
} }
@@ -1,4 +1,4 @@
import { ColumnDef, useReactTable } from '@tanstack/react-table' import { ColumnDef } from '@tanstack/react-table'
import { useMutation } from '@tanstack/react-query' import { useMutation } from '@tanstack/react-query'
import { RefreshCw } from 'lucide-react' import { RefreshCw } from 'lucide-react'
import { useMemo } from 'react' import { useMemo } from 'react'
@@ -6,11 +6,12 @@ import { toast } from 'sonner'
import { Button } from '@evobgp/ui/components/button' import { Button } from '@evobgp/ui/components/button'
import { DataGridShell } from '@/components/data-grid-shell' import { DataGridMutedCell, DataGridPrimaryCell } from '@/components/data-grid-cell'
import { DataGridSection } from '@/components/data-grid-shell'
import { ConfirmDialog } from '@/components/confirm-dialog' import { ConfirmDialog } from '@/components/confirm-dialog'
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 { apiMutate } from '@/lib/api-client' import { apiMutate } from '@/lib/api-client'
import { createClientDataGridOptions } from '@/lib/data-grid-defaults'
import type { RevisionRow } from '@/types/api' import type { RevisionRow } from '@/types/api'
import type { QueryClient } from '@tanstack/react-query' import type { QueryClient } from '@tanstack/react-query'
@@ -40,7 +41,7 @@ export function OperationsRevisionsGrid({
accessorFn: (row) => row.id, accessorFn: (row) => row.id,
header: ({ column }) => <DataGridColumnHeader column={column} title="ID" />, header: ({ column }) => <DataGridColumnHeader column={column} title="ID" />,
cell: ({ row }) => ( cell: ({ row }) => (
<span className="font-mono text-xs">{row.original.id.slice(0, 12)}</span> <DataGridPrimaryCell title={`${row.original.id.slice(0, 12)}`} accent="mono" />
), ),
meta: { headerTitle: 'ID' }, meta: { headerTitle: 'ID' },
}, },
@@ -48,9 +49,9 @@ export function OperationsRevisionsGrid({
accessorKey: 'created_at', accessorKey: 'created_at',
header: ({ column }) => <DataGridColumnHeader column={column} title="Создана" />, header: ({ column }) => <DataGridColumnHeader column={column} title="Создана" />,
cell: ({ row }) => ( cell: ({ row }) => (
<span className="text-xs text-muted-foreground"> <DataGridMutedCell>
{new Date(row.original.created_at).toLocaleString('ru-RU')} {new Date(row.original.created_at).toLocaleString('ru-RU')}
</span> </DataGridMutedCell>
), ),
meta: { headerTitle: 'Создана' }, meta: { headerTitle: 'Создана' },
}, },
@@ -87,19 +88,22 @@ export function OperationsRevisionsGrid({
[rollbackMutation], [rollbackMutation],
) )
const table = useReactTable({ const { table, globalFilter, setGlobalFilter, filteredCount } = useClientDataGrid({
data: items, data: items,
columns, columns,
...createClientDataGridOptions<RevisionRow>(), getSearchText: (row) => `${row.id} ${row.materialized_prefix_count}`,
getRowId: (row) => row.id, getRowId: (row) => row.id,
}) })
return ( return (
<DataGridShell <DataGridSection
table={table} table={table}
recordCount={items.length} recordCount={filteredCount}
isLoading={isLoading} isLoading={isLoading}
emptyMessage="Нет ревизий" emptyMessage="Нет ревизий"
searchValue={globalFilter}
onSearchChange={setGlobalFilter}
searchPlaceholder="Поиск ревизий…"
/> />
) )
} }
+13 -9
View File
@@ -5,7 +5,11 @@ import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@evobgp/ui/lib/utils" import { cn } from "@evobgp/ui/lib/utils"
const badgeVariants = cva( const badgeVariants = cva(
"relative inline-flex shrink-0 items-center justify-center w-fit border border-transparent font-medium whitespace-nowrap outline-none transition-shadow focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1 focus-visible:ring-offset-background disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*=size-])]:size-3", [
"relative inline-flex shrink-0 items-center justify-center w-fit border border-transparent font-medium whitespace-nowrap outline-none transition-shadow",
"focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1 focus-visible:ring-offset-background disabled:pointer-events-none disabled:opacity-50",
"[&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*=size-])]:size-3",
],
{ {
variants: { variants: {
variant: { variant: {
@@ -19,19 +23,19 @@ const badgeVariants = cva(
focus: "bg-focus text-focus-foreground", focus: "bg-focus text-focus-foreground",
invert: "bg-invert text-invert-foreground", invert: "bg-invert text-invert-foreground",
"primary-light": "primary-light":
"bg-primary/10 border-none text-primary dark:bg-primary/20", "border-primary/10 bg-primary/10 text-primary dark:border-primary/25 dark:bg-primary/15 dark:text-primary",
"warning-light": "warning-light":
"bg-warning/10 border-none text-warning-foreground dark:bg-warning/20", "border-warning/15 bg-warning/10 text-warning-foreground dark:border-warning/25 dark:bg-warning/15 dark:text-warning",
"success-light": "success-light":
"bg-success/10 border-none text-success-foreground dark:bg-success/20", "border-success/15 bg-success/10 text-success-foreground dark:border-success/25 dark:bg-success/15 dark:text-success",
"info-light": "info-light":
"bg-info/10 border-none text-info-foreground dark:bg-info/20", "border-info/15 bg-info/10 text-info-foreground dark:border-info/25 dark:bg-info/15 dark:text-info",
"destructive-light": "destructive-light":
"bg-destructive/10 border-none text-destructive-foreground dark:bg-destructive/20", "border-destructive/15 bg-destructive/10 text-destructive-foreground dark:border-destructive/25 dark:bg-destructive/15 dark:text-destructive",
"invert-light": "invert-light":
"bg-invert/10 border-none text-foreground dark:bg-invert/20", "border-invert/15 bg-invert/10 text-foreground dark:border-invert/45 dark:bg-invert/35 dark:text-invert-foreground",
"focus-light": "focus-light":
"bg-focus/10 border-none text-focus-foreground dark:bg-focus/20", "border-focus/15 bg-focus/10 text-focus-foreground dark:border-focus/25 dark:bg-focus/15 dark:text-focus",
"primary-outline": "primary-outline":
"bg-background border-border text-primary dark:bg-input/30", "bg-background border-border text-primary dark:bg-input/30",
"warning-outline": "warning-outline":
@@ -54,7 +58,7 @@ const badgeVariants = cva(
lg: "px-1.5 py-0.5 text-xs h-5.5 min-w-5.5 gap-1", lg: "px-1.5 py-0.5 text-xs h-5.5 min-w-5.5 gap-1",
xl: "px-2 py-0.75 text-sm h-6 min-w-6 gap-1.5", xl: "px-2 py-0.75 text-sm h-6 min-w-6 gap-1.5",
}, },
/** `default`: per-theme radius. `full`: max radius per theme (Lyra stays `rounded-none`). */ /** `default`: active style radius. `full`: pill radius. */
radius: { radius: {
default: default:
"rounded-sm", "rounded-sm",
@@ -4,12 +4,8 @@ import { useDataGrid } from "@/components/reui/data-grid/data-grid"
import { cn } from "@evobgp/ui/lib/utils" import { cn } from "@evobgp/ui/lib/utils"
import { Button } from "@evobgp/ui/components/button" import { Button } from "@evobgp/ui/components/button"
import { import {
Select, SelectMenu,
SelectContent, } from "@/components/select-field"
SelectItem,
SelectTrigger,
SelectValue,
} from "@evobgp/ui/components/select"
import { Skeleton } from "@evobgp/ui/components/skeleton" import { Skeleton } from "@evobgp/ui/components/skeleton"
import { ChevronLeftIcon, ChevronRightIcon } from "lucide-react" import { ChevronLeftIcon, ChevronRightIcon } from "lucide-react"
@@ -152,28 +148,23 @@ function DataGridPagination(props: DataGridPaginationProps): React.JSX.Element {
<div className="text-muted-foreground text-sm"> <div className="text-muted-foreground text-sm">
{mergedProps.rowsPerPageLabel} {mergedProps.rowsPerPageLabel}
</div> </div>
<Select <SelectMenu
items={mergedProps?.sizes?.map((size: number) => ({ items={
value: `${size}`, mergedProps?.sizes?.map((size: number) => ({
label: `${size}`, value: `${size}`,
}))} label: `${size}`,
})) ?? []
}
value={`${pageSize}`} value={`${pageSize}`}
triggerClassName="w-14"
size="sm"
side="top"
contentClassName="min-w-18"
onValueChange={(value) => { onValueChange={(value) => {
const newPageSize = Number(value) if (!value) return
table.setPageSize(newPageSize) table.setPageSize(Number(value))
}} }}
> />
<SelectTrigger className="w-14" size="sm">
<SelectValue />
</SelectTrigger>
<SelectContent side="top" className="min-w-18">
{mergedProps?.sizes?.map((size: number) => (
<SelectItem key={size} value={`${size}`}>
{size}
</SelectItem>
))}
</SelectContent>
</Select>
</> </>
)} )}
</div> </div>
@@ -1,11 +1,11 @@
import { ColumnDef, useReactTable } from '@tanstack/react-table' import { ColumnDef } from '@tanstack/react-table'
import { useMemo } from 'react' import { useMemo } from 'react'
import { Badge } from '@evobgp/ui/components/badge' import { DataGridMutedCell, DataGridPrimaryCell } from '@/components/data-grid-cell'
import { DataGridSection } from '@/components/data-grid-shell'
import { DataGridShell } from '@/components/data-grid-shell' 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 { createClientDataGridOptions } from '@/lib/data-grid-defaults' import { useClientDataGrid } from '@/hooks/use-client-data-grid'
import type { JobRow } from '@/types/api' import type { JobRow } from '@/types/api'
export function ScheduleJobsGrid({ export function ScheduleJobsGrid({
@@ -20,25 +20,19 @@ export function ScheduleJobsGrid({
{ {
accessorKey: 'kind', accessorKey: 'kind',
header: ({ column }) => <DataGridColumnHeader column={column} title="Вид" />, header: ({ column }) => <DataGridColumnHeader column={column} title="Вид" />,
cell: ({ row }) => <span className="font-medium">{row.original.kind}</span>, cell: ({ row }) => <DataGridPrimaryCell title={row.original.kind} accent="mono" />,
meta: { headerTitle: 'Вид' }, meta: { headerTitle: 'Вид' },
}, },
{ {
accessorKey: 'status', accessorKey: 'status',
header: ({ column }) => <DataGridColumnHeader column={column} title="Статус" />, header: ({ column }) => <DataGridColumnHeader column={column} title="Статус" />,
cell: ({ row }) => ( cell: ({ row }) => {
<Badge const finished = row.original.finished_at
variant={ const hint = finished
row.original.status === 'succeeded' ? new Date(finished).toLocaleTimeString('ru-RU', { hour: '2-digit', minute: '2-digit' })
? 'default' : undefined
: row.original.status === 'failed' return <StatusBadge status={row.original.status} hint={hint} />
? 'destructive' },
: 'secondary'
}
>
{row.original.status}
</Badge>
),
meta: { headerTitle: 'Статус' }, meta: { headerTitle: 'Статус' },
}, },
{ {
@@ -46,11 +40,11 @@ export function ScheduleJobsGrid({
accessorFn: (row) => row.created_at ?? '', accessorFn: (row) => row.created_at ?? '',
header: ({ column }) => <DataGridColumnHeader column={column} title="Создана" />, header: ({ column }) => <DataGridColumnHeader column={column} title="Создана" />,
cell: ({ row }) => ( cell: ({ row }) => (
<span className="whitespace-nowrap text-xs text-muted-foreground"> <DataGridMutedCell>
{row.original.created_at {row.original.created_at
? new Date(row.original.created_at).toLocaleString('ru-RU') ? new Date(row.original.created_at).toLocaleString('ru-RU')
: '—'} : '—'}
</span> </DataGridMutedCell>
), ),
meta: { headerTitle: 'Создана' }, meta: { headerTitle: 'Создана' },
}, },
@@ -59,11 +53,11 @@ export function ScheduleJobsGrid({
accessorFn: (row) => row.finished_at ?? '', accessorFn: (row) => row.finished_at ?? '',
header: ({ column }) => <DataGridColumnHeader column={column} title="Завершена" />, header: ({ column }) => <DataGridColumnHeader column={column} title="Завершена" />,
cell: ({ row }) => ( cell: ({ row }) => (
<span className="whitespace-nowrap text-xs text-muted-foreground"> <DataGridMutedCell>
{row.original.finished_at {row.original.finished_at
? new Date(row.original.finished_at).toLocaleString('ru-RU') ? new Date(row.original.finished_at).toLocaleString('ru-RU')
: '—'} : '—'}
</span> </DataGridMutedCell>
), ),
meta: { headerTitle: 'Завершена' }, meta: { headerTitle: 'Завершена' },
}, },
@@ -82,19 +76,22 @@ export function ScheduleJobsGrid({
[], [],
) )
const table = useReactTable({ const { table, globalFilter, setGlobalFilter, filteredCount } = useClientDataGrid({
data: items, data: items,
columns, columns,
...createClientDataGridOptions<JobRow>(), getSearchText: (row) => `${row.kind} ${row.status} ${row.error ?? ''}`,
getRowId: (row) => row.job_id, getRowId: (row) => row.job_id,
}) })
return ( return (
<DataGridShell <DataGridSection
table={table} table={table}
recordCount={items.length} recordCount={filteredCount}
isLoading={isLoading} isLoading={isLoading}
emptyMessage="Нет задач" emptyMessage="Нет задач"
searchValue={globalFilter}
onSearchChange={setGlobalFilter}
searchPlaceholder="Поиск задач…"
/> />
) )
} }
@@ -1,13 +1,13 @@
import { ColumnDef, useReactTable } from '@tanstack/react-table' import { ColumnDef } from '@tanstack/react-table'
import { RefreshCw } from 'lucide-react' import { RefreshCw } from 'lucide-react'
import { useMemo } from 'react' import { useMemo } from 'react'
import { Badge } from '@evobgp/ui/components/badge' import { CategoryBadge, ModeBadge } from '@/components/category-badge'
import { DataGridMutedCell, DataGridPrimaryCell } from '@/components/data-grid-cell'
import { DataGridShell } from '@/components/data-grid-shell' 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 { createClientDataGridOptions } from '@/lib/data-grid-defaults' import { useClientDataGrid } from '@/hooks/use-client-data-grid'
import type { ModuleRow } from '@/types/api' import type { ModuleRow } from '@/types/api'
export function ScheduleModulesGrid({ export function ScheduleModulesGrid({
@@ -26,13 +26,13 @@ export function ScheduleModulesGrid({
{ {
accessorKey: 'name', accessorKey: 'name',
header: ({ column }) => <DataGridColumnHeader column={column} title="Модуль" />, header: ({ column }) => <DataGridColumnHeader column={column} title="Модуль" />,
cell: ({ row }) => <span className="font-medium">{row.original.name}</span>, cell: ({ row }) => <DataGridPrimaryCell title={row.original.name} accent="primary" />,
meta: { headerTitle: 'Модуль' }, meta: { headerTitle: 'Модуль' },
}, },
{ {
accessorKey: 'type', accessorKey: 'type',
header: ({ column }) => <DataGridColumnHeader column={column} title="Тип" />, header: ({ column }) => <DataGridColumnHeader column={column} title="Тип" />,
cell: ({ row }) => <Badge variant="outline">{row.original.type}</Badge>, cell: ({ row }) => <CategoryBadge>{row.original.type}</CategoryBadge>,
meta: { headerTitle: 'Тип' }, meta: { headerTitle: 'Тип' },
}, },
{ {
@@ -52,11 +52,11 @@ export function ScheduleModulesGrid({
accessorFn: (row) => row.last_refreshed_at ?? '', accessorFn: (row) => row.last_refreshed_at ?? '',
header: ({ column }) => <DataGridColumnHeader column={column} title="Обновлено" />, header: ({ column }) => <DataGridColumnHeader column={column} title="Обновлено" />,
cell: ({ row }) => ( cell: ({ row }) => (
<span className="whitespace-nowrap text-xs text-muted-foreground"> <DataGridMutedCell>
{row.original.last_refreshed_at {row.original.last_refreshed_at
? new Date(row.original.last_refreshed_at).toLocaleString('ru-RU') ? new Date(row.original.last_refreshed_at).toLocaleString('ru-RU')
: '—'} : '—'}
</span> </DataGridMutedCell>
), ),
meta: { headerTitle: 'Обновлено' }, meta: { headerTitle: 'Обновлено' },
}, },
@@ -64,12 +64,9 @@ export function ScheduleModulesGrid({
id: 'enabled', id: 'enabled',
accessorFn: (row) => (row.enabled ? 'on' : 'off'), accessorFn: (row) => (row.enabled ? 'on' : 'off'),
header: ({ column }) => <DataGridColumnHeader column={column} title="Статус" />, header: ({ column }) => <DataGridColumnHeader column={column} title="Статус" />,
cell: ({ row }) => cell: ({ row }) => (
row.original.enabled ? ( <ModeBadge enabled={row.original.enabled} onLabel="Вкл" offLabel="Выкл" />
<Badge variant="default">Вкл</Badge> ),
) : (
<Badge variant="secondary">Выкл</Badge>
),
meta: { headerTitle: 'Статус' }, meta: { headerTitle: 'Статус' },
}, },
{ {
@@ -95,19 +92,22 @@ export function ScheduleModulesGrid({
[onRefresh, refreshing], [onRefresh, refreshing],
) )
const table = useReactTable({ const { table, globalFilter, setGlobalFilter, filteredCount } = useClientDataGrid({
data: items, data: items,
columns, columns,
...createClientDataGridOptions<ModuleRow>(), getSearchText: (row) => `${row.name} ${row.type} ${row.enabled ? 'вкл' : 'выкл'}`,
getRowId: (row) => row.id, getRowId: (row) => row.id,
}) })
return ( return (
<DataGridShell <DataGridSection
table={table} table={table}
recordCount={items.length} recordCount={filteredCount}
isLoading={isLoading} isLoading={isLoading}
emptyMessage="Нет модулей" emptyMessage="Нет модулей"
searchValue={globalFilter}
onSearchChange={setGlobalFilter}
searchPlaceholder="Поиск модулей…"
/> />
) )
} }
+100
View File
@@ -0,0 +1,100 @@
import type { ComponentProps, ReactNode } from 'react'
import { Field, FieldDescription, FieldLabel } from '@evobgp/ui/components/field'
import {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectTrigger,
SelectValue,
} from '@evobgp/ui/components/select'
import { cn } from '@evobgp/ui/lib/utils'
export type SelectMenuItem<T extends string = string> = {
value: T
label: ReactNode
disabled?: boolean
}
type SelectRootProps = ComponentProps<typeof Select>
export interface SelectMenuProps<T extends string = string>
extends Omit<SelectRootProps, 'children' | 'onValueChange' | 'value' | 'defaultValue'> {
items: ReadonlyArray<SelectMenuItem<T>>
value?: T | null
defaultValue?: T | null
onValueChange?: (value: T | null) => void
placeholder?: string
id?: string
triggerClassName?: string
contentClassName?: string
size?: 'sm' | 'default'
side?: ComponentProps<typeof SelectContent>['side']
}
/** ReUI c-select-4: `items` на Root, опции в `SelectGroup`. */
export function SelectMenu<T extends string = string>({
items,
placeholder,
id,
triggerClassName,
contentClassName,
size = 'default',
side,
value,
defaultValue,
onValueChange,
...selectProps
}: SelectMenuProps<T>) {
return (
<Select
items={items}
value={value}
defaultValue={defaultValue}
onValueChange={(next) => onValueChange?.(next as T | null)}
{...selectProps}
>
<SelectTrigger id={id} className={cn('w-full', triggerClassName)} size={size}>
<SelectValue placeholder={placeholder} />
</SelectTrigger>
<SelectContent side={side} className={contentClassName}>
<SelectGroup>
{items.map((item) => (
<SelectItem key={item.value} value={item.value} disabled={item.disabled}>
{item.label}
</SelectItem>
))}
</SelectGroup>
</SelectContent>
</Select>
)
}
export interface SelectFieldProps<T extends string = string> extends SelectMenuProps<T> {
label?: ReactNode
description?: ReactNode
fieldClassName?: string
}
export function SelectField<T extends string = string>({
label,
description,
fieldClassName,
id,
...menuProps
}: SelectFieldProps<T>) {
return (
<Field className={fieldClassName}>
{label ? <FieldLabel htmlFor={id}>{label}</FieldLabel> : null}
<SelectMenu id={id} {...menuProps} />
{description ? (
typeof description === 'string' ? (
<FieldDescription className="font-mono text-xs">{description}</FieldDescription>
) : (
description
)
) : null}
</Field>
)
}
@@ -1,9 +1,10 @@
import { ColumnDef, useReactTable } from '@tanstack/react-table' import { ColumnDef } from '@tanstack/react-table'
import { useMemo } from 'react' import { useMemo } from 'react'
import { DataGridShell } from '@/components/data-grid-shell' import { DataGridPrimaryCell } from '@/components/data-grid-cell'
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 { createClientDataGridOptions } from '@/lib/data-grid-defaults' import { useClientDataGrid } from '@/hooks/use-client-data-grid'
export interface SettingsKvRow { export interface SettingsKvRow {
id: string | number id: string | number
@@ -23,35 +24,37 @@ export function SettingsKvGrid({
{ {
accessorKey: 'key', accessorKey: 'key',
header: ({ column }) => <DataGridColumnHeader column={column} title="Ключ" />, header: ({ column }) => <DataGridColumnHeader column={column} title="Ключ" />,
cell: ({ row }) => <span className="font-mono text-xs">{row.original.key}</span>, cell: ({ row }) => <DataGridPrimaryCell title={row.original.key} accent="mono" />,
meta: { headerTitle: 'Ключ' }, meta: { headerTitle: 'Ключ' },
}, },
{ {
accessorKey: 'value', accessorKey: 'value',
header: ({ column }) => <DataGridColumnHeader column={column} title="Значение" />, header: ({ column }) => <DataGridColumnHeader column={column} title="Значение" />,
cell: ({ row }) => <span className="font-mono text-xs">{row.original.value}</span>, cell: ({ row }) => <DataGridPrimaryCell title={row.original.value} accent="mono" />,
meta: { headerTitle: 'Значение' }, meta: { headerTitle: 'Значение' },
}, },
], ],
[], [],
) )
const table = useReactTable({ const { table, globalFilter, setGlobalFilter, filteredCount } = useClientDataGrid({
data: items, data: items,
columns, columns,
...createClientDataGridOptions<SettingsKvRow>({ getSearchText: (row) => `${row.key} ${row.value}`,
initialState: { pagination: { pageSize: 25 } },
}),
getRowId: (row) => String(row.id), getRowId: (row) => String(row.id),
pageSize: 25,
}) })
return ( return (
<DataGridShell <DataGridSection
table={table} table={table}
recordCount={items.length} recordCount={filteredCount}
isLoading={isLoading} isLoading={isLoading}
emptyMessage="Нет дополнительных настроек" emptyMessage="Нет дополнительных настроек"
showPagination={items.length > 10} showPagination={items.length > 10}
searchValue={globalFilter}
onSearchChange={setGlobalFilter}
searchPlaceholder="Поиск настроек…"
/> />
) )
} }
+35 -2
View File
@@ -1,6 +1,7 @@
import { SectionCards } from './section-cards'
import { Skeleton } from '@evobgp/ui/components/skeleton'
import { Card, CardContent } from '@evobgp/ui/components/card' import { Card, CardContent } from '@evobgp/ui/components/card'
import { Skeleton } from '@evobgp/ui/components/skeleton'
import { SectionCards } from './section-cards'
export function SectionCardsSkeleton({ count = 4 }: { count?: number }) { export function SectionCardsSkeleton({ count = 4 }: { count?: number }) {
return ( return (
@@ -14,6 +15,38 @@ export function SectionCardsSkeleton({ count = 4 }: { count?: number }) {
) )
} }
export function AnalyticsDashboardSkeleton() {
return (
<div className="grid gap-4 lg:grid-cols-3 lg:grid-rows-2">
<Card className="gap-0 lg:row-span-2">
<CardContent className="space-y-4 p-5">
<Skeleton className="h-4 w-40" />
<div className="grid gap-4 sm:grid-cols-3">
{Array.from({ length: 3 }).map((_, i) => (
<Skeleton key={i} className="h-16 w-full" />
))}
</div>
<Skeleton className="h-2 w-full" />
<Skeleton className="h-32 w-full" />
</CardContent>
</Card>
<Card className="gap-0">
<CardContent className="space-y-4 p-5">
<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">
<Skeleton className="h-4 w-32" />
<Skeleton className="h-44 w-full" />
</CardContent>
</Card>
</div>
)
}
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 className="gap-0">
+60 -24
View File
@@ -1,35 +1,71 @@
import type { ComponentProps } from 'react' import type { ComponentProps } from 'react'
import { cn } from '@evobgp/ui/lib/utils'
import { Badge } from '@/components/reui/badge' import { Badge } from '@/components/reui/badge'
type BadgeVariant = NonNullable<ComponentProps<typeof Badge>['variant']> type BadgeVariant = NonNullable<ComponentProps<typeof Badge>['variant']>
const STATUS_VARIANT: Record<string, BadgeVariant> = { const STATUS_VARIANT: Record<string, BadgeVariant> = {
active: 'success', active: 'success-light',
ok: 'success', ok: 'success-light',
paid: 'success', paid: 'success-light',
established: 'success', established: 'success-light',
succeeded: 'success', succeeded: 'success-light',
healthy: 'success', healthy: 'success-light',
paused: 'secondary', approved: 'success-light',
disabled: 'secondary', accept: 'success-light',
paused: 'invert-light',
disabled: 'invert-light',
archived: 'outline', archived: 'outline',
error: 'destructive', error: 'destructive-light',
failed: 'destructive', failed: 'destructive-light',
running: 'info', revoked: 'destructive-light',
queued: 'info', block: 'destructive-light',
overdue: 'warning', cancelled: 'destructive-light',
stale: 'warning', running: 'info-light',
warning: 'warning', queued: 'info-light',
mismatch: 'warning', overdue: 'warning-light',
pending: 'warning', stale: 'warning-light',
approved: 'success', warning: 'warning-light',
revoked: 'destructive', mismatch: 'warning-light',
block: 'destructive', pending: 'warning-light',
accept: 'success',
} }
export function StatusBadge({ status, label }: { status: string; label?: string }) { const DOT_COLOR: Record<string, string> = {
const variant = STATUS_VARIANT[status.toLowerCase()] ?? 'outline' 'success-light': 'bg-success',
return <Badge variant={variant}>{label ?? status}</Badge> 'info-light': 'bg-info',
'warning-light': 'bg-warning',
'destructive-light': 'bg-destructive',
'invert-light': 'bg-muted-foreground',
outline: 'bg-muted-foreground',
}
export function jobStatusVariant(status: string): BadgeVariant {
return STATUS_VARIANT[status.toLowerCase()] ?? 'outline'
}
export function StatusBadge({
status,
label,
hint,
className,
}: {
status: string
label?: string
hint?: string
className?: string
}) {
const variant = jobStatusVariant(status)
const dotColor = DOT_COLOR[variant] ?? 'bg-muted-foreground'
return (
<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}
</Badge>
{hint ? <span className="text-xs text-muted-foreground">{hint}</span> : null}
</div>
)
} }
@@ -0,0 +1,61 @@
import { useMemo, useState } from 'react'
import {
type ColumnDef,
type FilterFn,
type TableOptions,
useReactTable,
} from '@tanstack/react-table'
import {
createClientDataGridOptions,
createTextGlobalFilter,
} from '@/lib/data-grid-defaults'
interface UseClientDataGridOptions<TData extends object> {
data: TData[]
columns: ColumnDef<TData>[]
getSearchText: (row: TData) => string
getRowId: (row: TData) => string
pageSize?: number
tableOptions?: Partial<TableOptions<TData>>
globalFilterFn?: FilterFn<TData>
}
export function useClientDataGrid<TData extends object>({
data,
columns,
getSearchText,
getRowId,
pageSize = 10,
tableOptions,
globalFilterFn,
}: UseClientDataGridOptions<TData>) {
const [globalFilter, setGlobalFilter] = useState('')
const filterFn = useMemo(
() => globalFilterFn ?? createTextGlobalFilter(getSearchText),
[getSearchText, globalFilterFn],
)
const table = useReactTable({
data,
columns,
state: { globalFilter },
onGlobalFilterChange: setGlobalFilter,
globalFilterFn: filterFn,
...createClientDataGridOptions<TData>({
initialState: { pagination: { pageSize } },
...tableOptions,
}),
getRowId,
})
const filteredCount = table.getFilteredRowModel().rows.length
return {
table,
globalFilter,
setGlobalFilter,
filteredCount,
}
}
+20 -1
View File
@@ -1,7 +1,9 @@
import { import {
getCoreRowModel, getCoreRowModel,
getFilteredRowModel,
getPaginationRowModel, getPaginationRowModel,
getSortedRowModel, getSortedRowModel,
type FilterFn,
type TableOptions, type TableOptions,
} from '@tanstack/react-table' } from '@tanstack/react-table'
@@ -28,14 +30,31 @@ export const DATA_GRID_DENSE_LAYOUT: NonNullable<DataGridProps<object>['tableLay
dense: true, dense: true,
} }
export function createTextGlobalFilter<TData extends object>(
getSearchText: (row: TData) => string,
): FilterFn<TData> {
return (row, _columnId, filterValue) => {
const query = String(filterValue ?? '')
.toLowerCase()
.trim()
if (!query) return true
return getSearchText(row.original).toLowerCase().includes(query)
}
}
export function createClientDataGridOptions<TData extends object>( export function createClientDataGridOptions<TData extends object>(
overrides?: Partial<TableOptions<TData>>, overrides?: Partial<TableOptions<TData>>,
): Pick< ): Pick<
TableOptions<TData>, TableOptions<TData>,
'getCoreRowModel' | 'getSortedRowModel' | 'getPaginationRowModel' | 'initialState' | 'getCoreRowModel'
| 'getFilteredRowModel'
| 'getSortedRowModel'
| 'getPaginationRowModel'
| 'initialState'
> { > {
return { return {
getCoreRowModel: getCoreRowModel(), getCoreRowModel: getCoreRowModel(),
getFilteredRowModel: getFilteredRowModel(),
getSortedRowModel: getSortedRowModel(), getSortedRowModel: getSortedRowModel(),
getPaginationRowModel: getPaginationRowModel(), getPaginationRowModel: getPaginationRowModel(),
initialState: { pagination: { pageSize: 10 } }, initialState: { pagination: { pageSize: 10 } },
@@ -0,0 +1,32 @@
import type { SpeakerRow } from '@/types/api'
import type { DeploymentProgress } from './types'
export function deploymentProgress(speakers: SpeakerRow[]): DeploymentProgress {
if (speakers.length === 0) {
return { percent: 0, synced: 0, total: 0, mode: 'online' }
}
const withRevision = speakers.filter(
(s) => s.published_revision_id && s.last_applied_revision_id,
)
if (withRevision.length > 0) {
const synced = withRevision.filter(
(s) => s.published_revision_id === s.last_applied_revision_id,
).length
return {
percent: Math.round((synced / withRevision.length) * 100),
synced,
total: withRevision.length,
mode: 'revision',
}
}
const online = speakers.filter((s) => s.live?.agent_ok).length
return {
percent: Math.round((online / speakers.length) * 100),
synced: online,
total: speakers.length,
mode: 'online',
}
}
+8
View File
@@ -0,0 +1,8 @@
export * from './types'
export * from './job-status-breakdown'
export * from './module-type-breakdown'
export * from './peer-capacity-bars'
export * from './deployment-progress'
export * from './readiness-breakdown'
export * from './peer-session-breakdown'
export * from './recent-platform-activity'
@@ -0,0 +1,52 @@
import type { JobRow } from '@/types/api'
import type { BreakdownSlice } from './types'
const STATUS_BUCKETS: { keys: string[]; label: string; color: string }[] = [
{ keys: ['succeeded', 'success'], label: 'Успешно', color: 'var(--color-chart-2)' },
{ keys: ['running', 'queued'], label: 'Активные', color: 'var(--color-chart-1)' },
{ keys: ['failed', 'error', 'cancelled'], label: 'Ошибки', color: 'var(--color-destructive)' },
]
function bucketForStatus(status: string): string {
const s = status.toLowerCase()
for (const bucket of STATUS_BUCKETS) {
if (bucket.keys.includes(s)) return bucket.label
}
return 'Прочее'
}
export function jobStatusBreakdown(jobs: JobRow[]): BreakdownSlice[] {
const counts = new Map<string, number>()
for (const job of jobs) {
const label = bucketForStatus(job.status)
counts.set(label, (counts.get(label) ?? 0) + 1)
}
const slices: BreakdownSlice[] = []
for (const bucket of STATUS_BUCKETS) {
const count = counts.get(bucket.label) ?? 0
if (count > 0) {
slices.push({
key: bucket.label,
label: bucket.label,
count,
color: bucket.color,
})
}
}
const other = counts.get('Прочее') ?? 0
if (other > 0) {
slices.push({
key: 'other',
label: 'Прочее',
count: other,
color: 'var(--color-chart-4)',
})
}
return slices
}
export function jobStatusTotal(jobs: JobRow[]): number {
return jobs.length
}
@@ -0,0 +1,26 @@
import type { ModuleRow, ModuleType } from '@/types/api'
import type { BreakdownSlice } from './types'
const TYPE_META: Record<ModuleType, { label: string; color: string }> = {
AS_PREFIXES: { label: 'AS / префиксы', color: 'var(--color-chart-1)' },
DOMAINS: { label: 'Домены', color: 'var(--color-chart-2)' },
CDN_CIDRS: { label: 'CDN', color: 'var(--color-chart-3)' },
IP_RANGES: { label: 'IP-диапазоны', color: 'var(--color-chart-4)' },
}
export function moduleTypeBreakdown(modules: ModuleRow[]): BreakdownSlice[] {
const counts = new Map<ModuleType, number>()
for (const mod of modules) {
counts.set(mod.type, (counts.get(mod.type) ?? 0) + 1)
}
return (Object.keys(TYPE_META) as ModuleType[])
.map((type) => ({
key: type,
label: TYPE_META[type].label,
count: counts.get(type) ?? 0,
color: TYPE_META[type].color,
}))
.filter((slice) => slice.count > 0)
}
@@ -0,0 +1,42 @@
import type { PeerRow, SpeakerRow } from '@/types/api'
import type { CapacityBar } from './types'
function peerLabel(peer: PeerRow): string {
return peer.name?.trim() || peer.neighbor || peer.id.slice(0, 8)
}
function speakerLabel(speaker: SpeakerRow): string {
return speaker.live?.label?.trim() || speaker.agent_domain || speaker.endpoint || speaker.id.slice(0, 8)
}
export function peerCapacityBars(peers: PeerRow[], max = 24): CapacityBar[] {
return peers
.filter((p) => p.enabled !== false)
.slice(0, max)
.map((peer) => ({
id: peer.id,
name: peerLabel(peer),
value: peer.session_state === 'Established' ? 100 : peer.session_state ? 40 : 10,
}))
}
export function speakerCapacityBars(speakers: SpeakerRow[], max = 24): CapacityBar[] {
return speakers.slice(0, max).map((speaker) => {
const online = speaker.live?.agent_ok === true
const established = speaker.live?.bgp_established ?? 0
const total = speaker.live?.bgp_sessions_total ?? 0
const ratio = total > 0 ? Math.round((established / total) * 100) : online ? 100 : 15
return {
id: speaker.id,
name: speakerLabel(speaker),
value: online ? ratio : 10,
}
})
}
export function capacityUtilization(bars: CapacityBar[]): number {
if (bars.length === 0) return 0
const sum = bars.reduce((acc, bar) => acc + bar.value, 0)
return Math.round(sum / bars.length)
}
@@ -0,0 +1,41 @@
import type { PeerRow } from '@/types/api'
import type { BreakdownSlice } from './types'
export function peerSessionBreakdown(peers: PeerRow[]): BreakdownSlice[] {
const enabled = peers.filter((p) => p.enabled !== false)
const established = enabled.filter((p) => p.session_state === 'Established').length
const pending = enabled.filter(
(p) => p.session_state && p.session_state !== 'Established',
).length
const disabled = peers.length - enabled.length
const slices: BreakdownSlice[] = []
if (established > 0) {
slices.push({
key: 'established',
label: 'Established',
count: established,
color: 'var(--color-chart-2)',
})
}
if (pending > 0) {
slices.push({
key: 'pending',
label: 'Не Established',
count: pending,
color: 'var(--color-warning)',
})
}
if (disabled > 0) {
slices.push({
key: 'disabled',
label: 'Выключены',
count: disabled,
color: 'var(--color-chart-4)',
})
}
return slices.length > 0
? slices
: [{ key: 'empty', label: 'Нет пиров', count: 1, color: 'var(--color-muted-foreground)' }]
}
@@ -0,0 +1,71 @@
import type { ReadyStatus } from '@/queries/monitoring'
import type { BreakdownSlice } from './types'
function checkOk(value: boolean | { ok?: boolean; error?: string } | undefined): boolean {
if (typeof value === 'boolean') return value
if (value && typeof value === 'object') return value.ok === true
return false
}
export function readinessBreakdown(
ready: ReadyStatus | null | undefined,
healthOk: boolean,
): BreakdownSlice[] {
if (!healthOk) {
return [
{
key: 'health-fail',
label: 'API недоступен',
count: 1,
color: 'var(--color-destructive)',
},
]
}
const checks = ready?.checks ?? {}
let okCount = 0
let failCount = 0
for (const value of Object.values(checks)) {
if (checkOk(value)) okCount += 1
else failCount += 1
}
const slices: BreakdownSlice[] = [
{
key: 'health',
label: 'Health OK',
count: 1,
color: 'var(--color-chart-2)',
},
]
if (okCount > 0) {
slices.push({
key: 'checks-ok',
label: 'Checks OK',
count: okCount,
color: 'var(--color-chart-1)',
})
}
if (failCount > 0) {
slices.push({
key: 'checks-fail',
label: 'Checks fail',
count: failCount,
color: 'var(--color-warning)',
})
}
if (slices.length === 1 && okCount === 0 && failCount === 0) {
slices.push({
key: 'ready',
label: ready?.status === 'ok' ? 'Ready' : 'Ready pending',
count: 1,
color: 'var(--color-chart-4)',
})
}
return slices
}
@@ -0,0 +1,64 @@
import type { JobRow, PeerRow, RevisionRow, SpeakerRow } from '@/types/api'
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}`
}
export function recentPlatformActivity(
jobs: JobRow[],
revisions: RevisionRow[],
peers: PeerRow[],
speakers: SpeakerRow[],
limit = 5,
): PlatformActivityItem[] {
const items: PlatformActivityItem[] = []
for (const job of jobs.slice(0, 3)) {
items.push({
id: `job-${job.job_id}`,
message: jobMessage(job),
status: job.status,
kind: 'job',
})
}
for (const rev of revisions.slice(0, 2)) {
items.push({
id: `rev-${rev.id}`,
message: `Ревизия ${rev.id.slice(0, 8)}… · ${rev.materialized_prefix_count} префиксов`,
status: 'ok',
statusLabel: 'Создана',
kind: 'revision',
})
}
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}`,
status: 'mismatch',
kind: 'network',
})
}
for (const speaker of speakers.filter((s) => s.live?.bgp_poll_error || s.live?.agent_ok === false).slice(0, 2)) {
items.push({
id: `speaker-${speaker.id}`,
message: `Нода недоступна: ${speaker.live?.label ?? speaker.endpoint}`,
status: speaker.live?.agent_ok === false ? 'error' : 'warning',
kind: 'network',
})
}
return items.slice(0, limit)
}
+28
View File
@@ -0,0 +1,28 @@
export type BreakdownSlice = {
key: string
label: string
count: number
color: string
}
export type CapacityBar = {
id: string
name: string
/** 0100 utilization */
value: number
}
export type PlatformActivityItem = {
id: string
message: string
status: string
statusLabel?: string
kind: 'job' | 'revision' | 'network'
}
export type DeploymentProgress = {
percent: number
synced: number
total: number
mode: 'revision' | 'online'
}
+98 -3
View File
@@ -1,6 +1,18 @@
import { queryOptions } from '@tanstack/react-query' import { queryOptions, useMutation, useQueryClient } from '@tanstack/react-query'
import { apiJSON } from '@/lib/api-client' import { toast } from 'sonner'
import type { BirdStatus, PeersResponse, SpeakersResponse } from '@/types/api'
import { apiJSON, apiMutate } from '@/lib/api-client'
import type {
BgpPeerCreate,
BgpPeerPatch,
BgpSpeakerCreate,
BgpSpeakerPatch,
BirdStatus,
PeerRow,
PeersResponse,
SpeakerRow,
SpeakersResponse,
} from '@/types/api'
export const NETWORK_AUTO_REFRESH_MS = 30_000 export const NETWORK_AUTO_REFRESH_MS = 30_000
@@ -34,3 +46,86 @@ export function networkBirdQueryOptions() {
staleTime: 15_000, staleTime: 15_000,
}) })
} }
function invalidateNetwork(qc: ReturnType<typeof useQueryClient>) {
void qc.invalidateQueries({ queryKey: networkKeys.all })
void qc.invalidateQueries({ queryKey: ['overview'] })
}
export function useCreatePeerMutation() {
const qc = useQueryClient()
return useMutation({
mutationFn: (body: BgpPeerCreate) =>
apiMutate<PeerRow>('/v1/peers', 'POST', body, { idempotent: false }),
onSuccess: () => {
toast.success('Пир создан')
invalidateNetwork(qc)
},
onError: (e) => toast.error(e instanceof Error ? e.message : 'Не удалось создать пира'),
})
}
export function useUpdatePeerMutation() {
const qc = useQueryClient()
return useMutation({
mutationFn: ({ id, body }: { id: string; body: BgpPeerPatch }) =>
apiMutate<PeerRow>(`/v1/peers/${id}`, 'PATCH', body, { idempotent: false }),
onSuccess: () => {
toast.success('Пир обновлён')
invalidateNetwork(qc)
},
onError: (e) => toast.error(e instanceof Error ? e.message : 'Не удалось обновить пира'),
})
}
export function useDeletePeerMutation() {
const qc = useQueryClient()
return useMutation({
mutationFn: (id: string) =>
apiMutate(`/v1/peers/${id}`, 'DELETE', undefined, { idempotent: false }),
onSuccess: () => {
toast.success('Пир удалён')
invalidateNetwork(qc)
},
onError: (e) => toast.error(e instanceof Error ? e.message : 'Не удалось удалить пира'),
})
}
export function useCreateSpeakerMutation() {
const qc = useQueryClient()
return useMutation({
mutationFn: (body: BgpSpeakerCreate) =>
apiMutate<SpeakerRow>('/v1/speakers', 'POST', body, { idempotent: false }),
onSuccess: () => {
toast.success('Спикер создан')
invalidateNetwork(qc)
},
onError: (e) => toast.error(e instanceof Error ? e.message : 'Не удалось создать спикера'),
})
}
export function useUpdateSpeakerMutation() {
const qc = useQueryClient()
return useMutation({
mutationFn: ({ id, body }: { id: string; body: BgpSpeakerPatch }) =>
apiMutate<SpeakerRow>(`/v1/speakers/${id}`, 'PATCH', body, { idempotent: false }),
onSuccess: () => {
toast.success('Спикер обновлён')
invalidateNetwork(qc)
},
onError: (e) => toast.error(e instanceof Error ? e.message : 'Не удалось обновить спикера'),
})
}
export function useDeleteSpeakerMutation() {
const qc = useQueryClient()
return useMutation({
mutationFn: (id: string) =>
apiMutate(`/v1/speakers/${id}`, 'DELETE', undefined, { idempotent: false }),
onSuccess: () => {
toast.success('Спикер удалён')
invalidateNetwork(qc)
},
onError: (e) => toast.error(e instanceof Error ? e.message : 'Не удалось удалить спикера'),
})
}
+1 -1
View File
@@ -58,7 +58,7 @@ export function overviewRevisionsQueryOptions() {
export function overviewJobsQueryOptions() { export function overviewJobsQueryOptions() {
return queryOptions<JobsResponse>({ return queryOptions<JobsResponse>({
queryKey: overviewKeys.jobs(), queryKey: overviewKeys.jobs(),
queryFn: () => apiJSON<JobsResponse>('/v1/jobs?limit=10'), queryFn: () => apiJSON<JobsResponse>('/v1/jobs?limit=100'),
staleTime: 15_000, staleTime: 15_000,
}) })
} }
+63 -127
View File
@@ -1,39 +1,31 @@
import { createFileRoute, useNavigate } from '@tanstack/react-router' import { createFileRoute } from '@tanstack/react-router'
import { useQueries } from '@tanstack/react-query' import { useQueries } from '@tanstack/react-query'
import { import { CheckCircle, Info, RefreshCw, XCircle } from 'lucide-react'
Boxes,
CheckCircle,
Clock,
GitBranch,
Info,
Radio,
RefreshCw,
Activity,
XCircle,
} from 'lucide-react'
import { useState } from 'react' import { useState } from 'react'
import { Alert, AlertDescription, AlertTitle } from '@evobgp/ui/components/alert' import { Alert, AlertDescription, AlertTitle } from '@evobgp/ui/components/alert'
import { Button } from '@evobgp/ui/components/button' import { Button } from '@evobgp/ui/components/button'
import {
Card,
CardDescription,
CardHeader,
CardTitle,
} from '@evobgp/ui/components/card'
import { Skeleton } from '@evobgp/ui/components/skeleton' import { Skeleton } from '@evobgp/ui/components/skeleton'
import { DashboardNetworkPanel } from '@/components/dashboard/dashboard-network-panel' import {
DashboardNetworkCapacityCard,
DashboardOperationsFlowCard,
DashboardPlatformCard,
} from '@/components/analytics'
import { DataGridCard } from '@/components/data-grid-shell'
import { DashboardQuickActions } from '@/components/dashboard/dashboard-quick-actions' import { DashboardQuickActions } from '@/components/dashboard/dashboard-quick-actions'
import { DashboardRecentJobsGrid } from '@/components/dashboard/dashboard-recent-jobs-grid' import { DashboardRecentJobsGrid } from '@/components/dashboard/dashboard-recent-jobs-grid'
import { DashboardRecentRevisionsGrid } from '@/components/dashboard/dashboard-recent-revisions-grid' import { DashboardRecentRevisionsGrid } from '@/components/dashboard/dashboard-recent-revisions-grid'
import { PageHeader } from '@/components/page-header' import { PageHeader } from '@/components/page-header'
import { import { AnalyticsDashboardSkeleton } from '@/components/skeletons'
Frame,
FrameDescription,
FrameHeader,
FramePanel,
FrameTitle,
} from '@/components/reui/frame'
import { SectionCards, type SectionCardItem } from '@/components/section-cards'
import { SectionCardsSkeleton } from '@/components/skeletons'
import { import {
aggregateNetworkMetrics,
moduleNameById, moduleNameById,
overviewHealthQueryOptions, overviewHealthQueryOptions,
overviewJobsQueryOptions, overviewJobsQueryOptions,
@@ -41,7 +33,6 @@ import {
overviewPeersQueryOptions, overviewPeersQueryOptions,
overviewRevisionsQueryOptions, overviewRevisionsQueryOptions,
overviewSpeakersQueryOptions, overviewSpeakersQueryOptions,
runningJobCount,
} from '@/queries/overview' } from '@/queries/overview'
export const Route = createFileRoute('/_auth/dashboard')({ export const Route = createFileRoute('/_auth/dashboard')({
@@ -49,7 +40,6 @@ export const Route = createFileRoute('/_auth/dashboard')({
}) })
function DashboardComponent() { function DashboardComponent() {
const navigate = useNavigate()
const [lastUpdated, setLastUpdated] = useState<Date | null>(null) const [lastUpdated, setLastUpdated] = useState<Date | null>(null)
const results = useQueries({ const results = useQueries({
@@ -82,60 +72,10 @@ function DashboardComponent() {
const speakers = speakersQ.data?.items ?? [] const speakers = speakersQ.data?.items ?? []
const revisions = revisionsQ.data?.items ?? [] const revisions = revisionsQ.data?.items ?? []
const jobs = jobsQ.data?.items ?? [] const jobs = jobsQ.data?.items ?? []
const modulesHasMore = modulesQ.data?.has_more ?? false
const peersHasMore = peersQ.data?.has_more ?? false
const speakersHasMore = speakersQ.data?.has_more ?? false
const revisionsHasMore = revisionsQ.data?.has_more ?? false
const net = aggregateNetworkMetrics(peers, speakers)
const running = runningJobCount(jobs)
const nameById = moduleNameById(modules) const nameById = moduleNameById(modules)
const countBadge = (n: number, hasMore: boolean, suffix: string) => (hasMore ? '200+' : suffix)
const items: SectionCardItem[] = [
{
label: 'Модули',
value: initialLoading ? '—' : String(modules.length),
icon: <Boxes className="size-4" />,
hint: countBadge(modules.length, modulesHasMore, 'AS, CDN, домены, IP'),
onClick: () => navigate({ to: '/modules' }),
},
{
label: 'Пиры',
value: initialLoading ? '—' : `${net.peersEstablished}/${net.peersEnabled}`,
icon: <GitBranch className="size-4" />,
hint: countBadge(peers.length, peersHasMore, 'Established / включённых'),
badge: net.peersMismatch > 0 ? `mismatch ${net.peersMismatch}` : undefined,
variant: net.peersMismatch > 0 ? 'warning' : 'default',
onClick: () => navigate({ to: '/network', search: { tab: 'peers' } }),
},
{
label: 'Спикеры',
value: initialLoading ? '—' : `${net.speakersOnline}/${net.speakersTotal}`,
icon: <Radio className="size-4" />,
hint: countBadge(speakers.length, speakersHasMore, 'online / всего'),
variant: net.speakersOnline < net.speakersTotal ? 'warning' : 'default',
onClick: () => navigate({ to: '/network', search: { tab: 'overview' } }),
},
{
label: 'Ревизии',
value: initialLoading ? '—' : String(revisions.length),
icon: <Activity className="size-4" />,
hint: countBadge(revisions.length, revisionsHasMore, 'configs'),
onClick: () => navigate({ to: '/operations', search: { tab: 'revisions' } }),
},
{
label: 'Активных задач',
value: initialLoading ? '—' : String(running),
icon: <Clock className="size-4" />,
hint: 'queued и running',
onClick: () => navigate({ to: '/operations', search: { tab: 'jobs' } }),
},
]
const activityLoading = const activityLoading =
refreshing && jobs.length === 0 && revisions.length === 0 && peers.length === 0 && speakers.length === 0 refreshing && jobs.length === 0 && revisions.length === 0
return ( return (
<div className="flex flex-col gap-5"> <div className="flex flex-col gap-5">
@@ -169,61 +109,57 @@ function DashboardComponent() {
loadError={modulesQ.isError || peersQ.isError ? 'Некоторые данные не загружены' : null} loadError={modulesQ.isError || peersQ.isError ? 'Некоторые данные не загружены' : null}
/> />
{initialLoading ? <SectionCardsSkeleton count={5} /> : <SectionCards items={items} className="gap-3" />} {initialLoading ? (
<AnalyticsDashboardSkeleton />
) : (
<div className="grid gap-4 lg:grid-cols-3 lg:grid-rows-2">
<div className="lg:row-span-2">
<DashboardPlatformCard
modules={modules}
peers={peers}
speakers={speakers}
jobs={jobs}
revisions={revisions}
/>
</div>
<DashboardNetworkCapacityCard peers={peers} speakers={speakers} jobs={jobs} />
<DashboardOperationsFlowCard jobs={jobs} modules={modules} />
</div>
)}
<div className="grid gap-4 lg:grid-cols-3"> <div className="grid gap-4 lg:grid-cols-2">
<Frame spacing="sm" className="h-full"> <DataGridCard
<FramePanel className="flex h-full flex-col p-0"> title="Недавние задачи"
<FrameHeader className="border-b"> description="Последние фоновые операции"
<FrameTitle>Недавние задачи</FrameTitle> className="h-full"
<FrameDescription>Последние фоновые операции</FrameDescription> >
</FrameHeader> {activityLoading ? (
{activityLoading ? ( <Skeleton className="m-3 h-24 w-auto" />
<Skeleton className="m-3 h-24 w-auto" /> ) : (
) : ( <DashboardRecentJobsGrid jobs={jobs.slice(0, 10)} nameById={nameById} isLoading={refreshing} />
<DashboardRecentJobsGrid jobs={jobs} nameById={nameById} isLoading={refreshing} /> )}
)} </DataGridCard>
</FramePanel>
</Frame>
<Frame spacing="sm" className="h-full"> <DataGridCard
<FramePanel className="flex h-full flex-col p-0"> title="Последние ревизии"
<FrameHeader className="border-b"> description="История конфигураций"
<FrameTitle>Последние ревизии</FrameTitle> className="h-full"
<FrameDescription>История конфигураций</FrameDescription> >
</FrameHeader> {activityLoading ? (
{activityLoading ? ( <Skeleton className="m-3 h-24 w-auto" />
<Skeleton className="m-3 h-24 w-auto" /> ) : (
) : ( <DashboardRecentRevisionsGrid revisions={revisions} isLoading={refreshing} />
<DashboardRecentRevisionsGrid revisions={revisions} isLoading={refreshing} /> )}
)} </DataGridCard>
</FramePanel>
</Frame>
<Frame spacing="sm" className="h-full">
<FramePanel className="flex h-full flex-col p-0">
<FrameHeader className="border-b">
<FrameTitle>Состояние сети</FrameTitle>
<FrameDescription>BGP-сессии и спикеры</FrameDescription>
</FrameHeader>
{activityLoading ? (
<Skeleton className="m-3 h-24 w-auto" />
) : (
<DashboardNetworkPanel peers={peers} speakers={speakers} />
)}
</FramePanel>
</Frame>
</div> </div>
<Frame spacing="sm"> <Card className="gap-0">
<FramePanel className="p-0"> <CardHeader className="border-b py-3">
<FrameHeader className="border-b"> <CardTitle className="text-base">Быстрые действия</CardTitle>
<FrameTitle>Быстрые действия</FrameTitle> <CardDescription>Частые переходы к настройке и деплою</CardDescription>
<FrameDescription>Частые переходы к настройке и деплою</FrameDescription> </CardHeader>
</FrameHeader> <DashboardQuickActions />
<DashboardQuickActions /> </Card>
</FramePanel>
</Frame>
</div> </div>
) )
} }
+11 -11
View File
@@ -4,8 +4,7 @@ import { BookText, Globe, Info, RefreshCw, Tags } from 'lucide-react'
import { Alert, AlertDescription, AlertTitle } from '@evobgp/ui/components/alert' import { Alert, AlertDescription, AlertTitle } from '@evobgp/ui/components/alert'
import { Button } from '@evobgp/ui/components/button' import { Button } from '@evobgp/ui/components/button'
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@evobgp/ui/components/tabs' import { BadgeTabs, TabsContent } from '@/components/badge-tabs'
import { DataGridCard } from '@/components/data-grid-shell' import { DataGridCard } from '@/components/data-grid-shell'
import { DirectoriesCommunitiesGrid } from '@/components/directories/directories-communities-grid' import { DirectoriesCommunitiesGrid } from '@/components/directories/directories-communities-grid'
import { DirectoriesDohGrid } from '@/components/directories/directories-doh-grid' import { DirectoriesDohGrid } from '@/components/directories/directories-doh-grid'
@@ -80,13 +79,14 @@ function DirectoriesComponent() {
{loading ? <SectionCardsSkeleton count={3} /> : <SectionCards items={items} />} {loading ? <SectionCardsSkeleton count={3} /> : <SectionCards items={items} />}
<Tabs defaultValue="communities"> <BadgeTabs
<TabsList> defaultValue="communities"
<TabsTrigger value="communities">Сообщества BGP</TabsTrigger> items={[
<TabsTrigger value="doh">DoH профили</TabsTrigger> { value: 'communities', label: 'Сообщества BGP', count: communities.length },
</TabsList> { value: 'doh', label: 'DoH профили', count: dohProfiles.length, badgeVariant: 'info-light' },
]}
<TabsContent value="communities" className="mt-4"> >
<TabsContent value="communities" className="mt-0">
<DataGridCard <DataGridCard
title="Сообщества BGP" title="Сообщества BGP"
description="Теги для префиксов в фильтрах BIRD" description="Теги для префиксов в фильтрах BIRD"
@@ -111,7 +111,7 @@ function DirectoriesComponent() {
</DataGridCard> </DataGridCard>
</TabsContent> </TabsContent>
<TabsContent value="doh" className="mt-4"> <TabsContent value="doh" className="mt-0">
<DataGridCard title="DoH профили" description="Резолверы DNS-over-HTTPS для доменных модулей"> <DataGridCard title="DoH профили" description="Резолверы DNS-over-HTTPS для доменных модулей">
<QueryState <QueryState
data={dohProfiles} data={dohProfiles}
@@ -132,7 +132,7 @@ function DirectoriesComponent() {
</QueryState> </QueryState>
</DataGridCard> </DataGridCard>
</TabsContent> </TabsContent>
</Tabs> </BadgeTabs>
</div> </div>
) )
} }
+18 -13
View File
@@ -9,8 +9,7 @@ import { Button } from '@evobgp/ui/components/button'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@evobgp/ui/components/card' import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@evobgp/ui/components/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 { Tabs, TabsContent, TabsList, TabsTrigger } from '@evobgp/ui/components/tabs' import { BadgeTabs, TabsContent } from '@/components/badge-tabs'
import { FirewallClientsGrid } from '@/components/firewall/firewall-clients-grid' import { FirewallClientsGrid } from '@/components/firewall/firewall-clients-grid'
import { FirewallRulesGrid } from '@/components/firewall/firewall-rules-grid' import { FirewallRulesGrid } from '@/components/firewall/firewall-rules-grid'
import { PageHeader } from '@/components/page-header' import { PageHeader } from '@/components/page-header'
@@ -185,14 +184,20 @@ function FirewallPage() {
</CardContent> </CardContent>
</Card> </Card>
<Tabs defaultValue="clients"> <BadgeTabs
<TabsList> defaultValue="clients"
<TabsTrigger value="clients">Клиенты ({activeClients.length})</TabsTrigger> items={[
<TabsTrigger value="rules">Правила ({rules.length})</TabsTrigger> { value: 'clients', label: 'Клиенты', count: activeClients.length },
<TabsTrigger value="requests">Запросы ({pending.length})</TabsTrigger> { value: 'rules', label: 'Правила', count: rules.length, badgeVariant: 'info-light' },
</TabsList> {
value: 'requests',
<TabsContent value="clients" className="mt-4"> label: 'Запросы',
count: pending.length,
badgeVariant: pending.length > 0 ? 'warning-light' : 'primary-light',
},
]}
>
<TabsContent value="clients" className="mt-0">
<QueryState <QueryState
data={clientsQ.data} data={clientsQ.data}
isLoading={clientsQ.isLoading} isLoading={clientsQ.isLoading}
@@ -214,7 +219,7 @@ function FirewallPage() {
</QueryState> </QueryState>
</TabsContent> </TabsContent>
<TabsContent value="rules" className="mt-4 space-y-4"> <TabsContent value="rules" className="mt-0 space-y-4">
<div className="flex flex-wrap items-end gap-3"> <div className="flex flex-wrap items-end gap-3">
<div className="space-y-1"> <div className="space-y-1">
<Label>Действие</Label> <Label>Действие</Label>
@@ -281,7 +286,7 @@ function FirewallPage() {
</QueryState> </QueryState>
</TabsContent> </TabsContent>
<TabsContent value="requests" className="mt-4"> <TabsContent value="requests" className="mt-0">
<QueryState <QueryState
data={clientsQ.data} data={clientsQ.data}
isLoading={clientsQ.isLoading} isLoading={clientsQ.isLoading}
@@ -303,7 +308,7 @@ function FirewallPage() {
)} )}
</QueryState> </QueryState>
</TabsContent> </TabsContent>
</Tabs> </BadgeTabs>
</div> </div>
) )
} }
+56 -60
View File
@@ -1,19 +1,21 @@
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 { Activity, AlertTriangle, Bird, Database, Gauge, HeartPulse, Info, ListTodo, RefreshCw } from 'lucide-react' import { Activity, AlertTriangle, Bird, Database, HeartPulse, Info, ListTodo, RefreshCw } from 'lucide-react'
import { Alert, AlertDescription, AlertTitle } from '@evobgp/ui/components/alert' import { Alert, AlertDescription, AlertTitle } from '@evobgp/ui/components/alert'
import { Badge } from '@evobgp/ui/components/badge'
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 { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@evobgp/ui/components/card'
import { Separator } from '@evobgp/ui/components/separator' import { Separator } from '@evobgp/ui/components/separator'
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@evobgp/ui/components/tabs' import { BadgeTabs, TabsContent } from '@/components/badge-tabs'
import { StatusBadge } from '@/components/status-badge'
import {
DashboardOperationsFlowCard,
MonitoringHealthCard,
} from '@/components/analytics'
import { MonitoringReadyGrid } from '@/components/monitoring/monitoring-ready-grid' import { MonitoringReadyGrid } from '@/components/monitoring/monitoring-ready-grid'
import { PageHeader } from '@/components/page-header' import { PageHeader } from '@/components/page-header'
import { QueryState } from '@/components/query-state' import { QueryState } from '@/components/query-state'
import { SectionCards, type SectionCardItem } from '@/components/section-cards' import { AnalyticsDashboardSkeleton } from '@/components/skeletons'
import { SectionCardsSkeleton } from '@/components/skeletons'
import { import {
monitoringHealthQueryOptions, monitoringHealthQueryOptions,
@@ -24,6 +26,7 @@ import {
} from '@/queries/monitoring' } from '@/queries/monitoring'
import { networkBirdQueryOptions } from '@/queries/network' import { networkBirdQueryOptions } from '@/queries/network'
import { operationsJobsQueryOptions } from '@/queries/operations' import { operationsJobsQueryOptions } from '@/queries/operations'
import { overviewModulesQueryOptions } from '@/queries/overview'
export const Route = createFileRoute('/_auth/monitoring')({ export const Route = createFileRoute('/_auth/monitoring')({
component: MonitoringComponent, component: MonitoringComponent,
@@ -37,58 +40,31 @@ export const Route = createFileRoute('/_auth/monitoring')({
function MonitoringComponent() { function MonitoringComponent() {
const search = useSearch({ from: '/_auth/monitoring' }) const search = useSearch({ from: '/_auth/monitoring' })
const navigate = Route.useNavigate()
const healthQ = useQuery(monitoringHealthQueryOptions()) const healthQ = useQuery(monitoringHealthQueryOptions())
const readyQ = useQuery(monitoringReadyQueryOptions()) const readyQ = useQuery(monitoringReadyQueryOptions())
const versionQ = useQuery(monitoringVersionQueryOptions()) const versionQ = useQuery(monitoringVersionQueryOptions())
const birdQ = useQuery(networkBirdQueryOptions()) const birdQ = useQuery(networkBirdQueryOptions())
const jobsQ = useQuery(operationsJobsQueryOptions()) const jobsQ = useQuery(operationsJobsQueryOptions())
const modulesQ = useQuery(overviewModulesQueryOptions())
const refreshing = const refreshing =
healthQ.isFetching || healthQ.isFetching ||
readyQ.isFetching || readyQ.isFetching ||
versionQ.isFetching || versionQ.isFetching ||
birdQ.isFetching || birdQ.isFetching ||
jobsQ.isFetching jobsQ.isFetching ||
modulesQ.isFetching
const jobs = jobsQ.data?.items ?? [] const jobs = jobsQ.data?.items ?? []
const running = jobs.filter((j) => j.status === 'running' || j.status === 'queued').length const modules = modulesQ.data?.items ?? []
const failed = jobs.filter((j) => const failed = jobs.filter((j) =>
['failed', 'error', 'cancelled'].includes(j.status.toLowerCase()), ['failed', 'error', 'cancelled'].includes(j.status.toLowerCase()),
).length ).length
const versionText = formatVersion(versionQ.data) const versionText = formatVersion(versionQ.data)
const analyticsLoading =
const items: SectionCardItem[] = [ (healthQ.isLoading || readyQ.isLoading || jobsQ.isLoading) && jobs.length === 0
{
label: 'Общий статус',
value: overallStatusLabel({ health: healthQ.data, ready: readyQ.data, jobsFailed: failed }),
icon: <Gauge className="size-4" />,
hint: overallHint({ health: healthQ.data, jobsFailed: failed }),
},
{
label: 'BGP сессии',
value: birdQ.data
? `${birdQ.data.bgp_established}/${birdQ.data.bgp_sessions_total}`
: '—',
icon: <Bird className="size-4" />,
hint: birdQ.data?.birdc_configured
? 'Established / total на API-хосте'
: 'birdc не настроен',
},
{
label: 'Задачи',
value: running,
icon: <Activity className="size-4" />,
hint: `активных из ${jobs.length}`,
variant: failed > 0 ? 'warning' : 'default',
},
{
label: 'Версия',
value: versionText,
icon: <Gauge className="size-4" />,
hint: versionQ.data?.git_sha ?? versionQ.data?.build_time ?? 'GET /v1/version',
},
]
function refetchAll() { function refetchAll() {
void healthQ.refetch() void healthQ.refetch()
@@ -96,6 +72,7 @@ function MonitoringComponent() {
void versionQ.refetch() void versionQ.refetch()
void birdQ.refetch() void birdQ.refetch()
void jobsQ.refetch() void jobsQ.refetch()
void modulesQ.refetch()
} }
const failedJobs = jobs const failedJobs = jobs
@@ -115,15 +92,41 @@ function MonitoringComponent() {
} }
/> />
<Tabs defaultValue={search.tab}> <BadgeTabs
<TabsList> value={search.tab}
<TabsTrigger value="system">Система</TabsTrigger> onValueChange={(tab) =>
<TabsTrigger value="postgres">PostgreSQL</TabsTrigger> navigate({ search: { tab: tab as 'system' | 'postgres' | 'runtime-logs' } })
<TabsTrigger value="runtime-logs">Файловые логи</TabsTrigger> }
</TabsList> items={[
{ value: 'system', label: 'Система' },
{ value: 'postgres', label: 'PostgreSQL' },
{ value: 'runtime-logs', label: 'Файловые логи' },
]}
>
<TabsContent value="system" className="mt-0 flex flex-col gap-6">
{analyticsLoading ? (
<AnalyticsDashboardSkeleton />
) : (
<div className="grid gap-4 lg:grid-cols-2">
<MonitoringHealthCard
healthOk={healthQ.data?.ok ?? false}
ready={readyQ.data}
loading={healthQ.isLoading || readyQ.isLoading}
/>
<DashboardOperationsFlowCard jobs={jobs} modules={modules} loading={jobsQ.isLoading} />
</div>
)}
<TabsContent value="system" className="mt-4 flex flex-col gap-6"> <Alert className="border-muted bg-muted/30">
{refreshing ? <SectionCardsSkeleton count={4} /> : <SectionCards items={items} />} <Info className="size-4" />
<AlertTitle className="text-sm">
Версия API: {versionText}
{versionQ.data?.git_sha ? ` · ${versionQ.data.git_sha.slice(0, 8)}` : ''}
</AlertTitle>
<AlertDescription className="text-xs">
{overallHint({ health: healthQ.data, jobsFailed: failed })}
</AlertDescription>
</Alert>
<div className="grid gap-4 lg:grid-cols-2"> <div className="grid gap-4 lg:grid-cols-2">
<Card> <Card>
@@ -179,7 +182,7 @@ function MonitoringComponent() {
</CardHeader> </CardHeader>
<CardContent className="space-y-4"> <CardContent className="space-y-4">
<div className="flex flex-wrap gap-4 text-sm"> <div className="flex flex-wrap gap-4 text-sm">
<Metric label="Активных" value={running} /> <Metric label="Активных" value={jobs.filter((j) => j.status === 'running' || j.status === 'queued').length} />
<Metric <Metric
label="С ошибками" label="С ошибками"
value={failed} value={failed}
@@ -196,7 +199,7 @@ function MonitoringComponent() {
<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">{job.kind}</p>
<Badge variant="destructive">{job.status}</Badge> <StatusBadge status={job.status} />
</div> </div>
{job.error ? ( {job.error ? (
<p className="mt-1 text-xs text-muted-foreground"> <p className="mt-1 text-xs text-muted-foreground">
@@ -260,7 +263,7 @@ function MonitoringComponent() {
</div> </div>
</TabsContent> </TabsContent>
<TabsContent value="postgres" className="mt-4"> <TabsContent value="postgres" className="mt-0">
<Card> <Card>
<CardHeader> <CardHeader>
<CardTitle className="text-base">PostgreSQL</CardTitle> <CardTitle className="text-base">PostgreSQL</CardTitle>
@@ -279,7 +282,7 @@ function MonitoringComponent() {
</Card> </Card>
</TabsContent> </TabsContent>
<TabsContent value="runtime-logs" className="mt-4"> <TabsContent value="runtime-logs" className="mt-0">
<Card> <Card>
<CardHeader> <CardHeader>
<CardTitle className="text-base">Файловые логи</CardTitle> <CardTitle className="text-base">Файловые логи</CardTitle>
@@ -297,7 +300,7 @@ function MonitoringComponent() {
</CardContent> </CardContent>
</Card> </Card>
</TabsContent> </TabsContent>
</Tabs> </BadgeTabs>
</div> </div>
) )
} }
@@ -322,13 +325,6 @@ interface OverallInput {
jobsFailed: number jobsFailed: number
} }
function overallStatusLabel(input: OverallInput): string {
if (!input.health?.ok) return 'Ошибка'
if (input.jobsFailed > 0) return 'Внимание'
if (input.ready?.status && input.ready.status !== 'ok') return 'Внимание'
return 'В норме'
}
function overallHint(input: OverallInput): string { function overallHint(input: OverallInput): string {
if (!input.health?.ok) return 'API недоступен или возвращает ошибку' if (!input.health?.ok) return 'API недоступен или возвращает ошибку'
if (input.jobsFailed > 0) return `Есть провальные задачи (${input.jobsFailed})` if (input.jobsFailed > 0) return `Есть провальные задачи (${input.jobsFailed})`
+78 -90
View File
@@ -4,16 +4,19 @@ import { Alert, AlertDescription, AlertTitle } from '@evobgp/ui/components/alert
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 { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@evobgp/ui/components/card'
import { Info, RefreshCw } from 'lucide-react' import { Info, RefreshCw } from 'lucide-react'
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@evobgp/ui/components/tabs'
import { DataGridCard } from '@/components/data-grid-shell' import {
import { NetworkPeersGrid } from '@/components/network/network-peers-grid' DashboardNetworkCapacityCard,
import { NetworkSpeakersGrid } from '@/components/network/network-speakers-grid' NetworkOverviewAnalyticsCard,
} from '@/components/analytics'
import { BadgeTabs, TabsContent } from '@/components/badge-tabs'
import { NetworkPeersCard } from '@/components/network/network-peers-card'
import { NetworkSpeakersCard } from '@/components/network/network-speakers-card'
import { PageHeader } from '@/components/page-header' import { PageHeader } from '@/components/page-header'
import { QueryState } from '@/components/query-state' import { QueryState } from '@/components/query-state'
import { TableSkeleton } from '@/components/skeletons' import { TableSkeleton } from '@/components/skeletons'
import { networkBirdQueryOptions, networkPeersQueryOptions, networkSpeakersQueryOptions } from '@/queries/network' import { networkBirdQueryOptions, networkPeersQueryOptions, networkSpeakersQueryOptions } from '@/queries/network'
import { aggregateNetworkMetrics } from '@/queries/overview' import { overviewJobsQueryOptions } from '@/queries/overview'
export const Route = createFileRoute('/_auth/network')({ export const Route = createFileRoute('/_auth/network')({
component: NetworkComponent, component: NetworkComponent,
@@ -26,14 +29,17 @@ export const Route = createFileRoute('/_auth/network')({
function NetworkComponent() { function NetworkComponent() {
const search = useSearch({ from: '/_auth/network' }) const search = useSearch({ from: '/_auth/network' })
const navigate = Route.useNavigate()
const peersQ = useQuery({ ...networkPeersQueryOptions(), refetchInterval: 30_000 }) const peersQ = useQuery({ ...networkPeersQueryOptions(), refetchInterval: 30_000 })
const speakersQ = useQuery({ ...networkSpeakersQueryOptions(), refetchInterval: 30_000 }) const speakersQ = useQuery({ ...networkSpeakersQueryOptions(), refetchInterval: 30_000 })
const birdQ = useQuery({ ...networkBirdQueryOptions(), refetchInterval: 30_000 }) const birdQ = useQuery({ ...networkBirdQueryOptions(), refetchInterval: 30_000 })
const jobsQ = useQuery(overviewJobsQueryOptions())
const refreshing = peersQ.isFetching || speakersQ.isFetching const refreshing = peersQ.isFetching || speakersQ.isFetching
const peers = peersQ.data?.items ?? [] const peers = peersQ.data?.items ?? []
const speakers = speakersQ.data?.items ?? [] const speakers = speakersQ.data?.items ?? []
const net = aggregateNetworkMetrics(peers, speakers) const jobs = jobsQ.data?.items ?? []
const overviewLoading = peersQ.isLoading || speakersQ.isLoading
function refetchAll() { function refetchAll() {
void peersQ.refetch() void peersQ.refetch()
@@ -62,96 +68,78 @@ function NetworkComponent() {
</AlertDescription> </AlertDescription>
</Alert> </Alert>
<Tabs defaultValue={search.tab}> <BadgeTabs
<TabsList> value={search.tab}
<TabsTrigger value="overview">Обзор</TabsTrigger> onValueChange={(tab) =>
<TabsTrigger value="peers">Пиры ({peers.length})</TabsTrigger> navigate({
<TabsTrigger value="speakers">Спикеры ({speakers.length})</TabsTrigger> search: {
<TabsTrigger value="control-plane">Control plane</TabsTrigger> tab: tab as 'overview' | 'peers' | 'speakers' | 'control-plane',
</TabsList> },
})
<TabsContent value="overview" className="mt-4"> }
<div className="grid gap-4 md:grid-cols-2"> items={[
<Card> { value: 'overview', label: 'Обзор' },
<CardHeader className="border-b py-3"> { value: 'peers', label: 'Пиры', count: peers.length },
<CardTitle className="text-base">Сводка сети</CardTitle> { value: 'speakers', label: 'Спикеры', count: speakers.length, badgeVariant: 'info-light' },
</CardHeader> { value: 'control-plane', label: 'Control plane' },
<CardContent className="grid grid-cols-2 gap-3 p-4 text-sm"> ]}
<Field label="Пиры всего" value={String(net.peersTotal)} /> >
<Field label="Established" value={`${net.peersEstablished} / ${net.peersEnabled}`} /> <TabsContent value="overview" className="mt-0">
<Field label="Спикеры всего" value={String(net.speakersTotal)} /> <div className="grid gap-4 lg:grid-cols-2">
<Field label="Online" value={`${net.speakersOnline} / ${net.speakersTotal}`} /> <NetworkOverviewAnalyticsCard
{net.peersMismatch > 0 ? ( peers={peers}
<Field label="Mismatches" value={String(net.peersMismatch)} /> speakers={speakers}
) : null} loading={overviewLoading}
</CardContent> />
</Card> <DashboardNetworkCapacityCard
<Card> peers={peers}
<CardHeader className="border-b py-3"> speakers={speakers}
<CardTitle className="text-base">BIRD (control plane)</CardTitle> jobs={jobs}
<CardDescription>Статус birdc на хосте API</CardDescription> loading={overviewLoading}
</CardHeader> />
<CardContent className="p-4">
<QueryState
data={birdQ.data}
isLoading={birdQ.isLoading}
isError={birdQ.isError}
error={birdQ.error}
skeleton={<TableSkeleton rows={3} cols={2} />}
onRetry={() => birdQ.refetch()}
>
{(bird) => <BirdSummary bird={bird} />}
</QueryState>
</CardContent>
</Card>
</div> </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">
<QueryState
data={birdQ.data}
isLoading={birdQ.isLoading}
isError={birdQ.isError}
error={birdQ.error}
skeleton={<TableSkeleton rows={3} cols={2} />}
onRetry={() => birdQ.refetch()}
>
{(bird) => <BirdSummary bird={bird} />}
</QueryState>
</CardContent>
</Card>
</TabsContent> </TabsContent>
<TabsContent value="peers" className="mt-4"> <TabsContent value="peers" className="mt-0">
<DataGridCard title="Пиры"> <NetworkPeersCard
<QueryState items={peers}
data={peers} speakers={speakers}
isLoading={peersQ.isLoading} isLoading={peersQ.isLoading}
isError={peersQ.isError} isError={peersQ.isError}
error={peersQ.error} error={peersQ.error}
empty={peers.length === 0} onRetry={() => peersQ.refetch()}
emptyTitle="Нет пиров" />
skeleton={<TableSkeleton rows={6} cols={4} />}
onRetry={() => peersQ.refetch()}
>
{(items) => (
<NetworkPeersGrid
items={items}
isLoading={peersQ.isFetching && !peersQ.isLoading}
/>
)}
</QueryState>
</DataGridCard>
</TabsContent> </TabsContent>
<TabsContent value="speakers" className="mt-4"> <TabsContent value="speakers" className="mt-0">
<DataGridCard title="Спикеры"> <NetworkSpeakersCard
<QueryState items={speakers}
data={speakers} isLoading={speakersQ.isLoading}
isLoading={speakersQ.isLoading} isError={speakersQ.isError}
isError={speakersQ.isError} error={speakersQ.error}
error={speakersQ.error} onRetry={() => speakersQ.refetch()}
empty={speakers.length === 0} />
emptyTitle="Нет спикеров"
skeleton={<TableSkeleton rows={6} cols={4} />}
onRetry={() => speakersQ.refetch()}
>
{(items) => (
<NetworkSpeakersGrid
items={items}
isLoading={speakersQ.isFetching && !speakersQ.isLoading}
/>
)}
</QueryState>
</DataGridCard>
</TabsContent> </TabsContent>
<TabsContent value="control-plane" className="mt-4"> <TabsContent value="control-plane" className="mt-0">
<Card> <Card>
<CardHeader className="border-b py-3"> <CardHeader className="border-b py-3">
<CardTitle className="text-base">Настройки Control Plane (BIRD)</CardTitle> <CardTitle className="text-base">Настройки Control Plane (BIRD)</CardTitle>
@@ -162,7 +150,7 @@ function NetworkComponent() {
</CardContent> </CardContent>
</Card> </Card>
</TabsContent> </TabsContent>
</Tabs> </BadgeTabs>
</div> </div>
) )
} }
+37 -74
View File
@@ -1,28 +1,20 @@
import { createFileRoute, useSearch } from '@tanstack/react-router' import { createFileRoute, useSearch } from '@tanstack/react-router'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { AlertTriangle, Clock, Activity, Info, RefreshCw } from 'lucide-react' import { Info, RefreshCw } from 'lucide-react'
import { toast } from 'sonner' import { toast } from 'sonner'
import { useState, useMemo } from 'react' import { useState, useMemo } from 'react'
import { Alert, AlertDescription, AlertTitle } from '@evobgp/ui/components/alert' import { Alert, AlertDescription, AlertTitle } from '@evobgp/ui/components/alert'
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 { Card, CardContent, CardHeader, CardTitle } from '@evobgp/ui/components/card'
import { import { BadgeTabs, TabsContent } from '@/components/badge-tabs'
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@evobgp/ui/components/select'
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@evobgp/ui/components/tabs'
import { DataGridCard } from '@/components/data-grid-shell' import { DataGridCard } from '@/components/data-grid-shell'
import { OperationsAnalyticsCard } from '@/components/analytics'
import { SelectMenu } from '@/components/select-field'
import { OperationsJobsGrid } from '@/components/operations/operations-jobs-grid' import { OperationsJobsGrid } from '@/components/operations/operations-jobs-grid'
import { OperationsRevisionsGrid } from '@/components/operations/operations-revisions-grid' import { OperationsRevisionsGrid } from '@/components/operations/operations-revisions-grid'
import { PageHeader } from '@/components/page-header' import { PageHeader } from '@/components/page-header'
import { QueryState } from '@/components/query-state' import { QueryState } from '@/components/query-state'
import { SectionCards, type SectionCardItem } from '@/components/section-cards'
import { SectionCardsSkeleton } from '@/components/skeletons'
import { ConfirmDialog } from '@/components/confirm-dialog' import { ConfirmDialog } from '@/components/confirm-dialog'
import { operationsJobsQueryOptions, operationsRevisionsQueryOptions, operationsDiffQueryOptions } from '@/queries/operations' import { operationsJobsQueryOptions, operationsRevisionsQueryOptions, operationsDiffQueryOptions } from '@/queries/operations'
@@ -41,6 +33,7 @@ export const Route = createFileRoute('/_auth/operations')({
function OperationsComponent() { function OperationsComponent() {
const search = useSearch({ from: '/_auth/operations' }) const search = useSearch({ from: '/_auth/operations' })
const navigate = Route.useNavigate()
const qc = useQueryClient() const qc = useQueryClient()
const revisionsQ = useQuery(operationsRevisionsQueryOptions()) const revisionsQ = useQuery(operationsRevisionsQueryOptions())
@@ -52,32 +45,6 @@ function OperationsComponent() {
const nameById = moduleNameById(modulesQ.data?.items ?? []) const nameById = moduleNameById(modulesQ.data?.items ?? [])
const refreshing = revisionsQ.isFetching || jobsQ.isFetching const refreshing = revisionsQ.isFetching || jobsQ.isFetching
const running = jobs.filter((j) => j.status === 'running' || j.status === 'queued').length
const failed = jobs.filter(
(j) => ['failed', 'error', 'cancelled'].includes(j.status.toLowerCase()),
).length
const items: SectionCardItem[] = [
{
label: 'Ревизий',
value: revisions.length,
icon: <Activity className="size-4" />,
hint: 'история конфигов',
},
{
label: 'Активных задач',
value: running,
icon: <Clock className="size-4" />,
hint: 'queued и running',
},
{
label: 'Задач с ошибкой',
value: failed,
icon: <AlertTriangle className="size-4" />,
hint: failed > 0 ? 'требуют внимания' : 'критичных сбоев нет',
variant: failed > 0 ? 'warning' : 'default',
},
]
function refetchAll() { function refetchAll() {
void revisionsQ.refetch() void revisionsQ.refetch()
@@ -161,16 +128,24 @@ function OperationsComponent() {
/> />
</div> </div>
{revisionsQ.isLoading ? <SectionCardsSkeleton count={3} /> : <SectionCards items={items} />} {revisionsQ.isLoading ? (
<OperationsAnalyticsCard jobs={[]} revisions={[]} loading />
) : (
<OperationsAnalyticsCard jobs={jobs} revisions={revisions} />
)}
<Tabs defaultValue={search.tab}> <BadgeTabs
<TabsList> value={search.tab}
<TabsTrigger value="revisions">Ревизии ({revisions.length})</TabsTrigger> onValueChange={(tab) =>
<TabsTrigger value="diff">Сравнение</TabsTrigger> navigate({ search: { tab: tab as 'revisions' | 'diff' | 'jobs' } })
<TabsTrigger value="jobs">Задачи ({jobs.length})</TabsTrigger> }
</TabsList> items={[
{ value: 'revisions', label: 'Ревизии', count: revisions.length },
<TabsContent value="revisions" className="mt-4"> { value: 'diff', label: 'Сравнение' },
{ value: 'jobs', label: 'Задачи', count: jobs.length, badgeVariant: 'info-light' },
]}
>
<TabsContent value="revisions" className="mt-0">
<DataGridCard title="История ревизий"> <DataGridCard title="История ревизий">
<QueryState <QueryState
data={revisions} data={revisions}
@@ -192,11 +167,11 @@ function OperationsComponent() {
</DataGridCard> </DataGridCard>
</TabsContent> </TabsContent>
<TabsContent value="diff" className="mt-4"> <TabsContent value="diff" className="mt-0">
<DiffTab revisions={revisions} /> <DiffTab revisions={revisions} />
</TabsContent> </TabsContent>
<TabsContent value="jobs" className="mt-4"> <TabsContent value="jobs" className="mt-0">
<DataGridCard title="Задачи"> <DataGridCard title="Задачи">
<QueryState <QueryState
data={jobs} data={jobs}
@@ -218,7 +193,7 @@ function OperationsComponent() {
</QueryState> </QueryState>
</DataGridCard> </DataGridCard>
</TabsContent> </TabsContent>
</Tabs> </BadgeTabs>
</div> </div>
) )
} }
@@ -245,33 +220,21 @@ function DiffTab({ revisions }: { revisions: import('@/types/api').RevisionRow[]
<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>
<Select items={revisionItems} value={a} onValueChange={(v) => v && setA(v)}> <SelectMenu
<SelectTrigger> items={revisionItems}
<SelectValue placeholder="Выберите" /> value={a}
</SelectTrigger> placeholder="Выберите"
<SelectContent> onValueChange={(v) => v && setA(v)}
{revisions.map((r) => ( />
<SelectItem key={r.id} value={r.id}>
{r.id.slice(0, 12)}
</SelectItem>
))}
</SelectContent>
</Select>
</div> </div>
<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">Ревизия B</span> <span className="text-xs text-muted-foreground">Ревизия B</span>
<Select items={revisionItems} value={b} onValueChange={(v) => v && setB(v)}> <SelectMenu
<SelectTrigger> items={revisionItems}
<SelectValue placeholder="Выберите" /> value={b}
</SelectTrigger> placeholder="Выберите"
<SelectContent> onValueChange={(v) => v && setB(v)}
{revisions.map((r) => ( />
<SelectItem key={r.id} value={r.id}>
{r.id.slice(0, 12)}
</SelectItem>
))}
</SelectContent>
</Select>
</div> </div>
<Button onClick={() => diffQ.refetch()} disabled={!a || !b || diffQ.isFetching}> <Button onClick={() => diffQ.refetch()} disabled={!a || !b || diffQ.isFetching}>
Сравнить Сравнить
+16 -9
View File
@@ -6,8 +6,7 @@ import { useState } from 'react'
import { Alert, AlertDescription, AlertTitle } from '@evobgp/ui/components/alert' import { Alert, AlertDescription, AlertTitle } from '@evobgp/ui/components/alert'
import { Button } from '@evobgp/ui/components/button' import { Button } from '@evobgp/ui/components/button'
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@evobgp/ui/components/tabs' import { BadgeTabs, TabsContent } from '@/components/badge-tabs'
import { DataGridCard } from '@/components/data-grid-shell' import { DataGridCard } from '@/components/data-grid-shell'
import { ScheduleJobsGrid } from '@/components/schedule/schedule-jobs-grid' import { ScheduleJobsGrid } from '@/components/schedule/schedule-jobs-grid'
import { ScheduleModulesGrid } from '@/components/schedule/schedule-modules-grid' import { ScheduleModulesGrid } from '@/components/schedule/schedule-modules-grid'
@@ -138,12 +137,20 @@ function JobsTabs({ jobs, loading }: { jobs: JobRow[]; loading: boolean }) {
) )
return ( return (
<Tabs defaultValue="all"> <BadgeTabs
<TabsList className="m-3 mb-0"> defaultValue="all"
<TabsTrigger value="all">Все ({jobs.length})</TabsTrigger> listClassName="mx-3 mb-0 w-auto"
<TabsTrigger value="refresh">Обновление ({refresh.length})</TabsTrigger> items={[
<TabsTrigger value="failed">С ошибкой ({failed.length})</TabsTrigger> { value: 'all', label: 'Все', count: jobs.length },
</TabsList> { value: 'refresh', label: 'Обновление', count: refresh.length, badgeVariant: 'info-light' },
{
value: 'failed',
label: 'С ошибкой',
count: failed.length,
badgeVariant: failed.length > 0 ? 'destructive-light' : 'primary-light',
},
]}
>
<TabsContent value="all" className="mt-0"> <TabsContent value="all" className="mt-0">
<ScheduleJobsGrid items={jobs} isLoading={loading} /> <ScheduleJobsGrid items={jobs} isLoading={loading} />
</TabsContent> </TabsContent>
@@ -153,6 +160,6 @@ function JobsTabs({ jobs, loading }: { jobs: JobRow[]; loading: boolean }) {
<TabsContent value="failed" className="mt-0"> <TabsContent value="failed" className="mt-0">
<ScheduleJobsGrid items={failed} isLoading={loading} /> <ScheduleJobsGrid items={failed} isLoading={loading} />
</TabsContent> </TabsContent>
</Tabs> </BadgeTabs>
) )
} }
+7 -19
View File
@@ -6,16 +6,10 @@ import { Button } from '@evobgp/ui/components/button'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@evobgp/ui/components/card' import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@evobgp/ui/components/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 {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@evobgp/ui/components/select'
import { PageHeader } from '@/components/page-header' import { PageHeader } from '@/components/page-header'
import { LoadingButton } from '@/components/loading-button' import { LoadingButton } from '@/components/loading-button'
import { SelectField } from '@/components/select-field'
import { DEV_API_TOKEN, normalizeApiToken, setToken, TOKEN_STORAGE_KEY } from '@/lib/api-client' import { DEV_API_TOKEN, normalizeApiToken, setToken, TOKEN_STORAGE_KEY } from '@/lib/api-client'
import { authKeys, authSessionQueryOptions } from '@/queries/auth' import { authKeys, authSessionQueryOptions } from '@/queries/auth'
import { toast } from 'sonner' import { toast } from 'sonner'
@@ -141,21 +135,15 @@ function SettingsComponent() {
</CardDescription> </CardDescription>
</CardHeader> </CardHeader>
<CardContent className="flex flex-col gap-2"> <CardContent className="flex flex-col gap-2">
<Label htmlFor="theme-select">Тема</Label> <SelectField
<Select id="theme-select"
label="Тема"
items={[...THEME_SELECT_ITEMS]} items={[...THEME_SELECT_ITEMS]}
value={theme ?? 'system'} value={theme ?? 'system'}
placeholder="Выберите тему"
triggerClassName="max-w-xs"
onValueChange={(v) => v && setTheme(v)} onValueChange={(v) => v && setTheme(v)}
> />
<SelectTrigger id="theme-select" className="w-full max-w-xs">
<SelectValue placeholder="Выберите тему" />
</SelectTrigger>
<SelectContent>
<SelectItem value="light">Светлая</SelectItem>
<SelectItem value="dark">Тёмная</SelectItem>
<SelectItem value="system">Как в системе</SelectItem>
</SelectContent>
</Select>
</CardContent> </CardContent>
</Card> </Card>
</div> </div>
+50 -73
View File
@@ -8,16 +8,8 @@ import { Alert, AlertDescription, AlertTitle } from '@evobgp/ui/components/alert
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@evobgp/ui/components/card' import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@evobgp/ui/components/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 { import { BadgeTabs, TabsContent } from '@/components/badge-tabs'
Select, import { SelectField } from '@/components/select-field'
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@evobgp/ui/components/select'
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@evobgp/ui/components/tabs'
import { SettingsKvGrid } from '@/components/settings/settings-kv-grid' import { SettingsKvGrid } from '@/components/settings/settings-kv-grid'
import { PageHeader } from '@/components/page-header' import { PageHeader } from '@/components/page-header'
import { QueryState } from '@/components/query-state' import { QueryState } from '@/components/query-state'
@@ -65,6 +57,7 @@ const BIRD_LABELS: Record<BirdSettingKey, string> = {
function TenantSettingsComponent() { function TenantSettingsComponent() {
const search = useSearch({ from: '/_auth/tenant-settings' }) const search = useSearch({ from: '/_auth/tenant-settings' })
const navigate = Route.useNavigate()
const settingsQ = useQuery(settingsQueryOptions()) const settingsQ = useQuery(settingsQueryOptions())
const qc = useQueryClient() const qc = useQueryClient()
@@ -119,15 +112,21 @@ function TenantSettingsComponent() {
</AlertDescription> </AlertDescription>
</Alert> </Alert>
<Tabs defaultValue={search.tab}> <BadgeTabs
<TabsList> value={search.tab}
<TabsTrigger value="bird">BIRD</TabsTrigger> onValueChange={(tab) =>
<TabsTrigger value="revision">Ревизии</TabsTrigger> navigate({
<TabsTrigger value="runtime-logs">Файловые логи</TabsTrigger> search: { tab: tab as 'bird' | 'revision' | 'runtime-logs' | 'additional' },
<TabsTrigger value="additional">Дополнительно</TabsTrigger> })
</TabsList> }
items={[
<TabsContent value="bird" className="mt-4"> { value: 'bird', label: 'BIRD' },
{ value: 'revision', label: 'Ревизии' },
{ value: 'runtime-logs', label: 'Файловые логи' },
{ value: 'additional', label: 'Дополнительно' },
]}
>
<TabsContent value="bird" className="mt-0">
<Card> <Card>
<CardHeader> <CardHeader>
<CardTitle>BIRD control plane</CardTitle> <CardTitle>BIRD control plane</CardTitle>
@@ -180,7 +179,7 @@ function TenantSettingsComponent() {
</Card> </Card>
</TabsContent> </TabsContent>
<TabsContent value="revision" className="mt-4"> <TabsContent value="revision" className="mt-0">
<Card> <Card>
<CardHeader> <CardHeader>
<CardTitle>Ревизии</CardTitle> <CardTitle>Ревизии</CardTitle>
@@ -227,7 +226,7 @@ function TenantSettingsComponent() {
</Card> </Card>
</TabsContent> </TabsContent>
<TabsContent value="runtime-logs" className="mt-4"> <TabsContent value="runtime-logs" className="mt-0">
<Card> <Card>
<CardHeader> <CardHeader>
<CardTitle>Файловые логи</CardTitle> <CardTitle>Файловые логи</CardTitle>
@@ -244,31 +243,20 @@ function TenantSettingsComponent() {
> >
{() => ( {() => (
<div className="grid gap-4 md:grid-cols-2"> <div className="grid gap-4 md:grid-cols-2">
<div className="flex flex-col gap-1.5"> <SelectField
<Label>Авто-очистка включена</Label> label="Авто-очистка включена"
<Select items={[...RUNTIME_LOGS_ENABLED_ITEMS]}
items={[...RUNTIME_LOGS_ENABLED_ITEMS]} value={runtimeLogsForm.runtime_logs_auto_enabled ?? 'false'}
value={runtimeLogsForm.runtime_logs_auto_enabled ?? 'false'} placeholder="Выберите"
onValueChange={(v) => description="runtime_logs_auto_enabled"
v && onValueChange={(v) =>
setRuntimeLogsForm((s) => ({ v &&
...s, setRuntimeLogsForm((s) => ({
runtime_logs_auto_enabled: v, ...s,
})) runtime_logs_auto_enabled: v,
} }))
> }
<SelectTrigger> />
<SelectValue placeholder="Выберите" />
</SelectTrigger>
<SelectContent>
<SelectItem value="true">Вкл</SelectItem>
<SelectItem value="false">Выкл</SelectItem>
</SelectContent>
</Select>
<p className="font-mono text-xs text-muted-foreground">
runtime_logs_auto_enabled
</p>
</div>
<div className="flex flex-col gap-1.5"> <div className="flex flex-col gap-1.5">
<Label htmlFor="runtime_logs_max_file_mb">Макс. размер файла (MB)</Label> <Label htmlFor="runtime_logs_max_file_mb">Макс. размер файла (MB)</Label>
<Input <Input
@@ -302,31 +290,20 @@ function TenantSettingsComponent() {
runtime_logs_auto_schedule runtime_logs_auto_schedule
</p> </p>
</div> </div>
<div className="flex flex-col gap-1.5"> <SelectField
<Label>Режим очистки</Label> label="Режим очистки"
<Select items={[...RUNTIME_LOGS_MODE_ITEMS]}
items={[...RUNTIME_LOGS_MODE_ITEMS]} value={runtimeLogsForm.runtime_logs_auto_mode ?? ''}
value={runtimeLogsForm.runtime_logs_auto_mode ?? ''} placeholder="Выберите"
onValueChange={(v) => description="runtime_logs_auto_mode"
v && onValueChange={(v) =>
setRuntimeLogsForm((s) => ({ v &&
...s, setRuntimeLogsForm((s) => ({
runtime_logs_auto_mode: v, ...s,
})) runtime_logs_auto_mode: v,
} }))
> }
<SelectTrigger> />
<SelectValue placeholder="Выберите" />
</SelectTrigger>
<SelectContent>
<SelectItem value="truncate">truncate обнулить</SelectItem>
<SelectItem value="delete">delete удалить файл</SelectItem>
</SelectContent>
</Select>
<p className="font-mono text-xs text-muted-foreground">
runtime_logs_auto_mode
</p>
</div>
<div className="md:col-span-2"> <div className="md:col-span-2">
<LoadingButton onClick={saveRuntimeLogs} loading={patchMutation.isPending}> <LoadingButton onClick={saveRuntimeLogs} loading={patchMutation.isPending}>
<Save /> <Save />
@@ -340,7 +317,7 @@ function TenantSettingsComponent() {
</Card> </Card>
</TabsContent> </TabsContent>
<TabsContent value="additional" className="mt-4"> <TabsContent value="additional" className="mt-0">
<Card> <Card>
<CardHeader> <CardHeader>
<CardTitle>Дополнительные параметры</CardTitle> <CardTitle>Дополнительные параметры</CardTitle>
@@ -369,7 +346,7 @@ function TenantSettingsComponent() {
</CardContent> </CardContent>
</Card> </Card>
</TabsContent> </TabsContent>
</Tabs> </BadgeTabs>
</div> </div>
) )
} }
File diff suppressed because one or more lines are too long
+109
View File
@@ -0,0 +1,109 @@
"use client"
import * as React from "react"
import { Avatar as AvatarPrimitive } from "@base-ui/react/avatar"
import { cn } from "@evobgp/ui/lib/utils"
function Avatar({
className,
size = "default",
...props
}: AvatarPrimitive.Root.Props & {
size?: "default" | "sm" | "lg"
}) {
return (
<AvatarPrimitive.Root
data-slot="avatar"
data-size={size}
className={cn(
"group/avatar relative flex size-8 shrink-0 rounded-full select-none after:absolute after:inset-0 after:rounded-full after:border after:border-border after:mix-blend-darken data-[size=lg]:size-10 data-[size=sm]:size-6 dark:after:mix-blend-lighten",
className
)}
{...props}
/>
)
}
function AvatarImage({ className, ...props }: AvatarPrimitive.Image.Props) {
return (
<AvatarPrimitive.Image
data-slot="avatar-image"
className={cn(
"aspect-square size-full rounded-full object-cover",
className
)}
{...props}
/>
)
}
function AvatarFallback({
className,
...props
}: AvatarPrimitive.Fallback.Props) {
return (
<AvatarPrimitive.Fallback
data-slot="avatar-fallback"
className={cn(
"flex size-full items-center justify-center rounded-full bg-muted text-sm text-muted-foreground group-data-[size=sm]/avatar:text-xs",
className
)}
{...props}
/>
)
}
function AvatarBadge({ className, ...props }: React.ComponentProps<"span">) {
return (
<span
data-slot="avatar-badge"
className={cn(
"absolute right-0 bottom-0 z-10 inline-flex items-center justify-center rounded-full bg-primary text-primary-foreground bg-blend-color ring-2 ring-background select-none",
"group-data-[size=sm]/avatar:size-2 group-data-[size=sm]/avatar:[&>svg]:hidden",
"group-data-[size=default]/avatar:size-2.5 group-data-[size=default]/avatar:[&>svg]:size-2",
"group-data-[size=lg]/avatar:size-3 group-data-[size=lg]/avatar:[&>svg]:size-2",
className
)}
{...props}
/>
)
}
function AvatarGroup({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="avatar-group"
className={cn(
"group/avatar-group flex -space-x-2 *:data-[slot=avatar]:ring-2 *:data-[slot=avatar]:ring-background",
className
)}
{...props}
/>
)
}
function AvatarGroupCount({
className,
...props
}: React.ComponentProps<"div">) {
return (
<div
data-slot="avatar-group-count"
className={cn(
"relative flex size-8 shrink-0 items-center justify-center rounded-full bg-muted text-sm text-muted-foreground ring-2 ring-background group-has-data-[size=lg]/avatar-group:size-10 group-has-data-[size=sm]/avatar-group:size-6 [&>svg]:size-4 group-has-data-[size=lg]/avatar-group:[&>svg]:size-5 group-has-data-[size=sm]/avatar-group:[&>svg]:size-3",
className
)}
{...props}
/>
)
}
export {
Avatar,
AvatarImage,
AvatarFallback,
AvatarGroup,
AvatarGroupCount,
AvatarBadge,
}
+2
View File
@@ -1,3 +1,5 @@
"use client"
import { Checkbox as CheckboxPrimitive } from "@base-ui/react/checkbox" import { Checkbox as CheckboxPrimitive } from "@base-ui/react/checkbox"
import { cn } from "@evobgp/ui/lib/utils" import { cn } from "@evobgp/ui/lib/utils"
+81
View File
@@ -0,0 +1,81 @@
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",
className
)}
data-slot="progress-track"
{...props}
/>
)
}
function ProgressIndicator({
className,
...props
}: ProgressPrimitive.Indicator.Props) {
return (
<ProgressPrimitive.Indicator
data-slot="progress-indicator"
className={cn("h-full bg-primary transition-all", className)}
{...props}
/>
)
}
function ProgressLabel({ className, ...props }: ProgressPrimitive.Label.Props) {
return (
<ProgressPrimitive.Label
className={cn("text-sm font-medium", className)}
data-slot="progress-label"
{...props}
/>
)
}
function ProgressValue({ className, ...props }: ProgressPrimitive.Value.Props) {
return (
<ProgressPrimitive.Value
className={cn(
"ml-auto text-sm text-muted-foreground tabular-nums",
className
)}
data-slot="progress-value"
{...props}
/>
)
}
export {
Progress,
ProgressTrack,
ProgressIndicator,
ProgressLabel,
ProgressValue,
}
-6
View File
@@ -1,5 +1,3 @@
"use client"
import * as React from "react" import * as React from "react"
import { Select as SelectPrimitive } from "@base-ui/react/select" import { Select as SelectPrimitive } from "@base-ui/react/select"
@@ -111,15 +109,11 @@ function SelectLabel({
function SelectItem({ function SelectItem({
className, className,
children, children,
label,
...props ...props
}: SelectPrimitive.Item.Props) { }: SelectPrimitive.Item.Props) {
const resolvedLabel = label ?? (typeof children === "string" ? children : undefined)
return ( return (
<SelectPrimitive.Item <SelectPrimitive.Item
data-slot="select-item" data-slot="select-item"
label={resolvedLabel}
className={cn( className={cn(
"relative flex w-full cursor-default items-center gap-1.5 rounded-md py-1 pr-8 pl-1.5 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground not-data-[variant=destructive]:focus:**:text-accent-foreground data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2", "relative flex w-full cursor-default items-center gap-1.5 rounded-md py-1 pr-8 pl-1.5 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground not-data-[variant=destructive]:focus:**:text-accent-foreground data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2",
className className
+2
View File
@@ -1,3 +1,5 @@
"use client"
import { Separator as SeparatorPrimitive } from "@base-ui/react/separator" import { Separator as SeparatorPrimitive } from "@base-ui/react/separator"
import { cn } from "@evobgp/ui/lib/utils" import { cn } from "@evobgp/ui/lib/utils"
+5 -6
View File
@@ -1,5 +1,3 @@
"use client"
import { Tabs as TabsPrimitive } from "@base-ui/react/tabs" import { Tabs as TabsPrimitive } from "@base-ui/react/tabs"
import { cva, type VariantProps } from "class-variance-authority" import { cva, type VariantProps } from "class-variance-authority"
@@ -13,10 +11,10 @@ function Tabs({
return ( return (
<TabsPrimitive.Root <TabsPrimitive.Root
data-slot="tabs" data-slot="tabs"
orientation={orientation}
data-orientation={orientation} data-orientation={orientation}
className={cn( className={cn(
"group/tabs flex gap-2 data-[orientation=horizontal]:flex-col", "group/tabs flex gap-2",
orientation === "horizontal" ? "flex-col" : "flex-row",
className className
)} )}
{...props} {...props}
@@ -25,7 +23,7 @@ function Tabs({
} }
const tabsListVariants = cva( const tabsListVariants = cva(
"group/tabs-list inline-flex w-fit items-center justify-center rounded-lg p-[3px] text-muted-foreground group-data-[orientation=horizontal]/tabs:h-8 group-data-[orientation=vertical]/tabs:h-fit group-data-[orientation=vertical]/tabs:flex-col data-[variant=line]:rounded-none", "group/tabs-list inline-flex w-fit items-center justify-center rounded-lg p-[3px] text-muted-foreground group-data-[orientation=horizontal]/tabs:h-8 group-data-[orientation=vertical]/tabs:h-fit group-data-[orientation=vertical]/tabs:flex-col data-[variant=line]:h-auto data-[variant=line]:rounded-none data-[variant=line]:border-b data-[variant=line]:border-border data-[variant=line]:bg-transparent data-[variant=line]:p-0",
{ {
variants: { variants: {
variant: { variant: {
@@ -60,9 +58,10 @@ function TabsTrigger({ className, ...props }: TabsPrimitive.Tab.Props) {
data-slot="tabs-trigger" data-slot="tabs-trigger"
className={cn( className={cn(
"relative inline-flex h-[calc(100%-1px)] flex-1 items-center justify-center gap-1.5 rounded-md border border-transparent px-1.5 py-0.5 text-sm font-medium whitespace-nowrap text-foreground/60 transition-all group-data-[orientation=vertical]/tabs:w-full group-data-[orientation=vertical]/tabs:justify-start hover:text-foreground focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 focus-visible:outline-1 focus-visible:outline-ring disabled:pointer-events-none disabled:opacity-50 has-data-[icon=inline-end]:pr-1 has-data-[icon=inline-start]:pl-1 aria-disabled:pointer-events-none aria-disabled:opacity-50 dark:text-muted-foreground dark:hover:text-foreground group-data-[variant=default]/tabs-list:data-active:shadow-sm group-data-[variant=line]/tabs-list:data-active:shadow-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4", "relative inline-flex h-[calc(100%-1px)] flex-1 items-center justify-center gap-1.5 rounded-md border border-transparent px-1.5 py-0.5 text-sm font-medium whitespace-nowrap text-foreground/60 transition-all group-data-[orientation=vertical]/tabs:w-full group-data-[orientation=vertical]/tabs:justify-start hover:text-foreground focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 focus-visible:outline-1 focus-visible:outline-ring disabled:pointer-events-none disabled:opacity-50 has-data-[icon=inline-end]:pr-1 has-data-[icon=inline-start]:pl-1 aria-disabled:pointer-events-none aria-disabled:opacity-50 dark:text-muted-foreground dark:hover:text-foreground group-data-[variant=default]/tabs-list:data-active:shadow-sm group-data-[variant=line]/tabs-list:data-active:shadow-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
"group-data-[variant=line]/tabs-list:h-auto group-data-[variant=line]/tabs-list:flex-none group-data-[variant=line]/tabs-list:rounded-none group-data-[variant=line]/tabs-list:border-0 group-data-[variant=line]/tabs-list:bg-transparent group-data-[variant=line]/tabs-list:px-0 group-data-[variant=line]/tabs-list:pb-3 group-data-[variant=line]/tabs-list:pt-1 group-data-[variant=line]/tabs-list:shadow-none",
"group-data-[variant=line]/tabs-list:bg-transparent group-data-[variant=line]/tabs-list:data-active:bg-transparent dark:group-data-[variant=line]/tabs-list:data-active:border-transparent dark:group-data-[variant=line]/tabs-list:data-active:bg-transparent", "group-data-[variant=line]/tabs-list:bg-transparent group-data-[variant=line]/tabs-list:data-active:bg-transparent dark:group-data-[variant=line]/tabs-list:data-active:border-transparent dark:group-data-[variant=line]/tabs-list:data-active:bg-transparent",
"data-active:bg-background data-active:text-foreground dark:data-active:border-input dark:data-active:bg-input/30 dark:data-active:text-foreground", "data-active:bg-background data-active:text-foreground dark:data-active:border-input dark:data-active:bg-input/30 dark:data-active:text-foreground",
"after:absolute after:bg-foreground after:opacity-0 after:transition-opacity group-data-[orientation=horizontal]/tabs:after:inset-x-0 group-data-[orientation=horizontal]/tabs:after:bottom-[-5px] group-data-[orientation=horizontal]/tabs:after:h-0.5 group-data-[orientation=vertical]/tabs:after:inset-y-0 group-data-[orientation=vertical]/tabs:after:-right-1 group-data-[orientation=vertical]/tabs:after:w-0.5 group-data-[variant=line]/tabs-list:data-active:after:opacity-100", "after:absolute after:bg-foreground after:opacity-0 after:transition-opacity group-data-[orientation=horizontal]/tabs:after:inset-x-0 group-data-[orientation=horizontal]/tabs:group-data-[variant=line]/tabs-list:after:bottom-0 group-data-[orientation=horizontal]/tabs:group-data-[variant=line]/tabs-list:after:h-0.5 group-data-[orientation=vertical]/tabs:after:inset-y-0 group-data-[orientation=vertical]/tabs:after:-right-1 group-data-[orientation=vertical]/tabs:after:w-0.5 group-data-[variant=line]/tabs-list:data-active:after:opacity-100 group-data-[variant=line]/tabs-list:data-active:after:z-10",
className className
)} )}
{...props} {...props}