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
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:
@@ -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 && (
|
||||
|
||||
Reference in New Issue
Block a user