Files
MikrotikManager/components/data-grids/route-optimizer-wan-matrix-data-grid.tsx
T

250 lines
11 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 { dataGridFeatures, type DataGridFeatures } from "@/components/reui/data-grid"
import { useEffect, useMemo, useState } from "react"
import { Input } from "@/components/ui/input"
import { Button } from "@/components/ui/button"
import { DataGridContainer, DataGridScrollArea, DataGridTable } from "@/components/reui/data-grid"
import {
type ColumnDef,
useTable,
} from "@tanstack/react-table"
import type { HomeRouter, JumpHost, WanJhLeg } from "@/lib/route-optimizer-data"
import { Flag } from "@/components/flag"
import { cn } from "@/lib/utils"
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 { WifiIcon } from "lucide-react"
function LossChip({ loss }: { loss: number }) {
if (loss === 0) return <span className="text-success text-[11px] font-mono">0%</span>
return (
<span className={cn("text-[11px] font-mono", loss > 1 ? "text-destructive" : "text-warning")}>
{loss}%
</span>
)
}
interface RouteOptimizerWanMatrixDataGridProps {
home: HomeRouter
legs: WanJhLeg[]
jumpHosts: JumpHost[]
}
function RouteOptimizerWanMatrixDataGrid({
home,
legs,
jumpHosts,
}: RouteOptimizerWanMatrixDataGridProps) {
const [wanQuery, setWanQuery] = useState("")
const [jhQuery, setJhQuery] = useState("")
const [jhPage, setJhPage] = useState(0)
const filteredWans = useMemo(() => home.wans.filter(wan =>
[wan.name, wan.iface, wan.isp, wan.ip, wan.id].join(" ").toLocaleLowerCase().includes(wanQuery.trim().toLocaleLowerCase())), [home.wans, wanQuery])
const filteredHosts = useMemo(() => jumpHosts.filter(jh =>
[jh.label, jh.site, jh.ip, jh.country, jh.id].join(" ").toLocaleLowerCase().includes(jhQuery.trim().toLocaleLowerCase())), [jumpHosts, jhQuery])
const lastHostPage = Math.max(0, Math.ceil(filteredHosts.length / 5) - 1)
const hostPage = Math.min(jhPage, lastHostPage)
if (jhPage !== hostPage) setJhPage(hostPage)
const visibleHosts = useMemo(() => filteredHosts.slice(hostPage * 5, hostPage * 5 + 5), [filteredHosts, hostPage])
const { bestScore, legMap } = useMemo(() => {
let bestScore = legs.length ? -Infinity : 0
const legMap = new Map<string, Map<string, WanJhLeg>>()
for (const leg of legs) {
bestScore = Math.max(bestScore, leg.score)
let byHost = legMap.get(leg.wanId)
if (!byHost) { byHost = new Map(); legMap.set(leg.wanId, byHost) }
// Preserve the first matching leg, as the former Array.find did.
if (!byHost.has(leg.jhId)) byHost.set(leg.jhId, leg)
}
return { bestScore, legMap }
}, [legs])
const columns = useMemo<ColumnDef<DataGridFeatures, HomeRouter["wans"][number]>[]>(() => {
const base: ColumnDef<DataGridFeatures, HomeRouter["wans"][number]>[] = [
{
id: "wan",
size: 180,
accessorKey: "name",
header: () => <span className="text-[11px] font-medium text-muted-foreground">WAN-аплинк</span>,
cell: ({ row }) => {
const wan = row.original
return (
<div className="flex items-center gap-2">
<WifiIcon aria-hidden="true" className="size-3.5 text-muted-foreground shrink-0" />
<div>
<p className="font-mono text-xs font-semibold">{wan.name}</p>
<p className="text-[10px] text-muted-foreground">{wan.iface}</p>
</div>
</div>
)
},
meta: { headerClassName: DATA_GRID_CELL_PAD_FIRST, cellClassName: DATA_GRID_CELL_PAD_FIRST },
},
{
id: "isp",
size: 160,
header: () => <span className="text-[11px] font-medium text-muted-foreground">ISP / IP</span>,
cell: ({ row }) => {
const wan = row.original
return (
<div>
<p className="text-xs font-medium">{wan.isp}</p>
<p className="font-mono text-[10px] text-muted-foreground">{wan.ip}</p>
</div>
)
},
meta: { headerClassName: DATA_GRID_CELL_PAD, cellClassName: DATA_GRID_CELL_PAD },
},
{
id: "bandwidth",
size: 112,
header: () => (
<span className="text-[11px] font-medium text-muted-foreground block text-right">
Лимит модели
</span>
),
cell: ({ row }) => {
const wan = row.original
return (
<div className="text-right">
<p className="font-mono text-xs">↓{wan.maxDl}</p>
<p className="font-mono text-[10px] text-muted-foreground">↑{wan.maxUl} Мбит</p>
</div>
)
},
meta: {
headerClassName: cn(DATA_GRID_CELL_PAD, "text-right"),
cellClassName: cn(DATA_GRID_CELL_PAD, "text-right"),
},
},
]
const jhCols: ColumnDef<DataGridFeatures, HomeRouter["wans"][number]>[] = visibleHosts.map((jh) => ({
id: `jh-${jh.id}`,
size: 200,
header: () => (
<div className="text-center">
<div className="text-[11px] font-medium text-muted-foreground">{jh.label}</div>
<div className="font-mono font-normal text-[10px] opacity-60 flex items-center justify-center gap-1">
<Flag code={jh.country} />
{jh.site} · {jh.ip}
</div>
</div>
),
cell: ({ row }) => {
const wan = row.original
const leg = legMap.get(wan.id)?.get(jh.id)
if (!leg) return <span className="text-center text-muted-foreground text-xs block">—</span>
const isBest = leg.score === bestScore
return (
<div className={cn("text-center", isBest && "bg-success/5")}>
<div
className={cn(
"flex flex-col items-center gap-0.5 rounded-md px-2 py-1.5 transition-colors",
isBest ? "border border-success/20 bg-success/8" : "border border-transparent",
)}
>
{isBest && (
<span className="text-[9px] font-bold uppercase tracking-wide text-success mb-0.5">
★ ЛУЧШИЙ
</span>
)}
<span
className={cn(
"font-mono text-xs font-semibold",
leg.observed && leg.observed.pingMs === null ? "text-muted-foreground" : (leg.observed?.pingMs ?? leg.pingMs) < 10
? "text-success "
: (leg.observed?.pingMs ?? leg.pingMs) < 25
? "text-foreground"
: "text-warning ",
)}
>
{leg.observed ? (leg.observed.pingMs ?? "—") : leg.pingMs} мс
</span>
<span className="text-[10px] text-muted-foreground font-mono">
↓{leg.observed ? (leg.observed.dlMbps ?? "—") : leg.dlMbps} ↑{leg.observed ? (leg.observed.ulMbps ?? "—") : leg.ulMbps}
</span>
{leg.observed && <span className="text-[10px] text-muted-foreground">
{leg.observed.probeId ? <>Снимок пробы · Ping: {leg.observed.pingAt ? <time dateTime={leg.observed.pingAt}>{new Date(leg.observed.pingAt).toLocaleString("ru-RU")}</time> : "дата неизвестна"}. Скорость: дата неизвестна.</> : "Измерения отсутствуют"}
</span>}
<div className="flex items-center gap-1.5 mt-0.5">
<span className="text-[10px] font-mono text-foreground/70">модель {leg.score}</span>
{(leg.observed ? (leg.observed.lossPct ?? 0) : leg.loss) > 0 && <LossChip loss={leg.observed?.lossPct ?? leg.loss} />}
</div>
</div>
</div>
)
},
meta: {
headerClassName: cn(DATA_GRID_CELL_PAD, "min-w-[130px] text-center"),
cellClassName: cn(DATA_GRID_CELL_PAD, "min-w-[130px]"),
},
}))
const last = jhCols[jhCols.length - 1]
if (last?.meta) {
last.meta.headerClassName = cn(last.meta.headerClassName, DATA_GRID_CELL_PAD_LAST.replace("py-3", ""))
last.meta.cellClassName = DATA_GRID_CELL_PAD_LAST
}
return [...base, ...jhCols]
}, [bestScore, visibleHosts, legMap])
const table = useTable({
features: dataGridFeatures,
manualPagination: false,
autoResetPageIndex: false,
initialState: { pagination: { pageIndex: 0, pageSize: 50 }, columnPinning: { start: ["wan"], end: [] } },
manualSorting: true,
data: filteredWans,
columns,
getRowId: (row) => row.id,
})
const { pageIndex, pageSize } = table.state.pagination
useEffect(() => {
const lastPage = Math.max(0, Math.ceil(filteredWans.length / pageSize) - 1)
if (pageIndex > lastPage) table.setPageIndex(lastPage)
}, [filteredWans.length, pageIndex, pageSize, table])
return (
<section aria-label="Матрица WAN и Jump Host" className="min-w-0">
<div className="flex flex-wrap gap-3 px-5 py-3">
<label className="min-w-0 flex-1 basis-48 text-xs">Поиск WAN
<Input className="mt-1" value={wanQuery} onChange={event => { setWanQuery(event.target.value); table.setPageIndex(0) }} placeholder="Название, интерфейс, ISP или IP" />
</label>
<label className="min-w-0 flex-1 basis-48 text-xs">Поиск Jump Host
<Input className="mt-1" value={jhQuery} onChange={event => { setJhQuery(event.target.value); setJhPage(0) }} placeholder="Название, площадка или IP" />
</label>
</div>
<div className="flex flex-wrap items-center gap-2 px-5 pb-3 text-xs text-muted-foreground">
<span>WAN: {filteredWans.length} из {home.wans.length}. Jump Host: {filteredHosts.length} из {jumpHosts.length}.</span>
{filteredHosts.length > 5 && <>
<span>Столбцы {hostPage * 5 + 1}–{Math.min((hostPage + 1) * 5, filteredHosts.length)} из {filteredHosts.length}</span>
<Button variant="outline" size="sm" aria-label="Предыдущие Jump Host" disabled={hostPage === 0} onClick={() => setJhPage(hostPage - 1)}>Назад</Button>
<Button variant="outline" size="sm" aria-label="Следующие Jump Host" disabled={hostPage === lastHostPage} onClick={() => setJhPage(hostPage + 1)}>Далее</Button>
</>}
</div>
<DataGridShell
table={table}
recordCount={filteredWans.length}
pagination={filteredWans.length > 50 || pageSize !== 50}
tableClassNames={{ headerRow: "border-b bg-muted/20", bodyRow: "group/row hover:bg-muted/30" }}
tableLayout={{ dense: true, width: "fixed", columnsPinnable: true }}
>
<DataGridContainer border={false}>
<DataGridScrollArea orientation="horizontal"><DataGridTable /></DataGridScrollArea>
</DataGridContainer>
</DataGridShell>
</section>
)
}
export { RouteOptimizerWanMatrixDataGrid, type RouteOptimizerWanMatrixDataGridProps }