373 lines
15 KiB
TypeScript
373 lines
15 KiB
TypeScript
"use client"
|
||
|
||
import { DataGridContainer, DataGridScrollArea, DataGridTable, dataGridFeatures, type DataGridFeatures } from "@/components/reui/data-grid"
|
||
|
||
import { useEffect, useMemo, useRef, useState } from "react"
|
||
import {
|
||
type ColumnDef,
|
||
useTable,
|
||
} from "@tanstack/react-table"
|
||
import { Flag } from "@/components/flag"
|
||
import { cn } from "@/lib/utils"
|
||
import { Button } from "@/components/ui/button"
|
||
import { DataGridShell } from "@/components/data-grids/shared/data-grid-shell"
|
||
import {
|
||
DATA_GRID_CELL_PAD,
|
||
DATA_GRID_CELL_PAD_FIRST,
|
||
DATA_GRID_CELL_PAD_LAST,
|
||
} from "@/components/data-grids/shared/data-grid-layout"
|
||
import { DataGridSortHeader } from "@/components/data-grids/shared/data-grid-sort-header"
|
||
import { EmptyState } from "@/components/empty-state"
|
||
import {
|
||
AlertCircleIcon,
|
||
ChevronDownIcon,
|
||
ChevronRightIcon,
|
||
PencilIcon,
|
||
RouteIcon,
|
||
TrashIcon,
|
||
} from "lucide-react"
|
||
|
||
export interface RecursiveRouteEndpoint {
|
||
id: string
|
||
gateway: string
|
||
distance: number
|
||
scope: number | null
|
||
targetScope: number | null
|
||
checkGateway: string
|
||
country: string
|
||
disabled: boolean
|
||
}
|
||
|
||
export interface RecursiveRouteGroup {
|
||
id?: string
|
||
key: string
|
||
dstAddress: string
|
||
routingTable: string
|
||
comment: string
|
||
endpoints: RecursiveRouteEndpoint[]
|
||
}
|
||
|
||
const INFER_COUNTRIES = [
|
||
{ code: "RU", keys: ["MSK", "SPB", "RTK", "MTS", "VPSVILLE", "IHOR"] },
|
||
{ code: "SE", keys: ["SWE", "STO"] },
|
||
{ code: "FI", keys: ["HEL", "FIN"] },
|
||
{ code: "DE", keys: ["FRA", "GER", "DE"] },
|
||
{ code: "NL", keys: ["AMS", "NLD", "NL"] },
|
||
{ code: "SG", keys: ["SGP", "SIN", "SG"] },
|
||
{ code: "TR", keys: ["TUR", "TR"] },
|
||
{ code: "US", keys: ["USA", "US", "NYC", "LAX"] },
|
||
]
|
||
|
||
function inferCountry(name: string): string | null {
|
||
const upper = name.toUpperCase()
|
||
for (const c of INFER_COUNTRIES) {
|
||
if (c.keys.some((k) => upper.includes(k))) return c.code
|
||
}
|
||
return null
|
||
}
|
||
|
||
function RouteGroupExpandedDetail({ group }: { group: RecursiveRouteGroup }) {
|
||
const [page, setPage] = useState(0)
|
||
const sorted = useMemo(() => [...group.endpoints].sort((a, b) => a.distance - b.distance), [group.endpoints])
|
||
const lastPage = Math.max(0, Math.ceil(sorted.length / 50) - 1)
|
||
if (page > lastPage) setPage(lastPage)
|
||
const currentPage = Math.min(page, lastPage)
|
||
const offset = currentPage * 50
|
||
return (
|
||
<div role="region" aria-label={`Конечные точки: ${group.dstAddress} · ${group.routingTable}`} className="sticky left-0 w-full max-w-[calc(100vw-3rem)] md:max-w-none flex flex-col gap-4 px-5 py-4 bg-muted/20">
|
||
<div className="flex flex-wrap gap-x-6 gap-y-2 text-xs">
|
||
<span className="text-muted-foreground">
|
||
Route: <span className="font-mono text-foreground">{group.dstAddress}</span>
|
||
</span>
|
||
<span className="text-muted-foreground">
|
||
Table:{" "}
|
||
<span className="font-mono text-foreground">{group.routingTable || "main"}</span>
|
||
</span>
|
||
{group.comment && <span className="text-muted-foreground italic">{group.comment}</span>}
|
||
</div>
|
||
<div className="grid grid-cols-1 md:grid-cols-2 gap-2.5">
|
||
{sorted.slice(offset, offset + 50).map((ep, idx) => (
|
||
<div key={ep.id} data-recursive-endpoint={ep.id} className="rounded-lg border border-border bg-background px-4 py-3">
|
||
<div className="flex items-center justify-between">
|
||
<div className="flex items-center gap-1.5">
|
||
{ep.country && (
|
||
<Flag code={ep.country} size={16} />
|
||
)}
|
||
<span className="text-[11px] font-semibold text-muted-foreground uppercase tracking-wide">
|
||
Endpoint {offset + idx + 1}
|
||
</span>
|
||
</div>
|
||
<span className="text-[11px] font-mono">distance: {ep.distance}</span>
|
||
</div>
|
||
<p className="mt-1.5 font-mono text-sm break-all leading-tight">{ep.gateway}</p>
|
||
<div className="mt-1.5 text-[11px] text-muted-foreground flex flex-wrap items-center gap-3">
|
||
<span>scope: {ep.scope ?? "—"}</span>
|
||
<span>t.scope: {ep.targetScope ?? "—"}</span>
|
||
<span>check: {ep.checkGateway || "—"}</span>
|
||
{ep.disabled && <span>Отключён</span>}
|
||
</div>
|
||
</div>
|
||
))}
|
||
</div>
|
||
{sorted.length > 50 && <div className="flex flex-wrap items-center gap-2">
|
||
<span role="status" className="text-xs text-muted-foreground">Конечные точки {offset + 1}–{Math.min(offset + 50, sorted.length)} из {sorted.length}</span>
|
||
<Button type="button" variant="outline" className="min-h-11" aria-label="Предыдущие конечные точки" disabled={currentPage === 0} onClick={() => setPage(currentPage - 1)}>Назад</Button>
|
||
<Button type="button" variant="outline" className="min-h-11" aria-label="Следующие конечные точки" disabled={currentPage === lastPage} onClick={() => setPage(currentPage + 1)}>Далее</Button>
|
||
</div>}
|
||
</div>
|
||
)
|
||
}
|
||
|
||
interface RecursiveRoutesDataGridProps {
|
||
groups: RecursiveRouteGroup[]
|
||
expandedKey?: string | null
|
||
onExpandedChange?: (key: string | null) => void
|
||
onEdit: (group: RecursiveRouteGroup) => void
|
||
onDelete: (group: RecursiveRouteGroup) => void
|
||
mutationsDisabled?: boolean
|
||
emptyTitle?: string
|
||
}
|
||
|
||
function RecursiveRoutesDataGrid({
|
||
groups,
|
||
expandedKey,
|
||
onExpandedChange,
|
||
onEdit,
|
||
onDelete,
|
||
mutationsDisabled = false,
|
||
emptyTitle = "Нет маршрутов",
|
||
}: RecursiveRoutesDataGridProps) {
|
||
const [confirmDeleteKey, setConfirmDeleteKey] = useState<string | null>(null)
|
||
|
||
const expanded = useMemo(() => {
|
||
if (!expandedKey) return {}
|
||
const g = groups.find((x) => x.key === expandedKey)
|
||
return g ? { [(g.id ?? g.key)]: true } : {}
|
||
}, [expandedKey, groups])
|
||
|
||
const columns = useMemo<ColumnDef<DataGridFeatures, RecursiveRouteGroup>[]>(
|
||
() => [
|
||
{
|
||
id: "route",
|
||
size: 176,
|
||
accessorKey: "dstAddress",
|
||
header: ({ column }) => (
|
||
<DataGridSortHeader column={column} title="Route / Comment" className="ml-1" />
|
||
),
|
||
cell: ({ row }) => {
|
||
const g = row.original
|
||
const isExpanded = expandedKey === g.key
|
||
return (
|
||
<button type="button" aria-label={`Раскрыть маршрут ${g.dstAddress} · ${g.routingTable}`} aria-expanded={isExpanded} onClick={event => { event.stopPropagation(); onExpandedChange?.(isExpanded ? null : g.key) }} className="flex items-start gap-2 min-h-11 min-w-0 text-left rounded-sm outline-none focus-visible:ring-2 focus-visible:ring-ring">
|
||
{isExpanded ? (
|
||
<ChevronDownIcon aria-hidden="true" className="size-3.5 mt-0.5 shrink-0 text-muted-foreground" />
|
||
) : (
|
||
<ChevronRightIcon aria-hidden="true" className="size-3.5 mt-0.5 shrink-0 text-muted-foreground/40" />
|
||
)}
|
||
<div className="min-w-0">
|
||
<p className="font-medium truncate">{g.dstAddress}</p>
|
||
<p className="text-xs font-mono text-muted-foreground">{g.comment || "—"}</p>
|
||
</div>
|
||
</button>
|
||
)
|
||
},
|
||
meta: {
|
||
headerTitle: "Route / Comment",
|
||
headerClassName: DATA_GRID_CELL_PAD_FIRST,
|
||
cellClassName: DATA_GRID_CELL_PAD_FIRST,
|
||
expandedContent: (group: RecursiveRouteGroup) => (
|
||
<RouteGroupExpandedDetail key={group.key} group={group} />
|
||
),
|
||
},
|
||
},
|
||
{
|
||
id: "gateways",
|
||
size: 208,
|
||
header: () => <span className="text-xs font-medium text-muted-foreground">Gateways</span>,
|
||
enableSorting: false,
|
||
cell: ({ row }) => {
|
||
const sorted = [...row.original.endpoints].sort((a, b) => a.distance - b.distance)
|
||
return (
|
||
<div className="flex flex-col gap-0.5">
|
||
{sorted.slice(0, 3).map(ep => {
|
||
const code = ep.country
|
||
return (
|
||
<div key={ep.id} className="flex items-center gap-1.5 text-[11px] font-mono">
|
||
<span aria-hidden="true"
|
||
className={cn(
|
||
"size-1.5 rounded-full shrink-0",
|
||
ep.disabled ? "bg-muted-foreground" : "bg-info",
|
||
)}
|
||
/>
|
||
{code ? (
|
||
<Flag code={code} size={14} className="shrink-0" />
|
||
) : (
|
||
<span className="text-[10px] text-muted-foreground w-3.5 text-center shrink-0">
|
||
?
|
||
</span>
|
||
)}
|
||
<span className="font-semibold text-info truncate min-w-0">
|
||
{ep.gateway}
|
||
</span>
|
||
</div>
|
||
)
|
||
})}
|
||
{sorted.length > 3 && <span className="text-xs text-muted-foreground">Ещё {sorted.length - 3} · раскройте маршрут</span>}
|
||
</div>
|
||
)
|
||
},
|
||
meta: { headerClassName: DATA_GRID_CELL_PAD, cellClassName: DATA_GRID_CELL_PAD },
|
||
},
|
||
{
|
||
id: "epCount",
|
||
size: 64,
|
||
header: () => <span className="text-xs font-medium text-muted-foreground">EP</span>,
|
||
enableSorting: false,
|
||
cell: ({ row }) => (
|
||
<span className="text-xs tabular-nums">{row.original.endpoints.length}</span>
|
||
),
|
||
meta: { headerClassName: DATA_GRID_CELL_PAD, cellClassName: DATA_GRID_CELL_PAD },
|
||
},
|
||
{
|
||
id: "priority",
|
||
size: 96,
|
||
header: () => <span className="text-xs font-medium text-muted-foreground">Priority</span>,
|
||
enableSorting: false,
|
||
cell: ({ row }) => {
|
||
const bestDistance = Math.min(...row.original.endpoints.map((ep) => ep.distance))
|
||
return <span className="font-mono text-xs">d{bestDistance}</span>
|
||
},
|
||
meta: { headerClassName: DATA_GRID_CELL_PAD, cellClassName: DATA_GRID_CELL_PAD },
|
||
},
|
||
{
|
||
id: "table",
|
||
size: 128,
|
||
accessorKey: "routingTable",
|
||
header: ({ column }) => <DataGridSortHeader column={column} title="Table" />,
|
||
cell: ({ row }) => (
|
||
<span className="font-mono text-xs text-muted-foreground">
|
||
{row.original.routingTable || "main"}
|
||
</span>
|
||
),
|
||
meta: {
|
||
headerTitle: "Table",
|
||
headerClassName: DATA_GRID_CELL_PAD,
|
||
cellClassName: DATA_GRID_CELL_PAD,
|
||
},
|
||
},
|
||
{
|
||
id: "actions",
|
||
header: () => <span className="sr-only">Действия</span>,
|
||
enableSorting: false,
|
||
cell: ({ row }) => {
|
||
const g = row.original
|
||
const confirmDel = confirmDeleteKey === g.key
|
||
return (
|
||
<div
|
||
className="flex items-center gap-0.5 justify-end"
|
||
onClick={(e) => e.stopPropagation()}
|
||
>
|
||
<Button type="button" size="sm"
|
||
variant="ghost"
|
||
className="size-11 p-0 text-muted-foreground hover:text-foreground"
|
||
aria-label={`Редактировать маршрут ${g.dstAddress} · ${g.routingTable}`}
|
||
disabled={mutationsDisabled}
|
||
onClick={() => onEdit(g)}
|
||
>
|
||
<PencilIcon aria-hidden="true" className="size-3.5" />
|
||
</Button>
|
||
<Button type="button" size="sm"
|
||
variant="ghost"
|
||
className={cn(
|
||
"size-11 p-0 transition-colors",
|
||
confirmDel
|
||
? "text-destructive bg-destructive/10 hover:bg-destructive/20"
|
||
: "text-muted-foreground hover:text-destructive",
|
||
)}
|
||
aria-label={`${confirmDel ? "Подтвердить удаление" : "Удалить маршрут"} ${g.dstAddress} · ${g.routingTable}`}
|
||
disabled={mutationsDisabled}
|
||
onClick={() => {
|
||
if (mutationsDisabled) return
|
||
if (!confirmDel) setConfirmDeleteKey(g.key)
|
||
else {
|
||
onDelete(g)
|
||
setConfirmDeleteKey(null)
|
||
}
|
||
}}
|
||
onBlur={() => setConfirmDeleteKey(null)}
|
||
>
|
||
{confirmDel ? (
|
||
<AlertCircleIcon aria-hidden="true" className="size-3.5" />
|
||
) : (
|
||
<TrashIcon aria-hidden="true" className="size-3.5" />
|
||
)}
|
||
</Button>
|
||
</div>
|
||
)
|
||
},
|
||
size: 112,
|
||
meta: { headerClassName: DATA_GRID_CELL_PAD_LAST, cellClassName: DATA_GRID_CELL_PAD_LAST },
|
||
},
|
||
],
|
||
[confirmDeleteKey, expandedKey, onDelete, onEdit, onExpandedChange, mutationsDisabled],
|
||
)
|
||
|
||
const table = useTable({
|
||
features: dataGridFeatures,
|
||
manualPagination: false,
|
||
autoResetPageIndex: false,
|
||
initialState: { pagination: { pageIndex: 0, pageSize: 50 }, columnPinning: { start: ["route"], end: [] } },
|
||
data: groups,
|
||
columns,
|
||
getRowId: (row) => row.id ?? row.key,
|
||
getRowCanExpand: () => true,
|
||
state: { expanded },
|
||
})
|
||
|
||
const requestedExpansion = useRef<string | null>(null)
|
||
const { pageIndex, pageSize } = table.state.pagination
|
||
useEffect(() => {
|
||
const lastPage = Math.max(0, Math.ceil(groups.length / pageSize) - 1)
|
||
if (pageIndex > lastPage) table.setPageIndex(lastPage)
|
||
if (!expandedKey) { requestedExpansion.current = null; return }
|
||
const index = table.getSortedRowModel().rows.findIndex(row => row.original.key === expandedKey)
|
||
if (index < 0) { requestedExpansion.current = null; return }
|
||
if (requestedExpansion.current !== expandedKey) {
|
||
requestedExpansion.current = expandedKey
|
||
table.setPageIndex(Math.floor(index / pageSize))
|
||
}
|
||
}, [groups.length, expandedKey, pageIndex, pageSize, table])
|
||
|
||
if (groups.length === 0) {
|
||
return (
|
||
<EmptyState
|
||
icon={<RouteIcon aria-hidden="true" className="size-4" />}
|
||
title={emptyTitle}
|
||
className="border-0 py-10"
|
||
/>
|
||
)
|
||
}
|
||
|
||
return (
|
||
<DataGridShell
|
||
table={table}
|
||
recordCount={groups.length}
|
||
pagination={groups.length > 50 || pageSize !== 50}
|
||
tableLayout={{ dense: true, columnsPinnable: true }}
|
||
onRowClick={(row) => {
|
||
onExpandedChange?.(expandedKey === row.key ? null : row.key)
|
||
}}
|
||
tableClassNames={{
|
||
headerRow: "border-b border-border",
|
||
bodyRow: cn("group/row cursor-pointer", expandedKey && "data-[state=selected]:bg-muted/30"),
|
||
}}
|
||
>
|
||
<DataGridContainer border={false} className="w-full rounded-none border-0">
|
||
<DataGridScrollArea orientation="horizontal"><DataGridTable /></DataGridScrollArea>
|
||
</DataGridContainer>
|
||
</DataGridShell>
|
||
)
|
||
}
|
||
|
||
export { RecursiveRoutesDataGrid, type RecursiveRoutesDataGridProps, inferCountry }
|