Compare commits

...
3 Commits
Author SHA1 Message Date
Denozordec ac727ad1e3 refactor: update dialog components and enhance UI consistency
CI / changes (push) Successful in 13s
CI / commitlint (push) Has been skipped
CI / openapi (push) Has been skipped
CI / web (push) Successful in 1m5s
CI / go (push) Has been skipped
CI / bird2 (push) Has been skipped
CI / release (push) Successful in 4m20s
Refactored ConfirmDialog and FormDrawer components to improve layout and user experience. Integrated new DrawerActionsFooter for better action handling in dialogs. Updated styles for consistency across components, including adjustments to the skeletons and analytics card layouts. Removed deprecated alert components from various routes to streamline the codebase and enhance clarity in the UI.
2026-07-09 16:54:18 +07:00
Denozordec c265c06f93 refactor: replace dialog components with FormDrawer for improved UI consistency
CI / changes (push) Successful in 10s
CI / commitlint (push) Has been skipped
CI / openapi (push) Has been skipped
CI / web (push) Successful in 50s
CI / go (push) Has been skipped
CI / bird2 (push) Has been skipped
CI / release (push) Successful in 3m58s
Updated multiple components to utilize the new FormDrawer for modal dialogs, enhancing the user interface and streamlining the layout. This change includes the ConfirmDialog, ApiKeyCreateDialog, FirewallRuleCreateDialog, and others, ensuring a more cohesive and modern design across the application. Additionally, refactored the ConfirmDialog to improve confirmation handling and user feedback during actions.
2026-07-09 14:09:40 +07:00
Denozordec d434eb0d94 feat: enhance UI components with DataGridCard integration and improved error handling
CI / changes (push) Successful in 9s
CI / commitlint (push) Has been skipped
CI / openapi (push) Has been skipped
CI / web (push) Successful in 50s
CI / go (push) Has been skipped
CI / bird2 (push) Has been skipped
CI / release (push) Successful in 4m21s
Refactored multiple components to utilize the new DataGridCard for better organization and presentation of data. Updated the FirewallPage and Monitoring components to enhance loading states and error handling using QueryState. Added success and error notifications for firewall rule creation, improving user feedback. This update streamlines the user experience and ensures a more consistent interface across the application.
2026-07-09 13:45:53 +07:00
30 changed files with 759 additions and 594 deletions

No files matched your search

