Compare commits

...
4 Commits
Author SHA1 Message Date
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
40 changed files with 1291 additions and 811 deletions
@@ -4,8 +4,9 @@ import { useMemo } from 'react'
import { Button } from '@evobgp/ui/components/button' import { Button } from '@evobgp/ui/components/button'
import { CategoryBadge } from '@/components/category-badge'
import { DataGridMutedCell, DataGridPrimaryCell } from '@/components/data-grid-cell'
import { DataGridSection } from '@/components/data-grid-shell' import { DataGridSection } from '@/components/data-grid-shell'
import { Badge } from '@/components/reui/badge'
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'
@@ -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: 'Последнее использование' },
}, },
@@ -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>
) )
} }
+10 -3
View File
@@ -1,6 +1,7 @@
import type { ComponentProps, ReactNode } from 'react' import type { ComponentProps, ReactNode } from 'react'
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@evobgp/ui/components/tabs' import { Tabs, TabsContent, TabsList, TabsTrigger } from '@evobgp/ui/components/tabs'
import { cn } from '@evobgp/ui/lib/utils'
import { Badge } from '@/components/reui/badge' import { Badge } from '@/components/reui/badge'
@@ -39,9 +40,15 @@ export function BadgeTabs({
value={value} value={value}
defaultValue={defaultValue} defaultValue={defaultValue}
onValueChange={onValueChange} onValueChange={onValueChange}
className={className} className={cn('w-full', className)}
> >
<TabsList variant="line" className={listClassName ?? 'mb-3.5 w-full'}> <TabsList
variant="line"
className={cn(
'mb-4 w-full justify-start gap-6',
listClassName,
)}
>
{items.map((item) => ( {items.map((item) => (
<TabsTrigger key={item.value} value={item.value} className="gap-2"> <TabsTrigger key={item.value} value={item.value} className="gap-2">
{item.icon} {item.icon}
@@ -54,7 +61,7 @@ export function BadgeTabs({
</TabsTrigger> </TabsTrigger>
))} ))}
</TabsList> </TabsList>
<div className={contentClassName}>{children}</div> <div className={cn('w-full min-w-0', contentClassName)}>{children}</div>
</Tabs> </Tabs>
) )
} }
@@ -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>
)
}
+103 -30
View File
@@ -1,53 +1,126 @@
import { Button } from '@evobgp/ui/components/button'
import { import {
AlertDialog, Drawer,
AlertDialogAction, DrawerClose,
AlertDialogCancel, DrawerContent,
AlertDialogContent, DrawerDescription,
AlertDialogDescription, DrawerFooter,
AlertDialogFooter, DrawerHeader,
AlertDialogHeader, DrawerTitle,
AlertDialogTitle, DrawerTrigger,
AlertDialogTrigger, } from '@evobgp/ui/components/drawer'
} from '@evobgp/ui/components/alert-dialog'
import type { ReactElement, ReactNode } from 'react' import type { ReactElement, ReactNode } from 'react'
interface ConfirmDialogProps { type ConfirmDialogBaseProps = {
trigger: ReactElement
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>
<AlertDialogContent> <DrawerTitle>{title}</DrawerTitle>
<AlertDialogHeader> {description ? <DrawerDescription>{description}</DrawerDescription> : null}
<AlertDialogTitle>{title}</AlertDialogTitle> </DrawerHeader>
{description ? <AlertDialogDescription>{description}</AlertDialogDescription> : null} <DrawerFooter className="border-t bg-muted/50 sm:flex-row sm:justify-end">
</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>
)}
</DrawerFooter>
</>
)
}
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="h-full max-h-none sm:max-w-sm">
<ConfirmDrawerBody {...bodyProps} />
</DrawerContent>
</Drawer>
)
}
return (
<Drawer open={props.open} onOpenChange={props.onOpenChange} swipeDirection="right">
<DrawerContent className="h-full max-h-none sm:max-w-sm">
<ConfirmDrawerBody {...bodyProps} controlled />
</DrawerContent>
</Drawer>
) )
} }
@@ -1,22 +1,14 @@
import { ColumnDef } from '@tanstack/react-table' import { ColumnDef } from '@tanstack/react-table'
import { useMemo } from 'react' import { useMemo } from 'react'
import { DataGridPrimaryCell } from '@/components/data-grid-cell'
import { DataGridSection } from '@/components/data-grid-shell' 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 { 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,
@@ -34,21 +26,22 @@ 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: 'Статус' },
}, },
], ],
@@ -1,6 +1,7 @@
import { ColumnDef } from '@tanstack/react-table' import { ColumnDef } from '@tanstack/react-table'
import { useMemo } from 'react' import { useMemo } from 'react'
import { DataGridMutedCell, DataGridPrimaryCell } from '@/components/data-grid-cell'
import { DataGridSection } from '@/components/data-grid-shell' import { DataGridSection } from '@/components/data-grid-shell'
import { DataGridColumnHeader } from '@/components/reui/data-grid/data-grid-column-header' import { DataGridColumnHeader } from '@/components/reui/data-grid/data-grid-column-header'
import { DATA_GRID_DENSE_LAYOUT } from '@/lib/data-grid-defaults' import { DATA_GRID_DENSE_LAYOUT } from '@/lib/data-grid-defaults'
@@ -23,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' },
}, },
@@ -33,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: 'Создана' },
}, },
@@ -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>
)
}
+5 -1
View File
@@ -46,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>
) )
} }
@@ -28,7 +28,7 @@ export function DataGridToolbar({
className, className,
}: DataGridToolbarProps) { }: DataGridToolbarProps) {
return ( return (
<div className={`flex flex-wrap items-center gap-2 border-b px-3 py-3 ${className ?? ''}`}> <div className={`flex flex-wrap items-center gap-2 border-b px-4 py-3 ${className ?? ''}`}>
<Field className="min-w-[200px] flex-1"> <Field className="min-w-[200px] flex-1">
<InputGroup> <InputGroup>
<InputGroupAddon align="inline-start"> <InputGroupAddon align="inline-start">
@@ -1,8 +1,8 @@
import { ColumnDef } 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 { DataGridSection } from '@/components/data-grid-shell' import { DataGridSection } from '@/components/data-grid-shell'
import { DataGridColumnHeader } from '@/components/reui/data-grid/data-grid-column-header' import { DataGridColumnHeader } from '@/components/reui/data-grid/data-grid-column-header'
import { useClientDataGrid } from '@/hooks/use-client-data-grid' import { useClientDataGrid } from '@/hooks/use-client-data-grid'
@@ -20,20 +20,22 @@ 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: 'Тип' },
}, },
], ],
@@ -1,8 +1,8 @@
import { ColumnDef } 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 { DataGridSection } from '@/components/data-grid-shell' import { DataGridSection } from '@/components/data-grid-shell'
import { DataGridColumnHeader } from '@/components/reui/data-grid/data-grid-column-header' import { DataGridColumnHeader } from '@/components/reui/data-grid/data-grid-column-header'
import { useClientDataGrid } from '@/hooks/use-client-data-grid' import { useClientDataGrid } from '@/hooks/use-client-data-grid'
@@ -21,20 +21,26 @@ 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: 'По умолчанию' },
}, },
], ],
@@ -3,6 +3,7 @@ import { useMemo } from 'react'
import { Button } from '@evobgp/ui/components/button' import { Button } from '@evobgp/ui/components/button'
import { DataGridMutedCell, DataGridPrimaryCell } from '@/components/data-grid-cell'
import { DataGridSection } 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'
@@ -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 ?? ''
@@ -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>
)
}
+47
View File
@@ -0,0 +1,47 @@
import type { ReactNode } from 'react'
import { cn } from '@evobgp/ui/lib/utils'
import {
Drawer,
DrawerContent,
DrawerDescription,
DrawerFooter,
DrawerHeader,
DrawerTitle,
} from '@evobgp/ui/components/drawer'
import { ScrollArea } from '@evobgp/ui/components/scroll-area'
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('h-full max-h-none sm:max-w-lg', className)}>
<DrawerHeader>
<DrawerTitle>{title}</DrawerTitle>
{description ? <DrawerDescription>{description}</DrawerDescription> : null}
</DrawerHeader>
<ScrollArea className="min-h-0 flex-1 px-4">
<div className="space-y-4 pb-4">{children}</div>
</ScrollArea>
<DrawerFooter className="border-t bg-muted/50 sm:flex-row sm:justify-end">{footer}</DrawerFooter>
</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>
) )
} }
@@ -4,6 +4,8 @@ import { useMemo } from 'react'
import { Button } from '@evobgp/ui/components/button' import { Button } from '@evobgp/ui/components/button'
import { CategoryBadge } from '@/components/category-badge'
import { DataGridMutedCell, DataGridPrimaryCell } from '@/components/data-grid-cell'
import { DataGridSection } from '@/components/data-grid-shell' import { DataGridSection } from '@/components/data-grid-shell'
import { DataGridColumnHeader } from '@/components/reui/data-grid/data-grid-column-header' import { DataGridColumnHeader } from '@/components/reui/data-grid/data-grid-column-header'
import { formatDateTime } from '@/lib/modules/display' import { formatDateTime } from '@/lib/modules/display'
@@ -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>
), ),
}, },
{ {
@@ -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>
) )
} }
@@ -3,10 +3,10 @@ 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 { CategoryBadge, ModeBadge } from '@/components/category-badge'
import { DataGridMutedCell, DataGridPrimaryCell } from '@/components/data-grid-cell'
import { DataGridSection } from '@/components/data-grid-shell' import { DataGridSection } from '@/components/data-grid-shell'
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 { TruncatedText } from '@/components/truncated-text'
import { useClientDataGrid } from '@/hooks/use-client-data-grid' import { useClientDataGrid } from '@/hooks/use-client-data-grid'
import type { ModuleRow } from '@/types/api' import type { ModuleRow } from '@/types/api'
@@ -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 ?? ''
@@ -2,9 +2,9 @@ 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 { 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 { useClientDataGrid } from '@/hooks/use-client-data-grid'
import type { ReadyStatus } from '@/queries/monitoring' import type { ReadyStatus } from '@/queries/monitoring'
@@ -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: 'Статус' },
}, },
@@ -1,12 +1,13 @@
import { ColumnDef } from '@tanstack/react-table' import { ColumnDef } from '@tanstack/react-table'
import { useMemo } from 'react' import { useMemo } from 'react'
import { CategoryBadge } from '@/components/category-badge'
import { DataGridPrimaryCell } from '@/components/data-grid-cell'
import { DataGridSection } from '@/components/data-grid-shell' import { DataGridSection } from '@/components/data-grid-shell'
import { Badge } from '@/components/reui/badge' import { StatusBadge } from '@/components/status-badge'
import { DataGridColumnHeader } from '@/components/reui/data-grid/data-grid-column-header' import { DataGridColumnHeader } from '@/components/reui/data-grid/data-grid-column-header'
import { useClientDataGrid } from '@/hooks/use-client-data-grid' import { useClientDataGrid } from '@/hooks/use-client-data-grid'
import type { PeerRow } from '@/types/api' import type { PeerRow } from '@/types/api'
import { StatusBadge } from '@/components/status-badge'
export function NetworkPeersGrid({ export function NetworkPeersGrid({
items, items,
@@ -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>
), ),
@@ -1,6 +1,8 @@
import { ColumnDef } from '@tanstack/react-table' import { ColumnDef } from '@tanstack/react-table'
import { useMemo } from 'react' import { useMemo } from 'react'
import { CategoryBadge } from '@/components/category-badge'
import { DataGridPrimaryCell } from '@/components/data-grid-cell'
import { DataGridSection } from '@/components/data-grid-shell' 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'
@@ -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' },
}, },
@@ -2,18 +2,11 @@ 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 { import { Checkbox } from '@evobgp/ui/components/checkbox'
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 { Checkbox } from '@evobgp/ui/components/checkbox'
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 { useCreatePeerMutation, useUpdatePeerMutation } from '@/queries/network' import { useCreatePeerMutation, useUpdatePeerMutation } from '@/queries/network'
@@ -102,74 +95,74 @@ export function PeerFormDialog({
} }
return ( return (
<Dialog open={open} onOpenChange={onOpenChange}> <FormDrawer
<DialogContent className="sm:max-w-sm"> open={open}
<DialogHeader> onOpenChange={onOpenChange}
<DialogTitle>{editTarget ? 'Редактировать пира' : 'Новый пир'}</DialogTitle> title={editTarget ? 'Редактировать пира' : 'Новый пир'}
<DialogDescription>BGP-сосед для установки сессии</DialogDescription> description="BGP-сосед для установки сессии"
</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="peer-name">Имя пира (опционально)</Label>
<Input
id="peer-name"
placeholder="Core-RTR-1"
value={name}
onChange={(e) => setName(e.target.value)}
/>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="peer-neighbor">Адрес соседа</Label>
<Input
id="peer-neighbor"
placeholder="192.0.2.1"
value={neighbor}
onChange={(e) => setNeighbor(e.target.value)}
required
/>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="peer-asn">Remote ASN</Label>
<Input
id="peer-asn"
type="number"
placeholder="65000"
value={remoteAsn}
onChange={(e) => setRemoteAsn(e.target.value)}
required
/>
</div>
<SelectField
id="peer-speaker"
label="Спикер (опционально)"
items={speakerItems}
value={bgpSpeakerId ?? ''}
onValueChange={(v) => setBgpSpeakerId(v || null)}
placeholder="Все спикеры"
/>
<div className="flex flex-row items-center justify-between gap-4 rounded-lg border border-border bg-muted/30 p-3">
<div className="grid min-w-0 flex-1 gap-1 pr-2">
<Label htmlFor="peer-enabled">Включён</Label>
<p className="text-xs text-muted-foreground">
Выключенный пир не попадает в конфиг BIRD до следующей ревизии.
</p>
</div>
<Checkbox
id="peer-enabled"
checked={enabled}
onCheckedChange={(v) => setEnabled(v === true)}
/>
</div>
</div>
<DialogFooter>
<Button variant="outline" type="button" onClick={() => onOpenChange(false)}> <Button variant="outline" type="button" onClick={() => onOpenChange(false)}>
Отмена Отмена
</Button> </Button>
<LoadingButton type="button" loading={saving} onClick={save}> <LoadingButton type="button" loading={saving} onClick={save}>
{editTarget ? 'Сохранить' : 'Создать'} {editTarget ? 'Сохранить' : 'Создать'}
</LoadingButton> </LoadingButton>
</DialogFooter> </>
</DialogContent> }
</Dialog> >
<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>
) )
} }
@@ -2,17 +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,
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 { SelectField } from '@/components/select-field' import { SelectField } from '@/components/select-field'
import { useCreateSpeakerMutation } from '@/queries/network' import { useCreateSpeakerMutation } from '@/queries/network'
@@ -98,72 +91,72 @@ export function SpeakerFormDialog({ open, onOpenChange }: SpeakerFormDialogProps
} }
return ( return (
<Dialog open={open} onOpenChange={onOpenChange}> <FormDrawer
<DialogContent className="sm:max-w-md"> open={open}
<DialogHeader> onOpenChange={onOpenChange}
<DialogTitle>Новый спикер</DialogTitle> title="Новый спикер"
<DialogDescription>BIRD-агент на ноде реплики или control plane</DialogDescription> description="BIRD-агент на ноде реплики или control plane"
</DialogHeader> className="sm:max-w-md"
<div className="flex flex-col gap-4 py-2"> footer={
<div className="flex flex-col gap-2"> <>
<Label htmlFor="speaker-endpoint">Endpoint</Label>
<Input
id="speaker-endpoint"
placeholder="https://node.example.com:8443"
value={endpoint}
onChange={(e) => handleEndpointChange(e.target.value)}
/>
</div>
<SelectField
id="speaker-role"
label="Роль"
items={[
{ value: 'replica', label: 'replica' },
{ value: 'master', label: 'master (CP)' },
]}
value={role}
onValueChange={(v) => setRole(v ?? 'replica')}
/>
<div className="flex flex-col gap-2">
<Label htmlFor="speaker-agent-domain">Agent domain</Label>
<Input
id="speaker-agent-domain"
placeholder="bird-agent.example.com"
value={agentDomain}
onChange={(e) => setAgentDomain(e.target.value)}
/>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="speaker-node-ipv4">Node IPv4</Label>
<Input
id="speaker-node-ipv4"
placeholder="203.0.113.10"
value={nodeIpv4}
onChange={(e) => handleNodeIpv4Change(e.target.value)}
/>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="speaker-bgp-source">BGP source IPv4</Label>
<Input
id="speaker-bgp-source"
placeholder="203.0.113.10"
value={bgpSourceIpv4}
onChange={(e) => {
setBgpSourceManual(true)
setBgpSourceIpv4(e.target.value)
}}
/>
</div>
</div>
<DialogFooter>
<Button variant="outline" type="button" onClick={() => onOpenChange(false)}> <Button variant="outline" type="button" onClick={() => onOpenChange(false)}>
Отмена Отмена
</Button> </Button>
<LoadingButton type="button" loading={createMutation.isPending} onClick={save}> <LoadingButton type="button" loading={createMutation.isPending} onClick={save}>
Создать Создать
</LoadingButton> </LoadingButton>
</DialogFooter> </>
</DialogContent> }
</Dialog> >
<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>
) )
} }
@@ -5,23 +5,15 @@ import { toast } from 'sonner'
import { Button } from '@evobgp/ui/components/button' import { Button } from '@evobgp/ui/components/button'
import { DataGridMutedCell, DataGridPrimaryCell } from '@/components/data-grid-cell'
import { DataGridSection } from '@/components/data-grid-shell' 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 { useClientDataGrid } from '@/hooks/use-client-data-grid'
import { apiMutate } from '@/lib/api-client' import { apiMutate } from '@/lib/api-client'
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: 'Завершена' },
}, },
@@ -6,6 +6,7 @@ import { toast } from 'sonner'
import { Button } from '@evobgp/ui/components/button' import { Button } from '@evobgp/ui/components/button'
import { DataGridMutedCell, DataGridPrimaryCell } from '@/components/data-grid-cell'
import { DataGridSection } 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 { DataGridColumnHeader } from '@/components/reui/data-grid/data-grid-column-header' import { DataGridColumnHeader } from '@/components/reui/data-grid/data-grid-column-header'
@@ -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: 'Создана' },
}, },
@@ -1,9 +1,9 @@
import { ColumnDef } 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 { 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 { useClientDataGrid } from '@/hooks/use-client-data-grid'
import type { JobRow } from '@/types/api' import type { JobRow } from '@/types/api'
@@ -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: 'Завершена' },
}, },
@@ -2,8 +2,8 @@ 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 { DataGridSection } 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'
@@ -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: 'Статус' },
}, },
{ {
@@ -1,6 +1,7 @@
import { ColumnDef } from '@tanstack/react-table' import { ColumnDef } from '@tanstack/react-table'
import { useMemo } from 'react' import { useMemo } from 'react'
import { DataGridPrimaryCell } from '@/components/data-grid-cell'
import { DataGridSection } from '@/components/data-grid-shell' import { DataGridSection } from '@/components/data-grid-shell'
import { DataGridColumnHeader } from '@/components/reui/data-grid/data-grid-column-header' import { DataGridColumnHeader } from '@/components/reui/data-grid/data-grid-column-header'
import { useClientDataGrid } from '@/hooks/use-client-data-grid' import { useClientDataGrid } from '@/hooks/use-client-data-grid'
@@ -23,13 +24,13 @@ 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: 'Значение' },
}, },
], ],
+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>
)
} }
+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 : 'Не удалось добавить правило'),
}) })
} }
+82 -109
View File
@@ -1,6 +1,6 @@
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, Info, 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'
@@ -10,10 +10,11 @@ import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@evob
import { Input } from '@evobgp/ui/components/input' import { Input } from '@evobgp/ui/components/input'
import { Label } from '@evobgp/ui/components/label' import { Label } from '@evobgp/ui/components/label'
import { BadgeTabs, TabsContent } from '@/components/badge-tabs' import { BadgeTabs, TabsContent } from '@/components/badge-tabs'
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'
@@ -22,7 +23,6 @@ import {
firewallInstallContextQueryOptions, firewallInstallContextQueryOptions,
firewallRulesQueryOptions, firewallRulesQueryOptions,
useApproveFirewallClient, useApproveFirewallClient,
useCreateFirewallRule,
useDeleteFirewallClient, useDeleteFirewallClient,
useDeleteFirewallRule, useDeleteFirewallRule,
} from '@/queries/firewall' } from '@/queries/firewall'
@@ -48,7 +48,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
@@ -58,6 +57,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) {
@@ -67,9 +67,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 ?? []
@@ -198,115 +195,91 @@ function FirewallPage() {
]} ]}
> >
<TabsContent value="clients" className="mt-0"> <TabsContent value="clients" className="mt-0">
<QueryState <DataGridCard title="Клиенты" description="Активные Linux-серверы с синхронизацией blocklist">
data={clientsQ.data} <QueryState
isLoading={clientsQ.isLoading} data={clientsQ.data}
isError={clientsQ.isError} isLoading={clientsQ.isLoading}
error={clientsQ.error} isError={clientsQ.isError}
onRetry={() => void clientsQ.refetch()} error={clientsQ.error}
skeleton={<TableSkeleton rows={5} cols={6} />} onRetry={() => void clientsQ.refetch()}
> skeleton={<TableSkeleton rows={5} cols={6} />}
{() => ( >
<FirewallClientsGrid {() => (
clients={activeClients} <FirewallClientsGrid
isLoading={clientsQ.isFetching && !clientsQ.isLoading} clients={activeClients}
onApprove={(id) => approve.mutate(id)} isLoading={clientsQ.isFetching && !clientsQ.isLoading}
onReject={(id) => deleteClient.mutate(id)} onApprove={(id) => approve.mutate(id)}
approvePending={approve.isPending} onReject={(id) => deleteClient.mutate(id)}
rejectPending={deleteClient.isPending} approvePending={approve.isPending}
/> rejectPending={deleteClient.isPending}
)} />
</QueryState> )}
</QueryState>
</DataGridCard>
</TabsContent> </TabsContent>
<TabsContent value="rules" className="mt-0 space-y-4"> <TabsContent value="rules" className="mt-0">
<div className="flex flex-wrap items-end gap-3"> <DataGridCard
<div className="space-y-1"> title="Правила"
<Label>Действие</Label> actions={
<select <Button size="sm" type="button" onClick={() => setCreateRuleOpen(true)}>
className="border-input bg-background h-9 rounded-md border px-2 text-sm" <Plus />
value={ruleAction} Добавить правило
onChange={(e) => setRuleAction(e.target.value as 'block' | 'accept')} </Button>
> }
<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>
</div>
<QueryState
data={rulesQ.data}
isLoading={rulesQ.isLoading}
isError={rulesQ.isError}
error={rulesQ.error}
onRetry={() => void rulesQ.refetch()}
skeleton={<TableSkeleton rows={5} cols={5} />}
> >
{() => ( <QueryState
<FirewallRulesGrid data={rulesQ.data}
rules={rules} isLoading={rulesQ.isLoading}
communities={communities} isError={rulesQ.isError}
isLoading={rulesQ.isFetching && !rulesQ.isLoading} error={rulesQ.error}
onDelete={(id) => deleteRule.mutate(id)} onRetry={() => void rulesQ.refetch()}
deletePending={deleteRule.isPending} skeleton={<TableSkeleton rows={5} cols={5} />}
/> >
)} {() => (
</QueryState> <FirewallRulesGrid
rules={rules}
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>
<TabsContent value="requests" className="mt-0"> <TabsContent value="requests" className="mt-0">
<QueryState <DataGridCard
data={clientsQ.data} title="Запросы"
isLoading={clientsQ.isLoading} description="Pending enroll — одобрите или отклоните новые клиенты"
isError={clientsQ.isError}
error={clientsQ.error}
onRetry={() => void clientsQ.refetch()}
skeleton={<TableSkeleton rows={3} cols={6} />}
> >
{() => ( <QueryState
<FirewallClientsGrid data={clientsQ.data}
clients={pending} isLoading={clientsQ.isLoading}
isLoading={clientsQ.isFetching && !clientsQ.isLoading} isError={clientsQ.isError}
onApprove={(id) => approve.mutate(id)} error={clientsQ.error}
onReject={(id) => deleteClient.mutate(id)} onRetry={() => void clientsQ.refetch()}
approvePending={approve.isPending} skeleton={<TableSkeleton rows={3} cols={6} />}
rejectPending={deleteClient.isPending} >
emptyTitle="Нет pending-запросов" {() => (
/> <FirewallClientsGrid
)} clients={pending}
</QueryState> 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> </TabsContent>
</BadgeTabs> </BadgeTabs>
</div> </div>
+18 -20
View File
@@ -3,11 +3,12 @@ import { useQuery } from '@tanstack/react-query'
import { Activity, AlertTriangle, Bird, Database, HeartPulse, Info, ListTodo, RefreshCw } from 'lucide-react' import { Activity, AlertTriangle, Bird, Database, HeartPulse, Info, ListTodo, RefreshCw } from 'lucide-react'
import { Alert, AlertDescription, AlertTitle } from '@evobgp/ui/components/alert' import { Alert, AlertDescription, AlertTitle } from '@evobgp/ui/components/alert'
import { Badge } from '@evobgp/ui/components/badge'
import { Button } from '@evobgp/ui/components/button' import { Button } from '@evobgp/ui/components/button'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@evobgp/ui/components/card' import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@evobgp/ui/components/card'
import { Separator } from '@evobgp/ui/components/separator' import { Separator } from '@evobgp/ui/components/separator'
import { BadgeTabs, TabsContent } from '@/components/badge-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,
@@ -129,24 +130,21 @@ function MonitoringComponent() {
</Alert> </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>
@@ -199,7 +197,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">
+23 -27
View File
@@ -9,6 +9,7 @@ import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@evob
import { Input } from '@evobgp/ui/components/input' import { Input } from '@evobgp/ui/components/input'
import { Label } from '@evobgp/ui/components/label' import { Label } from '@evobgp/ui/components/label'
import { BadgeTabs, TabsContent } from '@/components/badge-tabs' import { BadgeTabs, TabsContent } from '@/components/badge-tabs'
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'
@@ -318,33 +319,28 @@ function TenantSettingsComponent() {
</TabsContent> </TabsContent>
<TabsContent value="additional" className="mt-0"> <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>
</BadgeTabs> </BadgeTabs>
</div> </div>
File diff suppressed because one or more lines are too long
+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,
}
+6 -4
View File
@@ -13,7 +13,8 @@ function Tabs({
data-slot="tabs" data-slot="tabs"
data-orientation={orientation} data-orientation={orientation}
className={cn( className={cn(
"group/tabs flex gap-2 data-horizontal:flex-col", "group/tabs flex gap-2",
orientation === "horizontal" ? "flex-col" : "flex-row",
className className
)} )}
{...props} {...props}
@@ -22,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-horizontal/tabs:h-8 group-data-vertical/tabs:h-fit group-data-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: {
@@ -56,10 +57,11 @@ function TabsTrigger({ className, ...props }: TabsPrimitive.Tab.Props) {
<TabsPrimitive.Tab <TabsPrimitive.Tab
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-vertical/tabs:w-full group-data-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-horizontal/tabs:after:inset-x-0 group-data-horizontal/tabs:after:bottom-[-5px] group-data-horizontal/tabs:after:h-0.5 group-data-vertical/tabs:after:inset-y-0 group-data-vertical/tabs:after:-right-1 group-data-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}