feat: refactor data grid components to utilize DataGridSection for enhanced functionality
CI / changes (push) Successful in 9s
CI / commitlint (push) Has been skipped
CI / openapi (push) Has been skipped
CI / web (push) Successful in 52s
CI / go (push) Has been skipped
CI / bird2 (push) Has been skipped
CI / release (push) Successful in 4m16s

Updated multiple components to replace DataGridShell with DataGridSection, integrating search functionality and improved data handling. This change enhances user experience by providing a consistent interface across various grids, including AccessApiKeysGrid, DashboardRecentJobsGrid, and others. Additionally, introduced global filtering capabilities to streamline data retrieval and presentation, ensuring a more efficient user interaction with the data grids.
This commit is contained in:
Denozordec
2026-07-09 12:48:52 +07:00
parent e04fea657c
commit f66d68d1c7
41 changed files with 1392 additions and 275 deletions
@@ -1,16 +1,16 @@
import { ColumnDef, useReactTable } from '@tanstack/react-table'
import { ColumnDef } from '@tanstack/react-table'
import { RefreshCw, Trash2 } from 'lucide-react'
import { useMemo } from 'react'
import { Button } from '@evobgp/ui/components/button'
import { DataGridShell } 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 { StatusBadge } from '@/components/status-badge'
import { DataGridColumnHeader } from '@/components/reui/data-grid/data-grid-column-header'
import { formatApiKeyDate } from '@/lib/access/api-key-labels'
import { createClientDataGridOptions } from '@/lib/data-grid-defaults'
import { useClientDataGrid } from '@/hooks/use-client-data-grid'
import type { ApiKey } from '@/types/api'
export function AccessApiKeysGrid({
@@ -140,19 +140,23 @@ export function AccessApiKeysGrid({
[onRevoke, onRotate, revokePending, rotatePending],
)
const table = useReactTable({
const { table, globalFilter, setGlobalFilter, filteredCount } = useClientDataGrid({
data: items,
columns,
...createClientDataGridOptions<ApiKey>(),
getSearchText: (row) =>
`${row.name} ${row.role} ${row.prefix} ${row.revoked_at ? 'отозван' : 'активен'}`,
getRowId: (row) => row.id,
})
return (
<DataGridShell
<DataGridSection
table={table}
recordCount={items.length}
recordCount={filteredCount}
isLoading={isLoading}
emptyMessage="Нет ключей"
searchValue={globalFilter}
onSearchChange={setGlobalFilter}
searchPlaceholder="Поиск API-ключей…"
/>
)
}
+62
View File
@@ -0,0 +1,62 @@
import type { ComponentProps, ReactNode } from 'react'
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@evobgp/ui/components/tabs'
import { Badge } from '@/components/reui/badge'
export type BadgeTabItem = {
value: string
label: string
count?: number
badgeVariant?: ComponentProps<typeof Badge>['variant']
icon?: ReactNode
}
interface BadgeTabsProps {
items: BadgeTabItem[]
value?: string
defaultValue?: string
onValueChange?: (value: string) => void
children: ReactNode
className?: string
listClassName?: string
contentClassName?: string
}
/** Underline tabs with optional badge counts (ReUI c-tabs-7 pattern). */
export function BadgeTabs({
items,
value,
defaultValue,
onValueChange,
children,
className,
listClassName,
contentClassName,
}: BadgeTabsProps) {
return (
<Tabs
value={value}
defaultValue={defaultValue}
onValueChange={onValueChange}
className={className}
>
<TabsList variant="line" className={listClassName ?? 'mb-3.5 w-full'}>
{items.map((item) => (
<TabsTrigger key={item.value} value={item.value} className="gap-2">
{item.icon}
{item.label}
{item.count !== undefined ? (
<Badge variant={item.badgeVariant ?? 'primary-light'} size="sm">
{item.count}
</Badge>
) : null}
</TabsTrigger>
))}
</TabsList>
<div className={contentClassName}>{children}</div>
</Tabs>
)
}
export { TabsContent }
@@ -1,9 +1,10 @@
import { ColumnDef, getCoreRowModel, useReactTable } from '@tanstack/react-table'
import { ColumnDef } from '@tanstack/react-table'
import { useMemo } from 'react'
import { DataGridShell } from '@/components/data-grid-shell'
import { DataGridSection } from '@/components/data-grid-shell'
import { DataGridColumnHeader } from '@/components/reui/data-grid/data-grid-column-header'
import { DATA_GRID_DENSE_LAYOUT } from '@/lib/data-grid-defaults'
import { useClientDataGrid } from '@/hooks/use-client-data-grid'
import type { JobRow } from '@/types/api'
function StatusText({ status }: { status: string }) {
@@ -54,21 +55,30 @@ export function DashboardRecentJobsGrid({
[nameById],
)
const table = useReactTable({
const { table, globalFilter, setGlobalFilter, filteredCount } = useClientDataGrid({
data,
columns,
getCoreRowModel: getCoreRowModel(),
getSearchText: (row) => {
const moduleName = row.meta?.module_id
? (nameById.get(String(row.meta.module_id)) ?? '')
: ''
return `${row.kind} ${row.status} ${moduleName}`
},
getRowId: (row) => row.job_id,
pageSize: 8,
})
return (
<DataGridShell
<DataGridSection
table={table}
recordCount={data.length}
recordCount={filteredCount}
isLoading={isLoading}
emptyMessage="Нет задач"
showPagination={false}
tableLayout={DATA_GRID_DENSE_LAYOUT}
searchValue={globalFilter}
onSearchChange={setGlobalFilter}
searchPlaceholder="Поиск задач…"
/>
)
}
@@ -1,9 +1,10 @@
import { ColumnDef, getCoreRowModel, useReactTable } from '@tanstack/react-table'
import { ColumnDef } from '@tanstack/react-table'
import { useMemo } from 'react'
import { DataGridShell } from '@/components/data-grid-shell'
import { DataGridSection } from '@/components/data-grid-shell'
import { DataGridColumnHeader } from '@/components/reui/data-grid/data-grid-column-header'
import { DATA_GRID_DENSE_LAYOUT } from '@/lib/data-grid-defaults'
import { useClientDataGrid } from '@/hooks/use-client-data-grid'
import type { RevisionRow } from '@/types/api'
export function DashboardRecentRevisionsGrid({
@@ -42,21 +43,25 @@ export function DashboardRecentRevisionsGrid({
[],
)
const table = useReactTable({
const { table, globalFilter, setGlobalFilter, filteredCount } = useClientDataGrid({
data,
columns,
getCoreRowModel: getCoreRowModel(),
getSearchText: (row) => row.id,
getRowId: (row) => row.id,
pageSize: 8,
})
return (
<DataGridShell
<DataGridSection
table={table}
recordCount={data.length}
recordCount={filteredCount}
isLoading={isLoading}
emptyMessage="Нет ревизий"
showPagination={false}
tableLayout={DATA_GRID_DENSE_LAYOUT}
searchValue={globalFilter}
onSearchChange={setGlobalFilter}
searchPlaceholder="Поиск ревизий…"
/>
)
}
@@ -3,6 +3,7 @@ import type { Table } from '@tanstack/react-table'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@evobgp/ui/components/card'
import { DataGridToolbar } from '@/components/data-grid-toolbar'
import { DataGridPagination } from '@/components/reui/data-grid/data-grid-pagination'
import { DataGrid, DataGridContainer } from '@/components/reui/data-grid/data-grid'
import { DataGridTable } from '@/components/reui/data-grid/data-grid-table'
@@ -75,3 +76,36 @@ export function DataGridCard({ title, description, actions, children, className
</Card>
)
}
interface DataGridSectionProps<TData extends object> extends DataGridShellProps<TData> {
searchValue: string
onSearchChange: (value: string) => void
searchPlaceholder?: string
toolbarFilters?: ReactNode
toolbarActions?: ReactNode
beforeGrid?: ReactNode
}
export function DataGridSection<TData extends object>({
searchValue,
onSearchChange,
searchPlaceholder,
toolbarFilters,
toolbarActions,
beforeGrid,
...shellProps
}: DataGridSectionProps<TData>) {
return (
<>
<DataGridToolbar
searchValue={searchValue}
onSearchChange={onSearchChange}
searchPlaceholder={searchPlaceholder}
filters={toolbarFilters}
actions={toolbarActions}
/>
{beforeGrid}
<DataGridShell {...shellProps} />
</>
)
}
@@ -0,0 +1,73 @@
import type { ReactNode } from 'react'
import { Field } from '@evobgp/ui/components/field'
import {
InputGroup,
InputGroupAddon,
InputGroupButton,
InputGroupInput,
} from '@evobgp/ui/components/input-group'
import { ListFilterIcon, SearchIcon, XIcon } from 'lucide-react'
interface DataGridToolbarProps {
searchValue: string
onSearchChange: (value: string) => void
searchPlaceholder?: string
filters?: ReactNode
actions?: ReactNode
className?: string
}
/** Search row for data grids (ReUI c-input-group-37 pattern). */
export function DataGridToolbar({
searchValue,
onSearchChange,
searchPlaceholder = 'Поиск…',
filters,
actions,
className,
}: DataGridToolbarProps) {
return (
<div className={`flex flex-wrap items-center gap-2 border-b px-3 py-3 ${className ?? ''}`}>
<Field className="min-w-[200px] flex-1">
<InputGroup>
<InputGroupAddon align="inline-start">
<SearchIcon aria-hidden="true" />
</InputGroupAddon>
<InputGroupInput
placeholder={searchPlaceholder}
value={searchValue}
onChange={(event) => onSearchChange(event.target.value)}
aria-label={searchPlaceholder}
/>
<InputGroupAddon align="inline-end" className="gap-1">
{searchValue.length > 0 ? (
<InputGroupButton
aria-label="Очистить поиск"
size="icon-xs"
variant="ghost"
onClick={() => onSearchChange('')}
>
<XIcon aria-hidden="true" />
</InputGroupButton>
) : null}
{filters ? (
filters
) : (
<InputGroupButton
variant="ghost"
size="xs"
className="pointer-events-none gap-1.5 opacity-0"
aria-hidden="true"
tabIndex={-1}
>
<ListFilterIcon className="size-3.5" />
</InputGroupButton>
)}
</InputGroupAddon>
</InputGroup>
</Field>
{actions ? <div className="flex shrink-0 items-center gap-2">{actions}</div> : null}
</div>
)
}
@@ -1,11 +1,11 @@
import { ColumnDef, useReactTable } from '@tanstack/react-table'
import { ColumnDef } from '@tanstack/react-table'
import { useMemo } from 'react'
import { Badge } from '@evobgp/ui/components/badge'
import { DataGridShell } from '@/components/data-grid-shell'
import { DataGridSection } from '@/components/data-grid-shell'
import { DataGridColumnHeader } from '@/components/reui/data-grid/data-grid-column-header'
import { createClientDataGridOptions } from '@/lib/data-grid-defaults'
import { useClientDataGrid } from '@/hooks/use-client-data-grid'
import type { BgpCommunity } from '@/types/api'
export function DirectoriesCommunitiesGrid({
@@ -40,19 +40,22 @@ export function DirectoriesCommunitiesGrid({
[],
)
const table = useReactTable({
const { table, globalFilter, setGlobalFilter, filteredCount } = useClientDataGrid({
data: items,
columns,
...createClientDataGridOptions<BgpCommunity>(),
getSearchText: (row) => `${row.title} ${row.community}`,
getRowId: (row) => row.id,
})
return (
<DataGridShell
<DataGridSection
table={table}
recordCount={items.length}
recordCount={filteredCount}
isLoading={isLoading}
emptyMessage="Нет сообществ"
searchValue={globalFilter}
onSearchChange={setGlobalFilter}
searchPlaceholder="Поиск сообществ…"
/>
)
}
@@ -1,11 +1,11 @@
import { ColumnDef, useReactTable } from '@tanstack/react-table'
import { ColumnDef } from '@tanstack/react-table'
import { useMemo } from 'react'
import { Badge } from '@evobgp/ui/components/badge'
import { DataGridShell } from '@/components/data-grid-shell'
import { DataGridSection } from '@/components/data-grid-shell'
import { DataGridColumnHeader } from '@/components/reui/data-grid/data-grid-column-header'
import { createClientDataGridOptions } from '@/lib/data-grid-defaults'
import { useClientDataGrid } from '@/hooks/use-client-data-grid'
import type { DohProfile } from '@/types/api'
export function DirectoriesDohGrid({
@@ -41,19 +41,22 @@ export function DirectoriesDohGrid({
[],
)
const table = useReactTable({
const { table, globalFilter, setGlobalFilter, filteredCount } = useClientDataGrid({
data: items,
columns,
...createClientDataGridOptions<DohProfile>(),
getSearchText: (row) => `${row.name ?? ''} ${row.url}`,
getRowId: (row) => row.id,
})
return (
<DataGridShell
<DataGridSection
table={table}
recordCount={items.length}
recordCount={filteredCount}
isLoading={isLoading}
emptyMessage="Нет DoH профилей"
searchValue={globalFilter}
onSearchChange={setGlobalFilter}
searchPlaceholder="Поиск DoH профилей…"
/>
)
}
@@ -0,0 +1,107 @@
"use client"
import { useState } from "react"
import { Checkbox } from "@evobgp/ui/components/checkbox"
import { Field } from "@evobgp/ui/components/field"
import {
InputGroup,
InputGroupAddon,
InputGroupButton,
InputGroupInput,
} from "@evobgp/ui/components/input-group"
import { Label } from "@evobgp/ui/components/label"
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@evobgp/ui/components/popover"
import { SearchIcon, XIcon, ListFilterIcon } from "lucide-react"
const statuses = ["Pending", "Shipped", "Cancelled"] as const
type Status = (typeof statuses)[number]
function toggleStatus(values: Status[], value: Status) {
return values.includes(value)
? values.filter((item) => item !== value)
: [...values, value]
}
export function Pattern() {
const [searchQuery, setSearchQuery] = useState("")
const [selectedStatuses, setSelectedStatuses] = useState<Status[]>([])
return (
<Field className="max-w-sm">
<InputGroup>
<InputGroupAddon align="inline-start">
<SearchIcon aria-hidden="true" />
</InputGroupAddon>
<InputGroupInput
placeholder="Search orders..."
value={searchQuery}
onChange={(event) => setSearchQuery(event.target.value)}
/>
<InputGroupAddon align="inline-end" className="gap-1">
{searchQuery.length > 0 ? (
<InputGroupButton
aria-label="Clear search"
size="icon-xs"
variant="ghost"
onClick={() => setSearchQuery("")}
>
<XIcon aria-hidden="true" />
</InputGroupButton>
) : null}
<Popover>
<PopoverTrigger
render={
<InputGroupButton
variant="ghost"
size="xs"
className="gap-1.5"
aria-label="Filter order status"
/>
}
>
<ListFilterIcon className="size-3.5" aria-hidden="true" />
Status
{selectedStatuses.length > 0 ? (
<span className="text-muted-foreground tabular-nums">
{selectedStatuses.length}
</span>
) : null}
</PopoverTrigger>
<PopoverContent align="end" className="w-40 p-3">
<div className="flex flex-col gap-2">
{statuses.map((status) => (
<div key={status} className="flex items-center gap-2.5">
<Checkbox
id={`order-status-${status}`}
checked={selectedStatuses.includes(status)}
onCheckedChange={() =>
setSelectedStatuses((previous) =>
toggleStatus(previous, status)
)
}
/>
<Label
htmlFor={`order-status-${status}`}
className="text-sm font-normal"
>
{status}
</Label>
</div>
))}
</div>
</PopoverContent>
</Popover>
</InputGroupAddon>
</InputGroup>
</Field>
)
}
@@ -0,0 +1,46 @@
import { Card, CardContent } from "@evobgp/ui/components/card"
import {
Tabs,
TabsContent,
TabsList,
TabsTrigger,
} from "@evobgp/ui/components/tabs"
import { LayoutDashboardIcon, BarChart3Icon, SettingsIcon } from "lucide-react"
export function Pattern() {
return (
<div className="flex w-full max-w-md flex-col gap-6">
<Tabs defaultValue="overview">
<TabsList className="w-full">
<TabsTrigger value="overview">
<LayoutDashboardIcon className="size-4" />
Overview
</TabsTrigger>
<TabsTrigger value="analytics">
<BarChart3Icon className="size-4" />
Analytics
</TabsTrigger>
<TabsTrigger value="settings">
<SettingsIcon className="size-4" />
Settings
</TabsTrigger>
</TabsList>
<TabsContent value="overview">
<Card>
<CardContent>Overview dashboard content goes here.</CardContent>
</Card>
</TabsContent>
<TabsContent value="analytics">
<Card>
<CardContent>Analytics charts and metrics.</CardContent>
</Card>
</TabsContent>
<TabsContent value="settings">
<Card>
<CardContent>Application settings and preferences.</CardContent>
</Card>
</TabsContent>
</Tabs>
</div>
)
}
@@ -0,0 +1,61 @@
import { Badge } from "@/components/reui/badge"
import { Card, CardContent } from "@evobgp/ui/components/card"
import {
Tabs,
TabsContent,
TabsList,
TabsTrigger,
} from "@evobgp/ui/components/tabs"
export function Pattern() {
return (
<div className="flex w-full max-w-md flex-col gap-6">
<Tabs defaultValue="inbox">
<TabsList variant="line" className="mb-3.5 w-full">
<TabsTrigger value="inbox" className="gap-2">
Inbox
<Badge variant="primary-light" size="sm">
12
</Badge>
</TabsTrigger>
<TabsTrigger value="drafts" className="gap-2">
Drafts
<Badge variant="info-light" size="sm">
3
</Badge>
</TabsTrigger>
<TabsTrigger value="sent" className="gap-2">
Sent
</TabsTrigger>
<TabsTrigger value="spam" className="gap-2">
Spam
<Badge variant="destructive-light" size="sm">
24
</Badge>
</TabsTrigger>
</TabsList>
<TabsContent value="inbox">
<Card>
<CardContent>12 unread messages in your inbox.</CardContent>
</Card>
</TabsContent>
<TabsContent value="drafts">
<Card>
<CardContent>3 drafts waiting to be sent.</CardContent>
</Card>
</TabsContent>
<TabsContent value="sent">
<Card>
<CardContent>All sent messages appear here.</CardContent>
</Card>
</TabsContent>
<TabsContent value="spam">
<Card>
<CardContent>24 spam messages detected.</CardContent>
</Card>
</TabsContent>
</Tabs>
</div>
)
}
@@ -1,13 +1,13 @@
import { ColumnDef, useReactTable } from '@tanstack/react-table'
import { ColumnDef } from '@tanstack/react-table'
import { useMemo } from 'react'
import { Button } from '@evobgp/ui/components/button'
import { DataGridShell } from '@/components/data-grid-shell'
import { DataGridSection } from '@/components/data-grid-shell'
import { ConfirmDialog } from '@/components/confirm-dialog'
import { StatusBadge } from '@/components/status-badge'
import { DataGridColumnHeader } from '@/components/reui/data-grid/data-grid-column-header'
import { createClientDataGridOptions } from '@/lib/data-grid-defaults'
import { useClientDataGrid } from '@/hooks/use-client-data-grid'
import type { FirewallClient } from '@/types/api'
function formatPacketCount(value?: number | null): string | null {
@@ -173,19 +173,23 @@ export function FirewallClientsGrid({
[approvePending, onApprove, onReject, rejectPending],
)
const table = useReactTable({
const { table, globalFilter, setGlobalFilter, filteredCount } = useClientDataGrid({
data: clients,
columns,
...createClientDataGridOptions<FirewallClient>(),
getSearchText: (row) =>
`${row.name} ${row.hostname ?? ''} ${row.token_prefix} ${row.status ?? ''}`,
getRowId: (row) => row.id,
})
return (
<DataGridShell
<DataGridSection
table={table}
recordCount={clients.length}
recordCount={filteredCount}
isLoading={isLoading}
emptyMessage={emptyTitle}
searchValue={globalFilter}
onSearchChange={setGlobalFilter}
searchPlaceholder="Поиск клиентов…"
/>
)
}
@@ -1,13 +1,13 @@
import { ColumnDef, useReactTable } from '@tanstack/react-table'
import { ColumnDef } from '@tanstack/react-table'
import { useMemo } from 'react'
import { Button } from '@evobgp/ui/components/button'
import { DataGridShell } from '@/components/data-grid-shell'
import { DataGridSection } from '@/components/data-grid-shell'
import { ConfirmDialog } from '@/components/confirm-dialog'
import { StatusBadge } from '@/components/status-badge'
import { DataGridColumnHeader } from '@/components/reui/data-grid/data-grid-column-header'
import { createClientDataGridOptions } from '@/lib/data-grid-defaults'
import { useClientDataGrid } from '@/hooks/use-client-data-grid'
import { communityLabel } from '@/lib/modules/helpers'
import type { BgpCommunity, FirewallRule } from '@/types/api'
@@ -100,19 +100,23 @@ export function FirewallRulesGrid({
[communities, deletePending, onDelete],
)
const table = useReactTable({
const { table, globalFilter, setGlobalFilter, filteredCount } = useClientDataGrid({
data: rules,
columns,
...createClientDataGridOptions<FirewallRule>(),
getSearchText: (row) =>
`${row.priority} ${row.action} ${row.comment ?? ''} ${communityLabel(row.community_id, communities)}`,
getRowId: (row) => row.id,
})
return (
<DataGridShell
<DataGridSection
table={table}
recordCount={rules.length}
recordCount={filteredCount}
isLoading={isLoading}
emptyMessage={emptyTitle}
searchValue={globalFilter}
onSearchChange={setGlobalFilter}
searchPlaceholder="Поиск правил…"
/>
)
}
@@ -1,14 +1,14 @@
import { ColumnDef, useReactTable } from '@tanstack/react-table'
import { ColumnDef } from '@tanstack/react-table'
import { Pencil, Trash2 } from 'lucide-react'
import { useMemo } from 'react'
import { Button } from '@evobgp/ui/components/button'
import { DataGridShell } from '@/components/data-grid-shell'
import { DataGridSection } from '@/components/data-grid-shell'
import { DataGridColumnHeader } from '@/components/reui/data-grid/data-grid-column-header'
import { formatDateTime } from '@/lib/modules/display'
import { communityLabel } from '@/lib/modules/helpers'
import { createClientDataGridOptions } from '@/lib/data-grid-defaults'
import { useClientDataGrid } from '@/hooks/use-client-data-grid'
import type {
AsEntry,
BgpCommunity,
@@ -231,19 +231,27 @@ export function ModuleEntriesGrid({
type RowType = DomainEntry | IpRangeEntry | CdnSource | AsEntry
const data = rows as unknown as RowType[]
const table = useReactTable({
const { table, globalFilter, setGlobalFilter, filteredCount } = useClientDataGrid({
data,
columns: columns as ColumnDef<RowType>[],
...createClientDataGridOptions<RowType>(),
getSearchText: (row) => {
const r = row as Record<string, unknown>
return Object.values(r)
.filter((v) => typeof v === 'string' || typeof v === 'number')
.join(' ')
},
getRowId: (row) => row.id,
})
return (
<DataGridShell
<DataGridSection
table={table}
recordCount={data.length}
recordCount={filteredCount}
isLoading={isLoading}
emptyMessage="Нет записей"
searchValue={globalFilter}
onSearchChange={setGlobalFilter}
searchPlaceholder="Поиск записей…"
/>
)
}
@@ -1,13 +1,13 @@
import { ColumnDef, useReactTable } from '@tanstack/react-table'
import { ColumnDef } from '@tanstack/react-table'
import { useNavigate } from '@tanstack/react-router'
import { Boxes } from 'lucide-react'
import { useMemo } from 'react'
import { DataGridShell } 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 { TruncatedText } from '@/components/truncated-text'
import { createClientDataGridOptions } from '@/lib/data-grid-defaults'
import { useClientDataGrid } from '@/hooks/use-client-data-grid'
import type { ModuleRow } from '@/types/api'
export function ModulesListGrid({
@@ -80,19 +80,22 @@ export function ModulesListGrid({
[],
)
const table = useReactTable({
const { table, globalFilter, setGlobalFilter, filteredCount } = useClientDataGrid({
data: items,
columns,
...createClientDataGridOptions<ModuleRow>(),
getSearchText: (row) => `${row.name} ${row.type} ${row.enabled ? 'включён' : 'выключен'}`,
getRowId: (row) => row.id,
})
return (
<DataGridShell
<DataGridSection
table={table}
recordCount={items.length}
recordCount={filteredCount}
isLoading={isLoading}
emptyMessage="Нет модулей"
searchValue={globalFilter}
onSearchChange={setGlobalFilter}
searchPlaceholder="Поиск модулей…"
onRowClick={(row) => void navigate({ to: '/modules/$moduleId', params: { moduleId: row.id } })}
/>
)
@@ -1,12 +1,12 @@
import { ColumnDef, useReactTable } from '@tanstack/react-table'
import { ColumnDef } from '@tanstack/react-table'
import { Database, HardDrive, HeartPulse, ListTodo, ShieldCheck } from 'lucide-react'
import { useMemo } from 'react'
import { Badge } from '@evobgp/ui/components/badge'
import { DataGridShell } from '@/components/data-grid-shell'
import { DataGridSection } from '@/components/data-grid-shell'
import { DataGridColumnHeader } from '@/components/reui/data-grid/data-grid-column-header'
import { createClientDataGridOptions } from '@/lib/data-grid-defaults'
import { useClientDataGrid } from '@/hooks/use-client-data-grid'
import type { ReadyStatus } from '@/queries/monitoring'
const READY_CHECK_ICONS: Record<string, typeof Database> = {
@@ -103,21 +103,23 @@ export function MonitoringReadyGrid({
[],
)
const table = useReactTable({
const { table, globalFilter, setGlobalFilter, filteredCount } = useClientDataGrid({
data,
columns,
...createClientDataGridOptions<ReadyCheckRow>({
initialState: { pagination: { pageSize: 20 } },
}),
getSearchText: (row) => `${row.label} ${row.subtitle ?? ''} ${row.statusLabel}`,
getRowId: (row) => row.id,
pageSize: 20,
})
return (
<DataGridShell
<DataGridSection
table={table}
recordCount={data.length}
recordCount={filteredCount}
showPagination={false}
emptyMessage="Нет проверок"
searchValue={globalFilter}
onSearchChange={setGlobalFilter}
searchPlaceholder="Поиск проверок…"
/>
)
}
@@ -0,0 +1,68 @@
import { useState } from 'react'
import { Plus } from 'lucide-react'
import { Button } from '@evobgp/ui/components/button'
import { DataGridCard } from '@/components/data-grid-shell'
import { NetworkPeersGrid } from '@/components/network/network-peers-grid'
import { PeerFormDialog } from '@/components/network/peer-form-dialog'
import { QueryState } from '@/components/query-state'
import { TableSkeleton } from '@/components/skeletons'
import type { PeerRow, SpeakerRow } from '@/types/api'
interface NetworkPeersCardProps {
items: PeerRow[]
speakers: SpeakerRow[]
isLoading: boolean
isError: boolean
error: unknown
onRetry: () => void
}
export function NetworkPeersCard({
items,
speakers,
isLoading,
isError,
error,
onRetry,
}: NetworkPeersCardProps) {
const [dialogOpen, setDialogOpen] = useState(false)
return (
<>
<DataGridCard
title="Пиры"
description="BGP-соседи и привязка к спикерам"
actions={
<Button size="sm" type="button" onClick={() => setDialogOpen(true)}>
<Plus />
Добавить пира
</Button>
}
>
<QueryState
data={items}
isLoading={isLoading}
isError={isError}
error={error}
empty={items.length === 0}
emptyTitle="Нет пиров"
emptyDescription="Добавьте первого BGP-соседа для установки сессии."
skeleton={<TableSkeleton rows={6} cols={4} />}
onRetry={onRetry}
>
{(data) => (
<NetworkPeersGrid items={data} isLoading={isLoading && data.length > 0} />
)}
</QueryState>
</DataGridCard>
<PeerFormDialog
open={dialogOpen}
onOpenChange={setDialogOpen}
speakers={speakers}
/>
</>
)
}
@@ -1,12 +1,12 @@
import { ColumnDef, useReactTable } from '@tanstack/react-table'
import { ColumnDef } from '@tanstack/react-table'
import { useMemo } from 'react'
import { DataGridShell } from '@/components/data-grid-shell'
import { StatusBadge } from '@/components/status-badge'
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 { createClientDataGridOptions } from '@/lib/data-grid-defaults'
import { useClientDataGrid } from '@/hooks/use-client-data-grid'
import type { PeerRow } from '@/types/api'
import { StatusBadge } from '@/components/status-badge'
export function NetworkPeersGrid({
items,
@@ -59,19 +59,23 @@ export function NetworkPeersGrid({
[],
)
const table = useReactTable({
const { table, globalFilter, setGlobalFilter, filteredCount } = useClientDataGrid({
data: items,
columns,
...createClientDataGridOptions<PeerRow>(),
getSearchText: (row) =>
`${row.name ?? ''} ${row.neighbor} ${row.remote_asn ?? ''} ${row.session_state ?? ''}`,
getRowId: (row) => row.id,
})
return (
<DataGridShell
<DataGridSection
table={table}
recordCount={items.length}
recordCount={filteredCount}
isLoading={isLoading}
emptyMessage="Нет пиров"
searchValue={globalFilter}
onSearchChange={setGlobalFilter}
searchPlaceholder="Поиск пиров…"
/>
)
}
@@ -0,0 +1,62 @@
import { useState } from 'react'
import { Plus } from 'lucide-react'
import { Button } from '@evobgp/ui/components/button'
import { DataGridCard } from '@/components/data-grid-shell'
import { NetworkSpeakersGrid } from '@/components/network/network-speakers-grid'
import { SpeakerFormDialog } from '@/components/network/speaker-form-dialog'
import { QueryState } from '@/components/query-state'
import { TableSkeleton } from '@/components/skeletons'
import type { SpeakerRow } from '@/types/api'
interface NetworkSpeakersCardProps {
items: SpeakerRow[]
isLoading: boolean
isError: boolean
error: unknown
onRetry: () => void
}
export function NetworkSpeakersCard({
items,
isLoading,
isError,
error,
onRetry,
}: NetworkSpeakersCardProps) {
const [dialogOpen, setDialogOpen] = useState(false)
return (
<>
<DataGridCard
title="Спикеры"
description="BIRD-агенты на нодах tenant"
actions={
<Button size="sm" type="button" onClick={() => setDialogOpen(true)}>
<Plus />
Добавить спикера
</Button>
}
>
<QueryState
data={items}
isLoading={isLoading}
isError={isError}
error={error}
empty={items.length === 0}
emptyTitle="Нет спикеров"
emptyDescription="Добавьте первого BIRD-агента на ноде."
skeleton={<TableSkeleton rows={6} cols={4} />}
onRetry={onRetry}
>
{(data) => (
<NetworkSpeakersGrid items={data} isLoading={isLoading && data.length > 0} />
)}
</QueryState>
</DataGridCard>
<SpeakerFormDialog open={dialogOpen} onOpenChange={setDialogOpen} />
</>
)
}
@@ -1,11 +1,11 @@
import { ColumnDef, useReactTable } from '@tanstack/react-table'
import { ColumnDef } from '@tanstack/react-table'
import { useMemo } from 'react'
import { DataGridShell } from '@/components/data-grid-shell'
import { DataGridSection } from '@/components/data-grid-shell'
import { StatusBadge } from '@/components/status-badge'
import { Badge } from '@/components/reui/badge'
import { DataGridColumnHeader } from '@/components/reui/data-grid/data-grid-column-header'
import { createClientDataGridOptions } from '@/lib/data-grid-defaults'
import { useClientDataGrid } from '@/hooks/use-client-data-grid'
import type { SpeakerRow } from '@/types/api'
export function NetworkSpeakersGrid({
@@ -60,19 +60,23 @@ export function NetworkSpeakersGrid({
[],
)
const table = useReactTable({
const { table, globalFilter, setGlobalFilter, filteredCount } = useClientDataGrid({
data: items,
columns,
...createClientDataGridOptions<SpeakerRow>(),
getSearchText: (row) =>
`${row.endpoint} ${row.role} ${row.agent_domain ?? ''} ${row.node_ipv4 ?? ''}`,
getRowId: (row) => row.id,
})
return (
<DataGridShell
<DataGridSection
table={table}
recordCount={items.length}
recordCount={filteredCount}
isLoading={isLoading}
emptyMessage="Нет спикеров"
searchValue={globalFilter}
onSearchChange={setGlobalFilter}
searchPlaceholder="Поиск спикеров…"
/>
)
}
@@ -0,0 +1,175 @@
import { useEffect, useState } from 'react'
import { toast } from 'sonner'
import { Button } from '@evobgp/ui/components/button'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@evobgp/ui/components/dialog'
import { Input } from '@evobgp/ui/components/input'
import { Label } from '@evobgp/ui/components/label'
import { Checkbox } from '@evobgp/ui/components/checkbox'
import { LoadingButton } from '@/components/loading-button'
import { SelectField } from '@/components/select-field'
import { useCreatePeerMutation, useUpdatePeerMutation } from '@/queries/network'
import type { BgpPeerCreate, PeerRow, SpeakerRow } from '@/types/api'
interface PeerFormDialogProps {
open: boolean
onOpenChange: (open: boolean) => void
speakers: SpeakerRow[]
editTarget?: PeerRow | null
}
function speakerLabel(s: SpeakerRow): string {
if (s.role === 'master') {
const host = s.agent_domain ?? s.endpoint
return host ? `CP · ${host}` : 'CP (master)'
}
return s.agent_domain ?? s.endpoint ?? `${s.id.slice(0, 8)}`
}
export function PeerFormDialog({
open,
onOpenChange,
speakers,
editTarget = null,
}: PeerFormDialogProps) {
const createMutation = useCreatePeerMutation()
const updateMutation = useUpdatePeerMutation()
const saving = createMutation.isPending || updateMutation.isPending
const [name, setName] = useState('')
const [neighbor, setNeighbor] = useState('')
const [remoteAsn, setRemoteAsn] = useState('')
const [bgpSpeakerId, setBgpSpeakerId] = useState<string | null>(null)
const [enabled, setEnabled] = useState(true)
useEffect(() => {
if (!open) return
if (editTarget) {
setName(editTarget.name ?? '')
setNeighbor(editTarget.neighbor)
setRemoteAsn(String(editTarget.remote_asn ?? ''))
setBgpSpeakerId(editTarget.bgp_speaker_id ?? null)
setEnabled(editTarget.enabled !== false)
} else {
setName('')
setNeighbor('')
setRemoteAsn('')
setBgpSpeakerId(null)
setEnabled(true)
}
}, [editTarget, open])
const speakerItems = [
{ value: '', label: 'Все спикеры' },
...speakers.map((s) => ({ value: s.id, label: speakerLabel(s) })),
]
async function save() {
if (!neighbor.trim()) {
toast.error('Укажите адрес соседа')
return
}
const asn = Number(remoteAsn)
if (!asn || asn <= 0) {
toast.error('Remote ASN должен быть больше 0')
return
}
const body: BgpPeerCreate = {
name: name.trim() || undefined,
neighbor: neighbor.trim(),
remote_asn: asn,
bgp_speaker_id: bgpSpeakerId || null,
enabled,
}
try {
if (editTarget) {
await updateMutation.mutateAsync({ id: editTarget.id, body })
} else {
await createMutation.mutateAsync(body)
}
onOpenChange(false)
} catch {
// toast in mutation
}
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-sm">
<DialogHeader>
<DialogTitle>{editTarget ? 'Редактировать пира' : 'Новый пир'}</DialogTitle>
<DialogDescription>BGP-сосед для установки сессии</DialogDescription>
</DialogHeader>
<div className="flex flex-col gap-4 py-2">
<div className="flex flex-col gap-2">
<Label htmlFor="peer-name">Имя пира (опционально)</Label>
<Input
id="peer-name"
placeholder="Core-RTR-1"
value={name}
onChange={(e) => setName(e.target.value)}
/>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="peer-neighbor">Адрес соседа</Label>
<Input
id="peer-neighbor"
placeholder="192.0.2.1"
value={neighbor}
onChange={(e) => setNeighbor(e.target.value)}
required
/>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="peer-asn">Remote ASN</Label>
<Input
id="peer-asn"
type="number"
placeholder="65000"
value={remoteAsn}
onChange={(e) => setRemoteAsn(e.target.value)}
required
/>
</div>
<SelectField
id="peer-speaker"
label="Спикер (опционально)"
items={speakerItems}
value={bgpSpeakerId ?? ''}
onValueChange={(v) => setBgpSpeakerId(v || null)}
placeholder="Все спикеры"
/>
<div className="flex flex-row items-center justify-between gap-4 rounded-lg border border-border bg-muted/30 p-3">
<div className="grid min-w-0 flex-1 gap-1 pr-2">
<Label htmlFor="peer-enabled">Включён</Label>
<p className="text-xs text-muted-foreground">
Выключенный пир не попадает в конфиг BIRD до следующей ревизии.
</p>
</div>
<Checkbox
id="peer-enabled"
checked={enabled}
onCheckedChange={(v) => setEnabled(v === true)}
/>
</div>
</div>
<DialogFooter>
<Button variant="outline" type="button" onClick={() => onOpenChange(false)}>
Отмена
</Button>
<LoadingButton type="button" loading={saving} onClick={save}>
{editTarget ? 'Сохранить' : 'Создать'}
</LoadingButton>
</DialogFooter>
</DialogContent>
</Dialog>
)
}
@@ -0,0 +1,169 @@
import { useEffect, useState } from 'react'
import { toast } from 'sonner'
import { Button } from '@evobgp/ui/components/button'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@evobgp/ui/components/dialog'
import { Input } from '@evobgp/ui/components/input'
import { Label } from '@evobgp/ui/components/label'
import { LoadingButton } from '@/components/loading-button'
import { SelectField } from '@/components/select-field'
import { useCreateSpeakerMutation } from '@/queries/network'
import type { BgpSpeakerCreate } from '@/types/api'
interface SpeakerFormDialogProps {
open: boolean
onOpenChange: (open: boolean) => void
}
function parseIpv4FromEndpoint(ep: string): string {
try {
const u = ep.includes('://') ? new URL(ep) : new URL(`https://${ep}`)
const host = u.hostname
if (/^\d{1,3}(\.\d{1,3}){3}$/.test(host)) return host
} catch {
/* ignore */
}
return ''
}
function buildMetaJson(agentDomain: string, nodeIpv4: string, bgpSource: string): string {
const meta: Record<string, string> = {}
if (agentDomain.trim()) meta.agent_domain = agentDomain.trim()
if (nodeIpv4.trim()) meta.node_ipv4 = nodeIpv4.trim()
if (bgpSource.trim()) meta.bird_bgp_source_ipv4 = bgpSource.trim()
return JSON.stringify(meta)
}
export function SpeakerFormDialog({ open, onOpenChange }: SpeakerFormDialogProps) {
const createMutation = useCreateSpeakerMutation()
const [endpoint, setEndpoint] = useState('')
const [role, setRole] = useState('replica')
const [agentDomain, setAgentDomain] = useState('')
const [nodeIpv4, setNodeIpv4] = useState('')
const [bgpSourceIpv4, setBgpSourceIpv4] = useState('')
const [bgpSourceManual, setBgpSourceManual] = useState(false)
useEffect(() => {
if (!open) return
setEndpoint('')
setRole('replica')
setAgentDomain('')
setNodeIpv4('')
setBgpSourceIpv4('')
setBgpSourceManual(false)
}, [open])
function handleEndpointChange(value: string) {
setEndpoint(value)
const ip = parseIpv4FromEndpoint(value)
if (ip && !nodeIpv4) {
handleNodeIpv4Change(ip)
}
}
function handleNodeIpv4Change(value: string) {
setNodeIpv4(value)
if (!bgpSourceManual) {
setBgpSourceIpv4(value)
}
}
async function save() {
const ep =
endpoint.trim() || (agentDomain.trim() ? `https://${agentDomain.trim()}` : '')
if (!ep) {
toast.error('Укажите endpoint или agent domain')
return
}
const body: BgpSpeakerCreate = {
endpoint: ep,
role: role.trim() || 'replica',
meta_json: buildMetaJson(agentDomain, nodeIpv4, bgpSourceIpv4),
}
try {
await createMutation.mutateAsync(body)
onOpenChange(false)
} catch {
// toast in mutation
}
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>Новый спикер</DialogTitle>
<DialogDescription>BIRD-агент на ноде реплики или control plane</DialogDescription>
</DialogHeader>
<div className="flex flex-col gap-4 py-2">
<div className="flex flex-col gap-2">
<Label htmlFor="speaker-endpoint">Endpoint</Label>
<Input
id="speaker-endpoint"
placeholder="https://node.example.com:8443"
value={endpoint}
onChange={(e) => handleEndpointChange(e.target.value)}
/>
</div>
<SelectField
id="speaker-role"
label="Роль"
items={[
{ value: 'replica', label: 'replica' },
{ value: 'master', label: 'master (CP)' },
]}
value={role}
onValueChange={(v) => setRole(v ?? 'replica')}
/>
<div className="flex flex-col gap-2">
<Label htmlFor="speaker-agent-domain">Agent domain</Label>
<Input
id="speaker-agent-domain"
placeholder="bird-agent.example.com"
value={agentDomain}
onChange={(e) => setAgentDomain(e.target.value)}
/>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="speaker-node-ipv4">Node IPv4</Label>
<Input
id="speaker-node-ipv4"
placeholder="203.0.113.10"
value={nodeIpv4}
onChange={(e) => handleNodeIpv4Change(e.target.value)}
/>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="speaker-bgp-source">BGP source IPv4</Label>
<Input
id="speaker-bgp-source"
placeholder="203.0.113.10"
value={bgpSourceIpv4}
onChange={(e) => {
setBgpSourceManual(true)
setBgpSourceIpv4(e.target.value)
}}
/>
</div>
</div>
<DialogFooter>
<Button variant="outline" type="button" onClick={() => onOpenChange(false)}>
Отмена
</Button>
<LoadingButton type="button" loading={createMutation.isPending} onClick={save}>
Создать
</LoadingButton>
</DialogFooter>
</DialogContent>
</Dialog>
)
}
@@ -1,14 +1,14 @@
import { ColumnDef, useReactTable } from '@tanstack/react-table'
import { ColumnDef } from '@tanstack/react-table'
import { useMutation } from '@tanstack/react-query'
import { useMemo } from 'react'
import { toast } from 'sonner'
import { Button } from '@evobgp/ui/components/button'
import { DataGridShell } from '@/components/data-grid-shell'
import { DataGridSection } from '@/components/data-grid-shell'
import { DataGridColumnHeader } from '@/components/reui/data-grid/data-grid-column-header'
import { useClientDataGrid } from '@/hooks/use-client-data-grid'
import { apiMutate } from '@/lib/api-client'
import { createClientDataGridOptions } from '@/lib/data-grid-defaults'
import type { JobRow } from '@/types/api'
import type { QueryClient } from '@tanstack/react-query'
@@ -113,19 +113,27 @@ export function OperationsJobsGrid({
[cancelMutation, nameById],
)
const table = useReactTable({
const { table, globalFilter, setGlobalFilter, filteredCount } = useClientDataGrid({
data: items,
columns,
...createClientDataGridOptions<JobRow>(),
getSearchText: (row) => {
const moduleName = row.meta?.module_id
? (nameById.get(String(row.meta.module_id)) ?? String(row.meta.module_id))
: ''
return `${row.kind} ${row.status} ${row.job_id} ${moduleName}`
},
getRowId: (row) => row.job_id,
})
return (
<DataGridShell
<DataGridSection
table={table}
recordCount={items.length}
recordCount={filteredCount}
isLoading={isLoading}
emptyMessage="Нет задач"
searchValue={globalFilter}
onSearchChange={setGlobalFilter}
searchPlaceholder="Поиск задач…"
/>
)
}
@@ -1,4 +1,4 @@
import { ColumnDef, useReactTable } from '@tanstack/react-table'
import { ColumnDef } from '@tanstack/react-table'
import { useMutation } from '@tanstack/react-query'
import { RefreshCw } from 'lucide-react'
import { useMemo } from 'react'
@@ -6,11 +6,11 @@ import { toast } from 'sonner'
import { Button } from '@evobgp/ui/components/button'
import { DataGridShell } from '@/components/data-grid-shell'
import { DataGridSection } from '@/components/data-grid-shell'
import { ConfirmDialog } from '@/components/confirm-dialog'
import { DataGridColumnHeader } from '@/components/reui/data-grid/data-grid-column-header'
import { useClientDataGrid } from '@/hooks/use-client-data-grid'
import { apiMutate } from '@/lib/api-client'
import { createClientDataGridOptions } from '@/lib/data-grid-defaults'
import type { RevisionRow } from '@/types/api'
import type { QueryClient } from '@tanstack/react-query'
@@ -87,19 +87,22 @@ export function OperationsRevisionsGrid({
[rollbackMutation],
)
const table = useReactTable({
const { table, globalFilter, setGlobalFilter, filteredCount } = useClientDataGrid({
data: items,
columns,
...createClientDataGridOptions<RevisionRow>(),
getSearchText: (row) => `${row.id} ${row.materialized_prefix_count}`,
getRowId: (row) => row.id,
})
return (
<DataGridShell
<DataGridSection
table={table}
recordCount={items.length}
recordCount={filteredCount}
isLoading={isLoading}
emptyMessage="Нет ревизий"
searchValue={globalFilter}
onSearchChange={setGlobalFilter}
searchPlaceholder="Поиск ревизий…"
/>
)
}
+13 -9
View File
@@ -5,7 +5,11 @@ import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@evobgp/ui/lib/utils"
const badgeVariants = cva(
"relative inline-flex shrink-0 items-center justify-center w-fit border border-transparent font-medium whitespace-nowrap outline-none transition-shadow focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1 focus-visible:ring-offset-background disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*=size-])]:size-3",
[
"relative inline-flex shrink-0 items-center justify-center w-fit border border-transparent font-medium whitespace-nowrap outline-none transition-shadow",
"focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1 focus-visible:ring-offset-background disabled:pointer-events-none disabled:opacity-50",
"[&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*=size-])]:size-3",
],
{
variants: {
variant: {
@@ -19,19 +23,19 @@ const badgeVariants = cva(
focus: "bg-focus text-focus-foreground",
invert: "bg-invert text-invert-foreground",
"primary-light":
"bg-primary/10 border-none text-primary dark:bg-primary/20",
"border-primary/10 bg-primary/10 text-primary dark:border-primary/25 dark:bg-primary/15 dark:text-primary",
"warning-light":
"bg-warning/10 border-none text-warning-foreground dark:bg-warning/20",
"border-warning/15 bg-warning/10 text-warning-foreground dark:border-warning/25 dark:bg-warning/15 dark:text-warning",
"success-light":
"bg-success/10 border-none text-success-foreground dark:bg-success/20",
"border-success/15 bg-success/10 text-success-foreground dark:border-success/25 dark:bg-success/15 dark:text-success",
"info-light":
"bg-info/10 border-none text-info-foreground dark:bg-info/20",
"border-info/15 bg-info/10 text-info-foreground dark:border-info/25 dark:bg-info/15 dark:text-info",
"destructive-light":
"bg-destructive/10 border-none text-destructive-foreground dark:bg-destructive/20",
"border-destructive/15 bg-destructive/10 text-destructive-foreground dark:border-destructive/25 dark:bg-destructive/15 dark:text-destructive",
"invert-light":
"bg-invert/10 border-none text-foreground dark:bg-invert/20",
"border-invert/15 bg-invert/10 text-foreground dark:border-invert/45 dark:bg-invert/35 dark:text-invert-foreground",
"focus-light":
"bg-focus/10 border-none text-focus-foreground dark:bg-focus/20",
"border-focus/15 bg-focus/10 text-focus-foreground dark:border-focus/25 dark:bg-focus/15 dark:text-focus",
"primary-outline":
"bg-background border-border text-primary dark:bg-input/30",
"warning-outline":
@@ -54,7 +58,7 @@ const badgeVariants = cva(
lg: "px-1.5 py-0.5 text-xs h-5.5 min-w-5.5 gap-1",
xl: "px-2 py-0.75 text-sm h-6 min-w-6 gap-1.5",
},
/** `default`: per-theme radius. `full`: max radius per theme (Lyra stays `rounded-none`). */
/** `default`: active style radius. `full`: pill radius. */
radius: {
default:
"rounded-sm",
@@ -1,11 +1,11 @@
import { ColumnDef, useReactTable } from '@tanstack/react-table'
import { ColumnDef } from '@tanstack/react-table'
import { useMemo } from 'react'
import { Badge } from '@evobgp/ui/components/badge'
import { DataGridShell } from '@/components/data-grid-shell'
import { DataGridSection } from '@/components/data-grid-shell'
import { DataGridColumnHeader } from '@/components/reui/data-grid/data-grid-column-header'
import { createClientDataGridOptions } from '@/lib/data-grid-defaults'
import { useClientDataGrid } from '@/hooks/use-client-data-grid'
import type { JobRow } from '@/types/api'
export function ScheduleJobsGrid({
@@ -82,19 +82,22 @@ export function ScheduleJobsGrid({
[],
)
const table = useReactTable({
const { table, globalFilter, setGlobalFilter, filteredCount } = useClientDataGrid({
data: items,
columns,
...createClientDataGridOptions<JobRow>(),
getSearchText: (row) => `${row.kind} ${row.status} ${row.error ?? ''}`,
getRowId: (row) => row.job_id,
})
return (
<DataGridShell
<DataGridSection
table={table}
recordCount={items.length}
recordCount={filteredCount}
isLoading={isLoading}
emptyMessage="Нет задач"
searchValue={globalFilter}
onSearchChange={setGlobalFilter}
searchPlaceholder="Поиск задач…"
/>
)
}
@@ -1,13 +1,13 @@
import { ColumnDef, useReactTable } from '@tanstack/react-table'
import { ColumnDef } from '@tanstack/react-table'
import { RefreshCw } from 'lucide-react'
import { useMemo } from 'react'
import { Badge } from '@evobgp/ui/components/badge'
import { DataGridShell } from '@/components/data-grid-shell'
import { DataGridSection } from '@/components/data-grid-shell'
import { LoadingButton } from '@/components/loading-button'
import { DataGridColumnHeader } from '@/components/reui/data-grid/data-grid-column-header'
import { createClientDataGridOptions } from '@/lib/data-grid-defaults'
import { useClientDataGrid } from '@/hooks/use-client-data-grid'
import type { ModuleRow } from '@/types/api'
export function ScheduleModulesGrid({
@@ -95,19 +95,22 @@ export function ScheduleModulesGrid({
[onRefresh, refreshing],
)
const table = useReactTable({
const { table, globalFilter, setGlobalFilter, filteredCount } = useClientDataGrid({
data: items,
columns,
...createClientDataGridOptions<ModuleRow>(),
getSearchText: (row) => `${row.name} ${row.type} ${row.enabled ? 'вкл' : 'выкл'}`,
getRowId: (row) => row.id,
})
return (
<DataGridShell
<DataGridSection
table={table}
recordCount={items.length}
recordCount={filteredCount}
isLoading={isLoading}
emptyMessage="Нет модулей"
searchValue={globalFilter}
onSearchChange={setGlobalFilter}
searchPlaceholder="Поиск модулей…"
/>
)
}
@@ -1,9 +1,9 @@
import { ColumnDef, useReactTable } from '@tanstack/react-table'
import { ColumnDef } from '@tanstack/react-table'
import { useMemo } from 'react'
import { DataGridShell } from '@/components/data-grid-shell'
import { DataGridSection } from '@/components/data-grid-shell'
import { DataGridColumnHeader } from '@/components/reui/data-grid/data-grid-column-header'
import { createClientDataGridOptions } from '@/lib/data-grid-defaults'
import { useClientDataGrid } from '@/hooks/use-client-data-grid'
export interface SettingsKvRow {
id: string | number
@@ -36,22 +36,24 @@ export function SettingsKvGrid({
[],
)
const table = useReactTable({
const { table, globalFilter, setGlobalFilter, filteredCount } = useClientDataGrid({
data: items,
columns,
...createClientDataGridOptions<SettingsKvRow>({
initialState: { pagination: { pageSize: 25 } },
}),
getSearchText: (row) => `${row.key} ${row.value}`,
getRowId: (row) => String(row.id),
pageSize: 25,
})
return (
<DataGridShell
<DataGridSection
table={table}
recordCount={items.length}
recordCount={filteredCount}
isLoading={isLoading}
emptyMessage="Нет дополнительных настроек"
showPagination={items.length > 10}
searchValue={globalFilter}
onSearchChange={setGlobalFilter}
searchPlaceholder="Поиск настроек…"
/>
)
}
@@ -0,0 +1,61 @@
import { useMemo, useState } from 'react'
import {
type ColumnDef,
type FilterFn,
type TableOptions,
useReactTable,
} from '@tanstack/react-table'
import {
createClientDataGridOptions,
createTextGlobalFilter,
} from '@/lib/data-grid-defaults'
interface UseClientDataGridOptions<TData extends object> {
data: TData[]
columns: ColumnDef<TData>[]
getSearchText: (row: TData) => string
getRowId: (row: TData) => string
pageSize?: number
tableOptions?: Partial<TableOptions<TData>>
globalFilterFn?: FilterFn<TData>
}
export function useClientDataGrid<TData extends object>({
data,
columns,
getSearchText,
getRowId,
pageSize = 10,
tableOptions,
globalFilterFn,
}: UseClientDataGridOptions<TData>) {
const [globalFilter, setGlobalFilter] = useState('')
const filterFn = useMemo(
() => globalFilterFn ?? createTextGlobalFilter(getSearchText),
[getSearchText, globalFilterFn],
)
const table = useReactTable({
data,
columns,
state: { globalFilter },
onGlobalFilterChange: setGlobalFilter,
globalFilterFn: filterFn,
...createClientDataGridOptions<TData>({
initialState: { pagination: { pageSize } },
...tableOptions,
}),
getRowId,
})
const filteredCount = table.getFilteredRowModel().rows.length
return {
table,
globalFilter,
setGlobalFilter,
filteredCount,
}
}
+20 -1
View File
@@ -1,7 +1,9 @@
import {
getCoreRowModel,
getFilteredRowModel,
getPaginationRowModel,
getSortedRowModel,
type FilterFn,
type TableOptions,
} from '@tanstack/react-table'
@@ -28,14 +30,31 @@ export const DATA_GRID_DENSE_LAYOUT: NonNullable<DataGridProps<object>['tableLay
dense: true,
}
export function createTextGlobalFilter<TData extends object>(
getSearchText: (row: TData) => string,
): FilterFn<TData> {
return (row, _columnId, filterValue) => {
const query = String(filterValue ?? '')
.toLowerCase()
.trim()
if (!query) return true
return getSearchText(row.original).toLowerCase().includes(query)
}
}
export function createClientDataGridOptions<TData extends object>(
overrides?: Partial<TableOptions<TData>>,
): Pick<
TableOptions<TData>,
'getCoreRowModel' | 'getSortedRowModel' | 'getPaginationRowModel' | 'initialState'
| 'getCoreRowModel'
| 'getFilteredRowModel'
| 'getSortedRowModel'
| 'getPaginationRowModel'
| 'initialState'
> {
return {
getCoreRowModel: getCoreRowModel(),
getFilteredRowModel: getFilteredRowModel(),
getSortedRowModel: getSortedRowModel(),
getPaginationRowModel: getPaginationRowModel(),
initialState: { pagination: { pageSize: 10 } },
+98 -3
View File
@@ -1,6 +1,18 @@
import { queryOptions } from '@tanstack/react-query'
import { apiJSON } from '@/lib/api-client'
import type { BirdStatus, PeersResponse, SpeakersResponse } from '@/types/api'
import { queryOptions, useMutation, useQueryClient } from '@tanstack/react-query'
import { toast } from 'sonner'
import { apiJSON, apiMutate } from '@/lib/api-client'
import type {
BgpPeerCreate,
BgpPeerPatch,
BgpSpeakerCreate,
BgpSpeakerPatch,
BirdStatus,
PeerRow,
PeersResponse,
SpeakerRow,
SpeakersResponse,
} from '@/types/api'
export const NETWORK_AUTO_REFRESH_MS = 30_000
@@ -34,3 +46,86 @@ export function networkBirdQueryOptions() {
staleTime: 15_000,
})
}
function invalidateNetwork(qc: ReturnType<typeof useQueryClient>) {
void qc.invalidateQueries({ queryKey: networkKeys.all })
void qc.invalidateQueries({ queryKey: ['overview'] })
}
export function useCreatePeerMutation() {
const qc = useQueryClient()
return useMutation({
mutationFn: (body: BgpPeerCreate) =>
apiMutate<PeerRow>('/v1/peers', 'POST', body, { idempotent: false }),
onSuccess: () => {
toast.success('Пир создан')
invalidateNetwork(qc)
},
onError: (e) => toast.error(e instanceof Error ? e.message : 'Не удалось создать пира'),
})
}
export function useUpdatePeerMutation() {
const qc = useQueryClient()
return useMutation({
mutationFn: ({ id, body }: { id: string; body: BgpPeerPatch }) =>
apiMutate<PeerRow>(`/v1/peers/${id}`, 'PATCH', body, { idempotent: false }),
onSuccess: () => {
toast.success('Пир обновлён')
invalidateNetwork(qc)
},
onError: (e) => toast.error(e instanceof Error ? e.message : 'Не удалось обновить пира'),
})
}
export function useDeletePeerMutation() {
const qc = useQueryClient()
return useMutation({
mutationFn: (id: string) =>
apiMutate(`/v1/peers/${id}`, 'DELETE', undefined, { idempotent: false }),
onSuccess: () => {
toast.success('Пир удалён')
invalidateNetwork(qc)
},
onError: (e) => toast.error(e instanceof Error ? e.message : 'Не удалось удалить пира'),
})
}
export function useCreateSpeakerMutation() {
const qc = useQueryClient()
return useMutation({
mutationFn: (body: BgpSpeakerCreate) =>
apiMutate<SpeakerRow>('/v1/speakers', 'POST', body, { idempotent: false }),
onSuccess: () => {
toast.success('Спикер создан')
invalidateNetwork(qc)
},
onError: (e) => toast.error(e instanceof Error ? e.message : 'Не удалось создать спикера'),
})
}
export function useUpdateSpeakerMutation() {
const qc = useQueryClient()
return useMutation({
mutationFn: ({ id, body }: { id: string; body: BgpSpeakerPatch }) =>
apiMutate<SpeakerRow>(`/v1/speakers/${id}`, 'PATCH', body, { idempotent: false }),
onSuccess: () => {
toast.success('Спикер обновлён')
invalidateNetwork(qc)
},
onError: (e) => toast.error(e instanceof Error ? e.message : 'Не удалось обновить спикера'),
})
}
export function useDeleteSpeakerMutation() {
const qc = useQueryClient()
return useMutation({
mutationFn: (id: string) =>
apiMutate(`/v1/speakers/${id}`, 'DELETE', undefined, { idempotent: false }),
onSuccess: () => {
toast.success('Спикер удалён')
invalidateNetwork(qc)
},
onError: (e) => toast.error(e instanceof Error ? e.message : 'Не удалось удалить спикера'),
})
}
+11 -11
View File
@@ -4,8 +4,7 @@ import { BookText, Globe, Info, RefreshCw, Tags } from 'lucide-react'
import { Alert, AlertDescription, AlertTitle } from '@evobgp/ui/components/alert'
import { Button } from '@evobgp/ui/components/button'
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@evobgp/ui/components/tabs'
import { BadgeTabs, TabsContent } from '@/components/badge-tabs'
import { DataGridCard } from '@/components/data-grid-shell'
import { DirectoriesCommunitiesGrid } from '@/components/directories/directories-communities-grid'
import { DirectoriesDohGrid } from '@/components/directories/directories-doh-grid'
@@ -80,13 +79,14 @@ function DirectoriesComponent() {
{loading ? <SectionCardsSkeleton count={3} /> : <SectionCards items={items} />}
<Tabs defaultValue="communities">
<TabsList>
<TabsTrigger value="communities">Сообщества BGP</TabsTrigger>
<TabsTrigger value="doh">DoH профили</TabsTrigger>
</TabsList>
<TabsContent value="communities" className="mt-4">
<BadgeTabs
defaultValue="communities"
items={[
{ value: 'communities', label: 'Сообщества BGP', count: communities.length },
{ value: 'doh', label: 'DoH профили', count: dohProfiles.length, badgeVariant: 'info-light' },
]}
>
<TabsContent value="communities" className="mt-0">
<DataGridCard
title="Сообщества BGP"
description="Теги для префиксов в фильтрах BIRD"
@@ -111,7 +111,7 @@ function DirectoriesComponent() {
</DataGridCard>
</TabsContent>
<TabsContent value="doh" className="mt-4">
<TabsContent value="doh" className="mt-0">
<DataGridCard title="DoH профили" description="Резолверы DNS-over-HTTPS для доменных модулей">
<QueryState
data={dohProfiles}
@@ -132,7 +132,7 @@ function DirectoriesComponent() {
</QueryState>
</DataGridCard>
</TabsContent>
</Tabs>
</BadgeTabs>
</div>
)
}
+18 -13
View File
@@ -9,8 +9,7 @@ import { Button } from '@evobgp/ui/components/button'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@evobgp/ui/components/card'
import { Input } from '@evobgp/ui/components/input'
import { Label } from '@evobgp/ui/components/label'
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@evobgp/ui/components/tabs'
import { BadgeTabs, TabsContent } from '@/components/badge-tabs'
import { FirewallClientsGrid } from '@/components/firewall/firewall-clients-grid'
import { FirewallRulesGrid } from '@/components/firewall/firewall-rules-grid'
import { PageHeader } from '@/components/page-header'
@@ -185,14 +184,20 @@ function FirewallPage() {
</CardContent>
</Card>
<Tabs defaultValue="clients">
<TabsList>
<TabsTrigger value="clients">Клиенты ({activeClients.length})</TabsTrigger>
<TabsTrigger value="rules">Правила ({rules.length})</TabsTrigger>
<TabsTrigger value="requests">Запросы ({pending.length})</TabsTrigger>
</TabsList>
<TabsContent value="clients" className="mt-4">
<BadgeTabs
defaultValue="clients"
items={[
{ value: 'clients', label: 'Клиенты', count: activeClients.length },
{ value: 'rules', label: 'Правила', count: rules.length, badgeVariant: 'info-light' },
{
value: 'requests',
label: 'Запросы',
count: pending.length,
badgeVariant: pending.length > 0 ? 'warning-light' : 'primary-light',
},
]}
>
<TabsContent value="clients" className="mt-0">
<QueryState
data={clientsQ.data}
isLoading={clientsQ.isLoading}
@@ -214,7 +219,7 @@ function FirewallPage() {
</QueryState>
</TabsContent>
<TabsContent value="rules" className="mt-4 space-y-4">
<TabsContent value="rules" className="mt-0 space-y-4">
<div className="flex flex-wrap items-end gap-3">
<div className="space-y-1">
<Label>Действие</Label>
@@ -281,7 +286,7 @@ function FirewallPage() {
</QueryState>
</TabsContent>
<TabsContent value="requests" className="mt-4">
<TabsContent value="requests" className="mt-0">
<QueryState
data={clientsQ.data}
isLoading={clientsQ.isLoading}
@@ -303,7 +308,7 @@ function FirewallPage() {
)}
</QueryState>
</TabsContent>
</Tabs>
</BadgeTabs>
</div>
)
}
+17 -13
View File
@@ -7,8 +7,7 @@ import { Badge } from '@evobgp/ui/components/badge'
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 { Tabs, TabsContent, TabsList, TabsTrigger } from '@evobgp/ui/components/tabs'
import { BadgeTabs, TabsContent } from '@/components/badge-tabs'
import {
DashboardOperationsFlowCard,
MonitoringHealthCard,
@@ -41,6 +40,7 @@ export const Route = createFileRoute('/_auth/monitoring')({
function MonitoringComponent() {
const search = useSearch({ from: '/_auth/monitoring' })
const navigate = Route.useNavigate()
const healthQ = useQuery(monitoringHealthQueryOptions())
const readyQ = useQuery(monitoringReadyQueryOptions())
const versionQ = useQuery(monitoringVersionQueryOptions())
@@ -92,14 +92,18 @@ function MonitoringComponent() {
}
/>
<Tabs defaultValue={search.tab}>
<TabsList>
<TabsTrigger value="system">Система</TabsTrigger>
<TabsTrigger value="postgres">PostgreSQL</TabsTrigger>
<TabsTrigger value="runtime-logs">Файловые логи</TabsTrigger>
</TabsList>
<TabsContent value="system" className="mt-4 flex flex-col gap-6">
<BadgeTabs
value={search.tab}
onValueChange={(tab) =>
navigate({ search: { tab: tab as 'system' | 'postgres' | 'runtime-logs' } })
}
items={[
{ value: 'system', label: 'Система' },
{ value: 'postgres', label: 'PostgreSQL' },
{ value: 'runtime-logs', label: 'Файловые логи' },
]}
>
<TabsContent value="system" className="mt-0 flex flex-col gap-6">
{analyticsLoading ? (
<AnalyticsDashboardSkeleton />
) : (
@@ -259,7 +263,7 @@ function MonitoringComponent() {
</div>
</TabsContent>
<TabsContent value="postgres" className="mt-4">
<TabsContent value="postgres" className="mt-0">
<Card>
<CardHeader>
<CardTitle className="text-base">PostgreSQL</CardTitle>
@@ -278,7 +282,7 @@ function MonitoringComponent() {
</Card>
</TabsContent>
<TabsContent value="runtime-logs" className="mt-4">
<TabsContent value="runtime-logs" className="mt-0">
<Card>
<CardHeader>
<CardTitle className="text-base">Файловые логи</CardTitle>
@@ -296,7 +300,7 @@ function MonitoringComponent() {
</CardContent>
</Card>
</TabsContent>
</Tabs>
</BadgeTabs>
</div>
)
}
+40 -55
View File
@@ -4,15 +4,14 @@ 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 { Tabs, TabsContent, TabsList, TabsTrigger } from '@evobgp/ui/components/tabs'
import {
DashboardNetworkCapacityCard,
NetworkOverviewAnalyticsCard,
} from '@/components/analytics'
import { DataGridCard } from '@/components/data-grid-shell'
import { NetworkPeersGrid } from '@/components/network/network-peers-grid'
import { NetworkSpeakersGrid } from '@/components/network/network-speakers-grid'
import { BadgeTabs, TabsContent } from '@/components/badge-tabs'
import { NetworkPeersCard } from '@/components/network/network-peers-card'
import { NetworkSpeakersCard } from '@/components/network/network-speakers-card'
import { PageHeader } from '@/components/page-header'
import { QueryState } from '@/components/query-state'
import { TableSkeleton } from '@/components/skeletons'
@@ -30,6 +29,7 @@ export const Route = createFileRoute('/_auth/network')({
function NetworkComponent() {
const search = useSearch({ from: '/_auth/network' })
const navigate = Route.useNavigate()
const peersQ = useQuery({ ...networkPeersQueryOptions(), refetchInterval: 30_000 })
const speakersQ = useQuery({ ...networkSpeakersQueryOptions(), refetchInterval: 30_000 })
const birdQ = useQuery({ ...networkBirdQueryOptions(), refetchInterval: 30_000 })
@@ -68,15 +68,23 @@ function NetworkComponent() {
</AlertDescription>
</Alert>
<Tabs defaultValue={search.tab}>
<TabsList>
<TabsTrigger value="overview">Обзор</TabsTrigger>
<TabsTrigger value="peers">Пиры ({peers.length})</TabsTrigger>
<TabsTrigger value="speakers">Спикеры ({speakers.length})</TabsTrigger>
<TabsTrigger value="control-plane">Control plane</TabsTrigger>
</TabsList>
<TabsContent value="overview" className="mt-4">
<BadgeTabs
value={search.tab}
onValueChange={(tab) =>
navigate({
search: {
tab: tab as 'overview' | 'peers' | 'speakers' | 'control-plane',
},
})
}
items={[
{ value: 'overview', label: 'Обзор' },
{ value: 'peers', label: 'Пиры', count: peers.length },
{ value: 'speakers', label: 'Спикеры', count: speakers.length, badgeVariant: 'info-light' },
{ value: 'control-plane', label: 'Control plane' },
]}
>
<TabsContent value="overview" className="mt-0">
<div className="grid gap-4 lg:grid-cols-2">
<NetworkOverviewAnalyticsCard
peers={peers}
@@ -110,51 +118,28 @@ function NetworkComponent() {
</Card>
</TabsContent>
<TabsContent value="peers" className="mt-4">
<DataGridCard title="Пиры">
<QueryState
data={peers}
isLoading={peersQ.isLoading}
isError={peersQ.isError}
error={peersQ.error}
empty={peers.length === 0}
emptyTitle="Нет пиров"
skeleton={<TableSkeleton rows={6} cols={4} />}
onRetry={() => peersQ.refetch()}
>
{(items) => (
<NetworkPeersGrid
items={items}
isLoading={peersQ.isFetching && !peersQ.isLoading}
/>
)}
</QueryState>
</DataGridCard>
<TabsContent value="peers" className="mt-0">
<NetworkPeersCard
items={peers}
speakers={speakers}
isLoading={peersQ.isLoading}
isError={peersQ.isError}
error={peersQ.error}
onRetry={() => peersQ.refetch()}
/>
</TabsContent>
<TabsContent value="speakers" className="mt-4">
<DataGridCard title="Спикеры">
<QueryState
data={speakers}
isLoading={speakersQ.isLoading}
isError={speakersQ.isError}
error={speakersQ.error}
empty={speakers.length === 0}
emptyTitle="Нет спикеров"
skeleton={<TableSkeleton rows={6} cols={4} />}
onRetry={() => speakersQ.refetch()}
>
{(items) => (
<NetworkSpeakersGrid
items={items}
isLoading={speakersQ.isFetching && !speakersQ.isLoading}
/>
)}
</QueryState>
</DataGridCard>
<TabsContent value="speakers" className="mt-0">
<NetworkSpeakersCard
items={speakers}
isLoading={speakersQ.isLoading}
isError={speakersQ.isError}
error={speakersQ.error}
onRetry={() => speakersQ.refetch()}
/>
</TabsContent>
<TabsContent value="control-plane" className="mt-4">
<TabsContent value="control-plane" className="mt-0">
<Card>
<CardHeader className="border-b py-3">
<CardTitle className="text-base">Настройки Control Plane (BIRD)</CardTitle>
@@ -165,7 +150,7 @@ function NetworkComponent() {
</CardContent>
</Card>
</TabsContent>
</Tabs>
</BadgeTabs>
</div>
)
}
+18 -14
View File
@@ -7,10 +7,9 @@ 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 { Tabs, TabsContent, TabsList, TabsTrigger } from '@evobgp/ui/components/tabs'
import { OperationsAnalyticsCard } from '@/components/analytics'
import { BadgeTabs, TabsContent } from '@/components/badge-tabs'
import { DataGridCard } from '@/components/data-grid-shell'
import { OperationsAnalyticsCard } from '@/components/analytics'
import { SelectMenu } from '@/components/select-field'
import { OperationsJobsGrid } from '@/components/operations/operations-jobs-grid'
import { OperationsRevisionsGrid } from '@/components/operations/operations-revisions-grid'
@@ -34,6 +33,7 @@ export const Route = createFileRoute('/_auth/operations')({
function OperationsComponent() {
const search = useSearch({ from: '/_auth/operations' })
const navigate = Route.useNavigate()
const qc = useQueryClient()
const revisionsQ = useQuery(operationsRevisionsQueryOptions())
@@ -134,14 +134,18 @@ function OperationsComponent() {
<OperationsAnalyticsCard jobs={jobs} revisions={revisions} />
)}
<Tabs defaultValue={search.tab}>
<TabsList>
<TabsTrigger value="revisions">Ревизии ({revisions.length})</TabsTrigger>
<TabsTrigger value="diff">Сравнение</TabsTrigger>
<TabsTrigger value="jobs">Задачи ({jobs.length})</TabsTrigger>
</TabsList>
<TabsContent value="revisions" className="mt-4">
<BadgeTabs
value={search.tab}
onValueChange={(tab) =>
navigate({ search: { tab: tab as 'revisions' | 'diff' | 'jobs' } })
}
items={[
{ value: 'revisions', label: 'Ревизии', count: revisions.length },
{ value: 'diff', label: 'Сравнение' },
{ value: 'jobs', label: 'Задачи', count: jobs.length, badgeVariant: 'info-light' },
]}
>
<TabsContent value="revisions" className="mt-0">
<DataGridCard title="История ревизий">
<QueryState
data={revisions}
@@ -163,11 +167,11 @@ function OperationsComponent() {
</DataGridCard>
</TabsContent>
<TabsContent value="diff" className="mt-4">
<TabsContent value="diff" className="mt-0">
<DiffTab revisions={revisions} />
</TabsContent>
<TabsContent value="jobs" className="mt-4">
<TabsContent value="jobs" className="mt-0">
<DataGridCard title="Задачи">
<QueryState
data={jobs}
@@ -189,7 +193,7 @@ function OperationsComponent() {
</QueryState>
</DataGridCard>
</TabsContent>
</Tabs>
</BadgeTabs>
</div>
)
}
+16 -9
View File
@@ -6,8 +6,7 @@ import { useState } from 'react'
import { Alert, AlertDescription, AlertTitle } from '@evobgp/ui/components/alert'
import { Button } from '@evobgp/ui/components/button'
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@evobgp/ui/components/tabs'
import { BadgeTabs, TabsContent } from '@/components/badge-tabs'
import { DataGridCard } from '@/components/data-grid-shell'
import { ScheduleJobsGrid } from '@/components/schedule/schedule-jobs-grid'
import { ScheduleModulesGrid } from '@/components/schedule/schedule-modules-grid'
@@ -138,12 +137,20 @@ function JobsTabs({ jobs, loading }: { jobs: JobRow[]; loading: boolean }) {
)
return (
<Tabs defaultValue="all">
<TabsList className="m-3 mb-0">
<TabsTrigger value="all">Все ({jobs.length})</TabsTrigger>
<TabsTrigger value="refresh">Обновление ({refresh.length})</TabsTrigger>
<TabsTrigger value="failed">С ошибкой ({failed.length})</TabsTrigger>
</TabsList>
<BadgeTabs
defaultValue="all"
listClassName="mx-3 mb-0 w-auto"
items={[
{ value: 'all', label: 'Все', count: jobs.length },
{ value: 'refresh', label: 'Обновление', count: refresh.length, badgeVariant: 'info-light' },
{
value: 'failed',
label: 'С ошибкой',
count: failed.length,
badgeVariant: failed.length > 0 ? 'destructive-light' : 'primary-light',
},
]}
>
<TabsContent value="all" className="mt-0">
<ScheduleJobsGrid items={jobs} isLoading={loading} />
</TabsContent>
@@ -153,6 +160,6 @@ function JobsTabs({ jobs, loading }: { jobs: JobRow[]; loading: boolean }) {
<TabsContent value="failed" className="mt-0">
<ScheduleJobsGrid items={failed} isLoading={loading} />
</TabsContent>
</Tabs>
</BadgeTabs>
)
}
+21 -17
View File
@@ -8,11 +8,8 @@ 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 { Tabs, TabsContent, TabsList, TabsTrigger } from '@evobgp/ui/components/tabs'
import { BadgeTabs, TabsContent } from '@/components/badge-tabs'
import { SelectField } from '@/components/select-field'
import { SettingsKvGrid } from '@/components/settings/settings-kv-grid'
import { PageHeader } from '@/components/page-header'
import { QueryState } from '@/components/query-state'
@@ -60,6 +57,7 @@ const BIRD_LABELS: Record<BirdSettingKey, string> = {
function TenantSettingsComponent() {
const search = useSearch({ from: '/_auth/tenant-settings' })
const navigate = Route.useNavigate()
const settingsQ = useQuery(settingsQueryOptions())
const qc = useQueryClient()
@@ -114,15 +112,21 @@ function TenantSettingsComponent() {
</AlertDescription>
</Alert>
<Tabs defaultValue={search.tab}>
<TabsList>
<TabsTrigger value="bird">BIRD</TabsTrigger>
<TabsTrigger value="revision">Ревизии</TabsTrigger>
<TabsTrigger value="runtime-logs">Файловые логи</TabsTrigger>
<TabsTrigger value="additional">Дополнительно</TabsTrigger>
</TabsList>
<TabsContent value="bird" className="mt-4">
<BadgeTabs
value={search.tab}
onValueChange={(tab) =>
navigate({
search: { tab: tab as 'bird' | 'revision' | 'runtime-logs' | 'additional' },
})
}
items={[
{ value: 'bird', label: 'BIRD' },
{ value: 'revision', label: 'Ревизии' },
{ value: 'runtime-logs', label: 'Файловые логи' },
{ value: 'additional', label: 'Дополнительно' },
]}
>
<TabsContent value="bird" className="mt-0">
<Card>
<CardHeader>
<CardTitle>BIRD control plane</CardTitle>
@@ -175,7 +179,7 @@ function TenantSettingsComponent() {
</Card>
</TabsContent>
<TabsContent value="revision" className="mt-4">
<TabsContent value="revision" className="mt-0">
<Card>
<CardHeader>
<CardTitle>Ревизии</CardTitle>
@@ -222,7 +226,7 @@ function TenantSettingsComponent() {
</Card>
</TabsContent>
<TabsContent value="runtime-logs" className="mt-4">
<TabsContent value="runtime-logs" className="mt-0">
<Card>
<CardHeader>
<CardTitle>Файловые логи</CardTitle>
@@ -313,7 +317,7 @@ function TenantSettingsComponent() {
</Card>
</TabsContent>
<TabsContent value="additional" className="mt-4">
<TabsContent value="additional" className="mt-0">
<Card>
<CardHeader>
<CardTitle>Дополнительные параметры</CardTitle>
@@ -342,7 +346,7 @@ function TenantSettingsComponent() {
</CardContent>
</Card>
</TabsContent>
</Tabs>
</BadgeTabs>
</div>
)
}
File diff suppressed because one or more lines are too long
+2
View File
@@ -1,3 +1,5 @@
"use client"
import { Checkbox as CheckboxPrimitive } from "@base-ui/react/checkbox"
import { cn } from "@evobgp/ui/lib/utils"
+4 -7
View File
@@ -1,5 +1,3 @@
"use client"
import { Tabs as TabsPrimitive } from "@base-ui/react/tabs"
import { cva, type VariantProps } from "class-variance-authority"
@@ -13,10 +11,9 @@ function Tabs({
return (
<TabsPrimitive.Root
data-slot="tabs"
orientation={orientation}
data-orientation={orientation}
className={cn(
"group/tabs flex gap-2 data-[orientation=horizontal]:flex-col",
"group/tabs flex gap-2 data-horizontal:flex-col",
className
)}
{...props}
@@ -25,7 +22,7 @@ function Tabs({
}
const tabsListVariants = cva(
"group/tabs-list inline-flex w-fit items-center justify-center rounded-lg p-[3px] text-muted-foreground group-data-[orientation=horizontal]/tabs:h-8 group-data-[orientation=vertical]/tabs:h-fit group-data-[orientation=vertical]/tabs:flex-col data-[variant=line]:rounded-none",
"group/tabs-list inline-flex w-fit items-center justify-center rounded-lg p-[3px] text-muted-foreground group-data-horizontal/tabs:h-8 group-data-vertical/tabs:h-fit group-data-vertical/tabs:flex-col data-[variant=line]:rounded-none",
{
variants: {
variant: {
@@ -59,10 +56,10 @@ function TabsTrigger({ className, ...props }: TabsPrimitive.Tab.Props) {
<TabsPrimitive.Tab
data-slot="tabs-trigger"
className={cn(
"relative inline-flex h-[calc(100%-1px)] flex-1 items-center justify-center gap-1.5 rounded-md border border-transparent px-1.5 py-0.5 text-sm font-medium whitespace-nowrap text-foreground/60 transition-all group-data-[orientation=vertical]/tabs:w-full group-data-[orientation=vertical]/tabs:justify-start hover:text-foreground focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 focus-visible:outline-1 focus-visible:outline-ring disabled:pointer-events-none disabled:opacity-50 has-data-[icon=inline-end]:pr-1 has-data-[icon=inline-start]:pl-1 aria-disabled:pointer-events-none aria-disabled:opacity-50 dark:text-muted-foreground dark:hover:text-foreground group-data-[variant=default]/tabs-list:data-active:shadow-sm group-data-[variant=line]/tabs-list:data-active:shadow-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
"relative inline-flex h-[calc(100%-1px)] flex-1 items-center justify-center gap-1.5 rounded-md border border-transparent px-1.5 py-0.5 text-sm font-medium whitespace-nowrap text-foreground/60 transition-all group-data-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",
"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",
"after:absolute after:bg-foreground after:opacity-0 after:transition-opacity group-data-[orientation=horizontal]/tabs:after:inset-x-0 group-data-[orientation=horizontal]/tabs:after:bottom-[-5px] group-data-[orientation=horizontal]/tabs:after:h-0.5 group-data-[orientation=vertical]/tabs:after:inset-y-0 group-data-[orientation=vertical]/tabs:after:-right-1 group-data-[orientation=vertical]/tabs:after:w-0.5 group-data-[variant=line]/tabs-list:data-active:after:opacity-100",
"after:absolute after:bg-foreground after:opacity-0 after:transition-opacity group-data-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",
className
)}
{...props}