From be54666661c057670250810ac33f57848992bf60 Mon Sep 17 00:00:00 2001 From: Denozordec Date: Sun, 4 Oct 2026 17:50:45 +0700 Subject: [PATCH] =?UTF-8?q?fix(ui):=20=D0=BE=D0=B3=D1=80=D0=B0=D0=BD=D0=B8?= =?UTF-8?q?=D1=87=D0=B8=D1=82=D1=8C=20preview=20OSPF=20=D0=B1=D0=B5=D0=B7?= =?UTF-8?q?=20=D0=B8=D0=B7=D0=BC=D0=B5=D0=BD=D0=B5=D0=BD=D0=B8=D1=8F=20?= =?UTF-8?q?=D0=BF=D1=80=D0=B8=D0=BC=D0=B5=D0=BD=D0=B5=D0=BD=D0=B8=D1=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/(main)/route-optimizer/page.tsx | 1 + ...route-optimizer-ospf-preview-data-grid.tsx | 49 +++++++++++--- docs/ux-ui-performance.md | 7 +- docs/ux-ui-plan.md | 2 +- docs/ux-ui-progress.md | 2 +- docs/ux-ui-stage-12.md | 9 +++ .../optimizer-ospf-preview-2026-10-04.json | 7 ++ .../screens/optimizer-ospf-preview-320.png | Bin 0 -> 45370 bytes .../e2e/large-optimizer-ospf-preview.spec.ts | 64 ++++++++++++++++++ .../e2e/route-optimizer-ospf-preview.spec.ts | 12 ++++ 10 files changed, 138 insertions(+), 15 deletions(-) create mode 100644 docs/ux-ui/acceptance/optimizer-ospf-preview-2026-10-04.json create mode 100644 docs/ux-ui/screens/optimizer-ospf-preview-320.png create mode 100644 tests/e2e/large-optimizer-ospf-preview.spec.ts diff --git a/app/(main)/route-optimizer/page.tsx b/app/(main)/route-optimizer/page.tsx index 6970d31a..fbac66dd 100644 --- a/app/(main)/route-optimizer/page.tsx +++ b/app/(main)/route-optimizer/page.tsx @@ -903,6 +903,7 @@ function RouteOptimizerContent({requestedServer,onSelectServer}:{requestedServer { + const value = query.trim().toLowerCase() + return value ? rows.filter(row => `${row.interface} ${row.id}`.toLowerCase().includes(value)) : rows + }, [rows, query]) const columns = useMemo[]>( () => [ { id: "interface", + size: 120, accessorKey: "interface", header: ({ column }) => ( ), - cell: ({ row }) => {row.original.interface}, + cell: ({ row }) => {row.original.interface}, meta: { headerTitle: "Интерфейс", headerClassName: DATA_GRID_CELL_PAD_FIRST, @@ -65,6 +72,7 @@ function RouteOptimizerOspfPreviewDataGrid({ }, { id: "cost", + size: 144, accessorKey: "currentCost", header: ({ column }) => , cell: ({ row }) => { @@ -89,6 +97,7 @@ function RouteOptimizerOspfPreviewDataGrid({ }, { id: "score", + size: 96, accessorKey: "score", header: ({ column }) => , cell: ({ row }) => {row.original.score}, @@ -96,6 +105,7 @@ function RouteOptimizerOspfPreviewDataGrid({ }, { id: "pingMs", + size: 288, accessorKey: "pingMs", header: ({ column }) => , cell: ({ row }) =>
{row.original.pingMs}ms

{describeObserved(row.original.observed?.ping,"мс")}

, @@ -103,6 +113,7 @@ function RouteOptimizerOspfPreviewDataGrid({ }, { id: "speed", + size: 336, header: () => Speed (dl/ul) · модель, enableSorting: false, cell: ({ row }) => { @@ -121,12 +132,20 @@ function RouteOptimizerOspfPreviewDataGrid({ const table = useTable({ features: dataGridFeatures, - manualPagination: true, - data: rows, + manualPagination: false, + autoResetPageIndex: false, + initialState: { pagination: { pageIndex: 0, pageSize: 50 }, columnPinning: { start: ["interface"], end: [] } }, + data: filteredRows, columns, getRowId: (row) => row.id, }) + const { pageIndex, pageSize } = table.state.pagination + useEffect(() => { + const lastPage = Math.max(0, Math.ceil(filteredRows.length / pageSize) - 1) + if (pageIndex > lastPage) table.setPageIndex(lastPage) + }, [filteredRows.length, pageIndex, pageSize, table]) + if (error && rows.length === 0) { return (

Ошибка preview: {error}

@@ -144,18 +163,28 @@ function RouteOptimizerOspfPreviewDataGrid({ } return ( - <> +
{error &&

Ошибка preview: {error}. Последний снимок; актуальность неизвестна.

} {loading && rows.length > 0 &&

Обновление preview OSPF…

} +
+ { setQuery(event.target.value); table.setPageIndex(0) }} /> +

Найдено {filteredRows.length} из {rows.length} интерфейсов. Фильтр меняет только отображение preview.

+
50 || pageSize !== 50} + emptyMessage="Нет интерфейсов по запросу" isLoading={loading && rows.length === 0} loadingMode="skeleton" tableClassNames={{ headerRow: "border-b bg-muted/30", bodyRow: "group/row" }} - tableLayout={{ dense: true }} - /> - + tableLayout={{ dense: true, width: "fixed", columnsPinnable: true }} + > + + + + +
) } diff --git a/docs/ux-ui-performance.md b/docs/ux-ui-performance.md index c32f51a1..e3174c4f 100644 --- a/docs/ux-ui-performance.md +++ b/docs/ux-ui-performance.md @@ -15,7 +15,7 @@ ## Оставшиеся специализированные grids -Аудит12.3.36 от2026-10-04: точный поиск `manualPagination: true` в components/data-grids —9 файлов после12.3.49. Это не9 доказанных дефектов: есть фиксированный источник, demo, неподключённый компонент и ограниченные срезы. Файлы ниже сопоставлены с действующими JSX-потребителями; ограничения установлены чтением кода, не нагрузочной приёмкой API. +Аудит12.3.36 от2026-10-04: точный поиск `manualPagination: true` в components/data-grids —8 файлов после12.3.53. Это не8 доказанных дефектов: есть фиксированный источник, demo, неподключённый компонент и ограниченные срезы. Файлы ниже сопоставлены с действующими JSX-потребителями; ограничения установлены чтением кода, не нагрузочной приёмкой API. | Файл | Источник и необходимая проверка | | --- | --- | @@ -24,12 +24,11 @@ | [probes-schedule-data-grid.tsx](../components/data-grids/probes-schedule-data-grid.tsx) | Только demo ScheduleTab/INIT_RULES3; live использует ProbesSpeedSchedule. Demo список расширяется пользователем; не доказательство live API. | | [route-optimizer-comm-recs-data-grid.tsx](../components/data-grids/route-optimizer-comm-recs-data-grid.tsx) | Live commRecs slice(0,24) на HomeCard, не на страницу. Проверить количество HomeCard и честность ограничения рекомендаций. | | [route-optimizer-full-routes-data-grid.tsx](../components/data-grids/route-optimizer-full-routes-data-grid.tsx) | Live сочетания WAN×jumpHost×exitNode на HomeCard; ограничение DOM не устраняет стоимость расчёта полного массива. | -| [route-optimizer-ospf-preview-data-grid.tsx](../components/data-grids/route-optimizer-ospf-preview-data-grid.tsx) | Полный interfaces из preview; проверить global сортировку/unknown и apply всего подтверждённого preview. | | [route-optimizer-wan-matrix-data-grid.tsx](../components/data-grids/route-optimizer-wan-matrix-data-grid.tsx) | Live сочетания WAN×jumpHost на HomeCard; динамические столбцы/много HomeCard требуют собственного профиля. | | [subusers-data-grid.tsx](../components/data-grids/subusers-data-grid.tsx) | Поиск импортов/JSX в app/components не нашёл потребителя: компонент экспортируется, но не подключён. Не доказывает дефект экрана /users. | | [traffic-flows-data-grid.tsx](../components/data-grids/traffic-flows-data-grid.tsx) | FlowAnalyticsPanel/conversationsList: backend traffic-flow-analytics.ts top=min(50,max(10,settings.topN)), slice(0,top). Это top slice, не полный каталог/серверная pagination. Проверить сообщение об ограничении50 и связывание с графиками. | -Очередь: OSPF topology/node details → optimizer preview/комбинаторика. Scheduler11, traffic cap50 и commRecs24 проверять на реальной границе, не искусственно подставлять1000 в недоступный DTO. Demo и неиспользуемый Subusers отделены от live приёмки. Все новые профили должны сохранять search/sort/refresh/clamp, точный target и права,320px и URL scope. +Очередь: many HomeRouterCard/optimizer матрицы/комбинаторика. OSPF topology/node details/preview приняты локально в12.3.50–53. Scheduler11, traffic cap50 и commRecs24 проверять на реальной границе, не искусственно подставлять1000 в недоступный DTO. Demo и неиспользуемый Subusers отделены от live приёмки. Все новые профили должны сохранять search/sort/refresh/clamp, точный target и права,320px и URL scope. Принятые после ранней карты: OSPF routes/BFD, WireGuard interfaces/peers/detail, VXLAN/GRE tunnels. Pagination сохраняет полный DTO в памяти и не доказывает серверную производительность. Текущий журнал: [ux-ui-progress.md](ux-ui-progress.md). @@ -100,3 +99,5 @@ Statistics pivot в backend/src/services/statistics-aggregate.ts огранич 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. 12.3.52: OSPF parallel1000 between2 nodes →8 raw edges per page/1 visual line, полный источник/state/cost сохранён.9+1 browser/type/lint/build; screenshot1440 принят. Далее optimizer preview. + +12.3.53: OSPF preview1000 interfaces/245293 bytes →50 rows/924 region nodes/1610 page DOM.47+16 main/8 context/type/lint/build. Полный POST/receipt1000 при search1, sort/cache/clamp/320px приняты.8 manualPagination:true; далее many HomeRouterCard/матрицы. diff --git a/docs/ux-ui-plan.md b/docs/ux-ui-plan.md index 2c07f2bf..b0145d7b 100644 --- a/docs/ux-ui-plan.md +++ b/docs/ux-ui-plan.md @@ -1,6 +1,6 @@ # План реализации UX/UI по пятичасовым окнам -Актуальное продолжение 2026-10-04: 12.3.52 принят локально — параллельные связи OSPF объединены визуально без общего выдуманного cost/state;9+1 browser/type/lint/build. Далее12.3.53 — большой OSPF preview оптимизатора; затем остальные матрицы/общая регрессия. Внешняя12.5 отдельно. +Актуальное продолжение 2026-10-04: 12.3.53 принят локально: OSPF preview оптимизатора1000→50 строк, полный поиск/sort/apply/refresh/cache/clamp/320px.47+16 main/8 context/type/lint/build. Далее12.3.54 — профиль множества HomeRouterCard; затем ограничение карточек/матриц и общая регрессия.8 specialized manualPagination:true остаются; внешняя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 c1b1689d..c0a47c13 100644 --- a/docs/ux-ui-progress.md +++ b/docs/ux-ui-progress.md @@ -1,6 +1,6 @@ # UX/UI: журнал реализации -Актуальное продолжение 2026-10-04: 12.3.52 принят локально — параллельные связи OSPF объединены визуально без общего выдуманного cost/state;9+1 browser/type/lint/build. Далее12.3.53 — большой OSPF preview оптимизатора; затем остальные матрицы/общая регрессия. Внешняя12.5 отдельно. +Актуальное продолжение 2026-10-04: 12.3.53 принят локально: OSPF preview оптимизатора1000→50 строк, полный поиск/sort/apply/refresh/cache/clamp/320px.47+16 main/8 context/type/lint/build. Далее12.3.54 — профиль множества HomeRouterCard; затем ограничение карточек/матриц и общая регрессия.8 specialized manualPagination:true остаются; внешняя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 f8989250..e6e0b779 100644 --- a/docs/ux-ui-stage-12.md +++ b/docs/ux-ui-stage-12.md @@ -621,3 +621,12 @@ TopologyGraph группирует видимые edges по неориенти 9/9 browser (large topology/details/identity/states/layout/keyboard/reduced motion) и финальный parallel1/1 прошли.1000 links between2 nodes, page8 links →одна visual group, aria-label Full/cost1 и Down/cost2, подпись8 связей. TypeScript без incremental/ESLint страницы/scope/теста и окончательный production build прошли. [Группировка1440px](ux-ui/screens/ospf-parallel-links-1440.png) сохранена и просмотрена; PNG после проверки показал недостаточный opacity, исправлен и повторно проверен. Следующий12.3.53: OSPF preview оптимизатора1000 rows/полная apply-ветка при pagination/search; затем матрицы и many HomeRouterCard.9 manualPagination:true specialized grids остаются. Общая main/context регрессия после delta и внешняя12.5 отдельно. Локальный коммит без push. + + +## 12.3.53 — большой OSPF preview оптимизатора (2026-10-04) + +Штатные ReUI pages50/client sorting/clamp и ScrollArea, закреплённый interface120px оставляет место cost на320px. Именованная область preview включает поиск полного массива id/interface, полные counts и явное пояснение, что фильтр меняет только отображение. Контекстный key server/weight сбрасывает поиск/страницу при смене устройства; фоновый refresh сохраняет их. Применение не использует фильтр/видимую страницу, существующий POST pingWeight и строгий receipt сохранены. + +47/47 профильных main,16/16 final preview/large/new context-search и8/8 backend/mode context прошли.1000 interfaces/245293 JSON bytes →50 rows/924 preview region DOM/1610 page DOM. Search ether1000→1 row/receipt1000 applied; Cost sort full array, page2/refresh/cache503/disabled apply/shrink1000→1/server+keep,320px/pinning/axe, смена server очищает no-match query. TypeScript без incremental/полный ESLint grid/page/двух тестов/production build прошли. [Профиль](ux-ui/acceptance/optimizer-ospf-preview-2026-10-04.json), [320px](ux-ui/screens/optimizer-ospf-preview-320.png) сохранены и просмотрены. API fixture receipt исправлен по существующему контракту currentCost=from; контракт не ослаблен. Это проверка HTTP live-ветки с подменёнными ответами, не реальное применение RouterOS. Полный массив остаётся в памяти. + +Осталось8 manualPagination:true specialized grids. Следующий12.3.54 — исходный профиль1000 HomeRouterCard с полным servers DTO; затем bounded cards/full-route/wan-matrix, честность24 commRecs. Общая main/context после delta и внешняя12.5 отдельно. Локальный коммит без push. diff --git a/docs/ux-ui/acceptance/optimizer-ospf-preview-2026-10-04.json b/docs/ux-ui/acceptance/optimizer-ospf-preview-2026-10-04.json new file mode 100644 index 00000000..7de8f725 --- /dev/null +++ b/docs/ux-ui/acceptance/optimizer-ospf-preview-2026-10-04.json @@ -0,0 +1,7 @@ +{ + "count": 1000, + "bytes": 245293, + "rendered": 50, + "gridNodes": 924, + "pageNodes": 1610 +} \ No newline at end of file diff --git a/docs/ux-ui/screens/optimizer-ospf-preview-320.png b/docs/ux-ui/screens/optimizer-ospf-preview-320.png new file mode 100644 index 0000000000000000000000000000000000000000..6de6483d8b74461eff0261596622085ed1a1d8a0 GIT binary patch literal 45370 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 literal 0 HcmV?d00001 diff --git a/tests/e2e/large-optimizer-ospf-preview.spec.ts b/tests/e2e/large-optimizer-ospf-preview.spec.ts new file mode 100644 index 00000000..3776afef --- /dev/null +++ b/tests/e2e/large-optimizer-ospf-preview.spec.ts @@ -0,0 +1,64 @@ +import { test, expect } from "@playwright/test" +import { writeFileSync } from "node:fs" +import axe from "axe-core" +import { optimizerOspfPreviewSchema } from "../../lib/route-optimizer-response" + +test("Оптимизатор1000 preview: полный apply при поиске, страницы и кэш", async ({ page }, info) => { + test.setTimeout(90000) + let interfaces = Array.from({ length: 1000 }, (_, i) => ({ id: `i${i+1}`, interface: `ether${i+1}`, currentCost: i+1, optimalCost: i+1001, score: i+1, pingMs: 10, dlMbps: 100, ulMbps: 50 })) + let applied = false, failed = false, previews = 0, writes = 0 + const preview = () => optimizerOspfPreviewSchema.parse({ serverId: 1, serverName: "Роутер 1", pingWeight: 60, interfacesTotal: interfaces.length, changedCount: applied ? 0 : interfaces.length, unchangedCount: applied ? interfaces.length : 0, interfaces: interfaces.map(row => applied ? { ...row, currentCost: row.optimalCost } : row), changes: applied ? [] : interfaces }) + await page.route("**/health", r => r.fulfill({ json: { ok: true } })) + await page.route("**/api/**", r => { + const path = new URL(r.request().url()).pathname + if (path.endsWith("/ospf/optimize/preview")) { previews++; return r.fulfill(failed ? { status: 503, json: { error: "Preview недоступен1000" } } : { json: preview() }) } + if (path.endsWith("/ospf/optimize")) { + writes++; expect(r.request().postDataJSON()).toEqual({ pingWeight: 60 }); applied = true + return r.fulfill({ json: { serverId: 1, serverName: "Роутер 1", pingWeight: 60, optimizedCount: interfaces.length, applied: interfaces.map(row => ({ interface: row.interface, from: row.currentCost, to: row.optimalCost })), interfaces } }) + } + return r.fulfill({ json: path === "/api/auth/config" ? { required: false } : path === "/api/servers" ? [{ id: 1, name: "Роутер 1", host: "192.0.2.1", type: "home-router", country: "RU", enabled: true, status: null }] : path === "/api/filters/rules" ? { rulesets: [] } : path === "/api/uptime/speed-probes" ? { probes: [] } : path === "/api/ospf/catalog" ? { interfaces: [], instances: [], neighbors: [], bfdSessions: [], routes: [], failures: [] } : {} }) + }) + await page.goto("/route-optimizer?server=1&keep=1") + const grid = page.getByRole("table").filter({ hasText: "Speed (dl/ul)" }) + const rows = grid.locator("tr[data-row-id]") + await expect(rows).toHaveCount(50) + await expect(rows.first()).toHaveAttribute("data-row-id", "i1") + const root = page.getByRole("region", { name: "Preview интерфейсов OSPF", exact: true }) + const profile = { count: interfaces.length, bytes: Buffer.byteLength(JSON.stringify(preview())), rendered: await rows.count(), gridNodes: await root.locator("*").count(), pageNodes: await page.locator("*").count() } + console.log("Optimizer preview", JSON.stringify(profile)); writeFileSync(info.outputPath("optimizer-ospf-preview-profile.json"), JSON.stringify(profile, null, 2)) + await root.getByRole("button", { name: "Следующая страница", exact: true }).click() + await expect(rows.first()).toHaveAttribute("data-row-id", "i51") + const before = previews; await page.getByRole("button", { name: "Обновить preview OSPF", exact: true }).click() + await expect.poll(() => previews).toBeGreaterThan(before); await expect(rows.first()).toHaveAttribute("data-row-id", "i51") + const search = page.getByRole("textbox", { name: "Поиск интерфейсов preview OSPF" }) + await search.fill("ether1000"); await expect(rows).toHaveCount(1); await expect(rows.first()).toHaveAttribute("data-row-id", "i1000") + await expect(page.getByRole("status").filter({ hasText: "Фильтр меняет только отображение" })).toContainText("Найдено 1 из 1000") + await page.getByRole("button", { name: "Оптимизировать OSPF", exact: true }).click() + await expect(page.getByRole("status").filter({ hasText: "Последнее подтверждение API" })).toContainText("изменено интерфейсов — 1000") + expect(writes).toBe(1); await expect(page.getByText("0 из 1000 изменятся", { exact: true })).toBeVisible() + await expect(rows).toHaveCount(1) + await search.fill(""); await expect(rows).toHaveCount(50) + await grid.getByRole("button", { name: "Cost", exact: true }).click() + await expect(rows.first()).toHaveAttribute("data-row-id", "i1000") + await root.getByRole("button", { name: "Следующая страница", exact: true }).click() + const previous = await rows.first().getAttribute("data-row-id") + failed = true; await page.getByRole("button", { name: "Обновить preview OSPF", exact: true }).click() + await expect(page.getByRole("alert").filter({ hasText: "Последний снимок" })).toBeVisible() + await expect(rows.first()).toHaveAttribute("data-row-id", previous!) + await expect(page.getByRole("button", { name: "Оптимизировать OSPF", exact: true })).toBeDisabled() + await page.setViewportSize({ width: 320, height: 1000 }) + const scroll = root.locator('[data-slot="scroll-area-viewport"]') + await scroll.evaluate(el => { el.scrollLeft = el.scrollWidth }) + expect(await rows.first().getByRole("cell").first().evaluate(el => { const a=el.getBoundingClientRect(), v=el.closest('[data-slot="scroll-area-viewport"]')!.getBoundingClientRect(); return a.left>=v.left-1 && a.right<=v.right+1 })).toBe(true) + expect(await page.evaluate(() => document.documentElement.scrollWidth)).toBeLessThanOrEqual(320) + await scroll.evaluate(el => { el.scrollLeft = 0 }) + await grid.evaluate(el => el.scrollIntoView({ block: "start" })) + await page.addStyleTag({ content: "nextjs-portal{display:none!important}" }) + await page.screenshot({ path: info.outputPath("optimizer-ospf-preview-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="Preview интерфейсов OSPF"]')!, { runOnly: { type: "tag", values: ["wcag2a", "wcag2aa", "wcag21a", "wcag21aa"] } }))).violations.map(v => ({ id: v.id, nodes: v.nodes.map(n => n.target) }))).toEqual([]) + failed = false; interfaces = interfaces.slice(0, 1) + await page.getByRole("button", { name: "Обновить preview OSPF", exact: true }).click() + await expect(rows).toHaveCount(1); await expect(rows.first()).toHaveAttribute("data-row-id", "i1") + await expect(page).toHaveURL(url => url.searchParams.get("server") === "1" && url.searchParams.get("keep") === "1") +}) diff --git a/tests/e2e/route-optimizer-ospf-preview.spec.ts b/tests/e2e/route-optimizer-ospf-preview.spec.ts index eb067d9b..237115cc 100644 --- a/tests/e2e/route-optimizer-ospf-preview.spec.ts +++ b/tests/e2e/route-optimizer-ospf-preview.spec.ts @@ -63,3 +63,15 @@ for(const server of ["999","3","abc","01",""])test("Оптимизатор: не await expect(page.getByText("0 iface · 0 area",{exact:true})).toBeVisible() await expect(page.getByRole("button",{name:"Оптимизировать OSPF",exact:true})).toBeEnabled() }) + + +test("Оптимизатор: поиск preview сбрасывается при смене устройства", async ({ page }) => { + await base(page); await page.goto("/route-optimizer?server=1&keep=value") + const search = page.getByRole("textbox", { name: "Поиск интерфейсов preview OSPF" }) + await search.fill("несуществующий") + await expect(page.getByText("Нет интерфейсов по запросу", { exact: true })).toBeVisible() + await page.getByRole("combobox", { name: "Устройство OSPF", exact: true }).selectOption("2") + await expect(search).toHaveValue("") + await expect(page.getByRole("table").filter({ hasText: "Speed (dl/ul)" }).locator("tr[data-row-id]")).toHaveCount(1) + await expect(page).toHaveURL(url => url.searchParams.get("server") === "2" && url.searchParams.get("keep") === "value") +})