253 lines
11 KiB
TypeScript
253 lines
11 KiB
TypeScript
"use client"
|
||
|
||
import { dataGridFeatures, type DataGridFeatures } from "@/components/reui/data-grid"
|
||
|
||
import { useIsMobile } from "@/hooks/use-mobile"
|
||
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 isMobile = useIsMobile()
|
||
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: isMobile ? 112 : 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 min-w-0 items-center gap-2">
|
||
<WifiIcon aria-hidden="true" className="size-3.5 text-muted-foreground shrink-0" />
|
||
<div className="min-w-0">
|
||
<p title={wan.name} className="truncate font-mono text-xs font-semibold">{wan.name}</p>
|
||
<p title={wan.iface} className="truncate 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: isMobile ? 160 : 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, isMobile])
|
||
|
||
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>
|
||
{legs.some(leg=>leg.observed?.probeId) && <p className="px-5 pb-3 text-xs text-muted-foreground">Скорость — снимок пары устройств. Сопоставление с WAN расчётное. ↓ к Home, ↑ от Home.</p>}
|
||
<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 }
|