fix(ui): ограничить списки деталей OSPF без потери данных

This commit is contained in:
Denozordec committed 2026-10-04 17:32:01 +07:00
1 parent 8d86c5cbb0
commit 086f118204
9 files changed
+110 -11

No files matched your search

+6 -9
View File
@@ -1,5 +1,6 @@
"use client" "use client"
import { OspfDetailList } from "@/components/ospf-detail-list"
import { OspfTopologyScope } from "@/components/ospf-topology-scope" import { OspfTopologyScope } from "@/components/ospf-topology-scope"
import { usePrefersReducedMotion } from "@/hooks/use-prefers-reduced-motion" import { usePrefersReducedMotion } from "@/hooks/use-prefers-reduced-motion"
import { deriveOspfRouterIdentity } from "@/lib/ospf-router-identity" import { deriveOspfRouterIdentity } from "@/lib/ospf-router-identity"
@@ -559,7 +560,7 @@ function NodeDetailPanel({
<p className="text-sm font-mono font-semibold text-foreground truncate leading-tight">{node.label}</p> <p className="text-sm font-mono font-semibold text-foreground truncate leading-tight">{node.label}</p>
<p className="text-[10px] font-mono text-muted-foreground mt-0.5">Router-ID: {routerId}</p> <p className="text-[10px] font-mono text-muted-foreground mt-0.5">Router-ID: {routerId}</p>
</div> </div>
<button type="button" aria-label="Закрыть" onClick={onClose} className="text-muted-foreground hover:text-foreground transition-colors mt-0.5 shrink-0"> <button type="button" aria-label="Закрыть" onClick={onClose} className="flex min-h-11 min-w-11 items-center justify-center text-muted-foreground hover:text-foreground transition-colors shrink-0">
<XIcon className="size-3.5" /> <XIcon className="size-3.5" />
</button> </button>
</div> </div>
@@ -578,8 +579,7 @@ function NodeDetailPanel({
<div className="px-4 py-3 border-b border-border"> <div className="px-4 py-3 border-b border-border">
<p className="text-[10px] font-semibold text-muted-foreground uppercase tracking-wider mb-2">Соседи ({adjacencies.length})</p> <p className="text-[10px] font-semibold text-muted-foreground uppercase tracking-wider mb-2">Соседи ({adjacencies.length})</p>
{adjacencies.length === 0 && <p className="text-[11px] text-muted-foreground">Нет активных adjacency</p>} {adjacencies.length === 0 && <p className="text-[11px] text-muted-foreground">Нет активных adjacency</p>}
<div className="flex flex-col gap-0 divide-y divide-border"> <OspfDetailList key={`neighbors-${nodeId}`} label="Соседи узла OSPF" items={adjacencies} getSearchValue={adj => `${adj.peerLabel} ${adj.peerId} ${adj.iface} ${adj.state}`} renderItem={(adj, i) => {
{adjacencies.map((adj, i) => {
const ec = EDGE_COLOR[adj.state] ?? "var(--muted-foreground)" const ec = EDGE_COLOR[adj.state] ?? "var(--muted-foreground)"
return ( return (
<div key={i} className="flex items-start gap-2.5 py-2.5"> <div key={i} className="flex items-start gap-2.5 py-2.5">
@@ -592,8 +592,7 @@ function NodeDetailPanel({
<span className="text-[10px] font-mono font-semibold shrink-0 mt-0.5" style={{ color: ec }}>{adj.state}</span> <span className="text-[10px] font-mono font-semibold shrink-0 mt-0.5" style={{ color: ec }}>{adj.state}</span>
</div> </div>
) )
})} }} />
</div>
</div> </div>
<div className="px-4 py-3"> <div className="px-4 py-3">
@@ -601,8 +600,7 @@ function NodeDetailPanel({
Интерфейсы OSPF{ifaces.length > 0 && <span className="ml-1 text-muted-foreground">({ifaces.length})</span>} Интерфейсы OSPF{ifaces.length > 0 && <span className="ml-1 text-muted-foreground">({ifaces.length})</span>}
</p> </p>
{ifaces.length === 0 && <p className="text-[11px] text-muted-foreground">Нет данных</p>} {ifaces.length === 0 && <p className="text-[11px] text-muted-foreground">Нет данных</p>}
<div className="flex flex-col gap-0 divide-y divide-border"> <OspfDetailList key={`interfaces-${nodeId}`} label="Интерфейсы узла OSPF" items={ifaces} getSearchValue={iface => `${iface.interfaceName} ${iface.area} ${iface.cost ?? ""}`} renderItem={iface => (
{ifaces.map(iface => (
<div key={iface.key} className="flex items-center gap-2 py-2"> <div key={iface.key} className="flex items-center gap-2 py-2">
<span className={cn("size-1.5 rounded-full shrink-0", iface.active ? "bg-emerald-500" : "bg-muted-foreground/40")} /> <span className={cn("size-1.5 rounded-full shrink-0", iface.active ? "bg-emerald-500" : "bg-muted-foreground/40")} />
<code className="text-[11px] font-mono flex-1 min-w-0 truncate" style={{ color: "var(--muted-foreground)" }}>{iface.interfaceName}</code> <code className="text-[11px] font-mono flex-1 min-w-0 truncate" style={{ color: "var(--muted-foreground)" }}>{iface.interfaceName}</code>
@@ -611,8 +609,7 @@ function NodeDetailPanel({
</span> </span>
<span className="text-[10px] font-mono shrink-0" style={{ color: "var(--info)" }}>cost {iface.cost ?? "—"}</span> <span className="text-[10px] font-mono shrink-0" style={{ color: "var(--info)" }}>cost {iface.cost ?? "—"}</span>
</div> </div>
))} )} />
</div>
</div> </div>
</div> </div>
) )
+33
View File
@@ -0,0 +1,33 @@
"use client"
import { useMemo, useState, type ReactNode } from "react"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
export function OspfDetailList<T>({ items, label, getSearchValue, renderItem }: {
items: T[]; label: string; getSearchValue: (item: T) => string
renderItem: (item: T, index: number) => ReactNode
}) {
const [query, setQuery] = useState("")
const [page, setPage] = useState(0)
const filtered = useMemo(() => {
const value = query.trim().toLowerCase()
return value ? items.filter(item => getSearchValue(item).toLowerCase().includes(value)) : items
}, [items, query, getSearchValue])
const pages = Math.max(1, Math.ceil(filtered.length / 50))
if (page >= pages) setPage(pages - 1)
const current = Math.min(page, pages - 1)
const controlled = items.length > 50 || !!query
return <section aria-label={label} className="min-w-0">
{controlled && <div className="flex min-w-0 flex-col gap-2 mb-2">
<Input aria-label={`Поиск: ${label}`} placeholder="Поиск во всём списке…" value={query} onChange={event => { setQuery(event.target.value); setPage(0) }} />
<p role="status" className="text-xs text-muted-foreground">Показаны {filtered.length ? current * 50 + 1 : 0}–{Math.min((current + 1) * 50, filtered.length)} из {filtered.length}. Всего {items.length}.</p>
<div className="flex flex-wrap gap-2">
<Button type="button" variant="outline" className="min-h-11" aria-label={`Предыдущая страница: ${label}`} disabled={!current} onClick={() => setPage(current - 1)}>Назад</Button>
<Button type="button" variant="outline" className="min-h-11" aria-label={`Следующая страница: ${label}`} disabled={current >= pages - 1} onClick={() => setPage(current + 1)}>Далее</Button>
</div>
{!filtered.length && <p className="text-xs text-muted-foreground">По вашему запросу ничего не найдено.</p>}
</div>}
<div className="flex flex-col gap-0 divide-y divide-border">{filtered.slice(current * 50, (current + 1) * 50).map((item, index) => renderItem(item, current * 50 + index))}</div>
</section>
}
+2
View File
@@ -96,3 +96,5 @@ Statistics pivot в backend/src/services/statistics-aggregate.ts огранич
12.3.49: OSPF1000 neighbors→50 rows,1001 SVG nodes/12015 descendants/13597 page nodes/2827ms dev ready. URL page20/search/global Cost sort/refresh/cache/clamp/320px/axe приняты;22 browser/type/lint/build. Весь граф не принят, KPI1000 переносится на320px, большой node detail вне scope; далее12.3.50–51.9 manualPagination:true остаются. 12.3.49: OSPF1000 neighbors→50 rows,1001 SVG nodes/12015 descendants/13597 page nodes/2827ms dev ready. URL page20/search/global Cost sort/refresh/cache/clamp/320px/axe приняты;22 browser/type/lint/build. Весь граф не принят, KPI1000 переносится на320px, большой node detail вне scope; далее12.3.50–51.9 manualPagination:true остаются.
12.3.50: OSPF1000 neighbors → scope8 связей/9 узлов, выбранный remote1000:2 SVG nodes/27 descendants/1630 page DOM.26+3 main/8 context/type/lint/build. Полный источник и статус/поиск/URL сохранены; большие node details и читаемость parallel edges не приняты. Далее12.3.51. 12.3.50: OSPF1000 neighbors → scope8 связей/9 узлов, выбранный remote1000:2 SVG nodes/27 descendants/1630 page DOM.26+3 main/8 context/type/lint/build. Полный источник и статус/поиск/URL сохранены; большие node details и читаемость parallel edges не приняты. Далее12.3.51.
12.3.51: OSPF node=1/1000 neighbors+1000 interfaces →50+50 rows/635 panel nodes/2335 page DOM.4+15+1 browser/type/lint/build. Search/full counts/refresh/clamp/320px/axe приняты; parallel link labels далее12.3.52.
+1 -1
View File
@@ -1,6 +1,6 @@
# План реализации UX/UI по пятичасовым окнам # План реализации UX/UI по пятичасовым окнам
Актуальное продолжение 2026-10-04: 12.3.50 принят локально: OSPF topology показывает до8 связей/9 узлов, полный статус и поиск/URL.26+3 main/8 context, types/lint/build; полный источник1000 сохранён. Далее12.3.51 — большие детали узла; затем читаемость параллельных связей и optimizer. Общая регрессия после delta и внешняя12.5 остаются. Актуальное продолжение 2026-10-04: 12.3.51 принят локально: большие детали OSPF по50 neighbors/interfaces, поиск полного списка/refresh/clamp/320px/axe.4+15+1 browser/type/lint/build. Далее12.3.52 — подписи параллельных связей; затем optimizer и общая main/context после delta. Внешняя12.5 отдельно.
Общий план проекта MikrotikManager. Основан на согласованном плане из задачи; точка продолжения обновлена 2026-10-04. Источник фактического прогресса, проверок и ограничений — [журнал реализации](ux-ui-progress.md). Этот документ задаёт объём и критерии завершения, журнал фиксирует результат. Общий план проекта MikrotikManager. Основан на согласованном плане из задачи; точка продолжения обновлена 2026-10-04. Источник фактического прогресса, проверок и ограничений — [журнал реализации](ux-ui-progress.md). Этот документ задаёт объём и критерии завершения, журнал фиксирует результат.
+1 -1
View File
@@ -1,6 +1,6 @@
# UX/UI: журнал реализации # UX/UI: журнал реализации
Актуальное продолжение 2026-10-04: 12.3.50 принят локально: OSPF topology показывает до8 связей/9 узлов, полный статус и поиск/URL.26+3 main/8 context, types/lint/build; полный источник1000 сохранён. Далее12.3.51 — большие детали узла; затем читаемость параллельных связей и optimizer. Общая регрессия после delta и внешняя12.5 остаются. Актуальное продолжение 2026-10-04: 12.3.51 принят локально: большие детали OSPF по50 neighbors/interfaces, поиск полного списка/refresh/clamp/320px/axe.4+15+1 browser/type/lint/build. Далее12.3.52 — подписи параллельных связей; затем optimizer и общая main/context после delta. Внешняя12.5 отдельно.
Общий план: [UX/UI по этапам и пятичасовым окнам](ux-ui-plan.md). План задаёт объём, этот журнал фиксирует выполненные блоки и ограничения. Общий план: [UX/UI по этапам и пятичасовым окнам](ux-ui-plan.md). План задаёт объём, этот журнал фиксирует выполненные блоки и ограничения.
+9
View File
@@ -603,3 +603,12 @@ OspfTopologyScope использует полный источник: до8 inci
26/26 профильных main,8/8 context и финальные3/3 large прошли. Fixture1000 neighbors/1000 interfaces/1001 nodes/583875 bytes →50 rows/2 SVG nodes/27 descendants/1630 page nodes/1531ms dev ready для URL remote1000. Проверены page2/clamp после уменьшения1000→13→12, полная hidden Full-связь при первых Down, выбор remote1000 из всего каталога/keep, close/focus,320px/axe, bounded parallel1000. TypeScript без incremental, ESLint страницы/компонента/теста и production build прошли. [Профиль](ux-ui/acceptance/ospf-topology-scope-2026-10-04.json), [срез1440px](ux-ui/screens/ospf-topology-scope-1440.png), [320px](ux-ui/screens/ospf-topology-scope-320.png) сохранены и просмотрены. Dev latency не production benchmark. На большой топологии SVG доступен через горизонтальный scroll; все узлы доступны через поиск. 26/26 профильных main,8/8 context и финальные3/3 large прошли. Fixture1000 neighbors/1000 interfaces/1001 nodes/583875 bytes →50 rows/2 SVG nodes/27 descendants/1630 page nodes/1531ms dev ready для URL remote1000. Проверены page2/clamp после уменьшения1000→13→12, полная hidden Full-связь при первых Down, выбор remote1000 из всего каталога/keep, close/focus,320px/axe, bounded parallel1000. TypeScript без incremental, ESLint страницы/компонента/теста и production build прошли. [Профиль](ux-ui/acceptance/ospf-topology-scope-2026-10-04.json), [срез1440px](ux-ui/screens/ospf-topology-scope-1440.png), [320px](ux-ui/screens/ospf-topology-scope-320.png) сохранены и просмотрены. Dev latency не production benchmark. На большой топологии SVG доступен через горизонтальный scroll; все узлы доступны через поиск.
Это не приёмка всех вариантов графа: параллельные edges ограничены по DOM, но их подписи на общей линии могут перекрываться. Большая NodeDetailPanel ещё рендерит все1000 neighbors/interfaces — следующий12.3.51. Затем параллельные связи/optimizer/общая main-context регрессия после delta.9 manualPagination:true specialized grids остаются. Внешние RouterOS/PG/интеграции12.5 не проверены. Локальный коммит без push. Это не приёмка всех вариантов графа: параллельные edges ограничены по DOM, но их подписи на общей линии могут перекрываться. Большая NodeDetailPanel ещё рендерит все1000 neighbors/interfaces — следующий12.3.51. Затем параллельные связи/optimizer/общая main-context регрессия после delta.9 manualPagination:true specialized grids остаются. Внешние RouterOS/PG/интеграции12.5 не проверены. Локальный коммит без push.
## 12.3.51 — большие детали узла OSPF (2026-10-04)
OspfDetailList: client pages50 отдельно для соседей/интерфейсов, поиск всего списка по peer/Router ID/interface/state и interface/area/cost; full counts сохраняются. Refresh не сбрасывает ручную страницу, shrink ограничивает её, новый nodeId пересоздаёт контекст списка. Close44px, Escape/focus прежние; сетевые правила не изменены.
4/4 large,15/15 profile keyboard/layout/URL/state/identity и финальный detail1/1 прошли. Полный fixture1000 neighbors/1000 interfaces →50+50 записей/635 panel DOM/2335 page DOM для URL node=1. Поиск ether1000/полный cost1000, no-match, page2/refresh, shrink1000→1/keep/node,320px/кнопки44/axe проверены. TypeScript без incremental, полный ESLint страницы/компонента/теста и production build прошли. [Профиль](ux-ui/acceptance/ospf-detail-2026-10-04.json), [панель320px](ux-ui/screens/ospf-large-detail-320.png) сохранены и просмотрены. Каталог не обрезается в памяти.
Следующий12.3.52: визуальная группировка параллельных links без подмены cost/state; затем optimizer и общая регрессия после всех delta.9 specialized manualPagination:true остаются. Внешние RouterOS/PG/интеграции12.5 не проверены. Локальный коммит без push.
@@ -0,0 +1,8 @@
{
"neighbors": 1000,
"interfaces": 1000,
"renderedNeighbors": 50,
"renderedInterfaces": 50,
"panelNodes": 635,
"pageNodes": 2335
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 44 KiB

+50
View File
@@ -144,3 +144,53 @@ test("OSPF: тысяча параллельных связей не обходи
await expect(page.locator("[data-ospf-node]")).toHaveCount(2) await expect(page.locator("[data-ospf-node]")).toHaveCount(2)
expect(await page.getByRole("group", { name: "Топология OSPF", exact: true }).locator("*").count()).toBeLessThan(200) expect(await page.getByRole("group", { name: "Топология OSPF", exact: true }).locator("*").count()).toBeLessThan(200)
}) })
test("OSPF детали1000: полные списки, поиск, страницы и сохранение узла", async ({ page }, info) => {
test.setTimeout(90000)
const host = { serverId: 1, serverName: "Роутер 1", serverSite: "", serverCountry: "RU" }
let neighbors = Array.from({ length: 1000 }, (_, i) => ({ ...host, id: `n${i + 1}`, address: `10.${Math.floor(i / 256)}.${i % 256}.1`, routerId: `10.${Math.floor(i / 256)}.${i % 256}.1`, instance: "default", area: "backbone", areaId: "0.0.0.0", interface: `ether${i + 1}`, state: "Full", uptime: "1h", stateChanges: 0, priority: 1 }))
const interfaces = neighbors.map((n, i) => ({ ...host, id: `i${i + 1}`, instance: "default", area: "backbone", areaId: "0.0.0.0", interface: n.interface, cost: i + 1, type: "broadcast", disabled: false, inactive: false, priority: 1, helloInterval: 10, deadInterval: 40, useBfd: false }))
const catalog = ospfCatalogSchema.parse({ neighbors, interfaces, instances: [{ ...host, id: "instance1", name: "default", routerId: "192.0.2.1", version: 2, disabled: false, inactive: false, redistribute: "" }], bfdSessions: [], routes: [], failures: [] })
let currentInterfaces = interfaces
await page.route("**/health", r => r.fulfill({ json: { ok: true } }))
await page.route("**/api/**", r => {
const path = new URL(r.request().url()).pathname
return r.fulfill({ json: path === "/api/auth/config" ? { required: false } : path === "/api/ospf/catalog" ? { ...catalog, neighbors, interfaces: currentInterfaces } : path === "/api/servers" ? [{ id: 1, name: "Роутер 1", host: "192.0.2.1", enabled: true, country: "RU", status: null }] : {} })
})
await page.goto("/ospf?tab=neighbors&server=1&node=1&keep=1")
const details = page.getByRole("region", { name: "Детали узла OSPF" })
const peers = details.getByRole("region", { name: "Соседи узла OSPF", exact: true })
const ports = details.getByRole("region", { name: "Интерфейсы узла OSPF", exact: true })
const peerRows = peers.locator(':scope > div').last().locator(':scope > div')
const portRows = ports.locator(':scope > div').last().locator(':scope > div')
await expect(peerRows).toHaveCount(50); await expect(portRows).toHaveCount(50)
await expect(peers.getByRole("status")).toContainText("1–50 из 1000. Всего 1000")
const profile = { neighbors: neighbors.length, interfaces: interfaces.length, renderedNeighbors: await peerRows.count(), renderedInterfaces: await portRows.count(), panelNodes: await details.locator("*").count(), pageNodes: await page.locator("*").count() }
console.log("OSPF detail", JSON.stringify(profile)); writeFileSync(info.outputPath("ospf-detail-profile.json"), JSON.stringify(profile, null, 2))
expect(profile.pageNodes).toBeLessThan(5000)
await peers.getByRole("button", { name: "Следующая страница: Соседи узла OSPF", exact: true }).click()
await expect(peerRows.first()).toContainText("ether51")
await page.getByRole("button", { name: "Обновить", exact: true }).click()
await expect(peers.getByRole("status")).toContainText("51–100 из 1000")
const peerSearch = peers.getByRole("textbox", { name: "Поиск: Соседи узла OSPF" })
await peerSearch.fill("ether1000"); await expect(peerRows).toHaveCount(1); await expect(peerRows.first()).toContainText("10.3.231.1")
const portSearch = ports.getByRole("textbox", { name: "Поиск: Интерфейсы узла OSPF" })
await portSearch.fill("ether1000"); await expect(portRows).toHaveCount(1); await expect(portRows.first()).toContainText("cost 1000")
await portSearch.fill("несуществующий"); await expect(ports).toContainText("По вашему запросу ничего не найдено")
await peerSearch.fill(""); await portSearch.fill("")
await page.setViewportSize({ width: 320, height: 1000 }); await details.evaluate(el => el.scrollIntoView({ block: "start" }))
for (const button of await details.locator("button").all()) expect((await button.boundingBox())!.height).toBeGreaterThanOrEqual(44)
expect(await page.evaluate(() => document.documentElement.scrollWidth)).toBeLessThanOrEqual(320)
await page.addStyleTag({ content: "nextjs-portal{display:none!important}" })
await page.screenshot({ path: info.outputPath("ospf-large-detail-320.png"), animations: "disabled" })
await page.addScriptTag({ content: axe.source })
expect((await page.evaluate(async () => (window as unknown as { axe: typeof axe }).axe.run(document.querySelector('[aria-label="Детали узла OSPF"]')!, { runOnly: { type: "tag", values: ["wcag2a", "wcag2aa", "wcag21a", "wcag21aa"] } }))).violations.map(v => ({ id: v.id, nodes: v.nodes.map(n => n.target) }))).toEqual([])
await peers.getByRole("button", { name: "Следующая страница: Соседи узла OSPF", exact: true }).click()
neighbors = neighbors.slice(0, 1); currentInterfaces = interfaces.slice(0, 1)
await page.getByRole("button", { name: "Обновить", exact: true }).click()
await expect(peerRows).toHaveCount(1); await expect(portRows).toHaveCount(1)
await expect(peerRows.first()).toContainText("ether1")
await expect(page).toHaveURL(url => url.searchParams.get("node") === "1" && url.searchParams.get("keep") === "1")
})