@@ -2,16 +2,10 @@ import { useEffect, useState } from 'react'
import { toast } from 'sonner'
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 { Label } from '@evobgp/ui/components/label'
import { FormDrawer } from '@/components/form-drawer'
import { LoadingButton } from '@/components/loading-button'
import { SelectField } from '@/components/select-field'
import { API_KEY_ROLE_ITEMS } from '@/lib/access/api-key-labels'
@@ -68,12 +62,22 @@ export function ApiKeyCreateDialog({ open, onOpenChange, onCreated }: ApiKeyCrea
}
return (
<Dialog open={open} onOpenChange={handleOpenChange}>
<DialogContent className="sm:max-w-sm">
<DialogHeader>
<DialogTitle>Новый API-ключ</DialogTitle>
</DialogHeader>
<div className="flex flex-col gap-4 py-2">
<FormDrawer
open={open}
onOpenChange={handleOpenChange}
title="Новый API-ключ"
className="sm:max-w-sm"
footer={
<>
<Button variant="outline" onClick={() => handleOpenChange(false)}>
Отмена
</Button>
<LoadingButton onClick={save} loading={createMutation.isPending}>
Создать
</LoadingButton>
</>
}
>
<div className="flex flex-col gap-2">
<Label htmlFor="key-name">Имя</Label>
<Input
@@ -100,16 +104,6 @@ export function ApiKeyCreateDialog({ open, onOpenChange, onCreated }: ApiKeyCrea
onChange={(e) => setExpiresLocal(e.target.value)}
/>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => handleOpenChange(false)}>
Отмена
</Button>
<LoadingButton onClick={save} loading={createMutation.isPending}>
Создать
</LoadingButton>
</DialogFooter>
</DialogContent>
</Dialog>
</FormDrawer>
)
}
@@ -34,7 +34,7 @@ export function AnalyticsCardShell({
children: ReactNode
}) {
return (
<Card className={cn('flex h-full flex-col gap-0 overflow-hidden', className)}>
<Card className={cn('flex flex-col gap-0 overflow-hidden', className)}>
<CardHeader className="flex flex-row items-start justify-between gap-3 border-b py-4">
<div className="min-w-0 space-y-1">
<CardTitle className="flex items-center gap-2 text-base">
@@ -57,7 +57,7 @@ export function AnalyticsCardShell({
</div>
{actions ? <div className="shrink-0">{actions}</div> : null}
</CardHeader>
<CardContent className="flex flex-1 flex-col gap-5 p-5">{children}</CardContent>
<CardContent className="flex flex-col gap-5 p-5">{children}</CardContent>
{footer ? <CardFooter className="gap-2 border-t p-4">{footer}</CardFooter> : null}
</Card>
)
+107 -30
View File
@@ -1,53 +1,130 @@
import { Button } from '@evobgp/ui/components/button'
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from '@evobgp/ui/components/alert-dialog'
Drawer,
DrawerClose,
DrawerContent,
DrawerDescription,
DrawerHeader,
DrawerTitle,
DrawerTrigger,
} from '@evobgp/ui/components/drawer'
import type { ReactElement, ReactNode } from 'react'
interface ConfirmDialogProps {
trigger: ReactElement
import {
confirmDrawerContentClassName,
DrawerActionsFooter,
} from '@/components/drawer-layout'
type ConfirmDialogBaseProps = {
title: string
description?: ReactNode
confirmLabel?: string
cancelLabel?: string
destructive?: boolean
onConfirm: () => void
confirmDisabled?: boolean
confirmLoading?: boolean
confirmLoadingLabel?: string
}
export function ConfirmDialog({
trigger,
type ConfirmDialogWithTrigger = ConfirmDialogBaseProps & {
trigger: ReactElement
open?: never
onOpenChange?: never
}
type ConfirmDialogControlled = ConfirmDialogBaseProps & {
trigger?: never
open: boolean
onOpenChange: (open: boolean) => void
}
type ConfirmDialogProps = ConfirmDialogWithTrigger | ConfirmDialogControlled
function ConfirmDrawerBody({
title,
description,
confirmLabel = 'Подтвердить',
cancelLabel = 'Отмена',
destructive,
onConfirm,
}: ConfirmDialogProps) {
confirmDisabled,
confirmLoading,
confirmLoadingLabel,
controlled,
}: ConfirmDialogBaseProps & { controlled?: boolean }) {
const confirmText =
confirmLoading && confirmLoadingLabel
? confirmLoadingLabel
: confirmLoading
? `${confirmLabel}…`
: confirmLabel
return (
<AlertDialog>
<AlertDialogTrigger render={trigger} />
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>{title}</AlertDialogTitle>
{description ? <AlertDialogDescription>{description}</AlertDialogDescription> : null}
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>{cancelLabel}</AlertDialogCancel>
<AlertDialogAction
<>
<DrawerHeader className="shrink-0 border-b border-border pb-4">
<DrawerTitle>{title}</DrawerTitle>
{description ? <DrawerDescription>{description}</DrawerDescription> : null}
</DrawerHeader>
<DrawerActionsFooter>
<DrawerClose render={<Button variant="outline" disabled={confirmLoading} />}>
{cancelLabel}
</DrawerClose>
{controlled ? (
<Button
variant={destructive ? 'destructive' : 'default'}
disabled={confirmDisabled || confirmLoading}
onClick={onConfirm}
>
{confirmLabel}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
{confirmText}
</Button>
) : (
<DrawerClose
render={
<Button
variant={destructive ? 'destructive' : 'default'}
disabled={confirmDisabled || confirmLoading}
/>
}
onClick={onConfirm}
>
{confirmText}
</DrawerClose>
)}
</DrawerActionsFooter>
</>
)
}
export function ConfirmDialog(props: ConfirmDialogProps) {
const bodyProps: ConfirmDialogBaseProps = {
title: props.title,
description: props.description,
confirmLabel: props.confirmLabel,
cancelLabel: props.cancelLabel,
destructive: props.destructive,
onConfirm: props.onConfirm,
confirmDisabled: props.confirmDisabled,
confirmLoading: props.confirmLoading,
confirmLoadingLabel: props.confirmLoadingLabel,
}
if (props.trigger) {
return (
<Drawer swipeDirection="right">
<DrawerTrigger render={props.trigger} />
<DrawerContent className={confirmDrawerContentClassName}>
<ConfirmDrawerBody {...bodyProps} />
</DrawerContent>
</Drawer>
)
}
return (
<Drawer open={props.open} onOpenChange={props.onOpenChange} swipeDirection="right">
<DrawerContent className={confirmDrawerContentClassName}>
<ConfirmDrawerBody {...bodyProps} controlled />
</DrawerContent>
</Drawer>
)
}
+5 -1
View File
@@ -46,7 +46,11 @@ export function DataGridShell<TData extends object>({
<DataGridContainer>
<DataGridTable />
</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>
)
}
@@ -28,7 +28,7 @@ export function DataGridToolbar({
className,
}: DataGridToolbarProps) {
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">
<InputGroup>
<InputGroupAddon align="inline-start">
+31
View File
@@ -0,0 +1,31 @@
import type { ReactNode } from 'react'
import { cn } from '@evobgp/ui/lib/utils'
import { DrawerFooter } from '@evobgp/ui/components/drawer'
/** Shared footer layout for right-side form and confirm drawers. */
export function DrawerActionsFooter({
children,
className,
}: {
children: ReactNode
className?: string
}) {
return (
<DrawerFooter
className={cn(
'mt-0 shrink-0 border-t border-border bg-muted/50 p-4',
className,
)}
>
<div className="flex w-full flex-col-reverse gap-2 sm:flex-row sm:items-center sm:justify-end">
{children}
</div>
</DrawerFooter>
)
}
export const formDrawerContentClassName =
'flex h-full max-h-dvh flex-col sm:max-w-lg'
export const confirmDrawerContentClassName = 'flex h-auto max-h-dvh flex-col sm:max-w-sm'
@@ -0,0 +1,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>
)
}
+51
View File
@@ -0,0 +1,51 @@
import type { ReactNode } from 'react'
import { cn } from '@evobgp/ui/lib/utils'
import {
Drawer,
DrawerContent,
DrawerDescription,
DrawerHeader,
DrawerTitle,
} from '@evobgp/ui/components/drawer'
import { ScrollArea } from '@evobgp/ui/components/scroll-area'
import {
DrawerActionsFooter,
formDrawerContentClassName,
} from '@/components/drawer-layout'
interface FormDrawerProps {
open: boolean
onOpenChange: (open: boolean) => void
title: string
description?: string
children: ReactNode
footer: ReactNode
className?: string
}
export function FormDrawer({
open,
onOpenChange,
title,
description,
children,
footer,
className,
}: FormDrawerProps) {
return (
<Drawer open={open} onOpenChange={onOpenChange} swipeDirection="right">
<DrawerContent className={cn(formDrawerContentClassName, className)}>
<DrawerHeader className="shrink-0 border-b border-border pb-4">
<DrawerTitle>{title}</DrawerTitle>
{description ? <DrawerDescription>{description}</DrawerDescription> : null}
</DrawerHeader>
<ScrollArea className="min-h-0 flex-1">
<div className="space-y-4 px-4 py-4">{children}</div>
</ScrollArea>
<DrawerActionsFooter>{footer}</DrawerActionsFooter>
</DrawerContent>
</Drawer>
)
}
@@ -1,17 +1,10 @@
import { useEffect, useState } from 'react'
import { toast } from 'sonner'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@evobgp/ui/components/dialog'
import { Input } from '@evobgp/ui/components/input'
import { Label } from '@evobgp/ui/components/label'
import { FormDrawer } from '@/components/form-drawer'
import { LoadingButton } from '@/components/loading-button'
import { CommunitySelect } from '@/components/modules/community-select'
import { ApiError, apiMutate } from '@/lib/api-client'
@@ -72,15 +65,23 @@ export function ModuleAsEntryDialog({
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-sm">
<DialogHeader>
<DialogTitle>{edit ? 'Редактировать запись' : 'Новая AS-запись'}</DialogTitle>
<DialogDescription>
Номер автономной системы и community для политики анонса.
</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-2">
<FormDrawer
open={open}
onOpenChange={onOpenChange}
title={edit ? 'Редактировать запись' : 'Новая AS-запись'}
description="Номер автономной системы и community для политики анонса."
className="sm:max-w-sm"
footer={
<>
<LoadingButton variant="outline" onClick={() => onOpenChange(false)}>
Отмена
</LoadingButton>
<LoadingButton loading={saving} onClick={() => void save()}>
{edit ? 'Сохранить' : 'Добавить'}
</LoadingButton>
</>
}
>
<div className="flex flex-col gap-1.5">
<Label htmlFor="as-asn">ASN</Label>
<Input
@@ -101,16 +102,6 @@ export function ModuleAsEntryDialog({
communities={communities}
nullable
/>
</div>
<DialogFooter>
<LoadingButton variant="outline" onClick={() => onOpenChange(false)}>
Отмена
</LoadingButton>
<LoadingButton loading={saving} onClick={() => void save()}>
{edit ? 'Сохранить' : 'Добавить'}
</LoadingButton>
</DialogFooter>
</DialogContent>
</Dialog>
</FormDrawer>
)
}
@@ -1,21 +1,14 @@
import { useEffect, useMemo, useState } from 'react'
import { toast } from 'sonner'
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@evobgp/ui/components/dialog'
import { Button } from '@evobgp/ui/components/button'
import { Input } from '@evobgp/ui/components/input'
import { Label } from '@evobgp/ui/components/label'
import { SelectField } from '@/components/select-field'
import { Button } from '@evobgp/ui/components/button'
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 { ApiError, apiMutate } from '@/lib/api-client'
import { normalizeCdnSourceKind } from '@/lib/modules/helpers'
import type { BgpCommunity, CdnPreviewResponse, CdnSource, CdnSourceCreate } from '@/types/api'
@@ -154,12 +147,22 @@ export function ModuleCdnSourceDialog({
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-lg">
<DialogHeader>
<DialogTitle>{edit ? 'Редактировать источник' : 'Новый CDN-источник'}</DialogTitle>
</DialogHeader>
<div className="space-y-4 py-2">
<FormDrawer
open={open}
onOpenChange={onOpenChange}
title={edit ? 'Редактировать источник' : 'Новый CDN-источник'}
className="sm:max-w-lg"
footer={
<>
<LoadingButton variant="outline" onClick={() => onOpenChange(false)}>
Отмена
</LoadingButton>
<LoadingButton loading={saving} onClick={() => void save()}>
{edit ? 'Сохранить' : 'Добавить'}
</LoadingButton>
</>
}
>
<div className="flex flex-col gap-1.5">
<Label htmlFor="cdn-url">URL</Label>
<Input
@@ -245,16 +248,6 @@ export function ModuleCdnSourceDialog({
</ul>
) : null}
</div>
</div>
<DialogFooter>
<LoadingButton variant="outline" onClick={() => onOpenChange(false)}>
Отмена
</LoadingButton>
<LoadingButton loading={saving} onClick={() => void save()}>
{edit ? 'Сохранить' : 'Добавить'}
</LoadingButton>
</DialogFooter>
</DialogContent>
</Dialog>
</FormDrawer>
)
}
@@ -1,16 +1,10 @@
import { useEffect, useState } from 'react'
import { toast } from 'sonner'
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@evobgp/ui/components/dialog'
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 { ApiError, apiMutate } from '@/lib/api-client'
@@ -70,12 +64,22 @@ export function ModuleDomainEntryDialog({
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-sm">
<DialogHeader>
<DialogTitle>{edit ? 'Редактировать домен' : 'Новый домен'}</DialogTitle>
</DialogHeader>
<div className="space-y-4 py-2">
<FormDrawer
open={open}
onOpenChange={onOpenChange}
title={edit ? 'Редактировать домен' : 'Новый домен'}
className="sm:max-w-sm"
footer={
<>
<LoadingButton variant="outline" onClick={() => onOpenChange(false)}>
Отмена
</LoadingButton>
<LoadingButton loading={saving} onClick={() => void save()}>
{edit ? 'Сохранить' : 'Добавить'}
</LoadingButton>
</>
}
>
<div className="flex flex-col gap-1.5">
<Label htmlFor="dom-fqdn">FQDN</Label>
<Input
@@ -93,16 +97,6 @@ export function ModuleDomainEntryDialog({
communities={communities}
nullable
/>
</div>
<DialogFooter>
<LoadingButton variant="outline" onClick={() => onOpenChange(false)}>
Отмена
</LoadingButton>
<LoadingButton loading={saving} onClick={() => void save()}>
{edit ? 'Сохранить' : 'Добавить'}
</LoadingButton>
</DialogFooter>
</DialogContent>
</Dialog>
</FormDrawer>
)
}
@@ -2,18 +2,9 @@ import { useState } from 'react'
import { Plus } from 'lucide-react'
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 { ConfirmDialog } from '@/components/confirm-dialog'
import { DataGridCard } from '@/components/data-grid-shell'
import { QueryState } from '@/components/query-state'
import { TableSkeleton } from '@/components/skeletons'
@@ -218,24 +209,17 @@ export function ModuleEntriesSection({
/>
) : null}
<AlertDialog open={deleteTarget !== null} onOpenChange={(open) => !open && setDeleteTarget(null)}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Удалить запись?</AlertDialogTitle>
<AlertDialogDescription>{deleteDescription(deleteTarget)}</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={deleting}>Отмена</AlertDialogCancel>
<AlertDialogAction
variant="destructive"
disabled={deleting}
onClick={() => void confirmDelete()}
>
{deleting ? 'Удаление…' : 'Удалить'}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
<ConfirmDialog
open={deleteTarget !== null}
onOpenChange={(open) => !open && setDeleteTarget(null)}
title="Удалить запись?"
description={deleteDescription(deleteTarget)}
confirmLabel="Удалить"
confirmLoadingLabel="Удаление…"
destructive
confirmLoading={deleting}
onConfirm={() => void confirmDelete()}
/>
</>
)
}
@@ -1,16 +1,10 @@
import { useEffect, useState } from 'react'
import { toast } from 'sonner'
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@evobgp/ui/components/dialog'
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 { ApiError, apiMutate } from '@/lib/api-client'
@@ -70,12 +64,22 @@ export function ModuleIpRangeEntryDialog({
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-sm">
<DialogHeader>
<DialogTitle>{edit ? 'Редактировать диапазон' : 'Новый IP-диапазон'}</DialogTitle>
</DialogHeader>
<div className="space-y-4 py-2">
<FormDrawer
open={open}
onOpenChange={onOpenChange}
title={edit ? 'Редактировать диапазон' : 'Новый IP-диапазон'}
className="sm:max-w-sm"
footer={
<>
<LoadingButton variant="outline" onClick={() => onOpenChange(false)}>
Отмена
</LoadingButton>
<LoadingButton loading={saving} onClick={() => void save()}>
{edit ? 'Сохранить' : 'Добавить'}
</LoadingButton>
</>
}
>
<div className="flex flex-col gap-1.5">
<Label htmlFor="ip-prefix">Префикс (CIDR)</Label>
<Input
@@ -93,16 +97,6 @@ export function ModuleIpRangeEntryDialog({
communities={communities}
placeholder="Выберите community"
/>
</div>
<DialogFooter>
<LoadingButton variant="outline" onClick={() => onOpenChange(false)}>
Отмена
</LoadingButton>
<LoadingButton loading={saving} onClick={() => void save()}>
{edit ? 'Сохранить' : 'Добавить'}
</LoadingButton>
</DialogFooter>
</DialogContent>
</Dialog>
</FormDrawer>
)
}
@@ -2,18 +2,11 @@ import { useEffect, useState } from 'react'
import { toast } from 'sonner'
import { Button } from '@evobgp/ui/components/button'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@evobgp/ui/components/dialog'
import { Checkbox } from '@evobgp/ui/components/checkbox'
import { Input } from '@evobgp/ui/components/input'
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 { SelectField } from '@/components/select-field'
import { useCreatePeerMutation, useUpdatePeerMutation } from '@/queries/network'
@@ -102,13 +95,23 @@ export function PeerFormDialog({
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-sm">
<DialogHeader>
<DialogTitle>{editTarget ? 'Редактировать пира' : 'Новый пир'}</DialogTitle>
<DialogDescription>BGP-сосед для установки сессии</DialogDescription>
</DialogHeader>
<div className="flex flex-col gap-4 py-2">
<FormDrawer
open={open}
onOpenChange={onOpenChange}
title={editTarget ? 'Редактировать пира' : 'Новый пир'}
description="BGP-сосед для установки сессии"
className="sm:max-w-sm"
footer={
<>
<Button variant="outline" type="button" onClick={() => onOpenChange(false)}>
Отмена
</Button>
<LoadingButton type="button" loading={saving} onClick={save}>
{editTarget ? 'Сохранить' : 'Создать'}
</LoadingButton>
</>
}
>
<div className="flex flex-col gap-2">
<Label htmlFor="peer-name">Имя пира (опционально)</Label>
<Input
@@ -160,16 +163,6 @@ export function PeerFormDialog({
onCheckedChange={(v) => setEnabled(v === true)}
/>
</div>
</div>
<DialogFooter>
<Button variant="outline" type="button" onClick={() => onOpenChange(false)}>
Отмена
</Button>
<LoadingButton type="button" loading={saving} onClick={save}>
{editTarget ? 'Сохранить' : 'Создать'}
</LoadingButton>
</DialogFooter>
</DialogContent>
</Dialog>
</FormDrawer>
)
}
@@ -2,17 +2,10 @@ import { useEffect, useState } from 'react'
import { toast } from 'sonner'
import { Button } from '@evobgp/ui/components/button'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@evobgp/ui/components/dialog'
import { Input } from '@evobgp/ui/components/input'
import { Label } from '@evobgp/ui/components/label'
import { FormDrawer } from '@/components/form-drawer'
import { LoadingButton } from '@/components/loading-button'
import { SelectField } from '@/components/select-field'
import { useCreateSpeakerMutation } from '@/queries/network'
@@ -98,13 +91,23 @@ export function SpeakerFormDialog({ open, onOpenChange }: SpeakerFormDialogProps
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>Новый спикер</DialogTitle>
<DialogDescription>BIRD-агент на ноде реплики или control plane</DialogDescription>
</DialogHeader>
<div className="flex flex-col gap-4 py-2">
<FormDrawer
open={open}
onOpenChange={onOpenChange}
title="Новый спикер"
description="BIRD-агент на ноде реплики или control plane"
className="sm:max-w-md"
footer={
<>
<Button variant="outline" type="button" onClick={() => onOpenChange(false)}>
Отмена
</Button>
<LoadingButton type="button" loading={createMutation.isPending} onClick={save}>
Создать
</LoadingButton>
</>
}
>
<div className="flex flex-col gap-2">
<Label htmlFor="speaker-endpoint">Endpoint</Label>
<Input
@@ -154,16 +157,6 @@ export function SpeakerFormDialog({ open, onOpenChange }: SpeakerFormDialogProps
}}
/>
</div>
</div>
<DialogFooter>
<Button variant="outline" type="button" onClick={() => onOpenChange(false)}>
Отмена
</Button>
<LoadingButton type="button" loading={createMutation.isPending} onClick={save}>
Создать
</LoadingButton>
</DialogFooter>
</DialogContent>
</Dialog>
</FormDrawer>
)
}
+2 -2
View File
@@ -17,8 +17,8 @@ export function SectionCardsSkeleton({ count = 4 }: { count?: number }) {
export function AnalyticsDashboardSkeleton() {
return (
<div className="grid gap-4 lg:grid-cols-3 lg:grid-rows-2">
<Card className="gap-0 lg:row-span-2">
<div className="grid gap-4 lg:grid-cols-3 lg:items-start">
<Card className="gap-0">
<CardContent className="space-y-4 p-5">
<Skeleton className="h-4 w-40" />
<div className="grid gap-4 sm:grid-cols-3">
+2
View File
@@ -83,8 +83,10 @@ export function useCreateFirewallRule() {
body: JSON.stringify(body),
}),
onSuccess: () => {
toast.success('Правило добавлено')
void qc.invalidateQueries({ queryKey: firewallKeys.all })
},
onError: (e) => toast.error(e instanceof Error ? e.message : 'Не удалось добавить правило'),
})
}
+1 -18
View File
@@ -1,9 +1,8 @@
import { createFileRoute, Link } from '@tanstack/react-router'
import { useQuery } from '@tanstack/react-query'
import { Info, KeyRound, RefreshCw, ShieldCheck, ShieldOff } from 'lucide-react'
import { KeyRound, RefreshCw, ShieldCheck, ShieldOff } from 'lucide-react'
import { useMemo } from 'react'
import { Alert, AlertDescription, AlertTitle } from '@evobgp/ui/components/alert'
import { Button } from '@evobgp/ui/components/button'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@evobgp/ui/components/card'
@@ -79,22 +78,6 @@ function AccessComponent() {
}
/>
<Alert className="border-info/30 bg-info/5">
<Info className="text-info" />
<AlertTitle>О API-ключах</AlertTitle>
<AlertDescription>
Роли: <code className="text-xs">viewer</code> (чтение),{' '}
<code className="text-xs">editor</code> (CRUD), <code className="text-xs">operator</code>{' '}
(apply и настройки), <code className="text-xs">node</code> (API ноды). Полный токен
показывается один раз при создании и ротации. Токен браузера — в{' '}
<Link to="/settings" className="text-primary underline-offset-4 hover:underline">
настройках
</Link>
; для локальной разработки с demo-seed подойдёт <code className="text-xs">dev</code>{' '}
(роль operator).
</AlertDescription>
</Alert>
{session ? (
<Card>
<CardHeader className="border-b py-3">
+3 -73
View File
@@ -1,9 +1,8 @@
import { createFileRoute } from '@tanstack/react-router'
import { useQueries } from '@tanstack/react-query'
import { CheckCircle, Info, RefreshCw, XCircle } from 'lucide-react'
import { RefreshCw } from 'lucide-react'
import { useState } from 'react'
import { Alert, AlertDescription, AlertTitle } from '@evobgp/ui/components/alert'
import { Button } from '@evobgp/ui/components/button'
import {
Card,
@@ -27,7 +26,6 @@ import { AnalyticsDashboardSkeleton } from '@/components/skeletons'
import {
moduleNameById,
overviewHealthQueryOptions,
overviewJobsQueryOptions,
overviewModulesQueryOptions,
overviewPeersQueryOptions,
@@ -44,7 +42,6 @@ function DashboardComponent() {
const results = useQueries({
queries: [
overviewHealthQueryOptions(),
overviewModulesQueryOptions(),
overviewPeersQueryOptions(),
overviewSpeakersQueryOptions(),
@@ -53,7 +50,7 @@ function DashboardComponent() {
],
})
const [healthQ, modulesQ, peersQ, speakersQ, revisionsQ, jobsQ] = results
const [modulesQ, peersQ, speakersQ, revisionsQ, jobsQ] = results
const initialLoading =
modulesQ.isLoading || peersQ.isLoading || speakersQ.isLoading || revisionsQ.isLoading || jobsQ.isLoading
const refreshing = results.some((r) => r.isFetching && !r.isLoading)
@@ -94,26 +91,10 @@ function DashboardComponent() {
}
/>
<Alert className="border-info/30 bg-info/5">
<Info className="text-info" />
<AlertTitle>Панель управления EvoBGP</AlertTitle>
<AlertDescription>
Сводка по модулям, сети и фоновым задачам. BGP и ноды — «Сеть», префиксы — «Модули»,
деплой — «Операции», здоровье API — «Мониторинг».
</AlertDescription>
</Alert>
<HealthAlert
loading={healthQ.isLoading}
ok={healthQ.data === true}
loadError={modulesQ.isError || peersQ.isError ? 'Некоторые данные не загружены' : null}
/>
{initialLoading ? (
<AnalyticsDashboardSkeleton />
) : (
<div className="grid gap-4 lg:grid-cols-3 lg:grid-rows-2">
<div className="lg:row-span-2">
<div className="grid gap-4 lg:grid-cols-3 lg:items-start">
<DashboardPlatformCard
modules={modules}
peers={peers}
@@ -121,7 +102,6 @@ function DashboardComponent() {
jobs={jobs}
revisions={revisions}
/>
</div>
<DashboardNetworkCapacityCard peers={peers} speakers={speakers} jobs={jobs} />
<DashboardOperationsFlowCard jobs={jobs} modules={modules} />
</div>
@@ -163,53 +143,3 @@ function DashboardComponent() {
</div>
)
}
function HealthAlert({
loading,
ok,
loadError,
}: {
loading: boolean
ok: boolean | undefined
loadError: string | null
}) {
if (loading) {
return (
<Alert>
<Skeleton className="size-5 rounded-full" />
<AlertTitle>Проверка API…</AlertTitle>
<AlertDescription>
Запрос к <code className="text-xs">/v1/health</code>
</AlertDescription>
</Alert>
)
}
if (ok && !loadError) {
return (
<Alert className="border-success/30 bg-success/5">
<CheckCircle className="text-success" />
<AlertTitle>API работает</AlertTitle>
<AlertDescription>Сервер отвечает на запросы health-check.</AlertDescription>
</Alert>
)
}
if (ok && loadError) {
return (
<Alert className="border-warning/30 bg-warning/5">
<Info className="text-warning" />
<AlertTitle>API доступен, данные не загружены</AlertTitle>
<AlertDescription>{loadError}. Проверьте Bearer-токен в «Настройках».</AlertDescription>
</Alert>
)
}
return (
<Alert variant="destructive" className="border-destructive/30 bg-destructive/5">
<XCircle className="text-destructive" />
<AlertTitle>API недоступен</AlertTitle>
<AlertDescription>
Не удалось получить ответ от сервера. Проверьте, что API запущен (порт 8080) и в dev работает
прокси Vite.
</AlertDescription>
</Alert>
)
}
+1 -11
View File
@@ -1,8 +1,7 @@
import { createFileRoute } from '@tanstack/react-router'
import { useQuery } from '@tanstack/react-query'
import { BookText, Globe, Info, RefreshCw, Tags } from 'lucide-react'
import { BookText, Globe, RefreshCw, Tags } from 'lucide-react'
import { Alert, AlertDescription, AlertTitle } from '@evobgp/ui/components/alert'
import { Button } from '@evobgp/ui/components/button'
import { BadgeTabs, TabsContent } from '@/components/badge-tabs'
import { DataGridCard } from '@/components/data-grid-shell'
@@ -68,15 +67,6 @@ function DirectoriesComponent() {
}
/>
<Alert className="border-info/30 bg-info/5">
<Info className="text-info" />
<AlertTitle>О справочниках</AlertTitle>
<AlertDescription>
Сообщества BGP используются в AS- и CDN-модулях для тегирования префиксов. DoH-профили — в
доменных модулях для DNS-over-HTTPS резолвинга.
</AlertDescription>
</Alert>
{loading ? <SectionCardsSkeleton count={3} /> : <SectionCards items={items} />}
<BadgeTabs
+25 -63
View File
@@ -1,19 +1,19 @@
import { createFileRoute } from '@tanstack/react-router'
import { useQuery } from '@tanstack/react-query'
import { Copy, Info, RefreshCw, Shield } from 'lucide-react'
import { Copy, Plus, RefreshCw, Shield } from 'lucide-react'
import { useEffect, useMemo, useState } from 'react'
import { toast } from 'sonner'
import { Alert, AlertDescription, AlertTitle } from '@evobgp/ui/components/alert'
import { Button } from '@evobgp/ui/components/button'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@evobgp/ui/components/card'
import { Input } from '@evobgp/ui/components/input'
import { Label } from '@evobgp/ui/components/label'
import { BadgeTabs, TabsContent } from '@/components/badge-tabs'
import { DataGridCard } from '@/components/data-grid-shell'
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 { PageHeader } from '@/components/page-header'
import { CommunitySelect } from '@/components/modules/community-select'
import { QueryState } from '@/components/query-state'
import { TableSkeleton } from '@/components/skeletons'
import { directoriesCommunitiesQueryOptions } from '@/queries/directories'
@@ -22,7 +22,6 @@ import {
firewallInstallContextQueryOptions,
firewallRulesQueryOptions,
useApproveFirewallClient,
useCreateFirewallRule,
useDeleteFirewallClient,
useDeleteFirewallRule,
} from '@/queries/firewall'
@@ -48,7 +47,6 @@ function FirewallPage() {
const rulesQ = useQuery(firewallRulesQueryOptions('tenant'))
const approve = useApproveFirewallClient()
const deleteClient = useDeleteFirewallClient()
const createRule = useCreateFirewallRule()
const deleteRule = useDeleteFirewallRule()
const installCtx = installCtxQ.data
@@ -58,6 +56,7 @@ function FirewallPage() {
typeof window !== 'undefined' ? httpsOrigin(window.location.origin) : 'https://api.example.com',
)
const [seed, setSeed] = useState('')
const [createRuleOpen, setCreateRuleOpen] = useState(false)
useEffect(() => {
if (installCtx?.suggested_cp_url) {
@@ -67,9 +66,6 @@ function FirewallPage() {
setSeed(installCtx.bundle_seed)
}
}, [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 ?? []
@@ -129,16 +125,6 @@ function FirewallPage() {
}
/>
<Alert className="border-info/30 bg-info/5">
<Info className="text-info" />
<AlertTitle>Политика</AlertTitle>
<AlertDescription>
Правила сопоставляются с <strong>BGP community</strong> префиксов опубликованной revision.{' '}
<strong>block</strong> добавляет префиксы community в kernel; <strong>accept</strong> — не блокирует.
Community «Все» — правило для любого community. Default без совпадений — accept.
</AlertDescription>
</Alert>
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
@@ -198,6 +184,7 @@ function FirewallPage() {
]}
>
<TabsContent value="clients" className="mt-0">
<DataGridCard title="Клиенты" description="Активные Linux-серверы с синхронизацией blocklist">
<QueryState
data={clientsQ.data}
isLoading={clientsQ.isLoading}
@@ -217,55 +204,19 @@ function FirewallPage() {
/>
)}
</QueryState>
</DataGridCard>
</TabsContent>
<TabsContent value="rules" className="mt-0 space-y-4">
<div className="flex flex-wrap items-end gap-3">
<div className="space-y-1">
<Label>Действие</Label>
<select
className="border-input bg-background h-9 rounded-md border px-2 text-sm"
value={ruleAction}
onChange={(e) => setRuleAction(e.target.value as 'block' | 'accept')}
>
<option value="block">block</option>
<option value="accept">accept</option>
</select>
</div>
<CommunitySelect
id="fw-rule-community"
label="Community"
value={ruleCommunityId}
onValueChange={setRuleCommunityId}
communities={communities}
nullable
placeholder="Все communities"
/>
<div className="space-y-1">
<Label htmlFor="fw-rule-comment">Комментарий</Label>
<Input
id="fw-rule-comment"
className="max-w-xs"
placeholder="Комментарий"
value={ruleComment}
onChange={(e) => setRuleComment(e.target.value)}
/>
</div>
<Button
size="sm"
className="mb-0.5"
onClick={() =>
createRule.mutate({
scope: 'tenant',
action: ruleAction,
community_id: ruleCommunityId,
comment: ruleComment,
})
}
>
<TabsContent value="rules" className="mt-0">
<DataGridCard
title="Правила"
actions={
<Button size="sm" type="button" onClick={() => setCreateRuleOpen(true)}>
<Plus />
Добавить правило
</Button>
</div>
}
>
<QueryState
data={rulesQ.data}
isLoading={rulesQ.isLoading}
@@ -284,9 +235,19 @@ function FirewallPage() {
/>
)}
</QueryState>
</DataGridCard>
<FirewallRuleCreateDialog
open={createRuleOpen}
onOpenChange={setCreateRuleOpen}
communities={communities}
/>
</TabsContent>
<TabsContent value="requests" className="mt-0">
<DataGridCard
title="Запросы"
description="Pending enroll — одобрите или отклоните новые клиенты"
>
<QueryState
data={clientsQ.data}
isLoading={clientsQ.isLoading}
@@ -307,6 +268,7 @@ function FirewallPage() {
/>
)}
</QueryState>
</DataGridCard>
</TabsContent>
</BadgeTabs>
</div>
@@ -1,8 +1,7 @@
import { createFileRoute, Link } from '@tanstack/react-router'
import { useQuery, useQueryClient } from '@tanstack/react-query'
import { ArrowLeft, Info, RefreshCw } from 'lucide-react'
import { ArrowLeft, RefreshCw } from 'lucide-react'
import { Alert, AlertDescription, AlertTitle } from '@evobgp/ui/components/alert'
import { Button } from '@evobgp/ui/components/button'
import { PageHeader } from '@/components/page-header'
@@ -17,25 +16,12 @@ import {
directoriesDohQueryOptions,
} from '@/queries/directories'
import { moduleDetailQueryOptions, moduleEntriesQueryOptions, modulesKeys } from '@/queries/modules'
import type { AsEntry, ModuleRow } from '@/types/api'
import type { AsEntry } from '@/types/api'
export const Route = createFileRoute('/_auth/modules/$moduleId')({
component: ModuleDetailComponent,
})
function moduleTypeAlert(type: ModuleRow['type']): string {
switch (type) {
case 'AS_PREFIXES':
return 'Модуль AS получает префиксы через RIPEstat по указанным ASN. После refresh счётчики префиксов обновляются в таблице записей.'
case 'CDN_CIDRS':
return 'Модуль CDN скачивает списки CIDR по URL (plaintext или JSON). Используйте предпросмотр при добавлении источника.'
case 'DOMAINS':
return 'Модуль доменов резолвит FQDN через DoH-профили и конвертирует IP в префиксы. Политика и профили настраиваются в редактировании модуля.'
case 'IP_RANGES':
return 'Модуль IP-диапазонов использует статические CIDR без внешнего refresh (сервер может вернуть 204). Записи участвуют в агрегации напрямую.'
}
}
function ModuleDetailComponent() {
const { moduleId } = Route.useParams()
const queryClient = useQueryClient()
@@ -114,12 +100,6 @@ function ModuleDetailComponent() {
)}
</div>
<Alert>
<Info />
<AlertTitle>О модуле</AlertTitle>
<AlertDescription>{moduleTypeAlert(m.type)}</AlertDescription>
</Alert>
<ModuleKpiCards
mod={m}
communities={communities}
+43 -88
View File
@@ -1,12 +1,12 @@
import { createFileRoute, useSearch } from '@tanstack/react-router'
import { useQuery } from '@tanstack/react-query'
import { Activity, AlertTriangle, Bird, Database, HeartPulse, Info, ListTodo, RefreshCw } from 'lucide-react'
import { Activity, AlertTriangle, Bird, RefreshCw } from 'lucide-react'
import { Alert, AlertDescription, AlertTitle } from '@evobgp/ui/components/alert'
import { Button } from '@evobgp/ui/components/button'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@evobgp/ui/components/card'
import { Separator } from '@evobgp/ui/components/separator'
import { BadgeTabs, TabsContent } from '@/components/badge-tabs'
import { DataGridCard } from '@/components/data-grid-shell'
import { StatusBadge } from '@/components/status-badge'
import {
DashboardOperationsFlowCard,
@@ -21,7 +21,6 @@ import {
monitoringHealthQueryOptions,
monitoringReadyQueryOptions,
monitoringVersionQueryOptions,
type ReadyStatus,
type VersionInfo,
} from '@/queries/monitoring'
import { networkBirdQueryOptions } from '@/queries/network'
@@ -83,7 +82,13 @@ function MonitoringComponent() {
<div className="flex flex-col gap-6">
<PageHeader
title="Мониторинг"
description="Состояние API, BGP и задач для диагностики инцидентов"
description={`Состояние API, BGP и задач для диагностики инцидентов${
versionText !== '—'
? ` · версия ${versionText}${
versionQ.data?.git_sha ? ` (${versionQ.data.git_sha.slice(0, 8)})` : ''
}`
: ''
}`}
actions={
<Button variant="outline" size="sm" onClick={refetchAll} disabled={refreshing}>
<RefreshCw className={refreshing ? 'animate-spin' : ''} />
@@ -117,24 +122,11 @@ function MonitoringComponent() {
</div>
)}
<Alert className="border-muted bg-muted/30">
<Info className="size-4" />
<AlertTitle className="text-sm">
Версия API: {versionText}
{versionQ.data?.git_sha ? ` · ${versionQ.data.git_sha.slice(0, 8)}` : ''}
</AlertTitle>
<AlertDescription className="text-xs">
{overallHint({ health: healthQ.data, jobsFailed: failed })}
</AlertDescription>
</Alert>
<div className="grid gap-4 lg:grid-cols-2">
<Card>
<CardHeader>
<CardTitle className="text-base">Доступность и готовность</CardTitle>
<CardDescription>GET /v1/health · GET /v1/ready</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<DataGridCard
title="Доступность и готовность"
description="GET /v1/health · GET /v1/ready"
>
<QueryState
data={readyQ.data}
isLoading={readyQ.isLoading}
@@ -145,8 +137,7 @@ function MonitoringComponent() {
>
{(ready) => <MonitoringReadyGrid health={healthQ.data} ready={ready} />}
</QueryState>
</CardContent>
</Card>
</DataGridCard>
<Card>
<CardHeader>
@@ -227,37 +218,27 @@ function MonitoringComponent() {
</CardTitle>
<CardDescription>Короткая шпаргалка для triage</CardDescription>
</CardHeader>
<CardContent className="space-y-3">
<Alert>
<HeartPulse className="size-4" />
<AlertTitle>API недоступен</AlertTitle>
<AlertDescription>
Если <code className="text-xs">/v1/health</code> возвращает ошибку — проверьте процесс
API и его логи.
</AlertDescription>
</Alert>
<Alert>
<Database className="size-4" />
<AlertTitle>Readiness не «Готов»</AlertTitle>
<AlertDescription>
Сначала <code className="text-xs">postgres</code>, затем{' '}
<code className="text-xs">store</code> и <code className="text-xs">jobs</code> в checks.
</AlertDescription>
</Alert>
<Alert>
<Bird className="size-4" />
<AlertTitle>Низкий ratio BGP</AlertTitle>
<AlertDescription>
Проверьте <code className="text-xs">/v1/bird/status</code>, затем состояние пиров в Сети.
</AlertDescription>
</Alert>
<Alert>
<ListTodo className="size-4" />
<AlertTitle>Ошибки задач</AlertTitle>
<AlertDescription>
Откройте Операции и проверьте последние неуспешные jobs.
</AlertDescription>
</Alert>
<CardContent>
<ul className="space-y-3 text-sm text-muted-foreground">
<li>
<span className="font-medium text-foreground">API недоступен.</span> Если{' '}
<code className="text-xs">/v1/health</code> возвращает ошибку — проверьте процесс API и
его логи.
</li>
<li>
<span className="font-medium text-foreground">Readiness не «Готов».</span> Сначала{' '}
<code className="text-xs">postgres</code>, затем <code className="text-xs">store</code>{' '}
и <code className="text-xs">jobs</code> в checks.
</li>
<li>
<span className="font-medium text-foreground">Низкий ratio BGP.</span> Проверьте{' '}
<code className="text-xs">/v1/bird/status</code>, затем состояние пиров в Сети.
</li>
<li>
<span className="font-medium text-foreground">Ошибки задач.</span> Откройте Операции и
проверьте последние неуспешные jobs.
</li>
</ul>
</CardContent>
</Card>
</div>
@@ -267,18 +248,11 @@ function MonitoringComponent() {
<Card>
<CardHeader>
<CardTitle className="text-base">PostgreSQL</CardTitle>
<CardDescription>Статус соединения и пул</CardDescription>
<CardDescription>
Статус соединения и пул. PostgreSQL отображается в readiness-проверке на вкладке «Система»
(check <code className="text-xs">postgres</code>).
</CardDescription>
</CardHeader>
<CardContent>
<Alert>
<Database className="size-4" />
<AlertTitle>Статус готовности</AlertTitle>
<AlertDescription>
PostgreSQL-соединение отображается в readiness-проверке на вкладке «Система» (check{' '}
<code className="text-xs">postgres</code>).
</AlertDescription>
</Alert>
</CardContent>
</Card>
</TabsContent>
@@ -286,18 +260,11 @@ function MonitoringComponent() {
<Card>
<CardHeader>
<CardTitle className="text-base">Файловые логи</CardTitle>
<CardDescription>Логи API и pipeline</CardDescription>
<CardDescription>
Логи API и pipeline настраиваются переменной <code className="text-xs">EVOBGP_LOG_*</code> и
управляются в tenant-settings.
</CardDescription>
</CardHeader>
<CardContent>
<Alert>
<Info className="size-4" />
<AlertTitle>Логи на сервере</AlertTitle>
<AlertDescription>
Файловые логи настраиваются переменной <code className="text-xs">EVOBGP_LOG_*</code> и
управляются tenant-settings на странице «Настройки BIRD».
</AlertDescription>
</Alert>
</CardContent>
</Card>
</TabsContent>
</BadgeTabs>
@@ -319,18 +286,6 @@ function formatVersion(version?: VersionInfo | null): string {
return version.version ?? version.app ?? '—'
}
interface OverallInput {
health?: { ok?: boolean } | null
ready?: ReadyStatus | null
jobsFailed: number
}
function overallHint(input: OverallInput): string {
if (!input.health?.ok) return 'API недоступен или возвращает ошибку'
if (input.jobsFailed > 0) return `Есть провальные задачи (${input.jobsFailed})`
return 'Все системы работают в штатном режиме'
}
function BirdSummary({ bird }: { bird: import('@/types/api').BirdStatus }) {
if (!bird.birdc_configured) {
return (
+1 -10
View File
@@ -1,9 +1,8 @@
import { createFileRoute, useSearch } from '@tanstack/react-router'
import { useQuery } from '@tanstack/react-query'
import { Alert, AlertDescription, AlertTitle } from '@evobgp/ui/components/alert'
import { Button } from '@evobgp/ui/components/button'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@evobgp/ui/components/card'
import { Info, RefreshCw } from 'lucide-react'
import { RefreshCw } from 'lucide-react'
import {
DashboardNetworkCapacityCard,
@@ -60,14 +59,6 @@ function NetworkComponent() {
}
/>
<Alert className="border-info/30 bg-info/5">
<Info className="text-info" />
<AlertTitle>О сетевой конфигурации</AlertTitle>
<AlertDescription>
Вкладка «Обзор» — live-статус agent и BGP на CP и репликах. Apply и ревизии — на странице «Операции».
</AlertDescription>
</Alert>
<BadgeTabs
value={search.tab}
onValueChange={(tab) =>
+1 -11
View File
@@ -1,10 +1,9 @@
import { createFileRoute, useSearch } from '@tanstack/react-router'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { Info, RefreshCw } from 'lucide-react'
import { RefreshCw } from 'lucide-react'
import { toast } from 'sonner'
import { useState, useMemo } from 'react'
import { Alert, AlertDescription, AlertTitle } from '@evobgp/ui/components/alert'
import { Button } from '@evobgp/ui/components/button'
import { Card, CardContent, CardHeader, CardTitle } from '@evobgp/ui/components/card'
import { BadgeTabs, TabsContent } from '@/components/badge-tabs'
@@ -94,15 +93,6 @@ function OperationsComponent() {
}
/>
<Alert className="border-info/30 bg-info/5">
<Info className="text-info" />
<AlertTitle>Три раздела на одной странице</AlertTitle>
<AlertDescription>
<strong>Ревизии</strong> — история конфигов и откат; <strong>Сравнение</strong> — diff
префиксов; <strong>Задачи</strong> — ingest, apply, rollback.
</AlertDescription>
</Alert>
<div className="flex flex-wrap gap-2">
<ConfirmDialog
trigger={
+1 -12
View File
@@ -1,10 +1,9 @@
import { createFileRoute } from '@tanstack/react-router'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { AlertTriangle, Clock, Info, ListTodo, RefreshCw } from 'lucide-react'
import { AlertTriangle, Clock, ListTodo, RefreshCw } from 'lucide-react'
import { toast } from 'sonner'
import { useState } from 'react'
import { Alert, AlertDescription, AlertTitle } from '@evobgp/ui/components/alert'
import { Button } from '@evobgp/ui/components/button'
import { BadgeTabs, TabsContent } from '@/components/badge-tabs'
import { DataGridCard } from '@/components/data-grid-shell'
@@ -87,16 +86,6 @@ function ScheduleComponent() {
}
/>
<Alert className="border-info/30 bg-info/5">
<Info className="text-info" />
<AlertTitle>Как работает расписание</AlertTitle>
<AlertDescription>
Планировщик использует <code className="text-xs">refresh_interval_sec</code> и опционально{' '}
<code className="text-xs">cron_expr</code>. Ручной запуск —{' '}
<code className="text-xs">POST /v1/modules/&#123;id&#125;/refresh</code>.
</AlertDescription>
</Alert>
{loading ? <SectionCardsSkeleton count={3} /> : <SectionCards items={items} />}
<DataGridCard
+1 -12
View File
@@ -1,7 +1,6 @@
import { createFileRoute, useNavigate } from '@tanstack/react-router'
import { useQuery, useQueryClient } from '@tanstack/react-query'
import { Alert, AlertDescription, AlertTitle } from '@evobgp/ui/components/alert'
import { Button } from '@evobgp/ui/components/button'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@evobgp/ui/components/card'
import { Input } from '@evobgp/ui/components/input'
@@ -13,7 +12,7 @@ import { SelectField } from '@/components/select-field'
import { DEV_API_TOKEN, normalizeApiToken, setToken, TOKEN_STORAGE_KEY } from '@/lib/api-client'
import { authKeys, authSessionQueryOptions } from '@/queries/auth'
import { toast } from 'sonner'
import { Info, Save } from 'lucide-react'
import { Save } from 'lucide-react'
import { useTheme } from 'next-themes'
import { useEffect, useState } from 'react'
@@ -70,16 +69,6 @@ function SettingsComponent() {
description="Параметры интерфейса и подключения браузера к API."
/>
<Alert className="border-info/30 bg-info/5">
<Info className="text-info" />
<AlertTitle>Локальная разработка</AlertTitle>
<AlertDescription>
При включённом demo-seed API принимает токен <code className="text-xs">dev</code> (роль{' '}
<code className="text-xs">operator</code>). Вводите только значение токена, без префикса{' '}
<code className="text-xs">Bearer</code> — он добавляется автоматически.
</AlertDescription>
</Alert>
<Card>
<CardHeader>
<CardTitle>Подключение к API</CardTitle>
+7 -29
View File
@@ -1,14 +1,14 @@
import { createFileRoute, useSearch } from '@tanstack/react-router'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { Info, Save } from 'lucide-react'
import { Save } from 'lucide-react'
import { useEffect, useState } from 'react'
import { toast } from 'sonner'
import { Alert, AlertDescription, AlertTitle } from '@evobgp/ui/components/alert'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@evobgp/ui/components/card'
import { Input } from '@evobgp/ui/components/input'
import { Label } from '@evobgp/ui/components/label'
import { BadgeTabs, TabsContent } from '@/components/badge-tabs'
import { DataGridCard } from '@/components/data-grid-shell'
import { SelectField } from '@/components/select-field'
import { SettingsKvGrid } from '@/components/settings/settings-kv-grid'
import { PageHeader } from '@/components/page-header'
@@ -103,15 +103,6 @@ function TenantSettingsComponent() {
description="Параметры control plane для текущего tenant (API /v1/settings)"
/>
<Alert className="border-info/30 bg-info/5">
<Info className="text-info" />
<AlertTitle>Operator-only</AlertTitle>
<AlertDescription>
Изменение значений через <code className="text-xs">PATCH /v1/settings</code> требует роли
operator. При отсутствии прав API вернёт 403.
</AlertDescription>
</Alert>
<BadgeTabs
value={search.tab}
onValueChange={(tab) =>
@@ -136,14 +127,6 @@ function TenantSettingsComponent() {
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<Alert className="border-info/30 bg-info/5">
<Info className="text-info" />
<AlertTitle>Подстановка в конфиг</AlertTitle>
<AlertDescription>
Значения используются при генерации BIRD-конфигурации (router id, local AS, адреса).
Пиры и спикеры настраиваются в разделе «Сеть».
</AlertDescription>
</Alert>
<QueryState
data={partitioned}
isLoading={settingsQ.isLoading}
@@ -318,14 +301,10 @@ function TenantSettingsComponent() {
</TabsContent>
<TabsContent value="additional" className="mt-0">
<Card>
<CardHeader>
<CardTitle>Дополнительные параметры</CardTitle>
<CardDescription>
Параметры вне стандартных групп (readonly — изменяются только через API)
</CardDescription>
</CardHeader>
<CardContent className="p-0">
<DataGridCard
title="Дополнительные параметры"
description="Параметры вне стандартных групп (readonly — изменяются только через API)"
>
<QueryState
data={partitioned?.additional ?? []}
isLoading={settingsQ.isLoading}
@@ -343,8 +322,7 @@ function TenantSettingsComponent() {
/>
)}
</QueryState>
</CardContent>
</Card>
</DataGridCard>
</TabsContent>
</BadgeTabs>
</div>
+1 -1
View File
@@ -1 +1 @@
{"root":["./src/main.tsx","./src/routetree.gen.ts","./src/components/badge-tabs.tsx","./src/components/category-badge.tsx","./src/components/confirm-dialog.tsx","./src/components/data-grid-cell.tsx","./src/components/data-grid-shell.tsx","./src/components/data-grid-toolbar.tsx","./src/components/empty-state.tsx","./src/components/loading-button.tsx","./src/components/mode-toggle.tsx","./src/components/page-header.tsx","./src/components/page-shell.tsx","./src/components/query-state.tsx","./src/components/section-cards.tsx","./src/components/select-field.tsx","./src/components/skeletons.tsx","./src/components/status-badge.tsx","./src/components/theme-provider.tsx","./src/components/truncated-text.tsx","./src/components/access/access-api-keys-card.tsx","./src/components/access/access-api-keys-grid.tsx","./src/components/access/api-key-create-dialog.tsx","./src/components/access/api-key-token-dialog.tsx","./src/components/analytics/analytics-activity-list.tsx","./src/components/analytics/analytics-card-shell.tsx","./src/components/analytics/analytics-kpi-row.tsx","./src/components/analytics/analytics-progress.tsx","./src/components/analytics/analytics-segment-control.tsx","./src/components/analytics/chart-bar-strip.tsx","./src/components/analytics/chart-donut-metric.tsx","./src/components/analytics/dashboard-network-capacity-card.tsx","./src/components/analytics/dashboard-operations-flow-card.tsx","./src/components/analytics/dashboard-platform-card.tsx","./src/components/analytics/index.ts","./src/components/analytics/monitoring-health-card.tsx","./src/components/analytics/network-overview-analytics-card.tsx","./src/components/analytics/operations-analytics-card.tsx","./src/components/dashboard/dashboard-network-panel.tsx","./src/components/dashboard/dashboard-quick-actions.tsx","./src/components/dashboard/dashboard-recent-jobs-grid.tsx","./src/components/dashboard/dashboard-recent-revisions-grid.tsx","./src/components/directories/directories-communities-grid.tsx","./src/components/directories/directories-doh-grid.tsx","./src/components/examples/c-input-group-37.tsx","./src/components/examples/c-select-4.tsx","./src/components/examples/c-tabs-6.tsx","./src/components/examples/c-tabs-7.tsx","./src/components/firewall/firewall-clients-grid.tsx","./src/components/firewall/firewall-rules-grid.tsx","./src/components/layout/app-shell.tsx","./src/components/modules/community-select.tsx","./src/components/modules/module-as-entry-dialog.tsx","./src/components/modules/module-cdn-source-dialog.tsx","./src/components/modules/module-domain-entry-dialog.tsx","./src/components/modules/module-entries-grid.tsx","./src/components/modules/module-entries-section.tsx","./src/components/modules/module-ip-range-entry-dialog.tsx","./src/components/modules/module-kpi-cards.tsx","./src/components/modules/modules-list-grid.tsx","./src/components/monitoring/monitoring-ready-grid.tsx","./src/components/network/network-peers-card.tsx","./src/components/network/network-peers-grid.tsx","./src/components/network/network-speakers-card.tsx","./src/components/network/network-speakers-grid.tsx","./src/components/network/peer-form-dialog.tsx","./src/components/network/speaker-form-dialog.tsx","./src/components/operations/operations-jobs-grid.tsx","./src/components/operations/operations-revisions-grid.tsx","./src/components/reui/autocomplete.tsx","./src/components/reui/badge.tsx","./src/components/reui/date-selector.tsx","./src/components/reui/filters.tsx","./src/components/reui/frame.tsx","./src/components/reui/number-field.tsx","./src/components/reui/data-grid/data-grid-column-filter.tsx","./src/components/reui/data-grid/data-grid-column-header.tsx","./src/components/reui/data-grid/data-grid-column-visibility.tsx","./src/components/reui/data-grid/data-grid-pagination.tsx","./src/components/reui/data-grid/data-grid-scroll-area.tsx","./src/components/reui/data-grid/data-grid-table-dnd-rows.tsx","./src/components/reui/data-grid/data-grid-table-dnd.tsx","./src/components/reui/data-grid/data-grid-table-virtual.tsx","./src/components/reui/data-grid/data-grid-table.tsx","./src/components/reui/data-grid/data-grid.tsx","./src/components/schedule/schedule-jobs-grid.tsx","./src/components/schedule/schedule-modules-grid.tsx","./src/components/settings/settings-kv-grid.tsx","./src/hooks/use-client-data-grid.ts","./src/lib/api-client.ts","./src/lib/data-grid-defaults.ts","./src/lib/queryclient.ts","./src/lib/router.ts","./src/lib/ui-labels.ts","./src/lib/access/api-key-labels.ts","./src/lib/metrics/deployment-progress.ts","./src/lib/metrics/index.ts","./src/lib/metrics/job-status-breakdown.ts","./src/lib/metrics/module-type-breakdown.ts","./src/lib/metrics/peer-capacity-bars.ts","./src/lib/metrics/peer-session-breakdown.ts","./src/lib/metrics/readiness-breakdown.ts","./src/lib/metrics/recent-platform-activity.ts","./src/lib/metrics/types.ts","./src/lib/modules/display.ts","./src/lib/modules/helpers.ts","./src/queries/api-keys.ts","./src/queries/auth.ts","./srcLine truncated
{"root":["./src/main.tsx","./src/routetree.gen.ts","./src/components/badge-tabs.tsx","./src/components/category-badge.tsx","./src/components/confirm-dialog.tsx","./src/components/data-grid-cell.tsx","./src/components/data-grid-shell.tsx","./src/components/data-grid-toolbar.tsx","./src/components/drawer-layout.tsx","./src/components/empty-state.tsx","./src/components/form-drawer.tsx","./src/components/loading-button.tsx","./src/components/mode-toggle.tsx","./src/components/page-header.tsx","./src/components/page-shell.tsx","./src/components/query-state.tsx","./src/components/section-cards.tsx","./src/components/select-field.tsx","./src/components/skeletons.tsx","./src/components/status-badge.tsx","./src/components/theme-provider.tsx","./src/components/truncated-text.tsx","./src/components/access/access-api-keys-card.tsx","./src/components/access/access-api-keys-grid.tsx","./src/components/access/api-key-create-dialog.tsx","./src/components/access/api-key-token-dialog.tsx","./src/components/analytics/analytics-activity-list.tsx","./src/components/analytics/analytics-card-shell.tsx","./src/components/analytics/analytics-kpi-row.tsx","./src/components/analytics/analytics-progress.tsx","./src/components/analytics/analytics-segment-control.tsx","./src/components/analytics/chart-bar-strip.tsx","./src/components/analytics/chart-donut-metric.tsx","./src/components/analytics/dashboard-network-capacity-card.tsx","./src/components/analytics/dashboard-operations-flow-card.tsx","./src/components/analytics/dashboard-platform-card.tsx","./src/components/analytics/index.ts","./src/components/analytics/monitoring-health-card.tsx","./src/components/analytics/network-overview-analytics-card.tsx","./src/components/analytics/operations-analytics-card.tsx","./src/components/dashboard/dashboard-network-panel.tsx","./src/components/dashboard/dashboard-quick-actions.tsx","./src/components/dashboard/dashboard-recent-jobs-grid.tsx","./src/components/dashboard/dashboard-recent-revisions-grid.tsx","./src/components/directories/directories-communities-grid.tsx","./src/components/directories/directories-doh-grid.tsx","./src/components/examples/c-input-group-37.tsx","./src/components/examples/c-select-4.tsx","./src/components/examples/c-tabs-6.tsx","./src/components/examples/c-tabs-7.tsx","./src/components/firewall/firewall-clients-grid.tsx","./src/components/firewall/firewall-rule-create-dialog.tsx","./src/components/firewall/firewall-rules-grid.tsx","./src/components/layout/app-shell.tsx","./src/components/modules/community-select.tsx","./src/components/modules/module-as-entry-dialog.tsx","./src/components/modules/module-cdn-source-dialog.tsx","./src/components/modules/module-domain-entry-dialog.tsx","./src/components/modules/module-entries-grid.tsx","./src/components/modules/module-entries-section.tsx","./src/components/modules/module-ip-range-entry-dialog.tsx","./src/components/modules/module-kpi-cards.tsx","./src/components/modules/modules-list-grid.tsx","./src/components/monitoring/monitoring-ready-grid.tsx","./src/components/network/network-peers-card.tsx","./src/components/network/network-peers-grid.tsx","./src/components/network/network-speakers-card.tsx","./src/components/network/network-speakers-grid.tsx","./src/components/network/peer-form-dialog.tsx","./src/components/network/speaker-form-dialog.tsx","./src/components/operations/operations-jobs-grid.tsx","./src/components/operations/operations-revisions-grid.tsx","./src/components/reui/autocomplete.tsx","./src/components/reui/badge.tsx","./src/components/reui/date-selector.tsx","./src/components/reui/filters.tsx","./src/components/reui/frame.tsx","./src/components/reui/number-field.tsx","./src/components/reui/data-grid/data-grid-column-filter.tsx","./src/components/reui/data-grid/data-grid-column-header.tsx","./src/components/reui/data-grid/data-grid-column-visibility.tsx","./src/components/reui/data-grid/data-grid-pagination.tsx","./src/components/reui/data-grid/data-grid-scroll-area.tsx","./src/components/reui/data-grid/data-grid-table-dnd-rows.tsx","./src/components/reui/data-grid/data-grid-table-dnd.tsx","./src/components/reui/data-grid/data-grid-table-virtual.tsx","./src/components/reui/data-grid/data-grid-table.tsx","./src/components/reui/data-grid/data-grid.tsx","./src/components/schedule/schedule-jobs-grid.tsx","./src/components/schedule/schedule-modules-grid.tsx","./src/components/settings/settings-kv-grid.tsx","./src/hooks/use-client-data-grid.ts","./src/lib/api-client.ts","./src/lib/data-grid-defaults.ts","./src/lib/queryclient.ts","./src/lib/router.ts","./src/lib/ui-labels.ts","./src/lib/access/api-key-labels.ts","./src/lib/metrics/deployment-progress.ts","./src/lib/metrics/index.ts","./src/lib/metrics/job-status-breakdown.ts","./src/lib/metrics/module-type-breakdown.ts","./src/lib/metrics/peer-capacity-bars.ts","./src/lib/metrics/peer-session-breakdown.ts","./src/lib/metrics/readiness-breakdown.ts","./src/lib/metrics/recent-platform-activity.ts","./src/lib/metricLine truncated
+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,
}