Compare commits

...
6 Commits
Author SHA1 Message Date
Denozordec ac727ad1e3 refactor: update dialog components and enhance UI consistency
CI / changes (push) Successful in 13s
CI / commitlint (push) Has been skipped
CI / openapi (push) Has been skipped
CI / web (push) Successful in 1m5s
CI / go (push) Has been skipped
CI / bird2 (push) Has been skipped
CI / release (push) Successful in 4m20s
Refactored ConfirmDialog and FormDrawer components to improve layout and user experience. Integrated new DrawerActionsFooter for better action handling in dialogs. Updated styles for consistency across components, including adjustments to the skeletons and analytics card layouts. Removed deprecated alert components from various routes to streamline the codebase and enhance clarity in the UI.
2026-07-09 16:54:18 +07:00
Denozordec c265c06f93 refactor: replace dialog components with FormDrawer for improved UI consistency
CI / changes (push) Successful in 10s
CI / commitlint (push) Has been skipped
CI / openapi (push) Has been skipped
CI / web (push) Successful in 50s
CI / go (push) Has been skipped
CI / bird2 (push) Has been skipped
CI / release (push) Successful in 3m58s
Updated multiple components to utilize the new FormDrawer for modal dialogs, enhancing the user interface and streamlining the layout. This change includes the ConfirmDialog, ApiKeyCreateDialog, FirewallRuleCreateDialog, and others, ensuring a more cohesive and modern design across the application. Additionally, refactored the ConfirmDialog to improve confirmation handling and user feedback during actions.
2026-07-09 14:09:40 +07:00
Denozordec d434eb0d94 feat: enhance UI components with DataGridCard integration and improved error handling
CI / changes (push) Successful in 9s
CI / commitlint (push) Has been skipped
CI / openapi (push) Has been skipped
CI / web (push) Successful in 50s
CI / go (push) Has been skipped
CI / bird2 (push) Has been skipped
CI / release (push) Successful in 4m21s
Refactored multiple components to utilize the new DataGridCard for better organization and presentation of data. Updated the FirewallPage and Monitoring components to enhance loading states and error handling using QueryState. Added success and error notifications for firewall rule creation, improving user feedback. This update streamlines the user experience and ensures a more consistent interface across the application.
2026-07-09 13:45:53 +07:00
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
62 changed files with 2630 additions and 1224 deletions
@@ -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-ключей…"
/> />
) )
} }
@@ -2,16 +2,10 @@ import { useEffect, useState } from 'react'
import { toast } from 'sonner' import { toast } from 'sonner'
import { Button } from '@evobgp/ui/components/button' import { Button } from '@evobgp/ui/components/button'
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
} 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 { FormDrawer } from '@/components/form-drawer'
import { LoadingButton } from '@/components/loading-button' import { LoadingButton } from '@/components/loading-button'
import { SelectField } from '@/components/select-field' 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'
@@ -68,48 +62,48 @@ export function ApiKeyCreateDialog({ open, onOpenChange, onCreated }: ApiKeyCrea
} }
return ( return (
<Dialog open={open} onOpenChange={handleOpenChange}> <FormDrawer
<DialogContent className="sm:max-w-sm"> open={open}
<DialogHeader> onOpenChange={handleOpenChange}
<DialogTitle>Новый API-ключ</DialogTitle> title="Новый API-ключ"
</DialogHeader> className="sm:max-w-sm"
<div className="flex flex-col gap-4 py-2"> footer={
<div className="flex flex-col gap-2"> <>
<Label htmlFor="key-name">Имя</Label>
<Input
id="key-name"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="CI / оператор UI"
/>
</div>
<SelectField
id="key-role"
label="Роль"
items={[...API_KEY_ROLE_ITEMS]}
value={role}
placeholder="Выберите роль"
onValueChange={(v) => v && setRole(v as ApiKeyRole)}
/>
<div className="flex flex-col gap-2">
<Label htmlFor="key-expires">Истекает (опционально)</Label>
<Input
id="key-expires"
type="datetime-local"
value={expiresLocal}
onChange={(e) => setExpiresLocal(e.target.value)}
/>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => handleOpenChange(false)}> <Button variant="outline" onClick={() => handleOpenChange(false)}>
Отмена Отмена
</Button> </Button>
<LoadingButton onClick={save} loading={createMutation.isPending}> <LoadingButton onClick={save} loading={createMutation.isPending}>
Создать Создать
</LoadingButton> </LoadingButton>
</DialogFooter> </>
</DialogContent> }
</Dialog> >
<div className="flex flex-col gap-2">
<Label htmlFor="key-name">Имя</Label>
<Input
id="key-name"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="CI / оператор UI"
/>
</div>
<SelectField
id="key-role"
label="Роль"
items={[...API_KEY_ROLE_ITEMS]}
value={role}
placeholder="Выберите роль"
onValueChange={(v) => v && setRole(v as ApiKeyRole)}
/>
<div className="flex flex-col gap-2">
<Label htmlFor="key-expires">Истекает (опционально)</Label>
<Input
id="key-expires"
type="datetime-local"
value={expiresLocal}
onChange={(e) => setExpiresLocal(e.target.value)}
/>
</div>
</FormDrawer>
) )
} }
@@ -34,7 +34,7 @@ export function AnalyticsCardShell({
children: ReactNode children: ReactNode
}) { }) {
return ( return (
<Card className={cn('flex h-full flex-col gap-0 overflow-hidden', className)}> <Card className={cn('flex flex-col gap-0 overflow-hidden', className)}>
<CardHeader className="flex flex-row items-start justify-between gap-3 border-b py-4"> <CardHeader className="flex flex-row items-start justify-between gap-3 border-b py-4">
<div className="min-w-0 space-y-1"> <div className="min-w-0 space-y-1">
<CardTitle className="flex items-center gap-2 text-base"> <CardTitle className="flex items-center gap-2 text-base">
@@ -57,7 +57,7 @@ export function AnalyticsCardShell({
</div> </div>
{actions ? <div className="shrink-0">{actions}</div> : null} {actions ? <div className="shrink-0">{actions}</div> : null}
</CardHeader> </CardHeader>
<CardContent className="flex flex-1 flex-col gap-5 p-5">{children}</CardContent> <CardContent className="flex flex-col gap-5 p-5">{children}</CardContent>
{footer ? <CardFooter className="gap-2 border-t p-4">{footer}</CardFooter> : null} {footer ? <CardFooter className="gap-2 border-t p-4">{footer}</CardFooter> : null}
</Card> </Card>
) )
+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>
)
}
+107 -30
View File
@@ -1,53 +1,130 @@
import { Button } from '@evobgp/ui/components/button'
import { import {
AlertDialog, Drawer,
AlertDialogAction, DrawerClose,
AlertDialogCancel, DrawerContent,
AlertDialogContent, DrawerDescription,
AlertDialogDescription, DrawerHeader,
AlertDialogFooter, DrawerTitle,
AlertDialogHeader, DrawerTrigger,
AlertDialogTitle, } from '@evobgp/ui/components/drawer'
AlertDialogTrigger,
} from '@evobgp/ui/components/alert-dialog'
import type { ReactElement, ReactNode } from 'react' import type { ReactElement, ReactNode } from 'react'
interface ConfirmDialogProps { import {
trigger: ReactElement confirmDrawerContentClassName,
DrawerActionsFooter,
} from '@/components/drawer-layout'
type ConfirmDialogBaseProps = {
title: string title: string
description?: ReactNode description?: ReactNode
confirmLabel?: string confirmLabel?: string
cancelLabel?: string cancelLabel?: string
destructive?: boolean destructive?: boolean
onConfirm: () => void onConfirm: () => void
confirmDisabled?: boolean
confirmLoading?: boolean
confirmLoadingLabel?: string
} }
export function ConfirmDialog({ type ConfirmDialogWithTrigger = ConfirmDialogBaseProps & {
trigger, trigger: ReactElement
open?: never
onOpenChange?: never
}
type ConfirmDialogControlled = ConfirmDialogBaseProps & {
trigger?: never
open: boolean
onOpenChange: (open: boolean) => void
}
type ConfirmDialogProps = ConfirmDialogWithTrigger | ConfirmDialogControlled
function ConfirmDrawerBody({
title, title,
description, description,
confirmLabel = 'Подтвердить', confirmLabel = 'Подтвердить',
cancelLabel = 'Отмена', cancelLabel = 'Отмена',
destructive, destructive,
onConfirm, onConfirm,
}: ConfirmDialogProps) { confirmDisabled,
confirmLoading,
confirmLoadingLabel,
controlled,
}: ConfirmDialogBaseProps & { controlled?: boolean }) {
const confirmText =
confirmLoading && confirmLoadingLabel
? confirmLoadingLabel
: confirmLoading
? `${confirmLabel}`
: confirmLabel
return ( return (
<AlertDialog> <>
<AlertDialogTrigger render={trigger} /> <DrawerHeader className="shrink-0 border-b border-border pb-4">
<AlertDialogContent> <DrawerTitle>{title}</DrawerTitle>
<AlertDialogHeader> {description ? <DrawerDescription>{description}</DrawerDescription> : null}
<AlertDialogTitle>{title}</AlertDialogTitle> </DrawerHeader>
{description ? <AlertDialogDescription>{description}</AlertDialogDescription> : null} <DrawerActionsFooter>
</AlertDialogHeader> <DrawerClose render={<Button variant="outline" disabled={confirmLoading} />}>
<AlertDialogFooter> {cancelLabel}
<AlertDialogCancel>{cancelLabel}</AlertDialogCancel> </DrawerClose>
<AlertDialogAction {controlled ? (
<Button
variant={destructive ? 'destructive' : 'default'} variant={destructive ? 'destructive' : 'default'}
disabled={confirmDisabled || confirmLoading}
onClick={onConfirm} onClick={onConfirm}
> >
{confirmLabel} {confirmText}
</AlertDialogAction> </Button>
</AlertDialogFooter> ) : (
</AlertDialogContent> <DrawerClose
</AlertDialog> render={
<Button
variant={destructive ? 'destructive' : 'default'}
disabled={confirmDisabled || confirmLoading}
/>
}
onClick={onConfirm}
>
{confirmText}
</DrawerClose>
)}
</DrawerActionsFooter>
</>
)
}
export function ConfirmDialog(props: ConfirmDialogProps) {
const bodyProps: ConfirmDialogBaseProps = {
title: props.title,
description: props.description,
confirmLabel: props.confirmLabel,
cancelLabel: props.cancelLabel,
destructive: props.destructive,
onConfirm: props.onConfirm,
confirmDisabled: props.confirmDisabled,
confirmLoading: props.confirmLoading,
confirmLoadingLabel: props.confirmLoadingLabel,
}
if (props.trigger) {
return (
<Drawer swipeDirection="right">
<DrawerTrigger render={props.trigger} />
<DrawerContent className={confirmDrawerContentClassName}>
<ConfirmDrawerBody {...bodyProps} />
</DrawerContent>
</Drawer>
)
}
return (
<Drawer open={props.open} onOpenChange={props.onOpenChange} swipeDirection="right">
<DrawerContent className={confirmDrawerContentClassName}>
<ConfirmDrawerBody {...bodyProps} controlled />
</DrawerContent>
</Drawer>
) )
} }
@@ -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>
)
}
+39 -1
View File
@@ -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'
@@ -45,7 +46,11 @@ export function DataGridShell<TData extends object>({
<DataGridContainer> <DataGridContainer>
<DataGridTable /> <DataGridTable />
</DataGridContainer> </DataGridContainer>
{showPagination ? <DataGridPagination {...DATA_GRID_PAGINATION_RU} /> : null} {showPagination ? (
<div className="border-t px-4 py-3">
<DataGridPagination {...DATA_GRID_PAGINATION_RU} />
</div>
) : null}
</DataGrid> </DataGrid>
) )
} }
@@ -75,3 +80,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-4 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 профилей…"
/> />
) )
} }
+31
View File
@@ -0,0 +1,31 @@
import type { ReactNode } from 'react'
import { cn } from '@evobgp/ui/lib/utils'
import { DrawerFooter } from '@evobgp/ui/components/drawer'
/** Shared footer layout for right-side form and confirm drawers. */
export function DrawerActionsFooter({
children,
className,
}: {
children: ReactNode
className?: string
}) {
return (
<DrawerFooter
className={cn(
'mt-0 shrink-0 border-t border-border bg-muted/50 p-4',
className,
)}
>
<div className="flex w-full flex-col-reverse gap-2 sm:flex-row sm:items-center sm:justify-end">
{children}
</div>
</DrawerFooter>
)
}
export const formDrawerContentClassName =
'flex h-full max-h-dvh flex-col sm:max-w-lg'
export const confirmDrawerContentClassName = 'flex h-auto max-h-dvh flex-col sm:max-w-sm'
@@ -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,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="Поиск клиентов…"
/> />
) )
} }
@@ -0,0 +1,101 @@
import { useEffect, useState } from 'react'
import { Button } from '@evobgp/ui/components/button'
import { Input } from '@evobgp/ui/components/input'
import { Label } from '@evobgp/ui/components/label'
import { FormDrawer } from '@/components/form-drawer'
import { LoadingButton } from '@/components/loading-button'
import { CommunitySelect } from '@/components/modules/community-select'
import { SelectField } from '@/components/select-field'
import { useCreateFirewallRule } from '@/queries/firewall'
import type { BgpCommunity } from '@/types/api'
const FIREWALL_ACTION_ITEMS = [
{ value: 'block', label: 'block' },
{ value: 'accept', label: 'accept' },
] as const
interface FirewallRuleCreateDialogProps {
open: boolean
onOpenChange: (open: boolean) => void
communities: BgpCommunity[]
}
export function FirewallRuleCreateDialog({
open,
onOpenChange,
communities,
}: FirewallRuleCreateDialogProps) {
const createMutation = useCreateFirewallRule()
const [action, setAction] = useState<'block' | 'accept'>('block')
const [communityId, setCommunityId] = useState<string | null>(null)
const [comment, setComment] = useState('')
useEffect(() => {
if (!open) return
setAction('block')
setCommunityId(null)
setComment('')
}, [open])
async function save() {
try {
await createMutation.mutateAsync({
scope: 'tenant',
action,
community_id: communityId,
comment: comment.trim(),
})
onOpenChange(false)
} catch {
// toast handled in mutation
}
}
return (
<FormDrawer
open={open}
onOpenChange={onOpenChange}
title="Новое правило"
className="sm:max-w-md"
footer={
<>
<Button variant="outline" type="button" onClick={() => onOpenChange(false)}>
Отмена
</Button>
<LoadingButton type="button" onClick={save} loading={createMutation.isPending}>
Добавить
</LoadingButton>
</>
}
>
<SelectField
id="fw-rule-action"
label="Действие"
items={[...FIREWALL_ACTION_ITEMS]}
value={action}
placeholder="Выберите действие"
onValueChange={(v) => v && setAction(v as 'block' | 'accept')}
/>
<CommunitySelect
id="fw-rule-community"
label="Community"
value={communityId}
onValueChange={setCommunityId}
communities={communities}
nullable
placeholder="Все communities"
/>
<div className="flex flex-col gap-2">
<Label htmlFor="fw-rule-comment">Комментарий</Label>
<Input
id="fw-rule-comment"
placeholder="Комментарий"
value={comment}
onChange={(e) => setComment(e.target.value)}
/>
</div>
</FormDrawer>
)
}
@@ -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="Поиск правил…"
/> />
) )
} }
+51
View File
@@ -0,0 +1,51 @@
import type { ReactNode } from 'react'
import { cn } from '@evobgp/ui/lib/utils'
import {
Drawer,
DrawerContent,
DrawerDescription,
DrawerHeader,
DrawerTitle,
} from '@evobgp/ui/components/drawer'
import { ScrollArea } from '@evobgp/ui/components/scroll-area'
import {
DrawerActionsFooter,
formDrawerContentClassName,
} from '@/components/drawer-layout'
interface FormDrawerProps {
open: boolean
onOpenChange: (open: boolean) => void
title: string
description?: string
children: ReactNode
footer: ReactNode
className?: string
}
export function FormDrawer({
open,
onOpenChange,
title,
description,
children,
footer,
className,
}: FormDrawerProps) {
return (
<Drawer open={open} onOpenChange={onOpenChange} swipeDirection="right">
<DrawerContent className={cn(formDrawerContentClassName, className)}>
<DrawerHeader className="shrink-0 border-b border-border pb-4">
<DrawerTitle>{title}</DrawerTitle>
{description ? <DrawerDescription>{description}</DrawerDescription> : null}
</DrawerHeader>
<ScrollArea className="min-h-0 flex-1">
<div className="space-y-4 px-4 py-4">{children}</div>
</ScrollArea>
<DrawerActionsFooter>{footer}</DrawerActionsFooter>
</DrawerContent>
</Drawer>
)
}
@@ -1,17 +1,10 @@
import { useEffect, useState } from 'react' import { useEffect, useState } from 'react'
import { toast } from 'sonner' import { toast } from 'sonner'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} 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 { FormDrawer } from '@/components/form-drawer'
import { LoadingButton } from '@/components/loading-button' import { LoadingButton } from '@/components/loading-button'
import { CommunitySelect } from '@/components/modules/community-select' import { CommunitySelect } from '@/components/modules/community-select'
import { ApiError, apiMutate } from '@/lib/api-client' import { ApiError, apiMutate } from '@/lib/api-client'
@@ -72,45 +65,43 @@ export function ModuleAsEntryDialog({
} }
return ( return (
<Dialog open={open} onOpenChange={onOpenChange}> <FormDrawer
<DialogContent className="sm:max-w-sm"> open={open}
<DialogHeader> onOpenChange={onOpenChange}
<DialogTitle>{edit ? 'Редактировать запись' : 'Новая AS-запись'}</DialogTitle> title={edit ? 'Редактировать запись' : 'Новая AS-запись'}
<DialogDescription> description="Номер автономной системы и community для политики анонса."
Номер автономной системы и community для политики анонса. className="sm:max-w-sm"
</DialogDescription> footer={
</DialogHeader> <>
<div className="space-y-4 py-2">
<div className="flex flex-col gap-1.5">
<Label htmlFor="as-asn">ASN</Label>
<Input
id="as-asn"
type="number"
placeholder="12345"
value={form.asn || ''}
min={1}
max={4294967295}
onChange={(e) => setForm((s) => ({ ...s, asn: Number(e.target.value) }))}
/>
</div>
<CommunitySelect
id="as-comm"
label="Community"
value={form.community_id ?? null}
onValueChange={(v) => setForm((s) => ({ ...s, community_id: v }))}
communities={communities}
nullable
/>
</div>
<DialogFooter>
<LoadingButton variant="outline" onClick={() => onOpenChange(false)}> <LoadingButton variant="outline" onClick={() => onOpenChange(false)}>
Отмена Отмена
</LoadingButton> </LoadingButton>
<LoadingButton loading={saving} onClick={() => void save()}> <LoadingButton loading={saving} onClick={() => void save()}>
{edit ? 'Сохранить' : 'Добавить'} {edit ? 'Сохранить' : 'Добавить'}
</LoadingButton> </LoadingButton>
</DialogFooter> </>
</DialogContent> }
</Dialog> >
<div className="flex flex-col gap-1.5">
<Label htmlFor="as-asn">ASN</Label>
<Input
id="as-asn"
type="number"
placeholder="12345"
value={form.asn || ''}
min={1}
max={4294967295}
onChange={(e) => setForm((s) => ({ ...s, asn: Number(e.target.value) }))}
/>
</div>
<CommunitySelect
id="as-comm"
label="Community"
value={form.community_id ?? null}
onValueChange={(v) => setForm((s) => ({ ...s, community_id: v }))}
communities={communities}
nullable
/>
</FormDrawer>
) )
} }
@@ -1,21 +1,14 @@
import { useEffect, useMemo, useState } from 'react' import { useEffect, useMemo, useState } from 'react'
import { toast } from 'sonner' import { toast } from 'sonner'
import { import { Button } from '@evobgp/ui/components/button'
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
} 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 { SelectField } from '@/components/select-field' import { FormDrawer } from '@/components/form-drawer'
import { Button } from '@evobgp/ui/components/button'
import { LoadingButton } from '@/components/loading-button' import { LoadingButton } from '@/components/loading-button'
import { CommunitySelect } from '@/components/modules/community-select' import { CommunitySelect } from '@/components/modules/community-select'
import { SelectField } from '@/components/select-field'
import { ApiError, apiMutate } from '@/lib/api-client' import { ApiError, apiMutate } from '@/lib/api-client'
import { normalizeCdnSourceKind } from '@/lib/modules/helpers' import { normalizeCdnSourceKind } from '@/lib/modules/helpers'
import type { BgpCommunity, CdnPreviewResponse, CdnSource, CdnSourceCreate } from '@/types/api' import type { BgpCommunity, CdnPreviewResponse, CdnSource, CdnSourceCreate } from '@/types/api'
@@ -154,107 +147,107 @@ export function ModuleCdnSourceDialog({
} }
return ( return (
<Dialog open={open} onOpenChange={onOpenChange}> <FormDrawer
<DialogContent className="sm:max-w-lg"> open={open}
<DialogHeader> onOpenChange={onOpenChange}
<DialogTitle>{edit ? 'Редактировать источник' : 'Новый CDN-источник'}</DialogTitle> title={edit ? 'Редактировать источник' : 'Новый CDN-источник'}
</DialogHeader> className="sm:max-w-lg"
<div className="space-y-4 py-2"> footer={
<div className="flex flex-col gap-1.5"> <>
<Label htmlFor="cdn-url">URL</Label>
<Input
id="cdn-url"
placeholder="https://example.com/list.txt"
value={form.url}
onChange={(e) => setForm((s) => ({ ...s, url: e.target.value }))}
/>
</div>
<SelectField
id="cdn-kind"
label="Тип источника"
items={kindItems}
value={form.source_kind}
onValueChange={(v) => v && setForm((s) => ({ ...s, source_kind: v }))}
/>
<div className="flex flex-col gap-1.5">
<Label htmlFor="cdn-prefix-path">JSON path (prefix_path)</Label>
<Input
id="cdn-prefix-path"
placeholder="напр. prefixes[] или data.items[].cidr"
value={form.prefix_path ?? ''}
onChange={(e) => setForm((s) => ({ ...s, prefix_path: e.target.value }))}
/>
{form.source_kind === 'json' && !form.prefix_path?.trim() ? (
<p className="text-xs text-muted-foreground">
Для JSON укажите путь к полям с CIDR; пустой путь может не дать префиксов.
</p>
) : null}
</div>
<CommunitySelect
id="cdn-comm"
label="Community"
value={form.community_id ?? null}
onValueChange={(v) => setForm((s) => ({ ...s, community_id: v }))}
communities={communities}
nullable
/>
<div className="flex flex-col gap-1.5">
<Label htmlFor="cdn-interval">Интервал обновления (сек)</Label>
<Input
id="cdn-interval"
type="number"
placeholder="3600"
value={form.refresh_interval_sec ?? ''}
onChange={(e) =>
setForm((s) => ({
...s,
refresh_interval_sec: e.target.value ? Number(e.target.value) : null,
}))
}
/>
</div>
<div className="flex flex-col gap-2 rounded-lg border border-border p-3">
<div className="flex flex-wrap items-center gap-2">
<Button
type="button"
variant="secondary"
size="sm"
onClick={() => void previewCdn()}
disabled={previewLoading}
>
{previewLoading ? 'Загрузка…' : 'Предпросмотр'}
</Button>
{previewError ? (
<span className="text-sm text-destructive">{previewError}</span>
) : previewOk ? (
<span className="text-sm text-muted-foreground">
Всего: {previewTotal}
{previewTruncated ? (
<span className="text-amber-600 dark:text-amber-500"> (обрезано)</span>
) : null}
</span>
) : null}
</div>
{previewItems.length > 0 ? (
<ul className="max-h-48 overflow-y-auto rounded-md border bg-muted/40 p-2 font-mono text-xs">
{previewItems.map((item, i) => (
<li key={`${i}-${item}`} className="py-0.5">
{item}
</li>
))}
</ul>
) : null}
</div>
</div>
<DialogFooter>
<LoadingButton variant="outline" onClick={() => onOpenChange(false)}> <LoadingButton variant="outline" onClick={() => onOpenChange(false)}>
Отмена Отмена
</LoadingButton> </LoadingButton>
<LoadingButton loading={saving} onClick={() => void save()}> <LoadingButton loading={saving} onClick={() => void save()}>
{edit ? 'Сохранить' : 'Добавить'} {edit ? 'Сохранить' : 'Добавить'}
</LoadingButton> </LoadingButton>
</DialogFooter> </>
</DialogContent> }
</Dialog> >
<div className="flex flex-col gap-1.5">
<Label htmlFor="cdn-url">URL</Label>
<Input
id="cdn-url"
placeholder="https://example.com/list.txt"
value={form.url}
onChange={(e) => setForm((s) => ({ ...s, url: e.target.value }))}
/>
</div>
<SelectField
id="cdn-kind"
label="Тип источника"
items={kindItems}
value={form.source_kind}
onValueChange={(v) => v && setForm((s) => ({ ...s, source_kind: v }))}
/>
<div className="flex flex-col gap-1.5">
<Label htmlFor="cdn-prefix-path">JSON path (prefix_path)</Label>
<Input
id="cdn-prefix-path"
placeholder="напр. prefixes[] или data.items[].cidr"
value={form.prefix_path ?? ''}
onChange={(e) => setForm((s) => ({ ...s, prefix_path: e.target.value }))}
/>
{form.source_kind === 'json' && !form.prefix_path?.trim() ? (
<p className="text-xs text-muted-foreground">
Для JSON укажите путь к полям с CIDR; пустой путь может не дать префиксов.
</p>
) : null}
</div>
<CommunitySelect
id="cdn-comm"
label="Community"
value={form.community_id ?? null}
onValueChange={(v) => setForm((s) => ({ ...s, community_id: v }))}
communities={communities}
nullable
/>
<div className="flex flex-col gap-1.5">
<Label htmlFor="cdn-interval">Интервал обновления (сек)</Label>
<Input
id="cdn-interval"
type="number"
placeholder="3600"
value={form.refresh_interval_sec ?? ''}
onChange={(e) =>
setForm((s) => ({
...s,
refresh_interval_sec: e.target.value ? Number(e.target.value) : null,
}))
}
/>
</div>
<div className="flex flex-col gap-2 rounded-lg border border-border p-3">
<div className="flex flex-wrap items-center gap-2">
<Button
type="button"
variant="secondary"
size="sm"
onClick={() => void previewCdn()}
disabled={previewLoading}
>
{previewLoading ? 'Загрузка…' : 'Предпросмотр'}
</Button>
{previewError ? (
<span className="text-sm text-destructive">{previewError}</span>
) : previewOk ? (
<span className="text-sm text-muted-foreground">
Всего: {previewTotal}
{previewTruncated ? (
<span className="text-amber-600 dark:text-amber-500"> (обрезано)</span>
) : null}
</span>
) : null}
</div>
{previewItems.length > 0 ? (
<ul className="max-h-48 overflow-y-auto rounded-md border bg-muted/40 p-2 font-mono text-xs">
{previewItems.map((item, i) => (
<li key={`${i}-${item}`} className="py-0.5">
{item}
</li>
))}
</ul>
) : null}
</div>
</FormDrawer>
) )
} }
@@ -1,16 +1,10 @@
import { useEffect, useState } from 'react' import { useEffect, useState } from 'react'
import { toast } from 'sonner' import { toast } from 'sonner'
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
} 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 { FormDrawer } from '@/components/form-drawer'
import { LoadingButton } from '@/components/loading-button' import { LoadingButton } from '@/components/loading-button'
import { CommunitySelect } from '@/components/modules/community-select' import { CommunitySelect } from '@/components/modules/community-select'
import { ApiError, apiMutate } from '@/lib/api-client' import { ApiError, apiMutate } from '@/lib/api-client'
@@ -70,39 +64,39 @@ export function ModuleDomainEntryDialog({
} }
return ( return (
<Dialog open={open} onOpenChange={onOpenChange}> <FormDrawer
<DialogContent className="sm:max-w-sm"> open={open}
<DialogHeader> onOpenChange={onOpenChange}
<DialogTitle>{edit ? 'Редактировать домен' : 'Новый домен'}</DialogTitle> title={edit ? 'Редактировать домен' : 'Новый домен'}
</DialogHeader> className="sm:max-w-sm"
<div className="space-y-4 py-2"> footer={
<div className="flex flex-col gap-1.5"> <>
<Label htmlFor="dom-fqdn">FQDN</Label>
<Input
id="dom-fqdn"
placeholder="example.com"
value={form.fqdn}
onChange={(e) => setForm((s) => ({ ...s, fqdn: e.target.value }))}
/>
</div>
<CommunitySelect
id="dom-comm"
label="Community"
value={form.community_id ?? null}
onValueChange={(v) => setForm((s) => ({ ...s, community_id: v }))}
communities={communities}
nullable
/>
</div>
<DialogFooter>
<LoadingButton variant="outline" onClick={() => onOpenChange(false)}> <LoadingButton variant="outline" onClick={() => onOpenChange(false)}>
Отмена Отмена
</LoadingButton> </LoadingButton>
<LoadingButton loading={saving} onClick={() => void save()}> <LoadingButton loading={saving} onClick={() => void save()}>
{edit ? 'Сохранить' : 'Добавить'} {edit ? 'Сохранить' : 'Добавить'}
</LoadingButton> </LoadingButton>
</DialogFooter> </>
</DialogContent> }
</Dialog> >
<div className="flex flex-col gap-1.5">
<Label htmlFor="dom-fqdn">FQDN</Label>
<Input
id="dom-fqdn"
placeholder="example.com"
value={form.fqdn}
onChange={(e) => setForm((s) => ({ ...s, fqdn: e.target.value }))}
/>
</div>
<CommunitySelect
id="dom-comm"
label="Community"
value={form.community_id ?? null}
onValueChange={(v) => setForm((s) => ({ ...s, community_id: v }))}
communities={communities}
nullable
/>
</FormDrawer>
) )
} }
@@ -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="Поиск записей…"
/> />
) )
} }
@@ -2,18 +2,9 @@ import { useState } from 'react'
import { Plus } from 'lucide-react' import { Plus } from 'lucide-react'
import { toast } from 'sonner' import { toast } from 'sonner'
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from '@evobgp/ui/components/alert-dialog'
import { Button } from '@evobgp/ui/components/button' import { Button } from '@evobgp/ui/components/button'
import { ConfirmDialog } from '@/components/confirm-dialog'
import { DataGridCard } from '@/components/data-grid-shell' import { DataGridCard } from '@/components/data-grid-shell'
import { QueryState } from '@/components/query-state' import { QueryState } from '@/components/query-state'
import { TableSkeleton } from '@/components/skeletons' import { TableSkeleton } from '@/components/skeletons'
@@ -218,24 +209,17 @@ export function ModuleEntriesSection({
/> />
) : null} ) : null}
<AlertDialog open={deleteTarget !== null} onOpenChange={(open) => !open && setDeleteTarget(null)}> <ConfirmDialog
<AlertDialogContent> open={deleteTarget !== null}
<AlertDialogHeader> onOpenChange={(open) => !open && setDeleteTarget(null)}
<AlertDialogTitle>Удалить запись?</AlertDialogTitle> title="Удалить запись?"
<AlertDialogDescription>{deleteDescription(deleteTarget)}</AlertDialogDescription> description={deleteDescription(deleteTarget)}
</AlertDialogHeader> confirmLabel="Удалить"
<AlertDialogFooter> confirmLoadingLabel="Удаление…"
<AlertDialogCancel disabled={deleting}>Отмена</AlertDialogCancel> destructive
<AlertDialogAction confirmLoading={deleting}
variant="destructive" onConfirm={() => void confirmDelete()}
disabled={deleting} />
onClick={() => void confirmDelete()}
>
{deleting ? 'Удаление…' : 'Удалить'}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</> </>
) )
} }
@@ -1,16 +1,10 @@
import { useEffect, useState } from 'react' import { useEffect, useState } from 'react'
import { toast } from 'sonner' import { toast } from 'sonner'
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
} 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 { FormDrawer } from '@/components/form-drawer'
import { LoadingButton } from '@/components/loading-button' import { LoadingButton } from '@/components/loading-button'
import { CommunitySelect } from '@/components/modules/community-select' import { CommunitySelect } from '@/components/modules/community-select'
import { ApiError, apiMutate } from '@/lib/api-client' import { ApiError, apiMutate } from '@/lib/api-client'
@@ -70,39 +64,39 @@ export function ModuleIpRangeEntryDialog({
} }
return ( return (
<Dialog open={open} onOpenChange={onOpenChange}> <FormDrawer
<DialogContent className="sm:max-w-sm"> open={open}
<DialogHeader> onOpenChange={onOpenChange}
<DialogTitle>{edit ? 'Редактировать диапазон' : 'Новый IP-диапазон'}</DialogTitle> title={edit ? 'Редактировать диапазон' : 'Новый IP-диапазон'}
</DialogHeader> className="sm:max-w-sm"
<div className="space-y-4 py-2"> footer={
<div className="flex flex-col gap-1.5"> <>
<Label htmlFor="ip-prefix">Префикс (CIDR)</Label>
<Input
id="ip-prefix"
placeholder="203.0.113.0/24"
value={form.prefix}
onChange={(e) => setForm((s) => ({ ...s, prefix: e.target.value }))}
/>
</div>
<CommunitySelect
id="ip-comm"
label="Community (обязательно)"
value={form.community_id || null}
onValueChange={(v) => setForm((s) => ({ ...s, community_id: v ?? '' }))}
communities={communities}
placeholder="Выберите community"
/>
</div>
<DialogFooter>
<LoadingButton variant="outline" onClick={() => onOpenChange(false)}> <LoadingButton variant="outline" onClick={() => onOpenChange(false)}>
Отмена Отмена
</LoadingButton> </LoadingButton>
<LoadingButton loading={saving} onClick={() => void save()}> <LoadingButton loading={saving} onClick={() => void save()}>
{edit ? 'Сохранить' : 'Добавить'} {edit ? 'Сохранить' : 'Добавить'}
</LoadingButton> </LoadingButton>
</DialogFooter> </>
</DialogContent> }
</Dialog> >
<div className="flex flex-col gap-1.5">
<Label htmlFor="ip-prefix">Префикс (CIDR)</Label>
<Input
id="ip-prefix"
placeholder="203.0.113.0/24"
value={form.prefix}
onChange={(e) => setForm((s) => ({ ...s, prefix: e.target.value }))}
/>
</div>
<CommunitySelect
id="ip-comm"
label="Community (обязательно)"
value={form.community_id || null}
onValueChange={(v) => setForm((s) => ({ ...s, community_id: v ?? '' }))}
communities={communities}
placeholder="Выберите community"
/>
</FormDrawer>
) )
} }
@@ -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,168 @@
import { useEffect, useState } from 'react'
import { toast } from 'sonner'
import { Button } from '@evobgp/ui/components/button'
import { Checkbox } from '@evobgp/ui/components/checkbox'
import { Input } from '@evobgp/ui/components/input'
import { Label } from '@evobgp/ui/components/label'
import { FormDrawer } from '@/components/form-drawer'
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 (
<FormDrawer
open={open}
onOpenChange={onOpenChange}
title={editTarget ? 'Редактировать пира' : 'Новый пир'}
description="BGP-сосед для установки сессии"
className="sm:max-w-sm"
footer={
<>
<Button variant="outline" type="button" onClick={() => onOpenChange(false)}>
Отмена
</Button>
<LoadingButton type="button" loading={saving} onClick={save}>
{editTarget ? 'Сохранить' : 'Создать'}
</LoadingButton>
</>
}
>
<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>
</FormDrawer>
)
}
@@ -0,0 +1,162 @@
import { useEffect, useState } from 'react'
import { toast } from 'sonner'
import { Button } from '@evobgp/ui/components/button'
import { Input } from '@evobgp/ui/components/input'
import { Label } from '@evobgp/ui/components/label'
import { FormDrawer } from '@/components/form-drawer'
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 (
<FormDrawer
open={open}
onOpenChange={onOpenChange}
title="Новый спикер"
description="BIRD-агент на ноде реплики или control plane"
className="sm:max-w-md"
footer={
<>
<Button variant="outline" type="button" onClick={() => onOpenChange(false)}>
Отмена
</Button>
<LoadingButton type="button" loading={createMutation.isPending} onClick={save}>
Создать
</LoadingButton>
</>
}
>
<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>
</FormDrawer>
)
}
@@ -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",
@@ -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="Поиск модулей…"
/> />
) )
} }
@@ -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="Поиск настроек…"
/> />
) )
} }
+2 -2
View File
@@ -17,8 +17,8 @@ export function SectionCardsSkeleton({ count = 4 }: { count?: number }) {
export function AnalyticsDashboardSkeleton() { export function AnalyticsDashboardSkeleton() {
return ( return (
<div className="grid gap-4 lg:grid-cols-3 lg:grid-rows-2"> <div className="grid gap-4 lg:grid-cols-3 lg:items-start">
<Card className="gap-0 lg:row-span-2"> <Card className="gap-0">
<CardContent className="space-y-4 p-5"> <CardContent className="space-y-4 p-5">
<Skeleton className="h-4 w-40" /> <Skeleton className="h-4 w-40" />
<div className="grid gap-4 sm:grid-cols-3"> <div className="grid gap-4 sm:grid-cols-3">
+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 } },
+2
View File
@@ -83,8 +83,10 @@ export function useCreateFirewallRule() {
body: JSON.stringify(body), body: JSON.stringify(body),
}), }),
onSuccess: () => { onSuccess: () => {
toast.success('Правило добавлено')
void qc.invalidateQueries({ queryKey: firewallKeys.all }) void qc.invalidateQueries({ queryKey: firewallKeys.all })
}, },
onError: (e) => toast.error(e instanceof Error ? e.message : 'Не удалось добавить правило'),
}) })
} }
+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 -18
View File
@@ -1,9 +1,8 @@
import { createFileRoute, Link } from '@tanstack/react-router' import { createFileRoute, Link } from '@tanstack/react-router'
import { useQuery } from '@tanstack/react-query' import { useQuery } from '@tanstack/react-query'
import { Info, KeyRound, RefreshCw, ShieldCheck, ShieldOff } from 'lucide-react' import { KeyRound, RefreshCw, ShieldCheck, ShieldOff } from 'lucide-react'
import { useMemo } from 'react' import { useMemo } from 'react'
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'
@@ -79,22 +78,6 @@ function AccessComponent() {
} }
/> />
<Alert className="border-info/30 bg-info/5">
<Info className="text-info" />
<AlertTitle>О API-ключах</AlertTitle>
<AlertDescription>
Роли: <code className="text-xs">viewer</code> (чтение),{' '}
<code className="text-xs">editor</code> (CRUD), <code className="text-xs">operator</code>{' '}
(apply и настройки), <code className="text-xs">node</code> (API ноды). Полный токен
показывается один раз при создании и ротации. Токен браузера в{' '}
<Link to="/settings" className="text-primary underline-offset-4 hover:underline">
настройках
</Link>
; для локальной разработки с demo-seed подойдёт <code className="text-xs">dev</code>{' '}
(роль operator).
</AlertDescription>
</Alert>
{session ? ( {session ? (
<Card> <Card>
<CardHeader className="border-b py-3"> <CardHeader className="border-b py-3">
+10 -80
View File
@@ -1,9 +1,8 @@
import { createFileRoute } from '@tanstack/react-router' import { createFileRoute } from '@tanstack/react-router'
import { useQueries } from '@tanstack/react-query' import { useQueries } from '@tanstack/react-query'
import { CheckCircle, Info, RefreshCw, XCircle } from 'lucide-react' import { RefreshCw } from 'lucide-react'
import { useState } from 'react' import { useState } from 'react'
import { Alert, AlertDescription, AlertTitle } from '@evobgp/ui/components/alert'
import { Button } from '@evobgp/ui/components/button' import { Button } from '@evobgp/ui/components/button'
import { import {
Card, Card,
@@ -27,7 +26,6 @@ import { AnalyticsDashboardSkeleton } from '@/components/skeletons'
import { import {
moduleNameById, moduleNameById,
overviewHealthQueryOptions,
overviewJobsQueryOptions, overviewJobsQueryOptions,
overviewModulesQueryOptions, overviewModulesQueryOptions,
overviewPeersQueryOptions, overviewPeersQueryOptions,
@@ -44,7 +42,6 @@ function DashboardComponent() {
const results = useQueries({ const results = useQueries({
queries: [ queries: [
overviewHealthQueryOptions(),
overviewModulesQueryOptions(), overviewModulesQueryOptions(),
overviewPeersQueryOptions(), overviewPeersQueryOptions(),
overviewSpeakersQueryOptions(), overviewSpeakersQueryOptions(),
@@ -53,7 +50,7 @@ function DashboardComponent() {
], ],
}) })
const [healthQ, modulesQ, peersQ, speakersQ, revisionsQ, jobsQ] = results const [modulesQ, peersQ, speakersQ, revisionsQ, jobsQ] = results
const initialLoading = const initialLoading =
modulesQ.isLoading || peersQ.isLoading || speakersQ.isLoading || revisionsQ.isLoading || jobsQ.isLoading modulesQ.isLoading || peersQ.isLoading || speakersQ.isLoading || revisionsQ.isLoading || jobsQ.isLoading
const refreshing = results.some((r) => r.isFetching && !r.isLoading) const refreshing = results.some((r) => r.isFetching && !r.isLoading)
@@ -94,34 +91,17 @@ function DashboardComponent() {
} }
/> />
<Alert className="border-info/30 bg-info/5">
<Info className="text-info" />
<AlertTitle>Панель управления EvoBGP</AlertTitle>
<AlertDescription>
Сводка по модулям, сети и фоновым задачам. BGP и ноды «Сеть», префиксы «Модули»,
деплой «Операции», здоровье API «Мониторинг».
</AlertDescription>
</Alert>
<HealthAlert
loading={healthQ.isLoading}
ok={healthQ.data === true}
loadError={modulesQ.isError || peersQ.isError ? 'Некоторые данные не загружены' : null}
/>
{initialLoading ? ( {initialLoading ? (
<AnalyticsDashboardSkeleton /> <AnalyticsDashboardSkeleton />
) : ( ) : (
<div className="grid gap-4 lg:grid-cols-3 lg:grid-rows-2"> <div className="grid gap-4 lg:grid-cols-3 lg:items-start">
<div className="lg:row-span-2"> <DashboardPlatformCard
<DashboardPlatformCard modules={modules}
modules={modules} peers={peers}
peers={peers} speakers={speakers}
speakers={speakers} jobs={jobs}
jobs={jobs} revisions={revisions}
revisions={revisions} />
/>
</div>
<DashboardNetworkCapacityCard peers={peers} speakers={speakers} jobs={jobs} /> <DashboardNetworkCapacityCard peers={peers} speakers={speakers} jobs={jobs} />
<DashboardOperationsFlowCard jobs={jobs} modules={modules} /> <DashboardOperationsFlowCard jobs={jobs} modules={modules} />
</div> </div>
@@ -163,53 +143,3 @@ function DashboardComponent() {
</div> </div>
) )
} }
function HealthAlert({
loading,
ok,
loadError,
}: {
loading: boolean
ok: boolean | undefined
loadError: string | null
}) {
if (loading) {
return (
<Alert>
<Skeleton className="size-5 rounded-full" />
<AlertTitle>Проверка API</AlertTitle>
<AlertDescription>
Запрос к <code className="text-xs">/v1/health</code>
</AlertDescription>
</Alert>
)
}
if (ok && !loadError) {
return (
<Alert className="border-success/30 bg-success/5">
<CheckCircle className="text-success" />
<AlertTitle>API работает</AlertTitle>
<AlertDescription>Сервер отвечает на запросы health-check.</AlertDescription>
</Alert>
)
}
if (ok && loadError) {
return (
<Alert className="border-warning/30 bg-warning/5">
<Info className="text-warning" />
<AlertTitle>API доступен, данные не загружены</AlertTitle>
<AlertDescription>{loadError}. Проверьте Bearer-токен в «Настройках».</AlertDescription>
</Alert>
)
}
return (
<Alert variant="destructive" className="border-destructive/30 bg-destructive/5">
<XCircle className="text-destructive" />
<AlertTitle>API недоступен</AlertTitle>
<AlertDescription>
Не удалось получить ответ от сервера. Проверьте, что API запущен (порт 8080) и в dev работает
прокси Vite.
</AlertDescription>
</Alert>
)
}
+12 -22
View File
@@ -1,11 +1,9 @@
import { createFileRoute } from '@tanstack/react-router' import { createFileRoute } from '@tanstack/react-router'
import { useQuery } from '@tanstack/react-query' import { useQuery } from '@tanstack/react-query'
import { BookText, Globe, Info, RefreshCw, Tags } from 'lucide-react' import { BookText, Globe, RefreshCw, Tags } from 'lucide-react'
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'
@@ -69,24 +67,16 @@ function DirectoriesComponent() {
} }
/> />
<Alert className="border-info/30 bg-info/5">
<Info className="text-info" />
<AlertTitle>О справочниках</AlertTitle>
<AlertDescription>
Сообщества BGP используются в AS- и CDN-модулях для тегирования префиксов. DoH-профили в
доменных модулях для DNS-over-HTTPS резолвинга.
</AlertDescription>
</Alert>
{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 +101,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 +122,7 @@ function DirectoriesComponent() {
</QueryState> </QueryState>
</DataGridCard> </DataGridCard>
</TabsContent> </TabsContent>
</Tabs> </BadgeTabs>
</div> </div>
) )
} }
+101 -134
View File
@@ -1,20 +1,19 @@
import { createFileRoute } from '@tanstack/react-router' import { createFileRoute } from '@tanstack/react-router'
import { useQuery } from '@tanstack/react-query' import { useQuery } from '@tanstack/react-query'
import { Copy, Info, RefreshCw, Shield } from 'lucide-react' import { Copy, Plus, RefreshCw, Shield } from 'lucide-react'
import { useEffect, useMemo, useState } from 'react' import { useEffect, useMemo, useState } from 'react'
import { toast } from 'sonner' import { toast } from 'sonner'
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 { 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 { DataGridCard } from '@/components/data-grid-shell'
import { FirewallClientsGrid } from '@/components/firewall/firewall-clients-grid' import { FirewallClientsGrid } from '@/components/firewall/firewall-clients-grid'
import { FirewallRuleCreateDialog } from '@/components/firewall/firewall-rule-create-dialog'
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'
import { CommunitySelect } from '@/components/modules/community-select'
import { QueryState } from '@/components/query-state' import { QueryState } from '@/components/query-state'
import { TableSkeleton } from '@/components/skeletons' import { TableSkeleton } from '@/components/skeletons'
import { directoriesCommunitiesQueryOptions } from '@/queries/directories' import { directoriesCommunitiesQueryOptions } from '@/queries/directories'
@@ -23,7 +22,6 @@ import {
firewallInstallContextQueryOptions, firewallInstallContextQueryOptions,
firewallRulesQueryOptions, firewallRulesQueryOptions,
useApproveFirewallClient, useApproveFirewallClient,
useCreateFirewallRule,
useDeleteFirewallClient, useDeleteFirewallClient,
useDeleteFirewallRule, useDeleteFirewallRule,
} from '@/queries/firewall' } from '@/queries/firewall'
@@ -49,7 +47,6 @@ function FirewallPage() {
const rulesQ = useQuery(firewallRulesQueryOptions('tenant')) const rulesQ = useQuery(firewallRulesQueryOptions('tenant'))
const approve = useApproveFirewallClient() const approve = useApproveFirewallClient()
const deleteClient = useDeleteFirewallClient() const deleteClient = useDeleteFirewallClient()
const createRule = useCreateFirewallRule()
const deleteRule = useDeleteFirewallRule() const deleteRule = useDeleteFirewallRule()
const installCtx = installCtxQ.data const installCtx = installCtxQ.data
@@ -59,6 +56,7 @@ function FirewallPage() {
typeof window !== 'undefined' ? httpsOrigin(window.location.origin) : 'https://api.example.com', typeof window !== 'undefined' ? httpsOrigin(window.location.origin) : 'https://api.example.com',
) )
const [seed, setSeed] = useState('') const [seed, setSeed] = useState('')
const [createRuleOpen, setCreateRuleOpen] = useState(false)
useEffect(() => { useEffect(() => {
if (installCtx?.suggested_cp_url) { if (installCtx?.suggested_cp_url) {
@@ -68,9 +66,6 @@ function FirewallPage() {
setSeed(installCtx.bundle_seed) setSeed(installCtx.bundle_seed)
} }
}, [installCtx?.bundle_seed, installCtx?.suggested_cp_url]) }, [installCtx?.bundle_seed, installCtx?.suggested_cp_url])
const [ruleAction, setRuleAction] = useState<'block' | 'accept'>('block')
const [ruleCommunityId, setRuleCommunityId] = useState<string | null>(null)
const [ruleComment, setRuleComment] = useState('')
const communities = communitiesQ.data?.items ?? [] const communities = communitiesQ.data?.items ?? []
@@ -130,16 +125,6 @@ function FirewallPage() {
} }
/> />
<Alert className="border-info/30 bg-info/5">
<Info className="text-info" />
<AlertTitle>Политика</AlertTitle>
<AlertDescription>
Правила сопоставляются с <strong>BGP community</strong> префиксов опубликованной revision.{' '}
<strong>block</strong> добавляет префиксы community в kernel; <strong>accept</strong> не блокирует.
Community «Все» правило для любого community. Default без совпадений accept.
</AlertDescription>
</Alert>
<Card> <Card>
<CardHeader> <CardHeader>
<CardTitle className="flex items-center gap-2"> <CardTitle className="flex items-center gap-2">
@@ -185,125 +170,107 @@ 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: 'Запросы',
<QueryState count: pending.length,
data={clientsQ.data} badgeVariant: pending.length > 0 ? 'warning-light' : 'primary-light',
isLoading={clientsQ.isLoading} },
isError={clientsQ.isError} ]}
error={clientsQ.error} >
onRetry={() => void clientsQ.refetch()} <TabsContent value="clients" className="mt-0">
skeleton={<TableSkeleton rows={5} cols={6} />} <DataGridCard title="Клиенты" description="Активные Linux-серверы с синхронизацией blocklist">
> <QueryState
{() => ( data={clientsQ.data}
<FirewallClientsGrid isLoading={clientsQ.isLoading}
clients={activeClients} isError={clientsQ.isError}
isLoading={clientsQ.isFetching && !clientsQ.isLoading} error={clientsQ.error}
onApprove={(id) => approve.mutate(id)} onRetry={() => void clientsQ.refetch()}
onReject={(id) => deleteClient.mutate(id)} skeleton={<TableSkeleton rows={5} cols={6} />}
approvePending={approve.isPending}
rejectPending={deleteClient.isPending}
/>
)}
</QueryState>
</TabsContent>
<TabsContent value="rules" className="mt-4 space-y-4">
<div className="flex flex-wrap items-end gap-3">
<div className="space-y-1">
<Label>Действие</Label>
<select
className="border-input bg-background h-9 rounded-md border px-2 text-sm"
value={ruleAction}
onChange={(e) => setRuleAction(e.target.value as 'block' | 'accept')}
>
<option value="block">block</option>
<option value="accept">accept</option>
</select>
</div>
<CommunitySelect
id="fw-rule-community"
label="Community"
value={ruleCommunityId}
onValueChange={setRuleCommunityId}
communities={communities}
nullable
placeholder="Все communities"
/>
<div className="space-y-1">
<Label htmlFor="fw-rule-comment">Комментарий</Label>
<Input
id="fw-rule-comment"
className="max-w-xs"
placeholder="Комментарий"
value={ruleComment}
onChange={(e) => setRuleComment(e.target.value)}
/>
</div>
<Button
size="sm"
className="mb-0.5"
onClick={() =>
createRule.mutate({
scope: 'tenant',
action: ruleAction,
community_id: ruleCommunityId,
comment: ruleComment,
})
}
> >
Добавить правило {() => (
</Button> <FirewallClientsGrid
</div> clients={activeClients}
<QueryState isLoading={clientsQ.isFetching && !clientsQ.isLoading}
data={rulesQ.data} onApprove={(id) => approve.mutate(id)}
isLoading={rulesQ.isLoading} onReject={(id) => deleteClient.mutate(id)}
isError={rulesQ.isError} approvePending={approve.isPending}
error={rulesQ.error} rejectPending={deleteClient.isPending}
onRetry={() => void rulesQ.refetch()} />
skeleton={<TableSkeleton rows={5} cols={5} />} )}
> </QueryState>
{() => ( </DataGridCard>
<FirewallRulesGrid
rules={rules}
communities={communities}
isLoading={rulesQ.isFetching && !rulesQ.isLoading}
onDelete={(id) => deleteRule.mutate(id)}
deletePending={deleteRule.isPending}
/>
)}
</QueryState>
</TabsContent> </TabsContent>
<TabsContent value="requests" className="mt-4"> <TabsContent value="rules" className="mt-0">
<QueryState <DataGridCard
data={clientsQ.data} title="Правила"
isLoading={clientsQ.isLoading} actions={
isError={clientsQ.isError} <Button size="sm" type="button" onClick={() => setCreateRuleOpen(true)}>
error={clientsQ.error} <Plus />
onRetry={() => void clientsQ.refetch()} Добавить правило
skeleton={<TableSkeleton rows={3} cols={6} />} </Button>
}
> >
{() => ( <QueryState
<FirewallClientsGrid data={rulesQ.data}
clients={pending} isLoading={rulesQ.isLoading}
isLoading={clientsQ.isFetching && !clientsQ.isLoading} isError={rulesQ.isError}
onApprove={(id) => approve.mutate(id)} error={rulesQ.error}
onReject={(id) => deleteClient.mutate(id)} onRetry={() => void rulesQ.refetch()}
approvePending={approve.isPending} skeleton={<TableSkeleton rows={5} cols={5} />}
rejectPending={deleteClient.isPending} >
emptyTitle="Нет pending-запросов" {() => (
/> <FirewallRulesGrid
)} rules={rules}
</QueryState> communities={communities}
isLoading={rulesQ.isFetching && !rulesQ.isLoading}
onDelete={(id) => deleteRule.mutate(id)}
deletePending={deleteRule.isPending}
/>
)}
</QueryState>
</DataGridCard>
<FirewallRuleCreateDialog
open={createRuleOpen}
onOpenChange={setCreateRuleOpen}
communities={communities}
/>
</TabsContent> </TabsContent>
</Tabs>
<TabsContent value="requests" className="mt-0">
<DataGridCard
title="Запросы"
description="Pending enroll — одобрите или отклоните новые клиенты"
>
<QueryState
data={clientsQ.data}
isLoading={clientsQ.isLoading}
isError={clientsQ.isError}
error={clientsQ.error}
onRetry={() => void clientsQ.refetch()}
skeleton={<TableSkeleton rows={3} cols={6} />}
>
{() => (
<FirewallClientsGrid
clients={pending}
isLoading={clientsQ.isFetching && !clientsQ.isLoading}
onApprove={(id) => approve.mutate(id)}
onReject={(id) => deleteClient.mutate(id)}
approvePending={approve.isPending}
rejectPending={deleteClient.isPending}
emptyTitle="Нет pending-запросов"
/>
)}
</QueryState>
</DataGridCard>
</TabsContent>
</BadgeTabs>
</div> </div>
) )
} }
@@ -1,8 +1,7 @@
import { createFileRoute, Link } from '@tanstack/react-router' import { createFileRoute, Link } from '@tanstack/react-router'
import { useQuery, useQueryClient } from '@tanstack/react-query' import { useQuery, useQueryClient } from '@tanstack/react-query'
import { ArrowLeft, Info, RefreshCw } from 'lucide-react' import { ArrowLeft, RefreshCw } from 'lucide-react'
import { Alert, AlertDescription, AlertTitle } from '@evobgp/ui/components/alert'
import { Button } from '@evobgp/ui/components/button' import { Button } from '@evobgp/ui/components/button'
import { PageHeader } from '@/components/page-header' import { PageHeader } from '@/components/page-header'
@@ -17,25 +16,12 @@ import {
directoriesDohQueryOptions, directoriesDohQueryOptions,
} from '@/queries/directories' } from '@/queries/directories'
import { moduleDetailQueryOptions, moduleEntriesQueryOptions, modulesKeys } from '@/queries/modules' import { moduleDetailQueryOptions, moduleEntriesQueryOptions, modulesKeys } from '@/queries/modules'
import type { AsEntry, ModuleRow } from '@/types/api' import type { AsEntry } from '@/types/api'
export const Route = createFileRoute('/_auth/modules/$moduleId')({ export const Route = createFileRoute('/_auth/modules/$moduleId')({
component: ModuleDetailComponent, component: ModuleDetailComponent,
}) })
function moduleTypeAlert(type: ModuleRow['type']): string {
switch (type) {
case 'AS_PREFIXES':
return 'Модуль AS получает префиксы через RIPEstat по указанным ASN. После refresh счётчики префиксов обновляются в таблице записей.'
case 'CDN_CIDRS':
return 'Модуль CDN скачивает списки CIDR по URL (plaintext или JSON). Используйте предпросмотр при добавлении источника.'
case 'DOMAINS':
return 'Модуль доменов резолвит FQDN через DoH-профили и конвертирует IP в префиксы. Политика и профили настраиваются в редактировании модуля.'
case 'IP_RANGES':
return 'Модуль IP-диапазонов использует статические CIDR без внешнего refresh (сервер может вернуть 204). Записи участвуют в агрегации напрямую.'
}
}
function ModuleDetailComponent() { function ModuleDetailComponent() {
const { moduleId } = Route.useParams() const { moduleId } = Route.useParams()
const queryClient = useQueryClient() const queryClient = useQueryClient()
@@ -114,12 +100,6 @@ function ModuleDetailComponent() {
)} )}
</div> </div>
<Alert>
<Info />
<AlertTitle>О модуле</AlertTitle>
<AlertDescription>{moduleTypeAlert(m.type)}</AlertDescription>
</Alert>
<ModuleKpiCards <ModuleKpiCards
mod={m} mod={m}
communities={communities} communities={communities}
+72 -113
View File
@@ -1,14 +1,13 @@
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, HeartPulse, Info, ListTodo, RefreshCw } from 'lucide-react' import { Activity, AlertTriangle, Bird, RefreshCw } from 'lucide-react'
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 { DataGridCard } from '@/components/data-grid-shell'
import { StatusBadge } from '@/components/status-badge'
import { import {
DashboardOperationsFlowCard, DashboardOperationsFlowCard,
MonitoringHealthCard, MonitoringHealthCard,
@@ -22,7 +21,6 @@ import {
monitoringHealthQueryOptions, monitoringHealthQueryOptions,
monitoringReadyQueryOptions, monitoringReadyQueryOptions,
monitoringVersionQueryOptions, monitoringVersionQueryOptions,
type ReadyStatus,
type VersionInfo, type VersionInfo,
} from '@/queries/monitoring' } from '@/queries/monitoring'
import { networkBirdQueryOptions } from '@/queries/network' import { networkBirdQueryOptions } from '@/queries/network'
@@ -41,6 +39,7 @@ 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())
@@ -83,7 +82,13 @@ function MonitoringComponent() {
<div className="flex flex-col gap-6"> <div className="flex flex-col gap-6">
<PageHeader <PageHeader
title="Мониторинг" title="Мониторинг"
description="Состояние API, BGP и задач для диагностики инцидентов" description={`Состояние API, BGP и задач для диагностики инцидентов${
versionText !== '—'
? ` · версия ${versionText}${
versionQ.data?.git_sha ? ` (${versionQ.data.git_sha.slice(0, 8)})` : ''
}`
: ''
}`}
actions={ actions={
<Button variant="outline" size="sm" onClick={refetchAll} disabled={refreshing}> <Button variant="outline" size="sm" onClick={refetchAll} disabled={refreshing}>
<RefreshCw className={refreshing ? 'animate-spin' : ''} /> <RefreshCw className={refreshing ? 'animate-spin' : ''} />
@@ -92,14 +97,18 @@ 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: 'Система' },
<TabsContent value="system" className="mt-4 flex flex-col gap-6"> { value: 'postgres', label: 'PostgreSQL' },
{ value: 'runtime-logs', label: 'Файловые логи' },
]}
>
<TabsContent value="system" className="mt-0 flex flex-col gap-6">
{analyticsLoading ? ( {analyticsLoading ? (
<AnalyticsDashboardSkeleton /> <AnalyticsDashboardSkeleton />
) : ( ) : (
@@ -113,36 +122,22 @@ function MonitoringComponent() {
</div> </div>
)} )}
<Alert className="border-muted bg-muted/30">
<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> <DataGridCard
<CardHeader> title="Доступность и готовность"
<CardTitle className="text-base">Доступность и готовность</CardTitle> description="GET /v1/health · GET /v1/ready"
<CardDescription>GET /v1/health · GET /v1/ready</CardDescription> >
</CardHeader> <QueryState
<CardContent className="space-y-4"> data={readyQ.data}
<QueryState isLoading={readyQ.isLoading}
data={readyQ.data} isError={readyQ.isError}
isLoading={readyQ.isLoading} error={readyQ.error}
isError={readyQ.isError} skeleton={<div className="h-40" />}
error={readyQ.error} onRetry={() => readyQ.refetch()}
skeleton={<div className="h-40" />} >
onRetry={() => readyQ.refetch()} {(ready) => <MonitoringReadyGrid health={healthQ.data} ready={ready} />}
> </QueryState>
{(ready) => <MonitoringReadyGrid health={healthQ.data} ready={ready} />} </DataGridCard>
</QueryState>
</CardContent>
</Card>
<Card> <Card>
<CardHeader> <CardHeader>
@@ -195,7 +190,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">
@@ -223,80 +218,56 @@ function MonitoringComponent() {
</CardTitle> </CardTitle>
<CardDescription>Короткая шпаргалка для triage</CardDescription> <CardDescription>Короткая шпаргалка для triage</CardDescription>
</CardHeader> </CardHeader>
<CardContent className="space-y-3"> <CardContent>
<Alert> <ul className="space-y-3 text-sm text-muted-foreground">
<HeartPulse className="size-4" /> <li>
<AlertTitle>API недоступен</AlertTitle> <span className="font-medium text-foreground">API недоступен.</span> Если{' '}
<AlertDescription> <code className="text-xs">/v1/health</code> возвращает ошибку проверьте процесс API и
Если <code className="text-xs">/v1/health</code> возвращает ошибку проверьте процесс его логи.
API и его логи. </li>
</AlertDescription> <li>
</Alert> <span className="font-medium text-foreground">Readiness не «Готов».</span> Сначала{' '}
<Alert> <code className="text-xs">postgres</code>, затем <code className="text-xs">store</code>{' '}
<Database className="size-4" /> и <code className="text-xs">jobs</code> в checks.
<AlertTitle>Readiness не «Готов»</AlertTitle> </li>
<AlertDescription> <li>
Сначала <code className="text-xs">postgres</code>, затем{' '} <span className="font-medium text-foreground">Низкий ratio BGP.</span> Проверьте{' '}
<code className="text-xs">store</code> и <code className="text-xs">jobs</code> в checks. <code className="text-xs">/v1/bird/status</code>, затем состояние пиров в Сети.
</AlertDescription> </li>
</Alert> <li>
<Alert> <span className="font-medium text-foreground">Ошибки задач.</span> Откройте Операции и
<Bird className="size-4" /> проверьте последние неуспешные jobs.
<AlertTitle>Низкий ratio BGP</AlertTitle> </li>
<AlertDescription> </ul>
Проверьте <code className="text-xs">/v1/bird/status</code>, затем состояние пиров в Сети.
</AlertDescription>
</Alert>
<Alert>
<ListTodo className="size-4" />
<AlertTitle>Ошибки задач</AlertTitle>
<AlertDescription>
Откройте Операции и проверьте последние неуспешные jobs.
</AlertDescription>
</Alert>
</CardContent> </CardContent>
</Card> </Card>
</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>
<CardDescription>Статус соединения и пул</CardDescription> <CardDescription>
Статус соединения и пул. PostgreSQL отображается в readiness-проверке на вкладке «Система»
(check <code className="text-xs">postgres</code>).
</CardDescription>
</CardHeader> </CardHeader>
<CardContent>
<Alert>
<Database className="size-4" />
<AlertTitle>Статус готовности</AlertTitle>
<AlertDescription>
PostgreSQL-соединение отображается в readiness-проверке на вкладке «Система» (check{' '}
<code className="text-xs">postgres</code>).
</AlertDescription>
</Alert>
</CardContent>
</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>
<CardDescription>Логи API и pipeline</CardDescription> <CardDescription>
Логи API и pipeline настраиваются переменной <code className="text-xs">EVOBGP_LOG_*</code> и
управляются в tenant-settings.
</CardDescription>
</CardHeader> </CardHeader>
<CardContent>
<Alert>
<Info className="size-4" />
<AlertTitle>Логи на сервере</AlertTitle>
<AlertDescription>
Файловые логи настраиваются переменной <code className="text-xs">EVOBGP_LOG_*</code> и
управляются tenant-settings на странице «Настройки BIRD».
</AlertDescription>
</Alert>
</CardContent>
</Card> </Card>
</TabsContent> </TabsContent>
</Tabs> </BadgeTabs>
</div> </div>
) )
} }
@@ -315,18 +286,6 @@ function formatVersion(version?: VersionInfo | null): string {
return version.version ?? version.app ?? '—' return version.version ?? version.app ?? '—'
} }
interface OverallInput {
health?: { ok?: boolean } | null
ready?: ReadyStatus | null
jobsFailed: number
}
function overallHint(input: OverallInput): string {
if (!input.health?.ok) return 'API недоступен или возвращает ошибку'
if (input.jobsFailed > 0) return `Есть провальные задачи (${input.jobsFailed})`
return 'Все системы работают в штатном режиме'
}
function BirdSummary({ bird }: { bird: import('@/types/api').BirdStatus }) { function BirdSummary({ bird }: { bird: import('@/types/api').BirdStatus }) {
if (!bird.birdc_configured) { if (!bird.birdc_configured) {
return ( return (
+41 -65
View File
@@ -1,18 +1,16 @@
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 { 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 { RefreshCw } from 'lucide-react'
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@evobgp/ui/components/tabs'
import { import {
DashboardNetworkCapacityCard, DashboardNetworkCapacityCard,
NetworkOverviewAnalyticsCard, NetworkOverviewAnalyticsCard,
} from '@/components/analytics' } from '@/components/analytics'
import { DataGridCard } from '@/components/data-grid-shell' import { BadgeTabs, TabsContent } from '@/components/badge-tabs'
import { NetworkPeersGrid } from '@/components/network/network-peers-grid' import { NetworkPeersCard } from '@/components/network/network-peers-card'
import { NetworkSpeakersGrid } from '@/components/network/network-speakers-grid' 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'
@@ -30,6 +28,7 @@ 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 })
@@ -60,23 +59,23 @@ function NetworkComponent() {
} }
/> />
<Alert className="border-info/30 bg-info/5"> <BadgeTabs
<Info className="text-info" /> value={search.tab}
<AlertTitle>О сетевой конфигурации</AlertTitle> onValueChange={(tab) =>
<AlertDescription> navigate({
Вкладка «Обзор» live-статус agent и BGP на CP и репликах. Apply и ревизии на странице «Операции». search: {
</AlertDescription> tab: tab as 'overview' | 'peers' | 'speakers' | 'control-plane',
</Alert> },
})
<Tabs defaultValue={search.tab}> }
<TabsList> items={[
<TabsTrigger value="overview">Обзор</TabsTrigger> { value: 'overview', label: 'Обзор' },
<TabsTrigger value="peers">Пиры ({peers.length})</TabsTrigger> { value: 'peers', label: 'Пиры', count: peers.length },
<TabsTrigger value="speakers">Спикеры ({speakers.length})</TabsTrigger> { value: 'speakers', label: 'Спикеры', count: speakers.length, badgeVariant: 'info-light' },
<TabsTrigger value="control-plane">Control plane</TabsTrigger> { value: 'control-plane', label: 'Control plane' },
</TabsList> ]}
>
<TabsContent value="overview" className="mt-4"> <TabsContent value="overview" className="mt-0">
<div className="grid gap-4 lg:grid-cols-2"> <div className="grid gap-4 lg:grid-cols-2">
<NetworkOverviewAnalyticsCard <NetworkOverviewAnalyticsCard
peers={peers} peers={peers}
@@ -110,51 +109,28 @@ function NetworkComponent() {
</Card> </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>
@@ -165,7 +141,7 @@ function NetworkComponent() {
</CardContent> </CardContent>
</Card> </Card>
</TabsContent> </TabsContent>
</Tabs> </BadgeTabs>
</div> </div>
) )
} }
+19 -25
View File
@@ -1,16 +1,14 @@
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 { Info, RefreshCw } from 'lucide-react' import { 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 { 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 { Tabs, TabsContent, TabsList, TabsTrigger } from '@evobgp/ui/components/tabs' import { BadgeTabs, TabsContent } from '@/components/badge-tabs'
import { OperationsAnalyticsCard } from '@/components/analytics'
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 { 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'
@@ -34,6 +32,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())
@@ -94,15 +93,6 @@ function OperationsComponent() {
} }
/> />
<Alert className="border-info/30 bg-info/5">
<Info className="text-info" />
<AlertTitle>Три раздела на одной странице</AlertTitle>
<AlertDescription>
<strong>Ревизии</strong> история конфигов и откат; <strong>Сравнение</strong> diff
префиксов; <strong>Задачи</strong> ingest, apply, rollback.
</AlertDescription>
</Alert>
<div className="flex flex-wrap gap-2"> <div className="flex flex-wrap gap-2">
<ConfirmDialog <ConfirmDialog
trigger={ trigger={
@@ -134,14 +124,18 @@ function OperationsComponent() {
<OperationsAnalyticsCard jobs={jobs} revisions={revisions} /> <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}
@@ -163,11 +157,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}
@@ -189,7 +183,7 @@ function OperationsComponent() {
</QueryState> </QueryState>
</DataGridCard> </DataGridCard>
</TabsContent> </TabsContent>
</Tabs> </BadgeTabs>
</div> </div>
) )
} }
+17 -21
View File
@@ -1,13 +1,11 @@
import { createFileRoute } from '@tanstack/react-router' import { createFileRoute } from '@tanstack/react-router'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { AlertTriangle, Clock, Info, ListTodo, RefreshCw } from 'lucide-react' import { AlertTriangle, Clock, ListTodo, RefreshCw } from 'lucide-react'
import { toast } from 'sonner' import { toast } from 'sonner'
import { useState } from 'react' import { useState } from 'react'
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'
@@ -88,16 +86,6 @@ function ScheduleComponent() {
} }
/> />
<Alert className="border-info/30 bg-info/5">
<Info className="text-info" />
<AlertTitle>Как работает расписание</AlertTitle>
<AlertDescription>
Планировщик использует <code className="text-xs">refresh_interval_sec</code> и опционально{' '}
<code className="text-xs">cron_expr</code>. Ручной запуск {' '}
<code className="text-xs">POST /v1/modules/&#123;id&#125;/refresh</code>.
</AlertDescription>
</Alert>
{loading ? <SectionCardsSkeleton count={3} /> : <SectionCards items={items} />} {loading ? <SectionCardsSkeleton count={3} /> : <SectionCards items={items} />}
<DataGridCard <DataGridCard
@@ -138,12 +126,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 +149,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>
) )
} }
+1 -12
View File
@@ -1,7 +1,6 @@
import { createFileRoute, useNavigate } from '@tanstack/react-router' import { createFileRoute, useNavigate } from '@tanstack/react-router'
import { useQuery, useQueryClient } from '@tanstack/react-query' import { useQuery, useQueryClient } from '@tanstack/react-query'
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 { Input } from '@evobgp/ui/components/input' import { Input } from '@evobgp/ui/components/input'
@@ -13,7 +12,7 @@ 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'
import { Info, Save } from 'lucide-react' import { Save } from 'lucide-react'
import { useTheme } from 'next-themes' import { useTheme } from 'next-themes'
import { useEffect, useState } from 'react' import { useEffect, useState } from 'react'
@@ -70,16 +69,6 @@ function SettingsComponent() {
description="Параметры интерфейса и подключения браузера к API." description="Параметры интерфейса и подключения браузера к API."
/> />
<Alert className="border-info/30 bg-info/5">
<Info className="text-info" />
<AlertTitle>Локальная разработка</AlertTitle>
<AlertDescription>
При включённом demo-seed API принимает токен <code className="text-xs">dev</code> (роль{' '}
<code className="text-xs">operator</code>). Вводите только значение токена, без префикса{' '}
<code className="text-xs">Bearer</code> он добавляется автоматически.
</AlertDescription>
</Alert>
<Card> <Card>
<CardHeader> <CardHeader>
<CardTitle>Подключение к API</CardTitle> <CardTitle>Подключение к API</CardTitle>
+45 -63
View File
@@ -1,18 +1,15 @@
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 { Info, Save } from 'lucide-react' import { Save } from 'lucide-react'
import { useEffect, useState } from 'react' import { useEffect, useState } from 'react'
import { toast } from 'sonner' import { toast } from 'sonner'
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 { Tabs, TabsContent, TabsList, TabsTrigger } from '@evobgp/ui/components/tabs' import { BadgeTabs, TabsContent } from '@/components/badge-tabs'
import { DataGridCard } from '@/components/data-grid-shell'
import { SelectField } from '@/components/select-field' import { SelectField } from '@/components/select-field'
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'
@@ -60,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()
@@ -105,24 +103,21 @@ function TenantSettingsComponent() {
description="Параметры control plane для текущего tenant (API /v1/settings)" description="Параметры control plane для текущего tenant (API /v1/settings)"
/> />
<Alert className="border-info/30 bg-info/5"> <BadgeTabs
<Info className="text-info" /> value={search.tab}
<AlertTitle>Operator-only</AlertTitle> onValueChange={(tab) =>
<AlertDescription> navigate({
Изменение значений через <code className="text-xs">PATCH /v1/settings</code> требует роли search: { tab: tab as 'bird' | 'revision' | 'runtime-logs' | 'additional' },
operator. При отсутствии прав API вернёт 403. })
</AlertDescription> }
</Alert> items={[
{ value: 'bird', label: 'BIRD' },
<Tabs defaultValue={search.tab}> { value: 'revision', label: 'Ревизии' },
<TabsList> { value: 'runtime-logs', label: 'Файловые логи' },
<TabsTrigger value="bird">BIRD</TabsTrigger> { value: 'additional', label: 'Дополнительно' },
<TabsTrigger value="revision">Ревизии</TabsTrigger> ]}
<TabsTrigger value="runtime-logs">Файловые логи</TabsTrigger> >
<TabsTrigger value="additional">Дополнительно</TabsTrigger> <TabsContent value="bird" className="mt-0">
</TabsList>
<TabsContent value="bird" className="mt-4">
<Card> <Card>
<CardHeader> <CardHeader>
<CardTitle>BIRD control plane</CardTitle> <CardTitle>BIRD control plane</CardTitle>
@@ -132,14 +127,6 @@ function TenantSettingsComponent() {
</CardDescription> </CardDescription>
</CardHeader> </CardHeader>
<CardContent className="space-y-4"> <CardContent className="space-y-4">
<Alert className="border-info/30 bg-info/5">
<Info className="text-info" />
<AlertTitle>Подстановка в конфиг</AlertTitle>
<AlertDescription>
Значения используются при генерации BIRD-конфигурации (router id, local AS, адреса).
Пиры и спикеры настраиваются в разделе «Сеть».
</AlertDescription>
</Alert>
<QueryState <QueryState
data={partitioned} data={partitioned}
isLoading={settingsQ.isLoading} isLoading={settingsQ.isLoading}
@@ -175,7 +162,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>
@@ -222,7 +209,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>
@@ -313,36 +300,31 @@ function TenantSettingsComponent() {
</Card> </Card>
</TabsContent> </TabsContent>
<TabsContent value="additional" className="mt-4"> <TabsContent value="additional" className="mt-0">
<Card> <DataGridCard
<CardHeader> title="Дополнительные параметры"
<CardTitle>Дополнительные параметры</CardTitle> description="Параметры вне стандартных групп (readonly — изменяются только через API)"
<CardDescription> >
Параметры вне стандартных групп (readonly изменяются только через API) <QueryState
</CardDescription> data={partitioned?.additional ?? []}
</CardHeader> isLoading={settingsQ.isLoading}
<CardContent className="p-0"> isError={settingsQ.isError}
<QueryState error={settingsQ.error}
data={partitioned?.additional ?? []} empty={(partitioned?.additional ?? []).length === 0}
isLoading={settingsQ.isLoading} emptyTitle="Нет дополнительных параметров"
isError={settingsQ.isError} skeleton={<div className="h-32" />}
error={settingsQ.error} onRetry={() => settingsQ.refetch()}
empty={(partitioned?.additional ?? []).length === 0} >
emptyTitle="Нет дополнительных параметров" {(items) => (
skeleton={<div className="h-32" />} <SettingsKvGrid
onRetry={() => settingsQ.refetch()} items={items}
> isLoading={settingsQ.isFetching && !settingsQ.isLoading}
{(items) => ( />
<SettingsKvGrid )}
items={items} </QueryState>
isLoading={settingsQ.isFetching && !settingsQ.isLoading} </DataGridCard>
/>
)}
</QueryState>
</CardContent>
</Card>
</TabsContent> </TabsContent>
</Tabs> </BadgeTabs>
</div> </div>
) )
} }
File diff suppressed because one or more lines are too long
+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"
+226
View File
@@ -0,0 +1,226 @@
import * as React from "react"
import { Drawer as DrawerPrimitive } from "@base-ui/react/drawer"
import { cn } from "@evobgp/ui/lib/utils"
type DrawerContextProps = {
hasSnapPoints: boolean
modal: DrawerPrimitive.Root.Props["modal"]
showSwipeHandle: boolean
swipeDirection: NonNullable<DrawerPrimitive.Root.Props["swipeDirection"]>
}
const DrawerContext = React.createContext<DrawerContextProps | null>(null)
function useDrawer() {
const context = React.useContext(DrawerContext)
if (!context) {
throw new Error("useDrawer must be used within a Drawer.")
}
return context
}
function Drawer({
modal = true,
showSwipeHandle = false,
snapPoints,
swipeDirection = "down",
...props
}: DrawerPrimitive.Root.Props & {
showSwipeHandle?: boolean
}) {
const hasSnapPoints = snapPoints != null && snapPoints.length > 0
const contextValue = React.useMemo(
() => ({ hasSnapPoints, modal, showSwipeHandle, swipeDirection }),
[hasSnapPoints, modal, showSwipeHandle, swipeDirection]
)
return (
<DrawerContext.Provider value={contextValue}>
<DrawerPrimitive.Root
data-slot="drawer"
modal={modal}
snapPoints={snapPoints}
swipeDirection={swipeDirection}
{...props}
/>
</DrawerContext.Provider>
)
}
function DrawerTrigger({ ...props }: DrawerPrimitive.Trigger.Props) {
return <DrawerPrimitive.Trigger data-slot="drawer-trigger" {...props} />
}
function DrawerPortal({ ...props }: DrawerPrimitive.Portal.Props) {
return <DrawerPrimitive.Portal data-slot="drawer-portal" {...props} />
}
function DrawerClose({ ...props }: DrawerPrimitive.Close.Props) {
return <DrawerPrimitive.Close data-slot="drawer-close" {...props} />
}
function DrawerOverlay({
className,
...props
}: DrawerPrimitive.Backdrop.Props) {
return (
<DrawerPrimitive.Backdrop
data-slot="drawer-overlay"
className={cn(
"fixed inset-0 z-50 min-h-dvh bg-black/10 opacity-[max(var(--drawer-overlay-min-opacity,0),calc(1-var(--drawer-swipe-progress)))] transition-opacity duration-450 ease-[cubic-bezier(0.32,0.72,0,1)] select-none data-ending-style:pointer-events-none data-ending-style:opacity-0 data-ending-style:duration-[calc(var(--drawer-swipe-strength)*400ms)] data-snap-points:[--drawer-overlay-min-opacity:0.5] data-starting-style:opacity-0 data-swiping:duration-0 supports-backdrop-filter:backdrop-blur-xs supports-[-webkit-touch-callout:none]:absolute",
className
)}
{...props}
/>
)
}
function DrawerSwipeHandle({
className,
...props
}: React.ComponentProps<"div">) {
return (
<div
data-slot="drawer-swipe-handle"
aria-hidden="true"
className={cn(
"relative z-10 flex shrink-0 cursor-grab transition-opacity duration-200 group-data-nested-drawer-open/drawer-popup:opacity-0 group-data-nested-drawer-swiping/drawer-popup:opacity-100 group-data-[swipe-axis=x]/drawer-popup:h-full group-data-[swipe-axis=x]/drawer-popup:w-3 group-data-[swipe-axis=x]/drawer-popup:items-center group-data-[swipe-axis=y]/drawer-popup:h-3 group-data-[swipe-axis=y]/drawer-popup:w-full group-data-[swipe-axis=y]/drawer-popup:justify-center group-data-[swipe-direction=down]/drawer-popup:items-end group-data-[swipe-direction=left]/drawer-popup:order-last group-data-[swipe-direction=left]/drawer-popup:justify-start group-data-[swipe-direction=right]/drawer-popup:justify-end group-data-[swipe-direction=up]/drawer-popup:order-last group-data-[swipe-direction=up]/drawer-popup:items-start after:block after:shrink-0 after:rounded-full after:bg-muted group-data-[swipe-axis=x]/drawer-popup:after:h-24 group-data-[swipe-axis=x]/drawer-popup:after:w-1 group-data-[swipe-axis=y]/drawer-popup:after:h-1 group-data-[swipe-axis=y]/drawer-popup:after:w-24 active:cursor-grabbing",
className
)}
{...props}
/>
)
}
function DrawerContent({
className,
children,
...props
}: DrawerPrimitive.Popup.Props) {
const { hasSnapPoints, modal, showSwipeHandle, swipeDirection } = useDrawer()
const swipeAxis =
swipeDirection === "down" || swipeDirection === "up" ? "y" : "x"
return (
<DrawerPortal data-slot="drawer-portal">
{modal === true && (
<DrawerOverlay data-snap-points={hasSnapPoints ? "" : undefined} />
)}
<DrawerPrimitive.Viewport
data-slot="drawer-viewport"
data-modal={modal}
className="pointer-events-none fixed inset-0 z-50 select-none data-[modal=true]:pointer-events-auto"
>
<DrawerPrimitive.Popup
data-slot="drawer-popup"
data-swipe-axis={swipeAxis}
data-snap-points={hasSnapPoints ? "" : undefined}
className={cn(
// Base.
"group/drawer-popup pointer-events-auto fixed z-50 m-(--drawer-inset,0px) flex h-(--drawer-content-height) max-h-(--drawer-content-max-height,none) min-h-0 w-(--drawer-content-width,auto) transform-[translate3d(var(--translate-x,0px),var(--translate-y,0px),0)_scale(var(--stack-scale))] flex-col bg-popover text-sm text-popover-foreground transition-[transform,height,opacity,filter] duration-450 ease-[cubic-bezier(0.22,1,0.36,1)] will-change-transform outline-none select-none [interpolate-size:allow-keywords] data-[swipe-direction=down]:rounded-t-xl data-[swipe-direction=down]:border-t data-[swipe-direction=left]:rounded-r-xl data-[swipe-direction=left]:border-r data-[swipe-direction=right]:rounded-l-xl data-[swipe-direction=right]:border-l data-[swipe-direction=up]:rounded-b-xl data-[swipe-direction=up]:border-b",
// Nested.
"data-nested-drawer-open:overflow-hidden data-nested-drawer-open:brightness-95",
// Bleed.
"after:pointer-events-none after:absolute after:bg-(--drawer-bleed-background,var(--color-popover)) data-[swipe-axis=x]:after:inset-y-0 data-[swipe-axis=x]:after:w-(--bleed) data-[swipe-axis=y]:after:inset-x-0 data-[swipe-axis=y]:after:h-(--bleed) data-[swipe-direction=down]:after:top-full data-[swipe-direction=left]:after:right-full data-[swipe-direction=right]:after:left-full data-[swipe-direction=up]:after:bottom-full",
// Sizing.
"[--drawer-content-height:var(--drawer-height,auto)] data-[swipe-axis=x]:[--drawer-content-width:75%] data-[swipe-axis=y]:[--drawer-content-max-height:calc(100dvh-6rem)] data-[swipe-axis=y]:data-snap-points:[--drawer-content-height:100dvh] data-[swipe-axis=x]:sm:[--drawer-content-width:24rem]",
// Stack.
"[--bleed:3rem] [--peek:1rem] [--stack-height:var(--drawer-frontmost-height,var(--drawer-height,0px))] [--stack-peek-offset:max(0px,calc((var(--nested-drawers)-var(--stack-progress))*var(--peek)))] [--stack-progress:clamp(0,var(--drawer-swipe-progress),1)] [--stack-scale-base:max(0,calc(1-(var(--nested-drawers)*var(--stack-step))))] [--stack-scale:clamp(0,calc(var(--stack-scale-base)+(var(--stack-step)*var(--stack-progress))),1)] [--stack-shrink:calc(1-var(--stack-scale))] [--stack-step:0.05]",
// Transitions.
"data-ending-style:transform-(--closed-transform) data-ending-style:opacity-[0.9999] data-ending-style:duration-[calc(var(--drawer-swipe-strength)*400ms)] data-nested-drawer-swiping:duration-0 data-ending-style:data-nested-drawer-swiping:duration-[calc(var(--drawer-swipe-strength)*400ms)] data-starting-style:transform-(--closed-transform) data-swiping:duration-0 data-ending-style:data-swiping:duration-[calc(var(--drawer-swipe-strength)*400ms)]",
// Axis: y.
"data-[swipe-axis=y]:inset-x-0 data-[swipe-axis=y]:data-nested-drawer-open:h-(--stack-height)",
// Axis: x.
"data-[swipe-axis=x]:inset-y-0 data-[swipe-axis=x]:flex-row",
// Direction: down.
"data-[swipe-direction=down]:bottom-0 data-[swipe-direction=down]:origin-bottom data-[swipe-direction=down]:[--closed-transform:translate3d(0,calc(100%+var(--drawer-inset,0px)+2px),0)] data-[swipe-direction=down]:[--translate-y:calc(var(--drawer-snap-point-offset,0px)+var(--drawer-swipe-movement-y)-var(--stack-peek-offset)-(var(--stack-shrink)*var(--stack-height)))]",
// Direction: up.
"data-[swipe-direction=up]:top-0 data-[swipe-direction=up]:origin-top data-[swipe-direction=up]:[--closed-transform:translate3d(0,calc(-100%-var(--drawer-inset,0px)-2px),0)] data-[swipe-direction=up]:[--translate-y:calc(var(--drawer-snap-point-offset,0px)+var(--drawer-swipe-movement-y)+var(--stack-peek-offset)+(var(--stack-shrink)*var(--stack-height)))]",
// Direction: left.
"data-[swipe-direction=left]:left-0 data-[swipe-direction=left]:origin-left data-[swipe-direction=left]:[--closed-transform:translate3d(calc(-100%-var(--drawer-inset,0px)-2px),0,0)] data-[swipe-direction=left]:[--translate-x:calc(var(--drawer-swipe-movement-x)+var(--stack-peek-offset)+(var(--stack-shrink)*100%))]",
// Direction: right.
"data-[swipe-direction=right]:right-0 data-[swipe-direction=right]:origin-right data-[swipe-direction=right]:[--closed-transform:translate3d(calc(100%+var(--drawer-inset,0px)+2px),0,0)] data-[swipe-direction=right]:[--translate-x:calc(var(--drawer-swipe-movement-x)-var(--stack-peek-offset)-(var(--stack-shrink)*100%))]",
className
)}
{...props}
>
{showSwipeHandle && <DrawerSwipeHandle />}
<DrawerPrimitive.Content
data-slot="drawer-content"
className={cn(
"flex min-h-0 flex-1 flex-col overflow-hidden overscroll-contain rounded-[inherit] transition-opacity duration-300 ease-[cubic-bezier(0.45,1.005,0,1.005)] select-text group-data-nested-drawer-open/drawer-popup:opacity-0 group-data-nested-drawer-swiping/drawer-popup:opacity-100 group-data-swiping/drawer-popup:select-none"
)}
>
{children}
</DrawerPrimitive.Content>
</DrawerPrimitive.Popup>
</DrawerPrimitive.Viewport>
</DrawerPortal>
)
}
function DrawerHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="drawer-header"
className={cn(
"flex shrink-0 flex-col gap-0.5 p-4 pb-0 group-data-[swipe-axis=y]/drawer-popup:text-center md:gap-0.5 md:text-left",
className
)}
{...props}
/>
)
}
function DrawerFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="drawer-footer"
className={cn("mt-auto flex shrink-0 flex-col gap-2 p-4 pt-0", className)}
{...props}
/>
)
}
function DrawerTitle({ className, ...props }: DrawerPrimitive.Title.Props) {
return (
<DrawerPrimitive.Title
data-slot="drawer-title"
className={cn(
"text-base font-medium text-foreground",
className
)}
{...props}
/>
)
}
function DrawerDescription({
className,
...props
}: DrawerPrimitive.Description.Props) {
return (
<DrawerPrimitive.Description
data-slot="drawer-description"
className={cn("text-sm text-balance text-muted-foreground", className)}
{...props}
/>
)
}
export {
Drawer,
DrawerPortal,
DrawerOverlay,
DrawerSwipeHandle,
DrawerTrigger,
DrawerClose,
DrawerContent,
DrawerHeader,
DrawerFooter,
DrawerTitle,
DrawerDescription,
}
+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}