From b321aa53218148c1e31986981d5cf698f732e8be Mon Sep 17 00:00:00 2001 From: Denozordec Date: Thu, 9 Jul 2026 02:31:50 +0700 Subject: [PATCH] feat: refactor components to utilize DataGridCard for improved UI consistency Updated various components including AccessApiKeysCard, FirewallClientsGrid, FirewallRulesGrid, ModuleEntriesSection, and DirectoriesComponent to replace traditional card and table structures with the new DataGridCard component. This change enhances the user interface by providing a more consistent layout and improved loading states. Additionally, integrated QueryState for better handling of loading and error scenarios across these components, streamlining the overall user experience. --- .../access/access-api-keys-card.tsx | 159 +++-------- .../access/access-api-keys-grid.tsx | 158 +++++++++++ .../dashboard/dashboard-network-panel.tsx | 38 +++ .../dashboard/dashboard-quick-actions.tsx | 41 +++ .../dashboard/dashboard-recent-jobs-grid.tsx | 74 +++++ .../dashboard-recent-revisions-grid.tsx | 62 ++++ apps/web/src/components/data-grid-shell.tsx | 77 +++++ .../directories-communities-grid.tsx | 58 ++++ .../directories/directories-doh-grid.tsx | 59 ++++ .../firewall/firewall-clients-grid.tsx | 36 +-- .../firewall/firewall-rules-grid.tsx | 36 +-- .../modules/module-entries-grid.tsx | 251 ++++++++++++++++ .../modules/module-entries-section.tsx | 268 +++--------------- .../components/modules/modules-list-grid.tsx | 99 +++++++ .../monitoring/monitoring-ready-grid.tsx | 123 ++++++++ .../components/network/network-peers-grid.tsx | 77 +++++ .../network/network-speakers-grid.tsx | 78 +++++ .../operations/operations-jobs-grid.tsx | 131 +++++++++ .../operations/operations-revisions-grid.tsx | 105 +++++++ apps/web/src/components/reui/frame.tsx | 175 ++++++++++++ .../schedule/schedule-jobs-grid.tsx | 100 +++++++ .../schedule/schedule-modules-grid.tsx | 113 ++++++++ .../components/settings/settings-kv-grid.tsx | 57 ++++ apps/web/src/lib/data-grid-defaults.ts | 44 +++ apps/web/src/routes/_auth/dashboard.tsx | 267 +++++------------ apps/web/src/routes/_auth/directories.tsx | 136 +++------ apps/web/src/routes/_auth/modules/index.tsx | 106 ++----- apps/web/src/routes/_auth/monitoring.tsx | 91 +----- apps/web/src/routes/_auth/network.tsx | 164 +++-------- apps/web/src/routes/_auth/operations.tsx | 225 +++------------ apps/web/src/routes/_auth/schedule.tsx | 171 +++-------- apps/web/src/routes/_auth/tenant-settings.tsx | 30 +- apps/web/tsconfig.tsbuildinfo | 2 +- 33 files changed, 2281 insertions(+), 1330 deletions(-) create mode 100644 apps/web/src/components/access/access-api-keys-grid.tsx create mode 100644 apps/web/src/components/dashboard/dashboard-network-panel.tsx create mode 100644 apps/web/src/components/dashboard/dashboard-quick-actions.tsx create mode 100644 apps/web/src/components/dashboard/dashboard-recent-jobs-grid.tsx create mode 100644 apps/web/src/components/dashboard/dashboard-recent-revisions-grid.tsx create mode 100644 apps/web/src/components/data-grid-shell.tsx create mode 100644 apps/web/src/components/directories/directories-communities-grid.tsx create mode 100644 apps/web/src/components/directories/directories-doh-grid.tsx create mode 100644 apps/web/src/components/modules/module-entries-grid.tsx create mode 100644 apps/web/src/components/modules/modules-list-grid.tsx create mode 100644 apps/web/src/components/monitoring/monitoring-ready-grid.tsx create mode 100644 apps/web/src/components/network/network-peers-grid.tsx create mode 100644 apps/web/src/components/network/network-speakers-grid.tsx create mode 100644 apps/web/src/components/operations/operations-jobs-grid.tsx create mode 100644 apps/web/src/components/operations/operations-revisions-grid.tsx create mode 100644 apps/web/src/components/reui/frame.tsx create mode 100644 apps/web/src/components/schedule/schedule-jobs-grid.tsx create mode 100644 apps/web/src/components/schedule/schedule-modules-grid.tsx create mode 100644 apps/web/src/components/settings/settings-kv-grid.tsx create mode 100644 apps/web/src/lib/data-grid-defaults.ts diff --git a/apps/web/src/components/access/access-api-keys-card.tsx b/apps/web/src/components/access/access-api-keys-card.tsx index c0fe108..ad69634 100644 --- a/apps/web/src/components/access/access-api-keys-card.tsx +++ b/apps/web/src/components/access/access-api-keys-card.tsx @@ -1,25 +1,14 @@ import { useState } from 'react' -import { Plus, RefreshCw, Trash2 } from 'lucide-react' +import { Plus, RefreshCw } from 'lucide-react' import { Button } from '@evobgp/ui/components/button' -import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@evobgp/ui/components/card' -import { - Table, - TableBody, - TableCell, - TableHead, - TableHeader, - TableRow, -} from '@evobgp/ui/components/table' +import { AccessApiKeysGrid } from '@/components/access/access-api-keys-grid' +import { DataGridCard } from '@/components/data-grid-shell' import { ApiKeyCreateDialog } from '@/components/access/api-key-create-dialog' import { ApiKeyTokenDialog } from '@/components/access/api-key-token-dialog' -import { Badge } from '@/components/reui/badge' -import { ConfirmDialog } from '@/components/confirm-dialog' import { QueryState } from '@/components/query-state' -import { StatusBadge } from '@/components/status-badge' import { TableSkeleton } from '@/components/skeletons' -import { formatApiKeyDate } from '@/lib/access/api-key-labels' import { useRevokeApiKeyMutation, useRotateApiKeyMutation } from '@/queries/api-keys' import type { ApiKey, ApiKeyCreated } from '@/types/api' @@ -58,121 +47,45 @@ export function AccessApiKeysCard({ return ( <> - - -
- API-ключи - - Управление ключами tenant. Полный токен показывается только при создании и ротации. - -
-
- - -
-
- - } - onRetry={onRetry} - > - {(data) => ( - - - - Имя - Роль - Префикс - Статус - Истекает - Последнее использование - - - - - {data.map((k) => ( - - {k.name} - - - {k.role} - - - - {k.prefix}… - - - {k.revoked_at ? ( - - ) : ( - - )} - - - {formatApiKeyDate(k.expires_at)} - - - {formatApiKeyDate(k.last_used_at)} - - -
- - - - } - title="Ротировать ключ?" - description="Старый токен перестанет работать сразу." - confirmLabel="Ротировать" - onConfirm={() => handleRotated(k.id)} - /> - - - - } - title="Отозвать API-ключ?" - description={`${k.name} (${k.prefix}…)`} - confirmLabel="Отозвать" - destructive - onConfirm={() => revoke.mutate(k.id)} - /> -
-
-
- ))} -
-
- )} -
-
-
+ + } + > + } + onRetry={onRetry} + > + {(data) => ( + revoke.mutate(id)} + rotatePending={rotate.isPending} + revokePending={revoke.isPending} + /> + )} + + void + onRevoke: (id: string) => void + rotatePending?: boolean + revokePending?: boolean +}) { + const columns = useMemo[]>( + () => [ + { + accessorKey: 'name', + header: ({ column }) => , + cell: ({ row }) => {row.original.name}, + meta: { headerTitle: 'Имя' }, + }, + { + accessorKey: 'role', + header: ({ column }) => , + cell: ({ row }) => ( + + {row.original.role} + + ), + meta: { headerTitle: 'Роль' }, + }, + { + accessorKey: 'prefix', + header: ({ column }) => , + cell: ({ row }) => ( + {row.original.prefix}… + ), + meta: { headerTitle: 'Префикс' }, + }, + { + id: 'status', + enableSorting: false, + header: 'Статус', + cell: ({ row }) => + row.original.revoked_at ? ( + + ) : ( + + ), + meta: { headerTitle: 'Статус' }, + }, + { + id: 'expires_at', + accessorFn: (row) => row.expires_at ?? '', + header: ({ column }) => , + cell: ({ row }) => ( + + {formatApiKeyDate(row.original.expires_at)} + + ), + meta: { headerTitle: 'Истекает' }, + }, + { + id: 'last_used_at', + accessorFn: (row) => row.last_used_at ?? '', + header: ({ column }) => , + cell: ({ row }) => ( + + {formatApiKeyDate(row.original.last_used_at)} + + ), + meta: { headerTitle: 'Последнее использование' }, + }, + { + id: 'actions', + enableSorting: false, + header: () => null, + cell: ({ row }) => { + const k = row.original + return ( +
+ + + + } + title="Ротировать ключ?" + description="Старый токен перестанет работать сразу." + confirmLabel="Ротировать" + onConfirm={() => onRotate(k.id)} + /> + + + + } + title="Отозвать API-ключ?" + description={`${k.name} (${k.prefix}…)`} + confirmLabel="Отозвать" + destructive + onConfirm={() => onRevoke(k.id)} + /> +
+ ) + }, + }, + ], + [onRevoke, onRotate, revokePending, rotatePending], + ) + + const table = useReactTable({ + data: items, + columns, + ...createClientDataGridOptions(), + getRowId: (row) => row.id, + }) + + return ( + + ) +} diff --git a/apps/web/src/components/dashboard/dashboard-network-panel.tsx b/apps/web/src/components/dashboard/dashboard-network-panel.tsx new file mode 100644 index 0000000..1b34666 --- /dev/null +++ b/apps/web/src/components/dashboard/dashboard-network-panel.tsx @@ -0,0 +1,38 @@ +import { aggregateNetworkMetrics } from '@/queries/overview' +import type { PeerRow, SpeakerRow } from '@/types/api' + +function Row({ label, value, variant = 'default' }: { label: string; value: string; variant?: 'default' | 'warning' }) { + return ( +
+ {label} + + {value} + +
+ ) +} + +export function DashboardNetworkPanel({ + peers, + speakers, +}: { + peers: PeerRow[] + speakers: SpeakerRow[] +}) { + const m = aggregateNetworkMetrics(peers, speakers) + return ( +
+ + + {m.peersMismatch > 0 ? ( + + ) : null} +
+ ) +} diff --git a/apps/web/src/components/dashboard/dashboard-quick-actions.tsx b/apps/web/src/components/dashboard/dashboard-quick-actions.tsx new file mode 100644 index 0000000..bc7d799 --- /dev/null +++ b/apps/web/src/components/dashboard/dashboard-quick-actions.tsx @@ -0,0 +1,41 @@ +import { Link } from '@tanstack/react-router' +import { Gauge, Network, Play, Plus, Share2, Tags } from 'lucide-react' + +import { Button } from '@evobgp/ui/components/button' +import { FrameFooter } from '@/components/reui/frame' + +export function DashboardQuickActions() { + return ( + + + + + + + + + ) +} diff --git a/apps/web/src/components/dashboard/dashboard-recent-jobs-grid.tsx b/apps/web/src/components/dashboard/dashboard-recent-jobs-grid.tsx new file mode 100644 index 0000000..e81a241 --- /dev/null +++ b/apps/web/src/components/dashboard/dashboard-recent-jobs-grid.tsx @@ -0,0 +1,74 @@ +import { ColumnDef, getCoreRowModel, useReactTable } from '@tanstack/react-table' +import { useMemo } from 'react' + +import { DataGridShell } 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 type { JobRow } from '@/types/api' + +function StatusText({ status }: { status: string }) { + const cls = + status === 'succeeded' + ? 'text-success' + : status === 'failed' || status === 'cancelled' + ? 'text-destructive' + : 'text-muted-foreground' + return {status} +} + +export function DashboardRecentJobsGrid({ + jobs, + nameById, + isLoading = false, +}: { + jobs: JobRow[] + nameById: Map + isLoading?: boolean +}) { + const data = useMemo(() => jobs.slice(0, 8), [jobs]) + + const columns = useMemo[]>( + () => [ + { + accessorKey: 'kind', + header: ({ column }) => , + cell: ({ row }) => ( +
+
{row.original.kind}
+ {row.original.meta?.module_id ? ( +
+ {nameById.get(String(row.original.meta.module_id)) ?? ''} +
+ ) : null} +
+ ), + meta: { headerTitle: 'Вид' }, + }, + { + accessorKey: 'status', + header: ({ column }) => , + cell: ({ row }) => , + meta: { headerTitle: 'Статус' }, + }, + ], + [nameById], + ) + + const table = useReactTable({ + data, + columns, + getCoreRowModel: getCoreRowModel(), + getRowId: (row) => row.job_id, + }) + + return ( + + ) +} diff --git a/apps/web/src/components/dashboard/dashboard-recent-revisions-grid.tsx b/apps/web/src/components/dashboard/dashboard-recent-revisions-grid.tsx new file mode 100644 index 0000000..ec494f7 --- /dev/null +++ b/apps/web/src/components/dashboard/dashboard-recent-revisions-grid.tsx @@ -0,0 +1,62 @@ +import { ColumnDef, getCoreRowModel, useReactTable } from '@tanstack/react-table' +import { useMemo } from 'react' + +import { DataGridShell } 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 type { RevisionRow } from '@/types/api' + +export function DashboardRecentRevisionsGrid({ + revisions, + isLoading = false, +}: { + revisions: RevisionRow[] + isLoading?: boolean +}) { + const data = useMemo(() => revisions.slice(0, 8), [revisions]) + + const columns = useMemo[]>( + () => [ + { + id: 'id', + accessorFn: (row) => row.id, + header: ({ column }) => , + cell: ({ row }) => ( + + {row.original.id.slice(0, 10)}… + + ), + meta: { headerTitle: 'ID' }, + }, + { + accessorKey: 'created_at', + header: ({ column }) => , + cell: ({ row }) => ( + + {new Date(row.original.created_at).toLocaleString('ru-RU')} + + ), + meta: { headerTitle: 'Создана' }, + }, + ], + [], + ) + + const table = useReactTable({ + data, + columns, + getCoreRowModel: getCoreRowModel(), + getRowId: (row) => row.id, + }) + + return ( + + ) +} diff --git a/apps/web/src/components/data-grid-shell.tsx b/apps/web/src/components/data-grid-shell.tsx new file mode 100644 index 0000000..8afb73c --- /dev/null +++ b/apps/web/src/components/data-grid-shell.tsx @@ -0,0 +1,77 @@ +import type { ReactNode } from 'react' +import type { Table } from '@tanstack/react-table' + +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@evobgp/ui/components/card' + +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' +import { + DATA_GRID_PAGINATION_RU, + DATA_GRID_TABLE_LAYOUT, +} from '@/lib/data-grid-defaults' + +interface DataGridShellProps { + table: Table + recordCount: number + isLoading?: boolean + emptyMessage?: ReactNode + showPagination?: boolean + tableLayout?: typeof DATA_GRID_TABLE_LAYOUT + className?: string + onRowClick?: (row: TData) => void +} + +export function DataGridShell({ + table, + recordCount, + isLoading = false, + emptyMessage, + showPagination = true, + tableLayout = DATA_GRID_TABLE_LAYOUT, + className, + onRowClick, +}: DataGridShellProps) { + return ( + + + + + {showPagination ? : null} + + ) +} + +interface DataGridCardProps { + title?: string + description?: string + actions?: ReactNode + children: ReactNode + className?: string +} + +export function DataGridCard({ title, description, actions, children, className }: DataGridCardProps) { + const hasHeader = Boolean(title || description || actions) + return ( + + {hasHeader ? ( + +
+ {title ? {title} : null} + {description ? {description} : null} +
+ {actions ?
{actions}
: null} +
+ ) : null} + {children} +
+ ) +} diff --git a/apps/web/src/components/directories/directories-communities-grid.tsx b/apps/web/src/components/directories/directories-communities-grid.tsx new file mode 100644 index 0000000..94d3cd1 --- /dev/null +++ b/apps/web/src/components/directories/directories-communities-grid.tsx @@ -0,0 +1,58 @@ +import { ColumnDef, useReactTable } from '@tanstack/react-table' +import { useMemo } from 'react' + +import { Badge } from '@evobgp/ui/components/badge' + +import { DataGridShell } from '@/components/data-grid-shell' +import { DataGridColumnHeader } from '@/components/reui/data-grid/data-grid-column-header' +import { createClientDataGridOptions } from '@/lib/data-grid-defaults' +import type { BgpCommunity } from '@/types/api' + +export function DirectoriesCommunitiesGrid({ + items, + isLoading = false, +}: { + items: BgpCommunity[] + isLoading?: boolean +}) { + const columns = useMemo[]>( + () => [ + { + accessorKey: 'title', + header: ({ column }) => , + cell: ({ row }) => {row.original.title}, + meta: { headerTitle: 'Название' }, + }, + { + accessorKey: 'community', + header: ({ column }) => , + cell: ({ row }) => {row.original.community}, + meta: { headerTitle: 'Значение' }, + }, + { + id: 'type', + enableSorting: false, + header: 'Тип', + cell: () => community, + meta: { headerTitle: 'Тип' }, + }, + ], + [], + ) + + const table = useReactTable({ + data: items, + columns, + ...createClientDataGridOptions(), + getRowId: (row) => row.id, + }) + + return ( + + ) +} diff --git a/apps/web/src/components/directories/directories-doh-grid.tsx b/apps/web/src/components/directories/directories-doh-grid.tsx new file mode 100644 index 0000000..13e7d7d --- /dev/null +++ b/apps/web/src/components/directories/directories-doh-grid.tsx @@ -0,0 +1,59 @@ +import { ColumnDef, useReactTable } from '@tanstack/react-table' +import { useMemo } from 'react' + +import { Badge } from '@evobgp/ui/components/badge' + +import { DataGridShell } from '@/components/data-grid-shell' +import { DataGridColumnHeader } from '@/components/reui/data-grid/data-grid-column-header' +import { createClientDataGridOptions } from '@/lib/data-grid-defaults' +import type { DohProfile } from '@/types/api' + +export function DirectoriesDohGrid({ + items, + isLoading = false, +}: { + items: DohProfile[] + isLoading?: boolean +}) { + const columns = useMemo[]>( + () => [ + { + id: 'name', + accessorFn: (row) => row.name ?? row.url, + header: ({ column }) => , + cell: ({ row }) => {row.original.name ?? row.original.url}, + meta: { headerTitle: 'Название' }, + }, + { + accessorKey: 'url', + header: ({ column }) => , + cell: ({ row }) => {row.original.url}, + meta: { headerTitle: 'URL' }, + }, + { + id: 'default', + enableSorting: false, + header: 'По умолчанию', + cell: () => , + meta: { headerTitle: 'По умолчанию' }, + }, + ], + [], + ) + + const table = useReactTable({ + data: items, + columns, + ...createClientDataGridOptions(), + getRowId: (row) => row.id, + }) + + return ( + + ) +} diff --git a/apps/web/src/components/firewall/firewall-clients-grid.tsx b/apps/web/src/components/firewall/firewall-clients-grid.tsx index 66c15e6..ec03e07 100644 --- a/apps/web/src/components/firewall/firewall-clients-grid.tsx +++ b/apps/web/src/components/firewall/firewall-clients-grid.tsx @@ -1,20 +1,13 @@ -import { - ColumnDef, - getCoreRowModel, - getPaginationRowModel, - getSortedRowModel, - useReactTable, -} from '@tanstack/react-table' +import { ColumnDef, useReactTable } from '@tanstack/react-table' import { useMemo } from 'react' import { Button } from '@evobgp/ui/components/button' +import { DataGridShell } 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 { 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' +import { createClientDataGridOptions } from '@/lib/data-grid-defaults' import type { FirewallClient } from '@/types/api' function formatPacketCount(value?: number | null): string | null { @@ -183,33 +176,16 @@ export function FirewallClientsGrid({ const table = useReactTable({ data: clients, columns, - getCoreRowModel: getCoreRowModel(), - getSortedRowModel: getSortedRowModel(), - getPaginationRowModel: getPaginationRowModel(), + ...createClientDataGridOptions(), getRowId: (row) => row.id, - initialState: { pagination: { pageSize: 10 } }, }) return ( - - - - - - + /> ) } diff --git a/apps/web/src/components/firewall/firewall-rules-grid.tsx b/apps/web/src/components/firewall/firewall-rules-grid.tsx index 20ce4f3..55d5f22 100644 --- a/apps/web/src/components/firewall/firewall-rules-grid.tsx +++ b/apps/web/src/components/firewall/firewall-rules-grid.tsx @@ -1,20 +1,13 @@ -import { - ColumnDef, - getCoreRowModel, - getPaginationRowModel, - getSortedRowModel, - useReactTable, -} from '@tanstack/react-table' +import { ColumnDef, useReactTable } from '@tanstack/react-table' import { useMemo } from 'react' import { Button } from '@evobgp/ui/components/button' +import { DataGridShell } 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 { 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' +import { createClientDataGridOptions } from '@/lib/data-grid-defaults' import { communityLabel } from '@/lib/modules/helpers' import type { BgpCommunity, FirewallRule } from '@/types/api' @@ -110,33 +103,16 @@ export function FirewallRulesGrid({ const table = useReactTable({ data: rules, columns, - getCoreRowModel: getCoreRowModel(), - getSortedRowModel: getSortedRowModel(), - getPaginationRowModel: getPaginationRowModel(), + ...createClientDataGridOptions(), getRowId: (row) => row.id, - initialState: { pagination: { pageSize: 10 } }, }) return ( - - - - - - + /> ) } diff --git a/apps/web/src/components/modules/module-entries-grid.tsx b/apps/web/src/components/modules/module-entries-grid.tsx new file mode 100644 index 0000000..df47c51 --- /dev/null +++ b/apps/web/src/components/modules/module-entries-grid.tsx @@ -0,0 +1,251 @@ +import { ColumnDef, useReactTable } 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 { 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 type { + AsEntry, + BgpCommunity, + CdnSource, + DomainEntry, + IpRangeEntry, + ModuleRow, +} from '@/types/api' + +type DeleteTarget = + | { kind: 'domain'; entry: DomainEntry } + | { kind: 'ip-range'; entry: IpRangeEntry } + | { kind: 'cdn'; entry: CdnSource } + | { kind: 'as'; entry: AsEntry } + +function RowActions({ onEdit, onDelete }: { onEdit: () => void; onDelete: () => void }) { + return ( +
+ + +
+ ) +} + +export function ModuleEntriesGrid({ + mod, + rows, + communities, + onEdit, + onDelete, + isLoading = false, +}: { + mod: ModuleRow + rows: Record[] + communities: BgpCommunity[] + onEdit: (target: DeleteTarget) => void + onDelete: (target: DeleteTarget) => void + isLoading?: boolean +}) { + const columns = useMemo(() => { + if (mod.type === 'DOMAINS') { + return [ + { + accessorKey: 'fqdn', + header: ({ column }: { column: { id: string } }) => ( + + ), + cell: ({ row }: { row: { original: DomainEntry } }) => ( + {row.original.fqdn} + ), + }, + { + id: 'community', + header: 'Community', + cell: ({ row }: { row: { original: DomainEntry } }) => ( + + {communityLabel(row.original.community_id, communities)} + + ), + }, + { + id: 'actions', + enableSorting: false, + header: () => null, + cell: ({ row }: { row: { original: DomainEntry } }) => ( + onEdit({ kind: 'domain', entry: row.original })} + onDelete={() => onDelete({ kind: 'domain', entry: row.original })} + /> + ), + }, + ] as ColumnDef[] + } + + if (mod.type === 'IP_RANGES') { + return [ + { + accessorKey: 'prefix', + header: ({ column }: { column: { id: string } }) => ( + + ), + cell: ({ row }: { row: { original: IpRangeEntry } }) => ( + {row.original.prefix} + ), + }, + { + id: 'community', + header: 'Community', + cell: ({ row }: { row: { original: IpRangeEntry } }) => ( + + {communityLabel(row.original.community_id, communities)} + + ), + }, + { + id: 'actions', + enableSorting: false, + header: () => null, + cell: ({ row }: { row: { original: IpRangeEntry } }) => ( + onEdit({ kind: 'ip-range', entry: row.original })} + onDelete={() => onDelete({ kind: 'ip-range', entry: row.original })} + /> + ), + }, + ] as ColumnDef[] + } + + if (mod.type === 'CDN_CIDRS') { + return [ + { + accessorKey: 'url', + header: ({ column }: { column: { id: string } }) => ( + + ), + cell: ({ row }: { row: { original: CdnSource } }) => ( + {row.original.url} + ), + }, + { + accessorKey: 'source_kind', + header: 'Тип', + cell: ({ row }: { row: { original: CdnSource } }) => ( + {row.original.source_kind} + ), + }, + { + id: 'community', + header: 'Community', + cell: ({ row }: { row: { original: CdnSource } }) => ( + + {communityLabel(row.original.community_id, communities)} + + ), + }, + { + id: 'last_refreshed_at', + accessorFn: (row: CdnSource) => row.last_refreshed_at ?? '', + header: ({ column }: { column: { id: string } }) => ( + + ), + cell: ({ row }: { row: { original: CdnSource } }) => ( + + {formatDateTime(row.original.last_refreshed_at)} + + ), + }, + { + id: 'actions', + enableSorting: false, + header: () => null, + cell: ({ row }: { row: { original: CdnSource } }) => ( + onEdit({ kind: 'cdn', entry: row.original })} + onDelete={() => onDelete({ kind: 'cdn', entry: row.original })} + /> + ), + }, + ] as ColumnDef[] + } + + return [ + { + accessorKey: 'asn', + header: ({ column }: { column: { id: string } }) => ( + + ), + cell: ({ row }: { row: { original: AsEntry } }) => ( + {row.original.asn} + ), + }, + { + accessorKey: 'asn_name', + header: 'Имя', + cell: ({ row }: { row: { original: AsEntry } }) => ( + {row.original.asn_name ?? '—'} + ), + }, + { + accessorKey: 'prefix_count', + header: 'Префиксов', + cell: ({ row }: { row: { original: AsEntry } }) => ( + {row.original.prefix_count ?? '—'} + ), + }, + { + id: 'community', + header: 'Community', + cell: ({ row }: { row: { original: AsEntry } }) => ( + + {communityLabel(row.original.community_id, communities)} + + ), + }, + { + id: 'actions', + enableSorting: false, + header: () => null, + cell: ({ row }: { row: { original: AsEntry } }) => ( + onEdit({ kind: 'as', entry: row.original })} + onDelete={() => onDelete({ kind: 'as', entry: row.original })} + /> + ), + }, + ] as ColumnDef[] + }, [communities, mod.type, onDelete, onEdit]) + + type RowType = DomainEntry | IpRangeEntry | CdnSource | AsEntry + const data = rows as unknown as RowType[] + + const table = useReactTable({ + data, + columns: columns as ColumnDef[], + ...createClientDataGridOptions(), + getRowId: (row) => row.id, + }) + + return ( + + ) +} + +export type { DeleteTarget as ModuleEntryDeleteTarget } diff --git a/apps/web/src/components/modules/module-entries-section.tsx b/apps/web/src/components/modules/module-entries-section.tsx index c277164..d03b5f6 100644 --- a/apps/web/src/components/modules/module-entries-section.tsx +++ b/apps/web/src/components/modules/module-entries-section.tsx @@ -1,5 +1,5 @@ import { useState } from 'react' -import { Pencil, Plus, Trash2 } from 'lucide-react' +import { Plus } from 'lucide-react' import { toast } from 'sonner' import { @@ -13,25 +13,16 @@ import { AlertDialogTitle, } from '@evobgp/ui/components/alert-dialog' import { Button } from '@evobgp/ui/components/button' -import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@evobgp/ui/components/card' -import { - Table, - TableBody, - TableCell, - TableHead, - TableHeader, - TableRow, -} from '@evobgp/ui/components/table' +import { DataGridCard } from '@/components/data-grid-shell' import { QueryState } from '@/components/query-state' import { TableSkeleton } from '@/components/skeletons' +import { ModuleEntriesGrid, type ModuleEntryDeleteTarget } from '@/components/modules/module-entries-grid' import { ModuleAsEntryDialog } from '@/components/modules/module-as-entry-dialog' import { ModuleCdnSourceDialog } from '@/components/modules/module-cdn-source-dialog' import { ModuleDomainEntryDialog } from '@/components/modules/module-domain-entry-dialog' import { ModuleIpRangeEntryDialog } from '@/components/modules/module-ip-range-entry-dialog' import { ApiError, apiMutate } from '@/lib/api-client' -import { communityLabel } from '@/lib/modules/helpers' -import { formatDateTime } from '@/lib/modules/display' import type { AsEntry, BgpCommunity, @@ -53,11 +44,7 @@ interface ModuleEntriesSectionProps { onChanged: () => void | Promise } -type DeleteTarget = - | { kind: 'domain'; entry: DomainEntry } - | { kind: 'ip-range'; entry: IpRangeEntry } - | { kind: 'cdn'; entry: CdnSource } - | { kind: 'as'; entry: AsEntry } +type DeleteTarget = ModuleEntryDeleteTarget const CARD_META: Record< ModuleRow['type'], @@ -150,49 +137,45 @@ export function ModuleEntriesSection({ return ( <> - - -
- {meta.title} - {meta.description} -
-
- -
-
- - } - onRetry={onRetry} - > - {(rows) => ( - { - if (target.kind === 'domain') setEditDomain(target.entry) - if (target.kind === 'ip-range') setEditIpRange(target.entry) - if (target.kind === 'cdn') setEditCdn(target.entry) - if (target.kind === 'as') setEditAs(target.entry) - setDialogOpen(true) - }} - onDelete={setDeleteTarget} - /> - )} - - -
+ + + Добавить + + } + > + } + onRetry={onRetry} + > + {(rows) => ( + { + if (target.kind === 'domain') setEditDomain(target.entry) + if (target.kind === 'ip-range') setEditIpRange(target.entry) + if (target.kind === 'cdn') setEditCdn(target.entry) + if (target.kind === 'as') setEditAs(target.entry) + setDialogOpen(true) + }} + onDelete={setDeleteTarget} + /> + )} + + {mod.type === 'DOMAINS' ? ( [] - communities: BgpCommunity[] - onEdit: (target: DeleteTarget) => void - onDelete: (target: DeleteTarget) => void -}) { - if (mod.type === 'DOMAINS') { - const entries = rows as unknown as DomainEntry[] - return ( - - - - FQDN - Community - - - - - {entries.map((entry) => ( - - {entry.fqdn} - - {communityLabel(entry.community_id, communities)} - - - onEdit({ kind: 'domain', entry })} - onDelete={() => onDelete({ kind: 'domain', entry })} - /> - - - ))} - -
- ) - } - - if (mod.type === 'IP_RANGES') { - const entries = rows as unknown as IpRangeEntry[] - return ( - - - - Префикс (CIDR) - Community - - - - - {entries.map((entry) => ( - - {entry.prefix} - - {communityLabel(entry.community_id, communities)} - - - onEdit({ kind: 'ip-range', entry })} - onDelete={() => onDelete({ kind: 'ip-range', entry })} - /> - - - ))} - -
- ) - } - - if (mod.type === 'CDN_CIDRS') { - const entries = rows as unknown as CdnSource[] - return ( - - - - URL - Тип - Community - Обновлено - - - - - {entries.map((entry) => ( - - {entry.url} - {entry.source_kind} - - {communityLabel(entry.community_id, communities)} - - - {formatDateTime(entry.last_refreshed_at)} - - - onEdit({ kind: 'cdn', entry })} - onDelete={() => onDelete({ kind: 'cdn', entry })} - /> - - - ))} - -
- ) - } - - const entries = rows as unknown as AsEntry[] - return ( - - - - ASN - Имя - Префиксов - Community - - - - - {entries.map((entry) => ( - - {entry.asn} - {entry.asn_name ?? '—'} - {entry.prefix_count ?? '—'} - - {communityLabel(entry.community_id, communities)} - - - onEdit({ kind: 'as', entry })} - onDelete={() => onDelete({ kind: 'as', entry })} - /> - - - ))} - -
- ) -} - -function RowActions({ onEdit, onDelete }: { onEdit: () => void; onDelete: () => void }) { - return ( -
- - -
- ) -} diff --git a/apps/web/src/components/modules/modules-list-grid.tsx b/apps/web/src/components/modules/modules-list-grid.tsx new file mode 100644 index 0000000..3cd3f97 --- /dev/null +++ b/apps/web/src/components/modules/modules-list-grid.tsx @@ -0,0 +1,99 @@ +import { ColumnDef, useReactTable } 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 { 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 type { ModuleRow } from '@/types/api' + +export function ModulesListGrid({ + items, + isLoading = false, +}: { + items: ModuleRow[] + isLoading?: boolean +}) { + const navigate = useNavigate() + + const columns = useMemo[]>( + () => [ + { + accessorKey: 'name', + header: ({ column }) => , + cell: ({ row }) => ( +
+ + {row.original.name} +
+ ), + meta: { headerTitle: 'Название' }, + }, + { + accessorKey: 'type', + header: ({ column }) => , + cell: ({ row }) => {row.original.type}, + meta: { headerTitle: 'Тип' }, + }, + { + accessorKey: 'priority', + header: ({ column }) => , + cell: ({ row }) => ( + {row.original.priority} + ), + meta: { headerTitle: 'Приоритет' }, + }, + { + id: 'enabled', + accessorFn: (row) => (row.enabled ? 'enabled' : 'disabled'), + header: ({ column }) => , + cell: ({ row }) => + row.original.enabled ? ( + включён + ) : ( + выключен + ), + meta: { headerTitle: 'Состояние' }, + }, + { + id: 'last_refreshed_at', + accessorFn: (row) => row.last_refreshed_at ?? '', + header: ({ column }) => , + cell: ({ row }) => ( + + {row.original.last_refreshed_at + ? new Date(row.original.last_refreshed_at).toLocaleString('ru-RU') + : '—'} + + ), + sortingFn: (a, b) => { + const av = a.original.last_refreshed_at ?? '' + const bv = b.original.last_refreshed_at ?? '' + return av.localeCompare(bv) + }, + meta: { headerTitle: 'Обновлено' }, + }, + ], + [], + ) + + const table = useReactTable({ + data: items, + columns, + ...createClientDataGridOptions(), + getRowId: (row) => row.id, + }) + + return ( + void navigate({ to: '/modules/$moduleId', params: { moduleId: row.id } })} + /> + ) +} diff --git a/apps/web/src/components/monitoring/monitoring-ready-grid.tsx b/apps/web/src/components/monitoring/monitoring-ready-grid.tsx new file mode 100644 index 0000000..f6caa85 --- /dev/null +++ b/apps/web/src/components/monitoring/monitoring-ready-grid.tsx @@ -0,0 +1,123 @@ +import { ColumnDef, useReactTable } 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 { DataGridColumnHeader } from '@/components/reui/data-grid/data-grid-column-header' +import { createClientDataGridOptions } from '@/lib/data-grid-defaults' +import type { ReadyStatus } from '@/queries/monitoring' + +const READY_CHECK_ICONS: Record = { + postgres: Database, + store: HardDrive, + jobs: ListTodo, +} + +interface ReadyCheckRow { + id: string + label: string + subtitle?: string + icon: typeof Database + ok: boolean + statusLabel: string + variant: 'default' | 'destructive' | 'secondary' +} + +export function MonitoringReadyGrid({ + health, + ready, +}: { + health?: { ok?: boolean; status?: string; error?: string } | null + ready: ReadyStatus +}) { + const data = useMemo(() => { + const checks = ready.checks ?? {} + const rows: ReadyCheckRow[] = [ + { + id: 'liveness', + label: 'Liveness', + subtitle: '/v1/health', + icon: HeartPulse, + ok: health?.ok === true, + statusLabel: health?.ok ? 'OK' : 'Ошибка', + variant: health?.ok ? 'default' : 'destructive', + }, + { + id: 'readiness', + label: 'Readiness', + subtitle: '/v1/ready', + icon: ShieldCheck, + ok: ready.status === 'ok', + statusLabel: ready.status ?? '—', + variant: ready.status === 'ok' ? 'default' : 'secondary', + }, + ] + for (const key of Object.keys(checks)) { + const value = checks[key] + const ok = typeof value === 'boolean' ? value : value?.ok !== false + rows.push({ + id: key, + label: key, + icon: READY_CHECK_ICONS[key] ?? ListTodo, + ok, + statusLabel: ok ? 'OK' : 'Ошибка', + variant: ok ? 'default' : 'destructive', + }) + } + return rows + }, [health?.ok, ready.checks, ready.status]) + + const columns = useMemo[]>( + () => [ + { + accessorKey: 'label', + header: ({ column }) => , + cell: ({ row }) => { + const Icon = row.original.icon + return ( +
+ +
+

{row.original.label}

+ {row.original.subtitle ? ( +

{row.original.subtitle}

+ ) : null} +
+
+ ) + }, + meta: { headerTitle: 'Проверка' }, + }, + { + id: 'status', + enableSorting: false, + header: 'Статус', + cell: ({ row }) => ( + {row.original.statusLabel} + ), + meta: { headerTitle: 'Статус' }, + }, + ], + [], + ) + + const table = useReactTable({ + data, + columns, + ...createClientDataGridOptions({ + initialState: { pagination: { pageSize: 20 } }, + }), + getRowId: (row) => row.id, + }) + + return ( + + ) +} diff --git a/apps/web/src/components/network/network-peers-grid.tsx b/apps/web/src/components/network/network-peers-grid.tsx new file mode 100644 index 0000000..c21cf05 --- /dev/null +++ b/apps/web/src/components/network/network-peers-grid.tsx @@ -0,0 +1,77 @@ +import { ColumnDef, useReactTable } from '@tanstack/react-table' +import { useMemo } from 'react' + +import { DataGridShell } 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 type { PeerRow } from '@/types/api' + +export function NetworkPeersGrid({ + items, + isLoading = false, +}: { + items: PeerRow[] + isLoading?: boolean +}) { + const columns = useMemo[]>( + () => [ + { + id: 'name', + accessorFn: (row) => row.name ?? row.neighbor, + header: ({ column }) => , + cell: ({ row }) => ( + {row.original.name ?? row.original.neighbor} + ), + meta: { headerTitle: 'Имя' }, + }, + { + accessorKey: 'neighbor', + header: ({ column }) => , + cell: ({ row }) => {row.original.neighbor}, + meta: { headerTitle: 'Neighbor' }, + }, + { + accessorKey: 'remote_asn', + header: ({ column }) => , + cell: ({ row }) => ( + {row.original.remote_asn ?? '—'} + ), + meta: { headerTitle: 'ASN' }, + }, + { + accessorKey: 'session_state', + header: ({ column }) => , + cell: ({ row }) => ( +
+ + {row.original.session_mismatch ? ( + + mismatch + + ) : null} +
+ ), + meta: { headerTitle: 'Состояние' }, + }, + ], + [], + ) + + const table = useReactTable({ + data: items, + columns, + ...createClientDataGridOptions(), + getRowId: (row) => row.id, + }) + + return ( + + ) +} diff --git a/apps/web/src/components/network/network-speakers-grid.tsx b/apps/web/src/components/network/network-speakers-grid.tsx new file mode 100644 index 0000000..2c855dc --- /dev/null +++ b/apps/web/src/components/network/network-speakers-grid.tsx @@ -0,0 +1,78 @@ +import { ColumnDef, useReactTable } from '@tanstack/react-table' +import { useMemo } from 'react' + +import { DataGridShell } 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 type { SpeakerRow } from '@/types/api' + +export function NetworkSpeakersGrid({ + items, + isLoading = false, +}: { + items: SpeakerRow[] + isLoading?: boolean +}) { + const columns = useMemo[]>( + () => [ + { + accessorKey: 'endpoint', + header: ({ column }) => , + cell: ({ row }) => {row.original.endpoint}, + meta: { headerTitle: 'Endpoint' }, + }, + { + accessorKey: 'role', + header: ({ column }) => , + cell: ({ row }) => {row.original.role}, + meta: { headerTitle: 'Роль' }, + }, + { + id: 'agent', + enableSorting: false, + header: 'Agent', + cell: ({ row }) => { + const live = row.original.live + if (live?.agent_ok === true) return + if (live?.agent_ok === false) return + return + }, + meta: { headerTitle: 'Agent' }, + }, + { + id: 'bgp', + enableSorting: false, + header: 'BGP', + cell: ({ row }) => { + const live = row.original.live + if (!live) return '—' + return ( + + {live.bgp_established ?? 0} / {live.bgp_sessions_total ?? 0} + + ) + }, + meta: { headerTitle: 'BGP' }, + }, + ], + [], + ) + + const table = useReactTable({ + data: items, + columns, + ...createClientDataGridOptions(), + getRowId: (row) => row.id, + }) + + return ( + + ) +} diff --git a/apps/web/src/components/operations/operations-jobs-grid.tsx b/apps/web/src/components/operations/operations-jobs-grid.tsx new file mode 100644 index 0000000..ce9e391 --- /dev/null +++ b/apps/web/src/components/operations/operations-jobs-grid.tsx @@ -0,0 +1,131 @@ +import { ColumnDef, useReactTable } 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 { DataGridColumnHeader } from '@/components/reui/data-grid/data-grid-column-header' +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' + +function StatusBadgeColored({ status }: { status: string }) { + const cls = + status === 'succeeded' + ? 'text-success' + : status === 'failed' || status === 'cancelled' + ? 'text-destructive' + : 'text-info' + return {status} +} + +export function OperationsJobsGrid({ + items, + nameById, + qc, + isLoading = false, +}: { + items: JobRow[] + nameById: Map + qc: QueryClient + isLoading?: boolean +}) { + const cancelMutation = useMutation({ + mutationFn: (jobId: string) => apiMutate(`/v1/jobs/${jobId}/cancel`, 'POST', {}), + onSuccess: () => { + toast.success('Задача отменена') + void qc.invalidateQueries({ queryKey: ['operations'] }) + }, + onError: (e) => toast.error(e instanceof Error ? e.message : 'Не удалось отменить'), + }) + + const columns = useMemo[]>( + () => [ + { + accessorKey: 'kind', + header: ({ column }) => , + cell: ({ row }) => ( +
+ {row.original.kind} + {row.original.meta?.module_id ? ( + + {nameById.get(String(row.original.meta.module_id)) ?? + String(row.original.meta.module_id)} + + ) : null} +
+ ), + meta: { headerTitle: 'Вид' }, + }, + { + accessorKey: 'status', + header: ({ column }) => , + cell: ({ row }) => , + meta: { headerTitle: 'Статус' }, + }, + { + id: 'created_at', + accessorFn: (row) => row.created_at ?? '', + header: ({ column }) => , + cell: ({ row }) => ( + + {row.original.created_at + ? new Date(row.original.created_at).toLocaleString('ru-RU') + : '—'} + + ), + meta: { headerTitle: 'Создана' }, + }, + { + id: 'finished_at', + accessorFn: (row) => row.finished_at ?? '', + header: ({ column }) => , + cell: ({ row }) => ( + + {row.original.finished_at + ? new Date(row.original.finished_at).toLocaleString('ru-RU') + : '—'} + + ), + meta: { headerTitle: 'Завершена' }, + }, + { + id: 'actions', + enableSorting: false, + header: () => null, + cell: ({ row }) => + row.original.status === 'running' || row.original.status === 'queued' ? ( + + ) : null, + }, + ], + [cancelMutation, nameById], + ) + + const table = useReactTable({ + data: items, + columns, + ...createClientDataGridOptions(), + getRowId: (row) => row.job_id, + }) + + return ( + + ) +} diff --git a/apps/web/src/components/operations/operations-revisions-grid.tsx b/apps/web/src/components/operations/operations-revisions-grid.tsx new file mode 100644 index 0000000..7e1401e --- /dev/null +++ b/apps/web/src/components/operations/operations-revisions-grid.tsx @@ -0,0 +1,105 @@ +import { ColumnDef, useReactTable } from '@tanstack/react-table' +import { useMutation } from '@tanstack/react-query' +import { RefreshCw } from 'lucide-react' +import { useMemo } from 'react' +import { toast } from 'sonner' + +import { Button } from '@evobgp/ui/components/button' + +import { DataGridShell } from '@/components/data-grid-shell' +import { ConfirmDialog } from '@/components/confirm-dialog' +import { DataGridColumnHeader } from '@/components/reui/data-grid/data-grid-column-header' +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' + +export function OperationsRevisionsGrid({ + items, + qc, + isLoading = false, +}: { + items: RevisionRow[] + qc: QueryClient + isLoading?: boolean +}) { + const rollbackMutation = useMutation({ + mutationFn: (id: string) => + apiMutate(`/v1/revisions/${id}/rollback`, 'POST', {}).then(() => id), + onSuccess: () => { + toast.success('Откат выполнен') + void qc.invalidateQueries({ queryKey: ['operations'] }) + }, + onError: (e) => toast.error(e instanceof Error ? e.message : 'Не удалось откатить'), + }) + + const columns = useMemo[]>( + () => [ + { + id: 'id', + accessorFn: (row) => row.id, + header: ({ column }) => , + cell: ({ row }) => ( + {row.original.id.slice(0, 12)}… + ), + meta: { headerTitle: 'ID' }, + }, + { + accessorKey: 'created_at', + header: ({ column }) => , + cell: ({ row }) => ( + + {new Date(row.original.created_at).toLocaleString('ru-RU')} + + ), + meta: { headerTitle: 'Создана' }, + }, + { + accessorKey: 'materialized_prefix_count', + header: ({ column }) => , + cell: ({ row }) => ( + + {row.original.materialized_prefix_count} + + ), + meta: { headerTitle: 'Префиксов' }, + }, + { + id: 'actions', + enableSorting: false, + header: () => null, + cell: ({ row }) => ( + + + + } + title={`Откатиться к ревизии ${row.original.id.slice(0, 8)}…?`} + description="Будет создана новая ревизия на основе выбранной. Требуется роль operator." + confirmLabel="Откатить" + destructive + onConfirm={() => rollbackMutation.mutate(row.original.id)} + /> + ), + }, + ], + [rollbackMutation], + ) + + const table = useReactTable({ + data: items, + columns, + ...createClientDataGridOptions(), + getRowId: (row) => row.id, + }) + + return ( + + ) +} diff --git a/apps/web/src/components/reui/frame.tsx b/apps/web/src/components/reui/frame.tsx new file mode 100644 index 0000000..812db16 --- /dev/null +++ b/apps/web/src/components/reui/frame.tsx @@ -0,0 +1,175 @@ +import { cva, type VariantProps } from "class-variance-authority" + +import { cn } from "@evobgp/ui/lib/utils" + +/** + * CSS variable architecture for FramePanel theming: + * + * The Frame parent sets --frame-panel-bg and --frame-panel-border-color. + * FramePanel consumes them directly via bg-(--frame-panel-bg) and + * border-(--frame-panel-border-color). This means: + * + * - variant="inverse" overrides those vars on Frame → all panels pick it up + * - adds a direct utility on the element + * which wins over bg-(--frame-panel-bg) by Tailwind source order — no + * :not() or !important needed + */ +const frameVariants = cva( + [ + "relative flex flex-col bg-muted/50 gap-(--frame-gap) px-(--frame-px) py-(--frame-py) rounded-(--frame-radius)", + "(--radius-xl)] [--frame-radius:var(--radius-xl)]", + "(--radius-none)] (--radius-2xl)] (--radius-lg)] (--radius-none)]", + "[--frame-gap:--spacing(0.75)] [--frame-px:--spacing(0.75)] [--frame-py:--spacing(0.75)] [--frame-panel-header-gap:0rem] [--frame-panel-footer-gap:--spacing(1)]", + "[--frame-panel-px-adjust:0px] [--frame-panel-py-adjust:0px] [--frame-panel-header-px-adjust:0px] [--frame-panel-header-py-adjust:0px] [--frame-panel-footer-px-adjust:0px] [--frame-panel-footer-py-adjust:0px]", + "[--frame-panel-px:calc(var(--frame-panel-px-base)_+_var(--frame-panel-px-adjust))] [--frame-panel-py:calc(var(--frame-panel-py-base)_+_var(--frame-panel-py-adjust))] [--frame-panel-header-px:calc(var(--frame-panel-header-px-base)_+_var(--frame-panel-header-px-adjust))] [--frame-panel-header-py:calc(var(--frame-panel-header-py-base)_+_var(--frame-panel-header-py-adjust))] [--frame-panel-footer-px:calc(var(--frame-panel-footer-px-base)_+_var(--frame-panel-footer-px-adjust))] [--frame-panel-footer-py:calc(var(--frame-panel-footer-py-base)_+_var(--frame-panel-footer-py-adjust))]", + "(1)] (1)] (1.25)] (1.5)] (1.5)] (0.5)] (1)] (1)]", + // Default panel token values — overridden per-variant below + "[--frame-panel-bg:var(--color-card)] [--frame-panel-border-color:var(--color-border)] [--frame-border-color:var(--color-border)]", + ], + { + variants: { + variant: { + default: "border border-[var(--frame-border-color)] bg-clip-padding", + inverse: + "[--frame-panel-bg:color-mix(in_oklch,var(--color-muted)_40%,transparent)] border border-[var(--frame-border-color)] bg-background bg-clip-padding", + ghost: "", + }, + spacing: { + xs: "[--frame-panel-px-base:--spacing(2)] [--frame-panel-py-base:--spacing(2)] [--frame-panel-header-px-base:--spacing(2)] [--frame-panel-header-py-base:--spacing(1)] [--frame-panel-footer-px-base:--spacing(2)] [--frame-panel-footer-py-base:--spacing(1)] (3)] (1)] (3)] (3)]", + sm: "[--frame-panel-px-base:--spacing(3)] [--frame-panel-py-base:--spacing(3.5)] [--frame-panel-header-px-base:--spacing(3)] [--frame-panel-header-py-base:--spacing(2.5)] [--frame-panel-footer-px-base:--spacing(3)] [--frame-panel-footer-py-base:--spacing(2.5)] (2)] (2)] (2)]", + default: + "[--frame-panel-px-base:--spacing(4)] [--frame-panel-py-base:--spacing(4)] [--frame-panel-header-px-base:--spacing(4)] [--frame-panel-header-py-base:--spacing(3)] [--frame-panel-footer-px-base:--spacing(4)] [--frame-panel-footer-py-base:--spacing(3)] (2)] (2)] (2)]", + lg: "[--frame-panel-px-base:--spacing(5)] [--frame-panel-py-base:--spacing(5)] [--frame-panel-header-px-base:--spacing(5)] [--frame-panel-header-py-base:--spacing(4)] [--frame-panel-footer-px-base:--spacing(5)] [--frame-panel-footer-py-base:--spacing(4)] (2)] (2)] (2)]", + }, + stacked: { + true: [ + "gap-0 *:has-[+[data-slot=frame-panel]]:rounded-b-none", + "*:has-[+[data-slot=frame-panel]]:before:hidden", + "*:[[data-slot=frame-panel]+[data-slot=frame-panel]]:rounded-t-none", + "*:[[data-slot=frame-panel]+[data-slot=frame-panel]]:border-t-0", + ], + false: [ + "data-[spacing=sm]:*:[[data-slot=frame-panel]+[data-slot=frame-panel]]:mt-0.5", + "data-[spacing=default]:*:[[data-slot=frame-panel]+[data-slot=frame-panel]]:mt-1", + "data-[spacing=lg]:*:[[data-slot=frame-panel]+[data-slot=frame-panel]]:mt-2", + ], + }, + dense: { + // Positional rules must stay as parent selectors — cannot be expressed via CSS vars + true: "p-0 gap-0 border-[var(--frame-border-color)] [&_[data-slot=frame-panel]]:-mx-px [&_[data-slot=frame-panel]]:before:hidden [&_[data-slot=frame-panel]:last-child]:-mb-px [&:not(:has([data-slot=frame-panel-header]))_[data-slot=frame-panel]:is(:first-child)]:-mt-px", + false: "", + }, + }, + defaultVariants: { + variant: "default", + spacing: "default", + stacked: false, + dense: false, + }, + } +) + +function Frame({ + className, + variant, + spacing, + stacked, + dense, + ...props +}: React.ComponentProps<"div"> & VariantProps) { + return ( +
+ ) +} + +function FramePanel({ + className, + fit, + ...props +}: React.ComponentProps<"div"> & { fit?: boolean }) { + return ( +
+ ) +} + +function FrameHeader({ className, ...props }: React.ComponentProps<"header">) { + return ( +
+ ) +} + +function FrameTitle({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ) +} + +function FrameDescription({ + className, + ...props +}: React.ComponentProps<"div">) { + return ( +
+ ) +} + +function FrameFooter({ className, ...props }: React.ComponentProps<"footer">) { + return ( +
+ ) +} + +export { + Frame, + FramePanel, + FrameHeader, + FrameTitle, + FrameDescription, + FrameFooter, + frameVariants, +} \ No newline at end of file diff --git a/apps/web/src/components/schedule/schedule-jobs-grid.tsx b/apps/web/src/components/schedule/schedule-jobs-grid.tsx new file mode 100644 index 0000000..11e1abe --- /dev/null +++ b/apps/web/src/components/schedule/schedule-jobs-grid.tsx @@ -0,0 +1,100 @@ +import { ColumnDef, useReactTable } from '@tanstack/react-table' +import { useMemo } from 'react' + +import { Badge } from '@evobgp/ui/components/badge' + +import { DataGridShell } from '@/components/data-grid-shell' +import { DataGridColumnHeader } from '@/components/reui/data-grid/data-grid-column-header' +import { createClientDataGridOptions } from '@/lib/data-grid-defaults' +import type { JobRow } from '@/types/api' + +export function ScheduleJobsGrid({ + items, + isLoading = false, +}: { + items: JobRow[] + isLoading?: boolean +}) { + const columns = useMemo[]>( + () => [ + { + accessorKey: 'kind', + header: ({ column }) => , + cell: ({ row }) => {row.original.kind}, + meta: { headerTitle: 'Вид' }, + }, + { + accessorKey: 'status', + header: ({ column }) => , + cell: ({ row }) => ( + + {row.original.status} + + ), + meta: { headerTitle: 'Статус' }, + }, + { + id: 'created_at', + accessorFn: (row) => row.created_at ?? '', + header: ({ column }) => , + cell: ({ row }) => ( + + {row.original.created_at + ? new Date(row.original.created_at).toLocaleString('ru-RU') + : '—'} + + ), + meta: { headerTitle: 'Создана' }, + }, + { + id: 'finished_at', + accessorFn: (row) => row.finished_at ?? '', + header: ({ column }) => , + cell: ({ row }) => ( + + {row.original.finished_at + ? new Date(row.original.finished_at).toLocaleString('ru-RU') + : '—'} + + ), + meta: { headerTitle: 'Завершена' }, + }, + { + accessorKey: 'error', + enableSorting: false, + header: 'Ошибка', + cell: ({ row }) => ( + + {row.original.error ?? ''} + + ), + meta: { headerTitle: 'Ошибка' }, + }, + ], + [], + ) + + const table = useReactTable({ + data: items, + columns, + ...createClientDataGridOptions(), + getRowId: (row) => row.job_id, + }) + + return ( + + ) +} diff --git a/apps/web/src/components/schedule/schedule-modules-grid.tsx b/apps/web/src/components/schedule/schedule-modules-grid.tsx new file mode 100644 index 0000000..5f98fa0 --- /dev/null +++ b/apps/web/src/components/schedule/schedule-modules-grid.tsx @@ -0,0 +1,113 @@ +import { ColumnDef, useReactTable } 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 { LoadingButton } from '@/components/loading-button' +import { DataGridColumnHeader } from '@/components/reui/data-grid/data-grid-column-header' +import { createClientDataGridOptions } from '@/lib/data-grid-defaults' +import type { ModuleRow } from '@/types/api' + +export function ScheduleModulesGrid({ + items, + refreshing, + onRefresh, + isLoading = false, +}: { + items: ModuleRow[] + refreshing: Record + onRefresh: (id: string) => void + isLoading?: boolean +}) { + const columns = useMemo[]>( + () => [ + { + accessorKey: 'name', + header: ({ column }) => , + cell: ({ row }) => {row.original.name}, + meta: { headerTitle: 'Модуль' }, + }, + { + accessorKey: 'type', + header: ({ column }) => , + cell: ({ row }) => {row.original.type}, + meta: { headerTitle: 'Тип' }, + }, + { + id: 'schedule', + accessorFn: (row) => row.cron_expr ?? String(row.refresh_interval_sec ?? ''), + header: ({ column }) => , + cell: ({ row }) => ( + + {row.original.cron_expr ?? + (row.original.refresh_interval_sec ? `${row.original.refresh_interval_sec}s` : '—')} + + ), + meta: { headerTitle: 'Расписание' }, + }, + { + id: 'last_refreshed_at', + accessorFn: (row) => row.last_refreshed_at ?? '', + header: ({ column }) => , + cell: ({ row }) => ( + + {row.original.last_refreshed_at + ? new Date(row.original.last_refreshed_at).toLocaleString('ru-RU') + : '—'} + + ), + meta: { headerTitle: 'Обновлено' }, + }, + { + id: 'enabled', + accessorFn: (row) => (row.enabled ? 'on' : 'off'), + header: ({ column }) => , + cell: ({ row }) => + row.original.enabled ? ( + Вкл + ) : ( + Выкл + ), + meta: { headerTitle: 'Статус' }, + }, + { + id: 'actions', + enableSorting: false, + header: () => null, + cell: ({ row }) => ( +
+ onRefresh(row.original.id)} + > + + Обновить + +
+ ), + }, + ], + [onRefresh, refreshing], + ) + + const table = useReactTable({ + data: items, + columns, + ...createClientDataGridOptions(), + getRowId: (row) => row.id, + }) + + return ( + + ) +} diff --git a/apps/web/src/components/settings/settings-kv-grid.tsx b/apps/web/src/components/settings/settings-kv-grid.tsx new file mode 100644 index 0000000..47669af --- /dev/null +++ b/apps/web/src/components/settings/settings-kv-grid.tsx @@ -0,0 +1,57 @@ +import { ColumnDef, useReactTable } from '@tanstack/react-table' +import { useMemo } from 'react' + +import { DataGridShell } from '@/components/data-grid-shell' +import { DataGridColumnHeader } from '@/components/reui/data-grid/data-grid-column-header' +import { createClientDataGridOptions } from '@/lib/data-grid-defaults' + +export interface SettingsKvRow { + id: string | number + key: string + value: string +} + +export function SettingsKvGrid({ + items, + isLoading = false, +}: { + items: SettingsKvRow[] + isLoading?: boolean +}) { + const columns = useMemo[]>( + () => [ + { + accessorKey: 'key', + header: ({ column }) => , + cell: ({ row }) => {row.original.key}, + meta: { headerTitle: 'Ключ' }, + }, + { + accessorKey: 'value', + header: ({ column }) => , + cell: ({ row }) => {row.original.value}, + meta: { headerTitle: 'Значение' }, + }, + ], + [], + ) + + const table = useReactTable({ + data: items, + columns, + ...createClientDataGridOptions({ + initialState: { pagination: { pageSize: 25 } }, + }), + getRowId: (row) => String(row.id), + }) + + return ( + 10} + /> + ) +} diff --git a/apps/web/src/lib/data-grid-defaults.ts b/apps/web/src/lib/data-grid-defaults.ts new file mode 100644 index 0000000..57a9749 --- /dev/null +++ b/apps/web/src/lib/data-grid-defaults.ts @@ -0,0 +1,44 @@ +import { + getCoreRowModel, + getPaginationRowModel, + getSortedRowModel, + type TableOptions, +} from '@tanstack/react-table' + +import type { DataGridProps } from '@/components/reui/data-grid/data-grid' + +export const DATA_GRID_TABLE_LAYOUT: NonNullable['tableLayout']> = { + dense: true, + headerSticky: true, + rowBorder: true, +} + +export const DATA_GRID_PAGINATION_RU = { + sizes: [10, 25, 50] as number[], + sizesLabel: 'Показать', + sizesDescription: 'на странице', + info: '{from}–{to} из {count}', + rowsPerPageLabel: 'Строк на странице', + previousPageLabel: 'Предыдущая страница', + nextPageLabel: 'Следующая страница', +} + +export const DATA_GRID_DENSE_LAYOUT: NonNullable['tableLayout']> = { + ...DATA_GRID_TABLE_LAYOUT, + dense: true, +} + +export function createClientDataGridOptions( + overrides?: Partial>, +): Pick< + TableOptions, + 'getCoreRowModel' | 'getSortedRowModel' | 'getPaginationRowModel' | 'initialState' +> { + return { + getCoreRowModel: getCoreRowModel(), + getSortedRowModel: getSortedRowModel(), + getPaginationRowModel: getPaginationRowModel(), + initialState: { pagination: { pageSize: 10 } }, + ...overrides, + } +} diff --git a/apps/web/src/routes/_auth/dashboard.tsx b/apps/web/src/routes/_auth/dashboard.tsx index a101d04..d2c28e0 100644 --- a/apps/web/src/routes/_auth/dashboard.tsx +++ b/apps/web/src/routes/_auth/dashboard.tsx @@ -1,4 +1,4 @@ -import { createFileRoute } from '@tanstack/react-router' +import { createFileRoute, useNavigate } from '@tanstack/react-router' import { useQueries } from '@tanstack/react-query' import { Boxes, @@ -6,26 +6,29 @@ import { Clock, GitBranch, Info, - Network, - Play, - Plus, Radio, RefreshCw, Activity, - Share2, - Tags, - Gauge, XCircle, } from 'lucide-react' -import { Link } from '@tanstack/react-router' import { useState } 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' import { Skeleton } from '@evobgp/ui/components/skeleton' +import { DashboardNetworkPanel } from '@/components/dashboard/dashboard-network-panel' +import { DashboardQuickActions } from '@/components/dashboard/dashboard-quick-actions' +import { DashboardRecentJobsGrid } from '@/components/dashboard/dashboard-recent-jobs-grid' +import { DashboardRecentRevisionsGrid } from '@/components/dashboard/dashboard-recent-revisions-grid' import { PageHeader } from '@/components/page-header' +import { + Frame, + FrameDescription, + FrameHeader, + FramePanel, + FrameTitle, +} from '@/components/reui/frame' import { SectionCards, type SectionCardItem } from '@/components/section-cards' import { SectionCardsSkeleton } from '@/components/skeletons' @@ -46,6 +49,7 @@ export const Route = createFileRoute('/_auth/dashboard')({ }) function DashboardComponent() { + const navigate = useNavigate() const [lastUpdated, setLastUpdated] = useState(null) const results = useQueries({ @@ -95,7 +99,7 @@ function DashboardComponent() { value: initialLoading ? '—' : String(modules.length), icon: , hint: countBadge(modules.length, modulesHasMore, 'AS, CDN, домены, IP'), - onClick: () => window.location.assign('/modules'), + onClick: () => navigate({ to: '/modules' }), }, { label: 'Пиры', @@ -104,7 +108,7 @@ function DashboardComponent() { hint: countBadge(peers.length, peersHasMore, 'Established / включённых'), badge: net.peersMismatch > 0 ? `mismatch ${net.peersMismatch}` : undefined, variant: net.peersMismatch > 0 ? 'warning' : 'default', - onClick: () => window.location.assign('/network?tab=peers'), + onClick: () => navigate({ to: '/network', search: { tab: 'peers' } }), }, { label: 'Спикеры', @@ -112,26 +116,29 @@ function DashboardComponent() { icon: , hint: countBadge(speakers.length, speakersHasMore, 'online / всего'), variant: net.speakersOnline < net.speakersTotal ? 'warning' : 'default', - onClick: () => window.location.assign('/network?tab=overview'), + onClick: () => navigate({ to: '/network', search: { tab: 'overview' } }), }, { label: 'Ревизии', value: initialLoading ? '—' : String(revisions.length), icon: , hint: countBadge(revisions.length, revisionsHasMore, 'configs'), - onClick: () => window.location.assign('/operations'), + onClick: () => navigate({ to: '/operations', search: { tab: 'revisions' } }), }, { label: 'Активных задач', value: initialLoading ? '—' : String(running), icon: , hint: 'queued и running', - onClick: () => window.location.assign('/operations?tab=jobs'), + onClick: () => navigate({ to: '/operations', search: { tab: 'jobs' } }), }, ] + const activityLoading = + refreshing && jobs.length === 0 && revisions.length === 0 && peers.length === 0 && speakers.length === 0 + return ( -
+
- {initialLoading ? : } + {initialLoading ? : }
- - - + + + + Недавние задачи + Последние фоновые операции + + {activityLoading ? ( + + ) : ( + + )} + + + + + + + Последние ревизии + История конфигураций + + {activityLoading ? ( + + ) : ( + + )} + + + + + + + Состояние сети + BGP-сессии и спикеры + + {activityLoading ? ( + + ) : ( + + )} + +
- - - Быстрые действия - Частые переходы к настройке и деплою - - - - - - - - - - + + + + Быстрые действия + Частые переходы к настройке и деплою + + + +
) } @@ -255,146 +277,3 @@ function HealthAlert({ ) } - -function RecentJobsCard({ - jobs, - nameById, - loading, -}: { - jobs: import('@/types/api').JobRow[] - nameById: Map - loading: boolean -}) { - return ( - - - Недавние задачи - Последние фоновые операции - - - {loading && jobs.length === 0 ? ( - - ) : jobs.length === 0 ? ( -

Нет задач

- ) : ( -
    - {jobs.slice(0, 8).map((j) => ( -
  • - - {j.kind} - - {j.meta?.module_id ? nameById.get(String(j.meta.module_id)) ?? '' : ''} - - - - {j.status} - -
  • - ))} -
- )} -
-
- ) -} - -function RecentRevisionsCard({ - revisions, - loading, -}: { - revisions: import('@/types/api').RevisionRow[] - loading: boolean -}) { - return ( - - - Последние ревизии - История конфигураций - - - {loading && revisions.length === 0 ? ( - - ) : revisions.length === 0 ? ( -

Нет ревизий

- ) : ( -
    - {revisions.slice(0, 8).map((r) => ( -
  • - {r.id.slice(0, 10)}… - - {new Date(r.created_at).toLocaleString('ru-RU')} - -
  • - ))} -
- )} -
-
- ) -} - -function NetworkStatusCard({ - peers, - speakers, - loading, -}: { - peers: import('@/types/api').PeerRow[] - speakers: import('@/types/api').SpeakerRow[] - loading: boolean -}) { - const m = aggregateNetworkMetrics(peers, speakers) - return ( - - - Состояние сети - BGP-сессии и спикеры - - - {loading && peers.length === 0 && speakers.length === 0 ? ( - - ) : ( -
- - - {m.peersMismatch > 0 ? ( - - ) : null} -
- )} -
-
- ) -} - -function Row({ - label, - value, - variant = 'default', -}: { - label: string - value: string - variant?: 'default' | 'warning' -}) { - return ( -
- {label} - - {value} - -
- ) -} diff --git a/apps/web/src/routes/_auth/directories.tsx b/apps/web/src/routes/_auth/directories.tsx index 49bb5fa..da324e5 100644 --- a/apps/web/src/routes/_auth/directories.tsx +++ b/apps/web/src/routes/_auth/directories.tsx @@ -3,19 +3,12 @@ import { useQuery } from '@tanstack/react-query' import { BookText, Globe, Info, RefreshCw, Tags } from 'lucide-react' import { Alert, AlertDescription, AlertTitle } from '@evobgp/ui/components/alert' -import { Badge } from '@evobgp/ui/components/badge' import { Button } from '@evobgp/ui/components/button' -import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@evobgp/ui/components/card' import { Tabs, TabsContent, TabsList, TabsTrigger } from '@evobgp/ui/components/tabs' -import { - Table, - TableBody, - TableCell, - TableHead, - TableHeader, - TableRow, -} from '@evobgp/ui/components/table' +import { DataGridCard } from '@/components/data-grid-shell' +import { DirectoriesCommunitiesGrid } from '@/components/directories/directories-communities-grid' +import { DirectoriesDohGrid } from '@/components/directories/directories-doh-grid' import { PageHeader } from '@/components/page-header' import { QueryState } from '@/components/query-state' import { SectionCards, type SectionCardItem } from '@/components/section-cards' @@ -94,91 +87,50 @@ function DirectoriesComponent() { - - - Сообщества BGP - Теги для префиксов в фильтрах BIRD - - - } - onRetry={() => communitiesQ.refetch()} - > - {(items) => ( - - - - Название - Значение - Тип - - - - {items.map((c) => ( - - {c.title} - {c.community} - - community - - - ))} - -
- )} -
-
-
+ + } + onRetry={() => communitiesQ.refetch()} + > + {(items) => ( + + )} + +
- - - DoH профили - Резолверы DNS-over-HTTPS для доменных модулей - - - } - onRetry={() => dohQ.refetch()} - > - {(items) => ( - - - - Название - URL - По умолчанию - - - - {items.map((p) => ( - - {p.name ?? p.url} - {p.url} - - - - - ))} - -
- )} -
-
-
+ + } + onRetry={() => dohQ.refetch()} + > + {(items) => ( + + )} + +
diff --git a/apps/web/src/routes/_auth/modules/index.tsx b/apps/web/src/routes/_auth/modules/index.tsx index 4f21b95..4e1f8bb 100644 --- a/apps/web/src/routes/_auth/modules/index.tsx +++ b/apps/web/src/routes/_auth/modules/index.tsx @@ -1,25 +1,15 @@ import { Link, createFileRoute } from '@tanstack/react-router' import { useQuery } from '@tanstack/react-query' -import { Boxes, Plus, RefreshCw } from 'lucide-react' +import { Plus, RefreshCw } from 'lucide-react' import { Button } from '@evobgp/ui/components/button' -import { Card, CardContent, CardHeader, CardTitle } from '@evobgp/ui/components/card' -import { - Table, - TableBody, - TableCell, - TableHead, - TableHeader, - TableRow, -} from '@evobgp/ui/components/table' -import { Badge } from '@/components/reui/badge' +import { DataGridCard } from '@/components/data-grid-shell' +import { ModulesListGrid } from '@/components/modules/modules-list-grid' import { PageHeader } from '@/components/page-header' import { QueryState } from '@/components/query-state' import { TableSkeleton } from '@/components/skeletons' -import { TruncatedText } from '@/components/truncated-text' import { modulesListQueryOptions } from '@/queries/modules' -import type { ModuleRow } from '@/types/api' export const Route = createFileRoute('/_auth/modules/')({ component: ModulesListComponent, @@ -48,76 +38,34 @@ function ModulesListComponent() { } /> - - - Все модули + }> Создать - - - } - onRetry={() => query.refetch()} - > - {(items) => } - - - + } + > + } + onRetry={() => query.refetch()} + > + {(items) => ( + + )} + +
) } - -function ModulesTable({ items }: { items: ModuleRow[] }) { - return ( - - - - Название - Тип - Приоритет - Состояние - Обновлено - - - - {items.map((m) => ( - (window.location.href = `/modules/${m.id}`)} - > - -
- - {m.name} -
-
- - {m.type} - - {m.priority} - - {m.enabled ? ( - включён - ) : ( - выключен - )} - - - {m.last_refreshed_at ? new Date(m.last_refreshed_at).toLocaleString('ru-RU') : '—'} - -
- ))} -
-
- ) -} diff --git a/apps/web/src/routes/_auth/monitoring.tsx b/apps/web/src/routes/_auth/monitoring.tsx index 8d94b19..f44ef93 100644 --- a/apps/web/src/routes/_auth/monitoring.tsx +++ b/apps/web/src/routes/_auth/monitoring.tsx @@ -1,6 +1,6 @@ import { createFileRoute, useSearch } from '@tanstack/react-router' import { useQuery } from '@tanstack/react-query' -import { Activity, AlertTriangle, Bird, Database, Gauge, HardDrive, HeartPulse, Info, ListTodo, RefreshCw, ShieldCheck } from 'lucide-react' +import { Activity, AlertTriangle, Bird, Database, Gauge, HeartPulse, Info, ListTodo, RefreshCw } from 'lucide-react' import { Alert, AlertDescription, AlertTitle } from '@evobgp/ui/components/alert' import { Badge } from '@evobgp/ui/components/badge' @@ -8,15 +8,8 @@ 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 { - Table, - TableBody, - TableCell, - TableHead, - TableHeader, - TableRow, -} from '@evobgp/ui/components/table' +import { MonitoringReadyGrid } from '@/components/monitoring/monitoring-ready-grid' import { PageHeader } from '@/components/page-header' import { QueryState } from '@/components/query-state' import { SectionCards, type SectionCardItem } from '@/components/section-cards' @@ -147,7 +140,7 @@ function MonitoringComponent() { skeleton={
} onRetry={() => readyQ.refetch()} > - {(ready) => } + {(ready) => } @@ -342,84 +335,6 @@ function overallHint(input: OverallInput): string { return 'Все системы работают в штатном режиме' } -function ReadyTable({ - health, - ready, -}: { - health?: { ok?: boolean; status?: string; error?: string } | null - ready: ReadyStatus -}) { - const checks = ready.checks ?? {} - const iconByKey: Record = { - postgres: Database, - store: HardDrive, - jobs: ListTodo, - } - return ( - - - - Проверка - Статус - - - - - -
- -
-

Liveness

-

/v1/health

-
-
-
- - - {health?.ok ? 'OK' : 'Ошибка'} - - -
- - -
- -
-

Readiness

-

/v1/ready

-
-
-
- - - {ready.status ?? '—'} - - -
- {Object.entries(checks).map(([key, value]) => { - const ok = typeof value === 'boolean' ? value : value?.ok !== false - const Icon = iconByKey[key] ?? ListTodo - return ( - - -
- -
-

{key}

-
-
-
- - {ok ? 'OK' : 'Ошибка'} - -
- ) - })} -
-
- ) -} - function BirdSummary({ bird }: { bird: import('@/types/api').BirdStatus }) { if (!bird.birdc_configured) { return ( diff --git a/apps/web/src/routes/_auth/network.tsx b/apps/web/src/routes/_auth/network.tsx index 7fac04c..35d7c67 100644 --- a/apps/web/src/routes/_auth/network.tsx +++ b/apps/web/src/routes/_auth/network.tsx @@ -5,17 +5,10 @@ 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 { - Table, - TableBody, - TableCell, - TableHead, - TableHeader, - TableRow, -} from '@evobgp/ui/components/table' -import { Badge } from '@/components/reui/badge' -import { StatusBadge } from '@/components/status-badge' +import { DataGridCard } from '@/components/data-grid-shell' +import { NetworkPeersGrid } from '@/components/network/network-peers-grid' +import { NetworkSpeakersGrid } from '@/components/network/network-speakers-grid' import { PageHeader } from '@/components/page-header' import { QueryState } from '@/components/query-state' import { TableSkeleton } from '@/components/skeletons' @@ -115,47 +108,47 @@ function NetworkComponent() { - - - Пиры - - - } - onRetry={() => peersQ.refetch()} - > - {(items) => } - - - + + } + onRetry={() => peersQ.refetch()} + > + {(items) => ( + + )} + + - - - Спикеры - - - } - onRetry={() => speakersQ.refetch()} - > - {(items) => } - - - + + } + onRetry={() => speakersQ.refetch()} + > + {(items) => ( + + )} + + @@ -196,78 +189,3 @@ function BirdSummary({ bird }: { bird: import('@/types/api').BirdStatus }) {
) } - -function PeersTable({ items }: { items: import('@/types/api').PeerRow[] }) { - return ( - - - - Имя - Neighbor - ASN - Состояние - - - - {items.map((p) => ( - - {p.name ?? p.neighbor} - {p.neighbor} - {p.remote_asn ?? '—'} - - - {p.session_mismatch ? ( - - mismatch - - ) : null} - - - ))} - -
- ) -} - -function SpeakersTable({ items }: { items: import('@/types/api').SpeakerRow[] }) { - return ( - - - - Endpoint - Роль - Agent - BGP - - - - {items.map((s) => ( - - {s.endpoint} - - {s.role} - - - {s.live?.agent_ok === true ? ( - - ) : s.live?.agent_ok === false ? ( - - ) : ( - - )} - - - {s.live ? ( - - {s.live.bgp_established ?? 0} / {s.live.bgp_sessions_total ?? 0} - - ) : ( - '—' - )} - - - ))} - -
- ) -} diff --git a/apps/web/src/routes/_auth/operations.tsx b/apps/web/src/routes/_auth/operations.tsx index 855b820..e94da12 100644 --- a/apps/web/src/routes/_auth/operations.tsx +++ b/apps/web/src/routes/_auth/operations.tsx @@ -15,15 +15,10 @@ import { SelectValue, } from '@evobgp/ui/components/select' import { Tabs, TabsContent, TabsList, TabsTrigger } from '@evobgp/ui/components/tabs' -import { - Table, - TableBody, - TableCell, - TableHead, - TableHeader, - TableRow, -} from '@evobgp/ui/components/table' +import { DataGridCard } from '@/components/data-grid-shell' +import { OperationsJobsGrid } from '@/components/operations/operations-jobs-grid' +import { OperationsRevisionsGrid } from '@/components/operations/operations-revisions-grid' import { PageHeader } from '@/components/page-header' import { QueryState } from '@/components/query-state' import { SectionCards, type SectionCardItem } from '@/components/section-cards' @@ -33,7 +28,6 @@ import { ConfirmDialog } from '@/components/confirm-dialog' import { operationsJobsQueryOptions, operationsRevisionsQueryOptions, operationsDiffQueryOptions } from '@/queries/operations' import { moduleNameById, overviewModulesQueryOptions } from '@/queries/overview' import { apiMutate, waitForJob } from '@/lib/api-client' -import type { JobRow } from '@/types/api' export const Route = createFileRoute('/_auth/operations')({ component: OperationsComponent, @@ -177,24 +171,25 @@ function OperationsComponent() { - - - История ревизий - - - revisionsQ.refetch()} - > - {(items) => } - - - + + revisionsQ.refetch()} + > + {(items) => ( + + )} + + @@ -202,168 +197,32 @@ function OperationsComponent() { - - - Задачи - - - jobsQ.refetch()} - > - {(items) => } - - - + + jobsQ.refetch()} + > + {(items) => ( + + )} + +
) } -function RevisionsTable({ - items, - qc, -}: { - items: import('@/types/api').RevisionRow[] - qc: import('@tanstack/react-query').QueryClient -}) { - const rollbackMutation = useMutation({ - mutationFn: (id: string) => - apiMutate(`/v1/revisions/${id}/rollback`, 'POST', {}).then(() => id), - onSuccess: () => { - toast.success('Откат выполнен') - void qc.invalidateQueries({ queryKey: ['operations'] }) - }, - onError: (e) => toast.error(e instanceof Error ? e.message : 'Не удалось откатить'), - }) - - return ( - - - - ID - Создана - Префиксов - - - - - {items.map((r) => ( - - {r.id.slice(0, 12)}… - - {new Date(r.created_at).toLocaleString('ru-RU')} - - - {r.materialized_prefix_count} - - - - - - } - title={`Откатиться к ревизии ${r.id.slice(0, 8)}…?`} - description="Будет создана новая ревизия на основе выбранной. Требуется роль operator." - confirmLabel="Откатить" - destructive - onConfirm={() => rollbackMutation.mutate(r.id)} - /> - - - ))} - -
- ) -} - -function JobsTable({ - items, - nameById, - qc, -}: { - items: JobRow[] - nameById: Map - qc: import('@tanstack/react-query').QueryClient -}) { - const cancelMutation = useMutation({ - mutationFn: (jobId: string) => apiMutate(`/v1/jobs/${jobId}/cancel`, 'POST', {}), - onSuccess: () => { - toast.success('Задача отменена') - void qc.invalidateQueries({ queryKey: ['operations'] }) - }, - onError: (e) => toast.error(e instanceof Error ? e.message : 'Не удалось отменить'), - }) - - return ( - - - - Вид - Статус - Создана - Завершена - - - - - {items.map((j) => ( - - -
- {j.kind} - {j.meta?.module_id ? ( - - {nameById.get(String(j.meta.module_id)) ?? String(j.meta.module_id)} - - ) : null} -
-
- - - - - {j.created_at ? new Date(j.created_at).toLocaleString('ru-RU') : '—'} - - - {j.finished_at ? new Date(j.finished_at).toLocaleString('ru-RU') : '—'} - - - {j.status === 'running' || j.status === 'queued' ? ( - - ) : null} - -
- ))} -
-
- ) -} - -function StatusBadgeColored({ status }: { status: string }) { - const cls = - status === 'succeeded' - ? 'text-success' - : status === 'failed' || status === 'cancelled' - ? 'text-destructive' - : 'text-info' - return {status} -} - function DiffTab({ revisions }: { revisions: import('@/types/api').RevisionRow[] }) { const [a, setA] = useState('') const [b, setB] = useState('') diff --git a/apps/web/src/routes/_auth/schedule.tsx b/apps/web/src/routes/_auth/schedule.tsx index 78f357f..c134158 100644 --- a/apps/web/src/routes/_auth/schedule.tsx +++ b/apps/web/src/routes/_auth/schedule.tsx @@ -5,24 +5,16 @@ import { toast } from 'sonner' import { useState } from 'react' import { Alert, AlertDescription, AlertTitle } from '@evobgp/ui/components/alert' -import { Badge } from '@evobgp/ui/components/badge' import { Button } from '@evobgp/ui/components/button' -import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@evobgp/ui/components/card' import { Tabs, TabsContent, TabsList, TabsTrigger } from '@evobgp/ui/components/tabs' -import { - Table, - TableBody, - TableCell, - TableHead, - TableHeader, - TableRow, -} from '@evobgp/ui/components/table' +import { DataGridCard } from '@/components/data-grid-shell' +import { ScheduleJobsGrid } from '@/components/schedule/schedule-jobs-grid' +import { ScheduleModulesGrid } from '@/components/schedule/schedule-modules-grid' import { PageHeader } from '@/components/page-header' import { QueryState } from '@/components/query-state' import { SectionCards, type SectionCardItem } from '@/components/section-cards' import { SectionCardsSkeleton } from '@/components/skeletons' -import { LoadingButton } from '@/components/loading-button' import { operationsJobsQueryOptions } from '@/queries/operations' import { modulesListQueryOptions } from '@/queries/modules' @@ -108,82 +100,33 @@ function ScheduleComponent() { {loading ? : } - - - Модули - Расписание обновления и ручной запуск ingest - - - modulesQ.refetch()} - > - {(items) => ( - - - - Модуль - Тип - Расписание - Обновлено - Статус - - - - - {items.map((m) => ( - - {m.name} - - {m.type} - - - {m.cron_expr ?? (m.refresh_interval_sec ? `${m.refresh_interval_sec}s` : '—')} - - - {m.last_refreshed_at ? new Date(m.last_refreshed_at).toLocaleString('ru-RU') : '—'} - - - {m.enabled ? ( - Вкл - ) : ( - Выкл - )} - - - refreshMutation.mutate(m.id)} - > - - Обновить - - - - ))} - -
- )} -
-
-
+ + modulesQ.refetch()} + > + {(items) => ( + refreshMutation.mutate(id)} + isLoading={modulesQ.isFetching && !modulesQ.isLoading} + /> + )} + + - - - Задачи - Последние задачи из API - - - - - + + +
) } @@ -196,68 +139,20 @@ function JobsTabs({ jobs, loading }: { jobs: JobRow[]; loading: boolean }) { return ( - + Все ({jobs.length}) Обновление ({refresh.length}) С ошибкой ({failed.length}) - + - + - + ) } - -function JobsTable({ items, loading }: { items: JobRow[]; loading: boolean }) { - if (loading) return
Загрузка…
- if (items.length === 0) - return
Нет задач
- return ( - - - - Вид - Статус - Создана - Завершена - Ошибка - - - - {items.map((j) => ( - - {j.kind} - - - {j.status} - - - - {j.created_at ? new Date(j.created_at).toLocaleString('ru-RU') : '—'} - - - {j.finished_at ? new Date(j.finished_at).toLocaleString('ru-RU') : '—'} - - - {j.error ?? ''} - - - ))} - -
- ) -} diff --git a/apps/web/src/routes/_auth/tenant-settings.tsx b/apps/web/src/routes/_auth/tenant-settings.tsx index 76dc885..d70367f 100644 --- a/apps/web/src/routes/_auth/tenant-settings.tsx +++ b/apps/web/src/routes/_auth/tenant-settings.tsx @@ -16,15 +16,9 @@ import { SelectValue, } from '@evobgp/ui/components/select' import { Tabs, TabsContent, TabsList, TabsTrigger } from '@evobgp/ui/components/tabs' -import { - Table, - TableBody, - TableCell, - TableHead, - TableHeader, - TableRow, -} from '@evobgp/ui/components/table' + +import { SettingsKvGrid } from '@/components/settings/settings-kv-grid' import { PageHeader } from '@/components/page-header' import { QueryState } from '@/components/query-state' import { LoadingButton } from '@/components/loading-button' @@ -366,22 +360,10 @@ function TenantSettingsComponent() { onRetry={() => settingsQ.refetch()} > {(items) => ( - - - - Ключ - Значение - - - - {items.map((row) => ( - - {row.key} - {row.value} - - ))} - -
+ )} diff --git a/apps/web/tsconfig.tsbuildinfo b/apps/web/tsconfig.tsbuildinfo index 55323ab..2da9d9c 100644 --- a/apps/web/tsconfig.tsbuildinfo +++ b/apps/web/tsconfig.tsbuildinfo @@ -1 +1 @@ -{"root":["./src/main.tsx","./src/routetree.gen.ts","./src/components/confirm-dialog.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/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/api-key-create-dialog.tsx","./src/components/access/api-key-token-dialog.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-section.tsx","./src/components/modules/module-ip-range-entry-dialog.tsx","./src/components/modules/module-kpi-cards.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/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/lib/api-client.ts","./src/lib/queryclient.ts","./src/lib/router.ts","./src/lib/ui-labels.ts","./src/lib/access/api-key-labels.ts","./src/lib/modules/display.ts","./src/lib/modules/helpers.ts","./src/queries/api-keys.ts","./src/queries/auth.ts","./src/queries/directories.ts","./src/queries/firewall.ts","./src/queries/modules.ts","./src/queries/monitoring.ts","./src/queries/network.ts","./src/queries/operations.ts","./src/queries/overview.ts","./src/queries/settings.ts","./src/routes/__root.tsx","./src/routes/_auth.tsx","./src/routes/index.tsx","./src/routes/_auth/access.tsx","./src/routes/_auth/dashboard.tsx","./src/routes/_auth/directories.tsx","./src/routes/_auth/firewall.tsx","./src/routes/_auth/monitoring.tsx","./src/routes/_auth/network.tsx","./src/routes/_auth/operations.tsx","./src/routes/_auth/schedule.tsx","./src/routes/_auth/settings.tsx","./src/routes/_auth/tenant-settings.tsx","./src/routes/_auth/modules/$moduleid.tsx","./src/routes/_auth/modules/index.tsx","./src/routes/_auth/modules/new.tsx","./src/types/api.ts","./vite.config.ts"],"version":"5.9.3"} \ No newline at end of file +{"root":["./src/main.tsx","./src/routetree.gen.ts","./src/components/confirm-dialog.tsx","./src/components/data-grid-shell.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/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/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/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-grid.tsx","./src/components/network/network-speakers-grid.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/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/modules/display.ts","./src/lib/modules/helpers.ts","./src/queries/api-keys.ts","./src/queries/auth.ts","./src/queries/directories.ts","./src/queries/firewall.ts","./src/queries/modules.ts","./src/queries/monitoring.ts","./src/queries/network.ts","./src/queries/operations.ts","./src/queries/overview.ts","./src/queries/settings.ts","./src/routes/__root.tsx","./src/routes/_auth.tsx","./src/routes/index.tsx","./src/routes/_auth/access.tsx","./src/routes/_auth/dashboard.tsx","./src/routes/_auth/directories.tsx","./src/routes/_auth/firewall.tsx","./src/routes/_auth/monitoring.tsx","./src/routes/_auth/network.tsx","./src/routes/_auth/operations.tsx","./src/routes/_auth/schedule.tsx","./src/routes/_auth/settings.tsx","./src/routes/_auth/tenant-settings.tsx","./src/routes/_auth/modules/$moduleid.tsx","./src/routes/_auth/modules/index.tsx","./src/routes/_auth/modules/new.tsx","./src/types/api.ts","./vite.config.ts"],"version":"5.9.3"} \ No newline at end of file