fix(ui): показать измерения рядом с закреплённым WAN на телефоне

This commit is contained in:
Denozordec committed 2026-10-07 14:28:07 +07:00
1 parent 5919af7f9b
commit 579e816bbb
7 files changed
+152 -9

No files matched your search

@@ -2,6 +2,7 @@
import { dataGridFeatures, type DataGridFeatures } from "@/components/reui/data-grid" import { dataGridFeatures, type DataGridFeatures } from "@/components/reui/data-grid"
import { useIsMobile } from "@/hooks/use-mobile"
import { useEffect, useMemo, useState } from "react" import { useEffect, useMemo, useState } from "react"
import { Input } from "@/components/ui/input" import { Input } from "@/components/ui/input"
import { Button } from "@/components/ui/button" import { Button } from "@/components/ui/button"
@@ -41,6 +42,7 @@ function RouteOptimizerWanMatrixDataGrid({
legs, legs,
jumpHosts, jumpHosts,
}: RouteOptimizerWanMatrixDataGridProps) { }: RouteOptimizerWanMatrixDataGridProps) {
const isMobile = useIsMobile()
const [wanQuery, setWanQuery] = useState("") const [wanQuery, setWanQuery] = useState("")
const [jhQuery, setJhQuery] = useState("") const [jhQuery, setJhQuery] = useState("")
const [jhPage, setJhPage] = useState(0) const [jhPage, setJhPage] = useState(0)
@@ -69,17 +71,17 @@ function RouteOptimizerWanMatrixDataGrid({
const base: ColumnDef<DataGridFeatures, HomeRouter["wans"][number]>[] = [ const base: ColumnDef<DataGridFeatures, HomeRouter["wans"][number]>[] = [
{ {
id: "wan", id: "wan",
size: 180, size: isMobile ? 112 : 180,
accessorKey: "name", accessorKey: "name",
header: () => <span className="text-[11px] font-medium text-muted-foreground">WAN-аплинк</span>, header: () => <span className="text-[11px] font-medium text-muted-foreground">WAN-аплинк</span>,
cell: ({ row }) => { cell: ({ row }) => {
const wan = row.original const wan = row.original
return ( return (
<div className="flex items-center gap-2"> <div className="flex min-w-0 items-center gap-2">
<WifiIcon aria-hidden="true" className="size-3.5 text-muted-foreground shrink-0" /> <WifiIcon aria-hidden="true" className="size-3.5 text-muted-foreground shrink-0" />
<div> <div className="min-w-0">
<p className="font-mono text-xs font-semibold">{wan.name}</p> <p title={wan.name} className="truncate font-mono text-xs font-semibold">{wan.name}</p>
<p className="text-[10px] text-muted-foreground">{wan.iface}</p> <p title={wan.iface} className="truncate text-[10px] text-muted-foreground">{wan.iface}</p>
</div> </div>
</div> </div>
) )
@@ -127,7 +129,7 @@ function RouteOptimizerWanMatrixDataGrid({
const jhCols: ColumnDef<DataGridFeatures, HomeRouter["wans"][number]>[] = visibleHosts.map((jh) => ({ const jhCols: ColumnDef<DataGridFeatures, HomeRouter["wans"][number]>[] = visibleHosts.map((jh) => ({
id: `jh-${jh.id}`, id: `jh-${jh.id}`,
size: 200, size: isMobile ? 160 : 200,
header: () => ( header: () => (
<div className="text-center"> <div className="text-center">
<div className="text-[11px] font-medium text-muted-foreground">{jh.label}</div> <div className="text-[11px] font-medium text-muted-foreground">{jh.label}</div>
@@ -194,7 +196,7 @@ function RouteOptimizerWanMatrixDataGrid({
} }
return [...base, ...jhCols] return [...base, ...jhCols]
}, [bestScore, visibleHosts, legMap]) }, [bestScore, visibleHosts, legMap, isMobile])
const table = useTable({ const table = useTable({
features: dataGridFeatures, features: dataGridFeatures,
+1 -1
View File
@@ -1,6 +1,6 @@
# План реализации UX/UI по пятичасовым окнам # План реализации UX/UI по пятичасовым окнам
Актуальное продолжение 2026-10-07: 12.3.75 завершён: OSPF raw/model используют client RX→DL/TX→UL,4backend suites и build. Следующий12.3.76: mobile pinned-column readable geometry. После этого обновить профильную и общую регрессию; команды/права не менять,12.5 отдельно. Актуальное продолжение 2026-10-07: 12.3.76 завершён: мобильная ReUI WAN/JH матрица112/160, геометрия/resize/WCAG,26browser/type/lint/build. Следующий12.4.8: общая main регрессия на новом HEAD; затем12.4.9 context. Реальная12.5 отдельно, stage12 пока не завершён.
Общий план проекта 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-07: 12.3.75 завершён: OSPF raw/model используют client RX→DL/TX→UL,4backend suites и build. Следующий12.3.76: mobile pinned-column readable geometry. После этого обновить профильную и общую регрессию; команды/права не менять,12.5 отдельно. Актуальное продолжение 2026-10-07: 12.3.76 завершён: мобильная ReUI WAN/JH матрица112/160, геометрия/resize/WCAG,26browser/type/lint/build. Следующий12.4.8: общая main регрессия на новом HEAD; затем12.4.9 context. Реальная12.5 отдельно, stage12 пока не завершён.
Общий план: [UX/UI по этапам и пятичасовым окнам](ux-ui-plan.md). План задаёт объём, этот журнал фиксирует выполненные блоки и ограничения. Общий план: [UX/UI по этапам и пятичасовым окнам](ux-ui-plan.md). План задаёт объём, этот журнал фиксирует выполненные блоки и ограничения.
+9
View File
@@ -835,3 +835,12 @@ CPU final:1000routes13.3ms;100000(10×100×100)49.7ms;100000(100×10×100)44.0ms
Четыре backend suites passed: pure metrics (новые asymmetric80.75/30.25,0/null,rounding/floor/fallback), identity, plan и HTTP optimizer с DI/in-memory writes. Backend build passed. Source review подтвердил фильтрацию по srcServerId и вызов helper в builder. Реальный DB builder и RouterOS не исполнялись; HTTP suite подменяет buildOptimizationPlan и отдельно проверяет отсутствие операций до валидного плана. Frontend не менялся, повторный browser/build для него не требовался. Четыре backend suites passed: pure metrics (новые asymmetric80.75/30.25,0/null,rounding/floor/fallback), identity, plan и HTTP optimizer с DI/in-memory writes. Backend build passed. Source review подтвердил фильтрацию по srcServerId и вызов helper в builder. Реальный DB builder и RouterOS не исполнялись; HTTP suite подменяет buildOptimizationPlan и отдельно проверяет отсутствие операций до валидного плана. Frontend не менялся, повторный browser/build для него не требовался.
Следующий76: на320px проверить видимость всей соседней metric cell за pinnedWAN, исправить column sizing при подтверждённой проблеме. Общая main/context регрессия прежних HEAD требует обновления позже;12.5 остаётся внешней. Следующий76: на320px проверить видимость всей соседней metric cell за pinnedWAN, исправить column sizing при подтверждённой проблеме. Общая main/context регрессия прежних HEAD требует обновления позже;12.5 остаётся внешней.
## 12.3.76 — читаемая мобильная WAN/JH матрица
2026-10-07. Геометрический baseline320px подтвердил дефект: viewport right303,pinned right197,cell right397 (200px колонка рядом с180px WAN). Пропорции ColumnDef на телефоне теперь112/160, desktop180/200 сохранены (actual table fill может расширять размеры). Hook useIsMobile и installed ReUI DataGridShell/ScrollArea/Table переиспользованы, primitive/hook/theme не менялись. Имя/интерфейс WAN ограничены truncate/min-w-0; полное значение остаётся DOM text/title и доступно поиску.
6 новых geometry cases320/390/1440×1/7JH проверяют всю metric cell и её speed text относительно pinnedRight/viewportRight, все7JH через переключение колонок, long WAN, отсутствие page overflow/API writes. Resize320→1440→320 прошёл, scoped WCAG axe passed. Related26browser, targeted lint, non-incremental TypeScript и frontend production build passed. Первый baseline намеренно failed на реальном переполнении, финальные assertions не ослаблялись. ReUI API и audit checklist прочитаны: v9useTable/dataGridFeatures/table/recordCount, existing layout/semantic tokens/security/scroll composition сохранены.
Доказательства: [геометрия до/после](ux-ui/acceptance/matrix-geometry-2026-10-07.json), [экран320px](ux-ui/screens/optimizer-matrix-readable-320.png) просмотрен. Historic9optimizer и2speed PNG восстановлены; предыдущие снимки не переписаны. Следующий12.4.8main/12.4.9context повтор на новом HEAD. Внешняя12.5 не выполнена.
@@ -0,0 +1,52 @@
{
"date": "2026-10-07",
"baseHead": "5919af7",
"sourceSha256": "70fb4d950ccef0c4feaec8e613c8c3fdb48f75a0fc862c8d7a06a4af51196b8e",
"baseline": {
"width": 320,
"cellLeft": 197,
"cellRight": 397,
"pinnedRight": 197,
"viewportRight": 303,
"pinnedWidth": 180,
"cellWidth": 200
},
"final": {
"width": 320,
"count": 1,
"measurements": [
{
"width": 320,
"count": 1,
"cellLeft": 143,
"cellRight": 303,
"pinnedRight": 129,
"viewportRight": 303,
"pinnedWidth": 112,
"cellWidth": 160
},
{
"width": 1440,
"count": 1,
"cellLeft": 1062.21875,
"cellRight": 1415,
"pinnedRight": 582.484375,
"viewportRight": 1415,
"pinnedWidth": 317.484375,
"cellWidth": 352.78125
},
{
"width": 320,
"count": 1,
"cellLeft": 143,
"cellRight": 303,
"pinnedRight": 129,
"viewportRight": 303,
"pinnedWidth": 112,
"cellWidth": 160
}
],
"writes": []
},
"scope": "6 geometry cases and related 26-case profile passed; WCAG scoped; no RouterOS/DB writes"
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 58 KiB

@@ -0,0 +1,80 @@
import { test, expect } from "@playwright/test"
import axe from "axe-core"
import { writeFileSync } from "node:fs"
for (const width of [320, 390, 1440]) for (const count of [1, 7]) {
test(`Матрица: измерение целиком рядом с pinned WAN/${width}/${count}`, async ({ page }, info) => {
await page.setViewportSize({ width, height: 1000 })
const longName = "WAN-" + "площадка".repeat(15)
const common = { country: "RU", enabled: true, status: "online", latency: 10, site: "TEST" }
const servers = [
{ ...common, id: 1, name: "Home", host: "192.0.2.1", type: "home-router", wanUplinks: [{ id: "wan", name: longName, iface: "ether1", isp: "ISP", ip: "198.51.100.1", maxDl: 100, maxUl: 100 }] },
...Array.from({ length: count }, (_, i) => ({ ...common, id: i + 2, name: `Jump ${i + 1}`, host: `192.0.2.${i + 2}`, type: "jump-host" })),
]
const probes = servers.slice(1).map(s => ({ id: `probe-${s.id}`, srcServerId: "1", dstServerId: String(s.id), srcInterface: "ether1", dstInterface: "", enabled: true, lastPingRttMs: 5, lastPingLossPct: 0, lastTxAvgMbps: 30, lastRxAvgMbps: 80, lastPingAt: "2026-10-07T00:00:00Z" }))
const writes: string[] = []
await page.route("**/health", r => r.fulfill({ json: { ok: true } }))
await page.route("**/api/**", r => {
const path = new URL(r.request().url()).pathname
if (r.request().method() !== "GET" && !path.endsWith("/preview")) writes.push(path)
return r.fulfill({ json: path === "/api/auth/config" ? { required: false } : path === "/api/servers" ? servers : path === "/api/filters/rules" ? { rulesets: [] } : path === "/api/uptime/speed-probes" ? { probes } : path === "/api/ospf/catalog" ? { interfaces: [], instances: [], neighbors: [], bfdSessions: [], routes: [], failures: [] } : path.endsWith("/preview") ? { serverId: 1, serverName: "Home", pingWeight: 60, interfacesTotal: 0, changedCount: 0, unchangedCount: 0, interfaces: [], changes: [] } : {} })
})
await page.goto("/route-optimizer")
const region = page.getByRole("region", { name: "Матрица WAN и Jump Host" })
const row = region.locator("tr[data-row-id]")
await expect(row).toHaveCount(1)
const viewport = region.locator('[data-slot="scroll-area-viewport"]')
const measurements: unknown[] = []
async function inspectColumns() {
const cells = row.locator("td").filter({ hasText: "↓80 ↑30 Мбит/с" })
for (let i = 0; i < await cells.count(); i++) {
const cell = cells.nth(i)
await cell.evaluate(el => {
const viewport = el.closest('[data-slot="scroll-area-viewport"]') as HTMLElement
const pinned = el.parentElement!.querySelector('td[data-pinned="start"]')!
viewport.scrollLeft += el.getBoundingClientRect().left - viewport.getBoundingClientRect().left - pinned.getBoundingClientRect().width
})
const geometry = await cell.evaluate(el => {
const viewport = el.closest('[data-slot="scroll-area-viewport"]')!, pinned = el.parentElement!.querySelector('td[data-pinned="start"]')!
const c = el.getBoundingClientRect(), v = viewport.getBoundingClientRect(), p = pinned.getBoundingClientRect()
return { cellLeft: c.left, cellRight: c.right, pinnedRight: p.right, viewportRight: v.right, pinnedWidth: p.width, cellWidth: c.width }
})
measurements.push({ width: page.viewportSize()!.width, count, ...geometry })
console.log("Matrix geometry", JSON.stringify({ width: page.viewportSize()!.width, count, ...geometry }))
expect(geometry.cellLeft).toBeGreaterThanOrEqual(geometry.pinnedRight - 1)
expect(geometry.cellRight).toBeLessThanOrEqual(geometry.viewportRight + 1)
const text = await cell.getByText("↓80 ↑30 Мбит/с", { exact: true }).boundingBox()
expect(text!.x).toBeGreaterThanOrEqual(geometry.pinnedRight - 1)
expect(text!.x + text!.width).toBeLessThanOrEqual(geometry.viewportRight + 1)
}
}
await inspectColumns()
if (count > 5) {
await region.getByRole("button", { name: "Следующие Jump Host" }).click()
await expect(region).toContainText("Столбцы 6–7 из 7")
await inspectColumns()
}
await expect(row.getByText(longName, { exact: true })).toHaveAttribute("title", longName)
const bounds = await viewport.boundingBox()
expect(bounds!.x + bounds!.width).toBeLessThanOrEqual(width)
expect(await page.evaluate(() => document.documentElement.scrollWidth)).toBeLessThanOrEqual(width)
if (width === 320 && count === 1) {
await page.setViewportSize({ width: 1440, height: 1000 })
await expect.poll(() => row.locator('td[data-pinned="start"]').evaluate(el => el.getBoundingClientRect().width)).toBeGreaterThanOrEqual(180)
await inspectColumns()
await page.setViewportSize({ width: 320, height: 1000 })
await expect.poll(() => row.locator('td[data-pinned="start"]').evaluate(el => el.getBoundingClientRect().width)).toBe(112)
await inspectColumns()
await region.scrollIntoViewIfNeeded()
await page.addStyleTag({ content: "nextjs-portal{display:none!important}" })
await page.screenshot({ path: info.outputPath("matrix-readable-320.png"), animations: "disabled" })
writeFileSync(info.outputPath("geometry.json"), JSON.stringify({ width, count, measurements, writes }, null, 2))
}
if (width === 320 && count === 1) {
await page.addScriptTag({ content: axe.source })
const result = await page.evaluate(async () => (window as unknown as { axe: typeof axe }).axe.run(document.querySelector('[aria-label="Матрица WAN и Jump Host"]')!, { runOnly: { type: "tag", values: ["wcag2a", "wcag2aa", "wcag21a", "wcag21aa"] } }))
expect(result.violations.map(v => ({ id: v.id, nodes: v.nodes.map(n => n.target) }))).toEqual([])
}
expect(writes).toEqual([])
})
}