Files
MikrotikManager/components/data-grids/recursive-routes-data-grid.tsx
T
Denozordec d3a2d38b37
Docker images / prepare-release (push) Successful in 6s
Docker images / backend-image (push) Successful in 1m37s
Docker images / frontend-image (push) Successful in 1m50s
Docker images / updater-image (push) Successful in 44s
Docker images / notify-webhook (push) Has been skipped
Docker images / publish-release (push) Successful in 7s
fix(ui): заменить таблицы на карточки данных и улучшить функциональность поиска
2026-06-30 22:22:51 +07:00

329 lines
11 KiB
TypeScript

"use client"
import { useMemo, useState } from "react"
import {
type ColumnDef,
getCoreRowModel,
getExpandedRowModel,
getSortedRowModel,
useReactTable,
} 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 sorted = [...group.endpoints].sort((a, b) => a.distance - b.distance)
return (
<div className="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.map((ep, idx) => (
<div key={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 || inferCountry(ep.gateway)) && (
<Flag code={ep.country || inferCountry(ep.gateway) || ""} size={16} />
)}
<span className="text-[11px] font-semibold text-muted-foreground uppercase tracking-wide">
Endpoint {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 items-center gap-3">
<span>scope: {ep.scope ?? "—"}</span>
<span>t.scope: {ep.targetScope ?? "—"}</span>
<span>check: {ep.checkGateway || "—"}</span>
</div>
</div>
))}
</div>
</div>
)
}
interface RecursiveRoutesDataGridProps {
groups: RecursiveRouteGroup[]
expandedKey?: string | null
onExpandedChange?: (key: string | null) => void
onEdit: (group: RecursiveRouteGroup) => void
onDelete: (group: RecursiveRouteGroup) => void
}
function RecursiveRoutesDataGrid({
groups,
expandedKey,
onExpandedChange,
onEdit,
onDelete,
}: 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<RecursiveRouteGroup>[]>(
() => [
{
id: "route",
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 (
<div className="flex items-start gap-2 min-w-0">
{isExpanded ? (
<ChevronDownIcon className="size-3.5 mt-0.5 shrink-0 text-muted-foreground" />
) : (
<ChevronRightIcon 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>
</div>
)
},
meta: {
headerTitle: "Route / Comment",
headerClassName: DATA_GRID_CELL_PAD_FIRST,
cellClassName: DATA_GRID_CELL_PAD_FIRST,
expandedContent: (group: RecursiveRouteGroup) => (
<RouteGroupExpandedDetail group={group} />
),
},
},
{
id: "gateways",
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.map((ep, idx) => {
const code = ep.country || inferCountry(ep.gateway)
return (
<div key={ep.id} className="flex items-center gap-1.5 text-[11px] font-mono">
<span
className={cn(
"size-1.5 rounded-full shrink-0",
idx === 0 ? "bg-emerald-500" : "bg-sky-500",
)}
/>
{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-sky-600 dark:text-sky-400 truncate min-w-0">
{ep.gateway}
</span>
</div>
)
})}
</div>
)
},
meta: { headerClassName: DATA_GRID_CELL_PAD, cellClassName: DATA_GRID_CELL_PAD },
},
{
id: "epCount",
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",
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",
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 opacity-0 group-hover/row:opacity-100 transition-opacity"
onClick={(e) => e.stopPropagation()}
>
<Button
size="sm"
variant="ghost"
className="size-7 p-0 text-muted-foreground hover:text-foreground"
onClick={() => onEdit(g)}
>
<PencilIcon className="size-3.5" />
</Button>
<Button
size="sm"
variant="ghost"
className={cn(
"size-7 p-0 transition-colors",
confirmDel
? "text-destructive bg-destructive/10 hover:bg-destructive/20"
: "text-muted-foreground hover:text-destructive",
)}
onClick={() => {
if (!confirmDel) setConfirmDeleteKey(g.key)
else {
onDelete(g)
setConfirmDeleteKey(null)
}
}}
onBlur={() => setConfirmDeleteKey(null)}
>
{confirmDel ? (
<AlertCircleIcon className="size-3.5" />
) : (
<TrashIcon className="size-3.5" />
)}
</Button>
</div>
)
},
size: 80,
meta: { headerClassName: DATA_GRID_CELL_PAD_LAST, cellClassName: DATA_GRID_CELL_PAD_LAST },
},
],
[confirmDeleteKey, expandedKey, onDelete, onEdit],
)
const table = useReactTable({
data: groups,
columns,
getCoreRowModel: getCoreRowModel(),
getSortedRowModel: getSortedRowModel(),
getExpandedRowModel: getExpandedRowModel(),
getRowId: (row) => row.id ?? row.key,
getRowCanExpand: () => true,
state: { expanded },
})
if (groups.length === 0) {
return (
<EmptyState
icon={<RouteIcon className="size-4" />}
title="Нет маршрутов"
className="border-0 py-10"
/>
)
}
return (
<DataGridShell
table={table}
recordCount={groups.length}
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"),
}}
/>
)
}
export { RecursiveRoutesDataGrid, type RecursiveRoutesDataGridProps, inferCountry }