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