Files
MikrotikManager/components/data-grids/recursive-routes-data-grid.tsx
T

373 lines
15 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"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 }