fix(ui): заменить таблицы на карточки данных и улучшить функциональность поиска
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

This commit is contained in:
Denozordec
2026-06-30 22:22:51 +07:00
parent a1a9124f3d
commit d3a2d38b37
63 changed files with 8509 additions and 3652 deletions
+16 -370
View File
@@ -3,6 +3,11 @@
import { useCallback, useEffect, useState, useMemo, useRef } from "react"
import Link from "next/link"
import { PageHeader } from "@/components/page-header"
import { DataPageCard } from "@/components/data-page-card"
import { RouteOptimizerWanMatrixDataGrid } from "@/components/data-grids/route-optimizer-wan-matrix-data-grid"
import { RouteOptimizerFullRoutesDataGrid } from "@/components/data-grids/route-optimizer-full-routes-data-grid"
import { RouteOptimizerCommRecsDataGrid } from "@/components/data-grids/route-optimizer-comm-recs-data-grid"
import { RouteOptimizerOspfPreviewDataGrid } from "@/components/data-grids/route-optimizer-ospf-preview-data-grid"
import { FormToggle } from "@/components/form-kit"
import { Card, CardContent } from "@/components/ui/card"
import { Button } from "@/components/ui/button"
@@ -266,327 +271,6 @@ function SettingRow({ label, unit, children }: { label: string; unit?: string; c
)
}
// ─── WAN Matrix table ─────────────────────────────────────────────────────────
// Rows = WANs, Columns = JHs, cells show ping / bw / score
function WanMatrix({ home, legs, jumpHosts, pw: _pw }: {
home: HomeRouter
legs: WanJhLeg[]
jumpHosts: JumpHost[]
pw: number
}) {
// find best leg overall
const bestScore = legs.length ? Math.max(...legs.map(l => l.score)) : 0
return (
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b text-[11px] text-muted-foreground bg-muted/20">
<th className="text-left font-medium px-4 py-2 w-[160px]">WAN-аплинк</th>
<th className="text-left font-medium px-3 py-2">ISP / IP</th>
<th className="text-right font-medium px-3 py-2">Макс. полоса</th>
{jumpHosts.map(jh => (
<th key={jh.id} className="text-center font-medium px-3 py-2 min-w-[130px]">
<div>{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>
</th>
))}
</tr>
</thead>
<tbody className="divide-y divide-border">
{home.wans.map(wan => (
<tr key={wan.id} className="hover:bg-muted/30 transition-colors">
{/* WAN name */}
<td className="px-4 py-3">
<div className="flex items-center gap-2">
<WifiIcon 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>
</td>
{/* ISP */}
<td className="px-3 py-3">
<p className="text-xs font-medium">{wan.isp}</p>
<p className="font-mono text-[10px] text-muted-foreground">{wan.ip}</p>
</td>
{/* Max bandwidth */}
<td className="px-3 py-3 text-right">
<p className="font-mono text-xs">{wan.maxDl}</p>
<p className="font-mono text-[10px] text-muted-foreground">{wan.maxUl} Мбит</p>
</td>
{/* Per-JH cells */}
{jumpHosts.map(jh => {
const leg = legs.find(l => l.wanId === wan.id && l.jhId === jh.id)
if (!leg) return <td key={jh.id} className="px-3 py-3 text-center text-muted-foreground text-xs"></td>
const isBest = leg.score === bestScore
return (
<td key={jh.id} className={cn(
"px-3 py-3 text-center",
isBest && "bg-emerald-500/5",
)}>
<div className={cn(
"flex flex-col items-center gap-0.5 rounded-md px-2 py-1.5 transition-colors",
isBest
? "border border-emerald-500/20 bg-emerald-500/8"
: "border border-transparent",
)}>
{isBest && (
<span className="text-[9px] font-bold uppercase tracking-wide text-emerald-600 dark:text-emerald-400 mb-0.5">
ЛУЧШИЙ
</span>
)}
<span className={cn("font-mono text-xs font-semibold",
leg.pingMs < 10 ? "text-emerald-600 dark:text-emerald-400"
: leg.pingMs < 25 ? "text-foreground"
: "text-amber-600 dark:text-amber-400"
)}>
{leg.pingMs} мс
</span>
<span className="text-[10px] text-muted-foreground font-mono">
{leg.dlMbps} {leg.ulMbps}
</span>
<div className="flex items-center gap-1.5 mt-0.5">
<span className="text-[10px] font-mono text-foreground/70">
score {leg.score}
</span>
{leg.loss > 0 && <LossChip loss={leg.loss} />}
</div>
</div>
</td>
)
})}
</tr>
))}
</tbody>
</table>
</div>
)
}
// ─── Full routes table ────────────────────────────────────────────────────────
function FullRoutesTable({ routes, bestId }: { routes: FullRoute[]; bestId?: string }) {
const [expanded, setExpanded] = useState(false)
const visible = expanded ? routes : routes.slice(0, 5)
return (
<div>
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b text-[11px] text-muted-foreground bg-muted/20">
<th className="text-left font-medium px-4 py-2"># Маршрут</th>
<th className="text-left font-medium px-3 py-2">WAN JH</th>
<th className="text-left font-medium px-3 py-2">JH Exit</th>
<th className="text-center font-medium px-3 py-2">Ping (итого)</th>
<th className="text-center font-medium px-3 py-2">BW (мин)</th>
<th className="text-center font-medium px-3 py-2">Score</th>
<th className="text-center font-medium px-3 py-2">P(opt)</th>
<th className="text-center font-medium px-3 py-2">Conf.</th>
</tr>
</thead>
<tbody className="divide-y divide-border/60">
{visible.map((r, i) => {
const isBest = r.id === bestId || i === 0
const totalPing = r.hw.pingMs + r.je.pingMs
const minDl = Math.min(r.hw.dlMbps, r.je.dlMbps)
const minUl = Math.min(r.hw.ulMbps, r.je.ulMbps)
return (
<tr key={r.id} className={cn(
"hover:bg-muted/30 transition-colors",
isBest && "bg-emerald-500/5",
)}>
<td className="px-4 py-2.5">
<div className="flex items-center gap-2">
<span className="text-[10px] font-mono text-muted-foreground w-4">{i + 1}</span>
{isBest && (
<span className="text-[9px] font-bold uppercase tracking-wide text-emerald-600 dark:text-emerald-400 bg-emerald-500/10 border border-emerald-500/20 px-1.5 py-0.5 rounded">
Лучший
</span>
)}
</div>
</td>
<td className="px-3 py-2.5">
<div className="flex items-center gap-1.5 text-xs">
<span className="font-mono font-semibold text-sky-600 dark:text-sky-400">{r.wan.name}</span>
<ArrowRightIcon className="size-3 text-muted-foreground shrink-0" />
<div>
<div className="font-medium">{r.jh.label}</div>
<div className="font-mono text-[10px] text-muted-foreground">{r.hw.pingMs} мс · {r.hw.dlMbps} {r.hw.ulMbps}</div>
</div>
</div>
</td>
<td className="px-3 py-2.5">
<div className="flex items-center gap-1.5 text-xs">
<div>
<div className="flex items-center gap-1 font-medium">
<Flag code={r.exit.country} />
{r.exit.label}
<span className="text-[10px] text-muted-foreground">({r.exit.site})</span>
</div>
<div className="font-mono text-[10px] text-muted-foreground">{r.je.pingMs} мс · {r.je.dlMbps} {r.je.ulMbps}</div>
</div>
</div>
</td>
<td className={cn("px-3 py-2.5 text-center font-mono text-xs",
totalPing < 40 ? "text-emerald-600 dark:text-emerald-400"
: totalPing < 80 ? "text-amber-600 dark:text-amber-400"
: "text-red-500"
)}>
{totalPing} мс
</td>
<td className="px-3 py-2.5 text-center font-mono text-xs text-muted-foreground">
<div>{minDl}</div>
<div>{minUl}</div>
</td>
<td className="px-3 py-2.5 text-center font-mono text-xs font-semibold">{r.score}</td>
<td className="px-3 py-2.5 text-center"><ProbChip prob={r.probabilityOptimal} best={isBest} /></td>
<td className="px-3 py-2.5 text-center"><ConfChip conf={r.confidence} /></td>
</tr>
)
})}
</tbody>
</table>
</div>
{routes.length > 5 && (
<button onClick={() => setExpanded(v => !v)}
className="w-full py-2 text-xs text-muted-foreground hover:text-foreground transition-colors border-t flex items-center justify-center gap-1">
{expanded
? <><ChevronUpIcon className="size-3" />Свернуть</>
: <><ChevronDownIcon className="size-3" />Показать все {routes.length} комбинаций</>}
</button>
)}
</div>
)
}
// ─── Community recs table ─────────────────────────────────────────────────────
function CommRecsTable({ recs, homeId, pinned, applied, applying, onPin, onApply, threshold: _threshold }: {
recs: CommRec[]
homeId: string
pinned: Set<string>
applied: Set<string>
applying: Set<string>
onPin: (k: string) => void
onApply: (comm: string, homeId: string) => void
threshold: number
}) {
return (
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b text-[11px] text-muted-foreground bg-muted/20">
<th className="text-left font-medium px-4 py-2">Community</th>
<th className="text-left font-medium px-3 py-2">Текущий (WAN JH Exit)</th>
<th className="text-left font-medium px-3 py-2">Рекомендуемый</th>
<th className="text-center font-medium px-3 py-2">P(тек / рек)</th>
<th className="text-right font-medium px-3 py-2">Действие</th>
</tr>
</thead>
<tbody className="divide-y divide-border">
{recs.map((r, idx) => {
const pinKey = `${homeId}::${r.community}`
const isPinned = pinned.has(pinKey)
const isApplied = applied.has(pinKey)
const isApplying = applying.has(pinKey)
const canApply = r.shouldSwitch && !isPinned && !isApplied
return (
<tr key={`${homeId}::${r.community}::${idx}`} className={cn(
"hover:bg-muted/30 transition-colors",
r.shouldSwitch && !isPinned && !isApplied && "bg-amber-500/5",
isApplied && "bg-emerald-500/5",
)}>
{/* community */}
<td className="px-4 py-2.5">
<div className="font-mono text-xs font-medium">{r.community}</div>
<div className="text-[11px] text-muted-foreground">{r.communityName}</div>
</td>
{/* current route */}
<td className="px-3 py-2.5">
{r.current ? (
<div className="text-xs flex items-center gap-1 flex-wrap">
<span className="font-mono font-medium text-sky-600 dark:text-sky-400">{r.current.wan}</span>
<ArrowRightIcon className="size-3 text-muted-foreground shrink-0" />
<span>{r.current.jh}</span>
<ArrowRightIcon className="size-3 text-muted-foreground shrink-0" />
<span className="text-muted-foreground">{r.current.exit}</span>
<span className="font-mono text-[10px] text-muted-foreground">({r.current.gateway})</span>
</div>
) : <span className="text-muted-foreground text-xs"></span>}
</td>
{/* recommended */}
<td className="px-3 py-2.5">
{r.recommended ? (
<div className={cn("text-xs flex items-center gap-1 flex-wrap",
r.shouldSwitch && !isPinned && "text-amber-600 dark:text-amber-400")}>
<span className="font-mono font-medium">{r.recommended.wan}</span>
<ArrowRightIcon className="size-3 shrink-0 opacity-60" />
<span>{r.recommended.jh}</span>
<ArrowRightIcon className="size-3 shrink-0 opacity-60" />
<span>{r.recommended.exit}</span>
{r.shouldSwitch && !isPinned && (
<span className="ml-1 text-[10px] font-bold bg-amber-500/10 border border-amber-500/20 px-1.5 py-0.5 rounded">
+{(r.recommended.prob ?? 0) - (r.current?.prob ?? 0)}%
</span>
)}
</div>
) : <span className="text-muted-foreground text-xs"></span>}
</td>
{/* probability */}
<td className="px-3 py-2.5 text-center">
<div className="flex items-center justify-center gap-1">
<ProbChip prob={r.current?.prob ?? 0} />
<span className="text-muted-foreground text-[10px]">/</span>
<ProbChip prob={r.recommended?.prob ?? 0} best={r.shouldSwitch && !isPinned} />
</div>
</td>
{/* actions */}
<td className="px-3 py-2.5">
<div className="flex items-center justify-end gap-1.5">
{isPinned && <PinIcon className="size-3 text-sky-500 fill-sky-500" />}
<Button variant="outline" size="sm"
className={cn("h-7 text-xs", isPinned && "text-sky-600 dark:text-sky-400 border-sky-500/30")}
onClick={() => onPin(pinKey)}>
<PinIcon className={cn("size-3", isPinned && "fill-current")} />
{isPinned ? "Открепить" : "Закрепить"}
</Button>
{canApply && (
<Button size="sm" className="h-7 text-xs" disabled={isApplying}
onClick={() => onApply(r.community, homeId)}>
{isApplying
? <RefreshCwIcon className="size-3 animate-spin" />
: <PlayIcon className="size-3" />}
Применить
</Button>
)}
{isApplied && (
<span className="text-xs text-emerald-600 dark:text-emerald-400 flex items-center gap-1">
<CheckCircleIcon className="size-3" />Применено
</span>
)}
</div>
</td>
</tr>
)
})}
</tbody>
</table>
</div>
)
}
// ─── Home Router card ─────────────────────────────────────────────────────────
type HomeTab = "wan-matrix" | "full-routes" | "bgp-community"
@@ -669,18 +353,17 @@ function HomeRouterCard({ entry, jumpHosts, settings, pinned, applied, applying,
{/* Tab content */}
{tab === "wan-matrix" && (
<WanMatrix home={home} legs={wanJhLegs} jumpHosts={jumpHosts} pw={settings.pingWeight} />
<RouteOptimizerWanMatrixDataGrid home={home} legs={wanJhLegs} jumpHosts={jumpHosts} />
)}
{tab === "full-routes" && (
<FullRoutesTable routes={fullRoutes} bestId={bestRoute?.id} />
<RouteOptimizerFullRoutesDataGrid routes={fullRoutes} bestId={bestRoute?.id} />
)}
{tab === "bgp-community" && (
<CommRecsTable
<RouteOptimizerCommRecsDataGrid
recs={commRecs}
homeId={home.id}
pinned={pinned} applied={applied} applying={applying}
onPin={onPin} onApply={onApply}
threshold={settings.switchThreshold}
/>
)}
</Card>
@@ -1254,51 +937,14 @@ export default function RouteOptimizerPage() {
: "нет данных"}
</span>
</div>
<div className="overflow-x-auto">
<table className="w-full text-xs">
<thead>
<tr className="border-b bg-muted/30">
{["Интерфейс", "Cost", "Score", "Ping", "Speed (dl/ul)"].map((h) => (
<th key={h} className="text-left px-3 py-1.5 font-medium text-muted-foreground">{h}</th>
))}
</tr>
</thead>
<tbody className="divide-y divide-border/60">
{ospfPreviewError && (
<tr>
<td colSpan={5} className="px-3 py-2 text-destructive">
Ошибка preview: {ospfPreviewError}
</td>
</tr>
)}
{!ospfPreviewLoading && !ospfPreviewError && (ospfPreview?.interfaces.length ?? 0) === 0 && (
<tr>
<td colSpan={5} className="px-3 py-2 text-muted-foreground">
Интерфейсы OSPF не найдены для выбранного сервера.
</td>
</tr>
)}
{(ospfPreview?.interfaces ?? []).map((row) => (
<tr key={`${row.interface}-${row.currentCost}-${row.optimalCost}`}>
<td className="px-3 py-1.5 font-mono">{row.interface}</td>
<td className="px-3 py-1.5 font-mono">
<span className="text-sky-600 dark:text-sky-400">{row.currentCost}</span>
{" → "}
<span className={row.currentCost === row.optimalCost
? "text-emerald-600 dark:text-emerald-400"
: "text-amber-600 dark:text-amber-400"}
>
{row.optimalCost}
</span>
</td>
<td className="px-3 py-1.5 font-mono">{row.score}</td>
<td className="px-3 py-1.5 font-mono">{row.pingMs}ms</td>
<td className="px-3 py-1.5 font-mono">{row.dlMbps} / {row.ulMbps}</td>
</tr>
))}
</tbody>
</table>
</div>
<RouteOptimizerOspfPreviewDataGrid
rows={(ospfPreview?.interfaces ?? []).map((row) => ({
id: `${row.interface}-${row.currentCost}-${row.optimalCost}`,
...row,
}))}
error={ospfPreviewError || null}
loading={ospfPreviewLoading}
/>
</div>
{ospfApplyResult && (