diff --git a/app/(main)/ospf/page.tsx b/app/(main)/ospf/page.tsx index b688ad0c..5066b480 100644 --- a/app/(main)/ospf/page.tsx +++ b/app/(main)/ospf/page.tsx @@ -1,5 +1,6 @@ "use client" +import { OspfDetailList } from "@/components/ospf-detail-list" import { OspfTopologyScope } from "@/components/ospf-topology-scope" import { usePrefersReducedMotion } from "@/hooks/use-prefers-reduced-motion" import { deriveOspfRouterIdentity } from "@/lib/ospf-router-identity" @@ -559,7 +560,7 @@ function NodeDetailPanel({

{node.label}

Router-ID: {routerId}

- @@ -578,8 +579,7 @@ function NodeDetailPanel({

Соседи ({adjacencies.length})

{adjacencies.length === 0 &&

Нет активных adjacency

} -
- {adjacencies.map((adj, i) => { + `${adj.peerLabel} ${adj.peerId} ${adj.iface} ${adj.state}`} renderItem={(adj, i) => { const ec = EDGE_COLOR[adj.state] ?? "var(--muted-foreground)" return (
@@ -592,8 +592,7 @@ function NodeDetailPanel({ {adj.state}
) - })} -
+ }} />
@@ -601,8 +600,7 @@ function NodeDetailPanel({ Интерфейсы OSPF{ifaces.length > 0 && ({ifaces.length})}

{ifaces.length === 0 &&

Нет данных

} -
- {ifaces.map(iface => ( + `${iface.interfaceName} ${iface.area} ${iface.cost ?? ""}`} renderItem={iface => (
{iface.interfaceName} @@ -611,8 +609,7 @@ function NodeDetailPanel({ cost {iface.cost ?? "—"}
- ))} -
+ )} />
) diff --git a/components/ospf-detail-list.tsx b/components/ospf-detail-list.tsx new file mode 100644 index 00000000..b4c4e42a --- /dev/null +++ b/components/ospf-detail-list.tsx @@ -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({ 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
+ {controlled &&
+ { setQuery(event.target.value); setPage(0) }} /> +

Показаны {filtered.length ? current * 50 + 1 : 0}–{Math.min((current + 1) * 50, filtered.length)} из {filtered.length}. Всего {items.length}.

+
+ + +
+ {!filtered.length &&

По вашему запросу ничего не найдено.

} +
} +
{filtered.slice(current * 50, (current + 1) * 50).map((item, index) => renderItem(item, current * 50 + index))}
+
+} diff --git a/docs/ux-ui-performance.md b/docs/ux-ui-performance.md index 2884cea1..cba9a804 100644 --- a/docs/ux-ui-performance.md +++ b/docs/ux-ui-performance.md @@ -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.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. diff --git a/docs/ux-ui-plan.md b/docs/ux-ui-plan.md index 153b6e5d..bb8040e6 100644 --- a/docs/ux-ui-plan.md +++ b/docs/ux-ui-plan.md @@ -1,6 +1,6 @@ # План реализации 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). Этот документ задаёт объём и критерии завершения, журнал фиксирует результат. diff --git a/docs/ux-ui-progress.md b/docs/ux-ui-progress.md index 5e2adf8c..faafb8bc 100644 --- a/docs/ux-ui-progress.md +++ b/docs/ux-ui-progress.md @@ -1,6 +1,6 @@ # 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). План задаёт объём, этот журнал фиксирует выполненные блоки и ограничения. diff --git a/docs/ux-ui-stage-12.md b/docs/ux-ui-stage-12.md index c2b31050..70659f3c 100644 --- a/docs/ux-ui-stage-12.md +++ b/docs/ux-ui-stage-12.md @@ -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; все узлы доступны через поиск. Это не приёмка всех вариантов графа: параллельные 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. diff --git a/docs/ux-ui/acceptance/ospf-detail-2026-10-04.json b/docs/ux-ui/acceptance/ospf-detail-2026-10-04.json new file mode 100644 index 00000000..a38dbc6f --- /dev/null +++ b/docs/ux-ui/acceptance/ospf-detail-2026-10-04.json @@ -0,0 +1,8 @@ +{ + "neighbors": 1000, + "interfaces": 1000, + "renderedNeighbors": 50, + "renderedInterfaces": 50, + "panelNodes": 635, + "pageNodes": 2335 +} \ No newline at end of file diff --git a/docs/ux-ui/screens/ospf-large-detail-320.png b/docs/ux-ui/screens/ospf-large-detail-320.png new file mode 100644 index 00000000..5ac1dbed Binary files /dev/null and b/docs/ux-ui/screens/ospf-large-detail-320.png differ diff --git a/tests/e2e/large-ospf-neighbors.spec.ts b/tests/e2e/large-ospf-neighbors.spec.ts index e99ca376..3e944b73 100644 --- a/tests/e2e/large-ospf-neighbors.spec.ts +++ b/tests/e2e/large-ospf-neighbors.spec.ts @@ -144,3 +144,53 @@ test("OSPF: тысяча параллельных связей не обходи await expect(page.locator("[data-ospf-node]")).toHaveCount(2) 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") +})