fix(ui): улучшить мобильные экраны и сводку GRE и VXLAN

This commit is contained in:
Denozordec committed 2026-09-30 16:27:10 +07:00
1 parent fc3d588cec
commit a29ee31052
15 files changed
+116 -111

No files matched your search

+21 -54
View File
@@ -492,7 +492,7 @@ function GrePageContent({ selectedServerId, selectedTunnelId, allTunnelsHref, on
setTunnelOpen(true)
}
const incomplete = isLive && (!hasLoaded || !!dataError || failures.length > 0)
const incomplete = isLive && (!hasLoaded || !!dataError || (selectedServerId === ALL_SERVERS_ID ? failures.length > 0 : failedServerIds.includes(selectedServerId)))
const unknownServerIds = useMemo(() => new Set(isLive ? dataError ? displayTunnels.map(t => t.serverId) : failedServerIds : []), [isLive, dataError, displayTunnels, failedServerIds])
const filtered = useMemo(() => {
@@ -511,8 +511,8 @@ function GrePageContent({ selectedServerId, selectedTunnelId, allTunnelsHref, on
})
}, [tabFilter, search, scopedTunnels, serverById, unknownServerIds])
const upCount = displayTunnels.filter((t) => t.status === "up").length
const ipsecCount = displayTunnels.filter((t) => t.ipsec).length
const upCount = scopedTunnels.filter((t) => t.status === "up").length
const ipsecCount = scopedTunnels.filter((t) => t.ipsec).length
const scopedUpCount = scopedTunnels.filter((t) => t.status === "up").length
const scopedIpsecCount = scopedTunnels.filter((t) => t.ipsec).length
@@ -587,7 +587,7 @@ function GrePageContent({ selectedServerId, selectedTunnelId, allTunnelsHref, on
<Alert variant={selectedTunnelError || ((!isLive || !dataLoading) && !selectedTunnel) ? "warning" : "info"} role="status" aria-label="GRE-туннель по ссылке">
<AlertTitle className="line-clamp-none wrap-anywhere">{selectedTunnel ? "Выбран GRE-туннель: " + selectedTunnel.name : "GRE-туннель по ссылке"}</AlertTitle>
<AlertDescription>
<p className="wrap-anywhere">{isLive && dataLoading ? "Загрузка туннеля…" : selectedTunnelError ? "Не удалось получить данные выбранного сервера. Повторите обновление; сохранённые данные могут быть устаревшими." : selectedTunnel ? (serverById[selectedServerId]?.name ?? selectedServerId) + " · " + selectedTunnel.remoteAddress + ". Список ограничен выбранным туннелем; сводка относится ко всем серверам." : "Туннель не найден или недоступен на указанном сервере. Проверьте ссылку и доступ к устройству."}</p>
<p className="wrap-anywhere">{isLive && dataLoading ? "Загрузка туннеля…" : selectedTunnelError ? "Не удалось получить данные выбранного сервера. Повторите обновление; сохранённые данные могут быть устаревшими." : selectedTunnel ? (serverById[selectedServerId]?.name ?? selectedServerId) + " · " + selectedTunnel.remoteAddress + ". Список ограничен выбранным туннелем; сводка относится к этому срезу." : "Туннель не найден или недоступен на указанном сервере. Проверьте ссылку и доступ к устройству."}</p>
<div className="flex flex-wrap gap-4">
<Link href={allTunnelsHref} className="underline underline-offset-2">Все туннели сервера</Link>
<Link href="/dashboard" className="underline underline-offset-2">К дашборду</Link>
@@ -607,27 +607,14 @@ function GrePageContent({ selectedServerId, selectedTunnelId, allTunnelsHref, on
</Alert>
)}
{/* Legacy banner */}
<div className="flex items-start gap-3 rounded-lg bg-amber-500/5 border border-amber-500/20 px-4 py-3 text-sm">
<ShieldCheckIcon className="size-5 text-amber-500 shrink-0 mt-0.5" />
<div>
<p className="font-medium text-amber-600 dark:text-amber-400">
В RouterOS 7.x рекомендуется использовать WireGuard вместо GRE+IPsec
</p>
<p className="text-muted-foreground text-xs mt-0.5">
WireGuard проще в настройке, обеспечивает лучшую производительность и современную криптографию (ChaCha20-Poly1305).
GRE-туннели остаются поддерживаемыми, но WireGuard — предпочтительный выбор для новых развёртываний.
</p>
</div>
</div>
<KpiStatGrid
aria-label="Сводка GRE"
gridClassName="grid-cols-2 @3xl:grid-cols-2 @6xl:grid-cols-4"
items={[
{
id: "tunnels",
label: "Всего туннелей",
value: incomplete ? "—" : displayTunnels.length,
label: "Туннелей",
value: incomplete ? "—" : scopedTunnels.length,
hint: incomplete ? "неизвестно" : undefined,
icon: <NetworkIcon className="size-4" />,
iconClassName: "text-muted-foreground",
@@ -642,7 +629,7 @@ function GrePageContent({ selectedServerId, selectedTunnelId, allTunnelsHref, on
},
{
id: "ipsec",
label: "Защищены IPsec",
label: "С IPsec",
value: incomplete ? "—" : ipsecCount,
hint: incomplete ? "неизвестно" : undefined,
icon: <LockIcon className="size-4" />,
@@ -750,35 +737,15 @@ function GrePageContent({ selectedServerId, selectedTunnelId, allTunnelsHref, on
)}
{/* RouterOS reference */}
<OpsPanel title="RouterOS 7.20+ — параметры GRE-интерфейса" contentClassName="px-5 py-4">
<div className="grid grid-cols-2 gap-x-8 gap-y-1 text-xs font-mono">
{[
["/interface gre add", ""],
[" name=", "имя интерфейса"],
[" remote-address=", "IP удалённого конца (обязателен)"],
[" local-address=", "0.0.0.0 = авто"],
[" ipsec-secret=", "PSK → auto peer+policy+proposal"],
[" mtu=", "по умолчанию 1476"],
[" keepalive=", "10s,10 (интервал,попытки)"],
[" dscp=", "inherit | 0-63"],
[" clamp-tcp-mss=", "yes | no"],
[" allow-fast-path=", "yes | no"],
["/ip address add", ""],
[" address=x.x.x.x/30", "внутренний IP туннеля"],
[" interface=<name>", ""],
].map(([cmd, desc], i) => (
<div key={i} className="flex gap-2 py-0.5">
<span className="text-foreground/70 shrink-0">{cmd}</span>
{desc && <span className="text-muted-foreground"># {desc}</span>}
</div>
))}
</div>
<OpsPanel title="Справка GRE" contentClassName="px-5 py-4">
<p className="text-xs text-muted-foreground">Экспорт содержит интерфейс и локальный IP выбранного туннеля. Удалённую сторону и маршруты настройте отдельно. <a href="https://help.mikrotik.com/docs/spaces/ROS/pages/24805531/GRE" target="_blank" rel="noreferrer" className="underline underline-offset-2">Параметры GRE в документации MikroTik</a></p>
</OpsPanel>
</div>
</ServerRailLayout>
{/* ══ Sheet: Code Preview ════════════════════════════════════════════════ */}
<CodeExportSheet
className="data-[side=right]:w-full data-[side=right]:sm:max-w-lg"
open={!!codePreviewTunnel}
onClose={() => setCodePreviewTunnel(null)}
title={codePreviewTunnel?.name ?? "GRE"}
@@ -822,7 +789,7 @@ function GrePageContent({ selectedServerId, selectedTunnelId, allTunnelsHref, on
{/* ══ Sheet: Add Tunnel ══════════════════════════════════════════════════ */}
<Sheet open={tunnelOpen} onOpenChange={v => { if (!formLock.current) setTunnelOpen(v) }}>
<SheetContent showCloseButton={!mutateBusy} side="right" className="w-full sm:max-w-lg flex flex-col gap-0 p-0">
<SheetContent showCloseButton={!mutateBusy} side="right" className="data-[side=right]:w-full data-[side=right]:sm:max-w-lg flex flex-col gap-0 p-0">
<SheetHeader className="px-6 pt-6 pb-4 border-b shrink-0">
<SheetTitle>{tunnelMode === "edit" ? "Редактировать GRE-туннель" : "Новый GRE-туннель"}</SheetTitle>
<SheetDescription>RouterOS 7.20+ · /interface gre {tunnelMode === "edit" ? "set" : "add"}</SheetDescription>
@@ -839,7 +806,7 @@ function GrePageContent({ selectedServerId, selectedTunnelId, allTunnelsHref, on
<select aria-label="Сервер (MikroTik)" value={tForm.serverId} onChange={(e) => setT("serverId", e.target.value)} disabled={tunnelMode === "edit"}
className="h-8 w-full rounded-lg border border-input bg-background px-2.5 text-sm text-foreground outline-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50">
<option value="" disabled>Выбрать сервер…</option>
{displayServers.map((s) => <option key={s.id} value={s.id} disabled={isLive && !canManage(s.id)}>{s.name} ({s.site}){isLive && !canManage(s.id) ? " — недоступен для изменений" : ""}</option>)}
{displayServers.map((s) => <option key={s.id} value={s.id} disabled={isLive && !canManage(s.id)}>{s.name}{s.site ? ` (${s.site})` : ""}{isLive && !canManage(s.id) ? " — недоступен для изменений" : ""}</option>)}
</select>
</FormField>
<FormField label="Комментарий">
@@ -865,11 +832,11 @@ function GrePageContent({ selectedServerId, selectedTunnelId, allTunnelsHref, on
<SectionTitle>Внутренний IP</SectionTitle>
{!isLive && <p className="text-xs text-muted-foreground">Моковые пулы доступны только для просмотра.</p>}
<div className="grid grid-cols-2 gap-3">
<FormField label="Локальный IP" required hint="/ip address на этом конце">
<Input className="font-mono" placeholder="10.200.0.1/30" value={tForm.localInnerIp} onChange={(e) => setT("localInnerIp", e.target.value)} />
<FormField label="Локальный IP" hint="/ip address на этом конце">
<Input className="font-mono" aria-label="Локальный IP" placeholder="10.200.0.1/30" value={tForm.localInnerIp} onChange={(e) => setT("localInnerIp", e.target.value)} />
</FormField>
<FormField label="Удалённый IP" required hint="/ip address на другом конце">
<Input className="font-mono" placeholder="10.200.0.2/30" value={tForm.remoteInnerIp} onChange={(e) => setT("remoteInnerIp", e.target.value)} />
<FormField label="Удалённый IP" hint="/ip address на другом конце">
<Input className="font-mono" aria-label="Удалённый IP" placeholder="10.200.0.2/30" value={tForm.remoteInnerIp} onChange={(e) => setT("remoteInnerIp", e.target.value)} />
</FormField>
</div>
</div>
@@ -912,17 +879,17 @@ function GrePageContent({ selectedServerId, selectedTunnelId, allTunnelsHref, on
<div className="flex flex-col gap-4">
<div className="grid grid-cols-3 gap-3">
<FormField label="MTU" hint="По умолч. 1476">
<Input type="number" className="font-mono" value={tForm.mtu} onChange={(e) => setT("mtu", Number(e.target.value))} />
<Input aria-label="MTU" type="number" className="font-mono" value={tForm.mtu} onChange={(e) => setT("mtu", Number(e.target.value))} />
</FormField>
<FormField label="Keepalive, с" hint="0 = откл.">
<Input type="number" className="font-mono" value={tForm.keepaliveInterval} onChange={(e) => setT("keepaliveInterval", Number(e.target.value))} />
<Input aria-label="Keepalive, с" type="number" className="font-mono" value={tForm.keepaliveInterval} onChange={(e) => setT("keepaliveInterval", Number(e.target.value))} />
</FormField>
<FormField label="Попытки">
<Input type="number" className="font-mono" value={tForm.keepaliveRetries} onChange={(e) => setT("keepaliveRetries", Number(e.target.value))} />
<Input aria-label="Попытки" type="number" className="font-mono" value={tForm.keepaliveRetries} onChange={(e) => setT("keepaliveRetries", Number(e.target.value))} />
</FormField>
</div>
<FormField label="DSCP">
<select value={tForm.dscp} onChange={(e) => setT("dscp", e.target.value)}
<select aria-label="DSCP" value={tForm.dscp} onChange={(e) => setT("dscp", e.target.value)}
className="h-8 w-full rounded-lg border border-input bg-background px-2.5 text-sm text-foreground outline-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50">
<option value="inherit">inherit</option>
{Array.from({ length: 64 }, (_, i) => <option key={i} value={String(i)}>{i}</option>)}
+6 -53
View File
@@ -86,6 +86,7 @@ function ExportSheet({
<CodeExportSheet
open={open}
onClose={onClose}
className="data-[side=right]:w-full data-[side=right]:sm:max-w-lg"
title="Экспорт VXLAN"
description={mock ? "Моковый снимок · данные не получены с RouterOS" : `Снимок на момент открытия · ${receivedAt ? new Date(receivedAt).toLocaleString("ru-RU") : "время получения неизвестно"} · ${stale ? "актуальность не подтверждена" : "последний успешный опрос"}`}
formats={[
@@ -297,6 +298,7 @@ function VxlanPageContent({ selectedServerId, selectedTunnelId, allTunnelsHref,
<KpiStatGrid
aria-label="Сводка VXLAN"
gridClassName="grid-cols-2 @3xl:grid-cols-2 @6xl:grid-cols-4"
items={[
{
id: "tunnels",
@@ -314,7 +316,7 @@ function VxlanPageContent({ selectedServerId, selectedTunnelId, allTunnelsHref,
},
{
id: "vni",
label: "Уникальных VNI",
label: "VNI",
value: isLive && (!hasLoaded || !!liveError || scopedFailures.length > 0) ? "—" : vnis,
icon: <LayersIcon className="size-4" />,
iconClassName: "text-info",
@@ -334,8 +336,7 @@ function VxlanPageContent({ selectedServerId, selectedTunnelId, allTunnelsHref,
<div>
<p className="font-medium text-sky-600 dark:text-sky-400">VXLAN — L2-over-L3 оверлей для RouterOS 7.x</p>
<p className="text-muted-foreground text-xs mt-0.5">
Доступен с RouterOS 7.1+. VNI (Virtual Network Identifier) — уникальный идентификатор сегмента (0–16777215).
Рекомендуется использовать совместно с WireGuard или GRE туннелями для шифрования.
VXLAN переносит L2 через IP-сеть. VNI определяет сегмент; шифрование зависит от настроенного транспорта.
</p>
</div>
</div>
@@ -355,56 +356,8 @@ function VxlanPageContent({ selectedServerId, selectedTunnelId, allTunnelsHref,
/>}
</DataPageCard>
<OpsPanel title="RouterOS 7 · /interface/vxlan — быстрые команды" contentClassName="px-5 py-4">
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4 text-xs font-mono">
{[
{
title: "Создать VXLAN",
lines: [
"/interface/vxlan/add \\",
" name=vxlan-10 \\",
" vni=10010 \\",
" port=8472 \\",
" arp-proxy=yes \\",
" mac-learning=yes",
],
},
{
title: "Добавить VTEP",
lines: [
"/interface/vxlan/vteps/add \\",
" interface=vxlan-10 \\",
" remote-ip=10.0.1.1",
"",
"/interface/vxlan/vteps/add \\",
" interface=vxlan-10 \\",
" remote-ip=10.0.2.1",
],
},
{
title: "Bridge + IP",
lines: [
"/interface/bridge/add \\",
" name=br-overlay",
"",
"/interface/bridge/port/add \\",
" bridge=br-overlay \\",
" interface=vxlan-10",
"",
"/ip/address/add \\",
" address=10.100.0.1/24 \\",
" interface=br-overlay",
],
},
].map((b) => (
<div key={b.title}>
<p className="font-sans font-semibold text-foreground/80 mb-1.5 text-[11px] uppercase tracking-wide">{b.title}</p>
<pre className="bg-zinc-950 rounded-md p-2.5 text-zinc-300 text-[11px] leading-relaxed overflow-x-auto whitespace-pre">
{b.lines.join("\n")}
</pre>
</div>
))}
</div>
<OpsPanel title="Справка VXLAN" contentClassName="px-5 py-4">
<p className="text-xs text-muted-foreground">Экспорт содержит интерфейс и статические VTEP выбранного туннеля. Bridge и FDB не входят в доступный снимок. <a href="https://help.mikrotik.com/docs/spaces/ROS/pages/100007937/VXLAN" target="_blank" rel="noreferrer" className="underline underline-offset-2">Параметры VXLAN в документации MikroTik</a></p>
</OpsPanel>
</div>
+4 -2
View File
@@ -203,6 +203,7 @@ interface KpiStatGridProps {
emptyMessage?: ReactNode
emptyIcon?: ReactNode
className?: string
gridClassName?: string
skeletonCount?: number
embedded?: boolean
"aria-label"?: string
@@ -255,6 +256,7 @@ export function KpiStatGrid({
emptyIcon,
className,
skeletonCount = 4,
gridClassName,
embedded = false,
"aria-label": ariaLabel,
}: KpiStatGridProps) {
@@ -280,7 +282,7 @@ export function KpiStatGrid({
if (embedded) {
return (
<section aria-label={ariaLabel} className={cn("@container w-full", className)}>
<div className={cn("grid gap-2", kpiCols(list.length || 1))}>
<div className={cn("grid gap-2", kpiCols(list.length || 1), gridClassName)}>
{list.map((item, index) => (
<KpiStatCardTile key={kpiStatItemKey(item, index)} item={item} embedded />
))}
@@ -291,7 +293,7 @@ export function KpiStatGrid({
return (
<Frame className={cn("@container w-full min-w-0", className)} aria-label={ariaLabel}>
<div className={cn("grid gap-2", kpiCols(list.length || 1))}>
<div className={cn("grid gap-2", kpiCols(list.length || 1), gridClassName)}>
{list.map((item, index) => (
<KpiStatCardItem key={kpiStatItemKey(item, index)} item={item} />
))}
+11
View File
@@ -126,3 +126,14 @@ flowchart LR
Для новой задачи:
> Продолжи UX/UI-план из docs/ux-ui-plan.md по журналу docs/ux-ui-progress.md. Сначала проверь состояние файлов и остаток лимита. Выполни следующий незавершённый блок, проверь результат, обнови журнал и сделай локальный коммит без push.
## GRE/VXLAN — локальная визуальная приёмка 2026-09-30
| Экран / референс | ПК | Телефон | Схема |
| --- | --- | --- | --- |
| GRE · List 9 / Stats 12 | [Снимок](ux-ui/screens/gre-desktop.png) | [Снимок](ux-ui/screens/gre-mobile.png) | Устройство → сводка среза → туннели |
| GRE · Sheet 8 | [Форма](ux-ui/screens/gre-form-desktop.png) | [Форма](ux-ui/screens/gre-form-mobile.png) | Контекст → поддерживаемые поля → подтверждение API |
| VXLAN · Data Grid Filtering 2 | [Снимок](ux-ui/screens/vxlan-desktop.png) | [Снимок](ux-ui/screens/vxlan-mobile.png) | Устройство → подтверждённый срез → просмотр |
| VXLAN · Sheet 8 | [Экспорт](ux-ui/screens/vxlan-export-desktop.png) | [Экспорт](ux-ui/screens/vxlan-export-mobile.png) | Снимок → актуальность → код → копирование/файл |
HTTP-фикстуры; layout/клавиатура проверены. Production frontend build и 183 E2E прошли в 9.63. RouterOS/JWT остаются отдельной приёмкой; актуальная точка продолжения — в журнале.
+8 -1
View File
@@ -25,12 +25,19 @@
| 6 | Навигация и дашборд | Завершён 2026-09-20 в границах локальной приёмки; ограничения ниже |
| 7 | Серверы и мониторинг: детали, формы, URL-контекст, права | Завершён в границах локальной приёмки 2026-09-23; внешние ограничения ниже |
| 8 | Трафик и статистика | Завершён 2026-09-28 в границах локальной приёмки; ограничения в блоке 8.13 |
| 9 | VPN, Firewall и Filters | В работе: блоки 9.1–9.62 — IPsec/GRE/VXLAN, Firewall и операции Filters |
| 9 | VPN, Firewall и Filters | В работе: блоки 9.1–9.63 — IPsec/GRE/VXLAN, Firewall и операции Filters |
| 10 | Маршрутизация, диагностика, карта и терминал | Не начат |
| 11А | Пользователи, контейнеры, сертификаты и резервные копии | Не начат |
| 11Б | Оповещения, сбор данных, интеграции, релизы и настройки | Не начат |
| 12 | Итоговая доступность, адаптивность, производительность и регрессия | Не начат |
## Блок 9.63 — приёмка GRE/VXLAN и мобильные панели (2026-09-30)
- GRE/VXLAN используют компактную сводку в две колонки на телефоне и краткие подписи. GRE KPI теперь относится к выбранному устройству/туннелю, а не глобальному списку. Неизвестная сводка учитывает выбранный срез. Для этого KpiStatGrid получил необязательный gridClassName; существующие потребители сохраняют прежнюю раскладку.
- Форма GRE и экспорт имеют ширину телефона; footer больше не обрезает «Копировать». На ПК панели ограничены шириной lg. GRE-поля получили доступные имена, optional IP не помечены обязательными; пустой site не выводит скобки. Убраны устаревшие справочные команды VXLAN arp-proxy/mac-learning и лишний баннер GRE; справка ведёт к MikroTik. VXLAN не обещает шифрование через обычный GRE.
- Просмотрены восемь PNG в docs/ux-ui/screens: GRE/VXLAN и формы/экспорт для ПК/телефона. Четыре сценария keyboard/layout проверили точный serverId/VNI, сохранение, отсутствие переполнения, полную мобильную ширину и границы кнопок footer. Общая регрессия этапа 9: 183 E2E прошли (source-конфигурации проверяются отдельно). Production frontend build — 34 страницы; TypeScript/lint/diff-check чистые. Backend test:live-maps, test:config-sync и unit экспортов прошли.
- Перегенерированные прежние Filters/IPsec PNG восстановлены к своим закоммиченным вариантам; новых продуктовых изменений там нет. Далее — отдельное исправление serverId GRE API и фиксация границ завершения этапа 9. Реальные RouterOS/JWT/БД не проверены. Локальный коммит без push.
## Блок 9.62 — возможности API GRE-пулов (2026-09-30)
- Удалён фиктивный live-каталог из poolId интерфейсов: он не содержит CIDR/ёмкость, но раньше изображал полное заполнение и свободные блоки. Live KPI пулов теперь неизвестен с подписью «нет данных API», раздел объясняет отсутствие каталога/управления. Инвентаризация настоящих /ip/pool и её API не изобретены.
+9 -1
View File
@@ -1,6 +1,6 @@
# Этап 9: VPN и сетевые операции
Статус: начат 2026-09-28, выполнены блоки 9.1–9.25. Основа — существующие ReUI PRO/base-nova, Frame, ServerTileRail, DataGrid и формы. Правила применения сетевой конфигурации не меняем.
Статус: начат 2026-09-28, выполнены блоки 9.1–9.63. Основа — существующие ReUI PRO/base-nova, Frame, ServerTileRail, DataGrid и формы. Правила применения сетевой конфигурации не меняем.
## Схема и референсы принятого плана
@@ -361,3 +361,11 @@ Live-копирование читает актуальный целевой rul
- Семь E2E прошли: числовая сортировка/поиск 12 туннелей, отсутствие подмены неизвестных пулов пустыми, mock-каталог/no writes и три формы. TypeScript/lint чистые.
- Далее — desktop/mobile GRE/VXLAN и профильная регрессия. Расширение backend настоящим управлением адресными пулами — отдельный функциональный объём, текущий UX честно отражает доступные API. Локальный коммит без push.
## Блок 9.63 — приёмка GRE/VXLAN и мобильные панели (2026-09-30)
- GRE/VXLAN используют компактную сводку в две колонки на телефоне и краткие подписи. GRE KPI теперь относится к выбранному устройству/туннелю, а не глобальному списку. Неизвестная сводка учитывает выбранный срез. Для этого KpiStatGrid получил необязательный gridClassName; существующие потребители сохраняют прежнюю раскладку.
- Форма GRE и экспорт имеют ширину телефона; footer больше не обрезает «Копировать». На ПК панели ограничены шириной lg. GRE-поля получили доступные имена, optional IP не помечены обязательными; пустой site не выводит скобки. Убраны устаревшие справочные команды VXLAN arp-proxy/mac-learning и лишний баннер GRE; справка ведёт к MikroTik. VXLAN не обещает шифрование через обычный GRE.
- Просмотрены восемь PNG в docs/ux-ui/screens: GRE/VXLAN и формы/экспорт для ПК/телефона. Четыре сценария keyboard/layout проверили точный serverId/VNI, сохранение, отсутствие переполнения, полную мобильную ширину и границы кнопок footer. Общая регрессия этапа 9: 183 E2E прошли (source-конфигурации проверяются отдельно). Production frontend build — 34 страницы; TypeScript/lint/diff-check чистые. Backend test:live-maps, test:config-sync и unit экспортов прошли.
- Перегенерированные прежние Filters/IPsec PNG восстановлены к своим закоммиченным вариантам; новых продуктовых изменений там нет. Далее — отдельное исправление serverId GRE API и фиксация границ завершения этапа 9. Реальные RouterOS/JWT/БД не проверены. Локальный коммит без push.
Binary file not shown.

After

Width:  |  Height:  |  Size: 131 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 243 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 54 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 48 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 131 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 219 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 43 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 55 KiB

+57
View File
@@ -0,0 +1,57 @@
import { test, expect } from "@playwright/test"
import { mkdirSync } from "node:fs"
for (const section of ["gre", "vxlan"]) for (const device of ["desktop", "mobile"] as const) {
test(`${section}: клавиатура и layout ${device}`, async ({ page }) => {
await page.setViewportSize(device === "desktop" ? { width: 1440, height: 1000 } : { width: 390, height: 844 })
await page.clock.install({ time: new Date("2026-09-30T05:00:00Z") })
const devices = [1, 2].map(id => ({ id, name: id === 1 ? "Основной роутер" : "Резервный роутер", host: "192.0.2." + id, enabled: true, country: "RU", status: "online", type: "exit-node" }))
const gre = ["1", "2"].map(id => ({ id: "*" + id, serverId: id, name: "gre-" + id, localAddress: "192.0.2." + id, remoteAddress: "198.51.100." + id, localInnerIp: "10.0.0.1/30", remoteInnerIp: "10.0.0.2/30", poolId: "live", ipsec: null, mtu: 1476, keepaliveInterval: 10, keepaliveRetries: 3, dscp: "inherit", clampTcpMss: true, allowFastPath: true, comment: "", enabled: true, status: "up" }))
const vxlan = ["1", "2"].map(id => ({ id: "*" + id, serverId: id, name: "vxlan-" + id, vni: Number(id) * 100, port: 0, dstPort: 8472, vtepIp: "192.0.2." + id, remoteVteps: ["198.51.100." + id], l2mtu: 1500, arpProxy: false, macLearning: true, comment: "", enabled: true, status: "up" }))
let writes = 0
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() === "PATCH") {
writes++
expect(path).toBe("/api/gre/tunnels")
expect(r.request().postDataJSON()).toMatchObject({ serverId: "2", rosId: "*2", comment: "Проверенный контекст" })
gre[1].comment = "Проверенный контекст"
return r.fulfill({ json: { ok: true } })
}
return r.fulfill({ json: path === "/api/servers" ? devices : path === "/api/gre/tunnels" ? { tunnels: gre } : path === "/api/vxlan" ? { tunnels: vxlan } : path === "/api/auth/config" ? { required: false } : {} })
})
await page.goto("/" + section + "?server=2")
await expect(page.locator("tr[data-row-id]")).toHaveCount(1)
expect(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth)).toBe(true)
await page.addStyleTag({ content: "nextjs-portal { display: none !important; }" })
mkdirSync("docs/ux-ui/screens", { recursive: true })
await page.screenshot({ path: `docs/ux-ui/screens/${section}-${device}.png`, animations: "disabled" })
const menu = page.getByRole("button", { name: "Действия: " + section + "-2", exact: true })
await menu.focus()
await page.keyboard.press("Enter")
await page.getByRole("menuitem", { name: section === "gre" ? "Редактировать" : "Экспорт .rsc", exact: true }).click()
const dialog = page.getByRole("dialog")
await expect.poll(() => dialog.evaluate(el => el.getBoundingClientRect().left >= 0 && el.getBoundingClientRect().right <= innerWidth)).toBe(true)
if (device === "mobile") {
await expect.poll(() => dialog.evaluate(el => el.getBoundingClientRect().width >= innerWidth - 1)).toBe(true)
const footer = dialog.locator('[data-slot="sheet-footer"]')
expect(await footer.evaluate(el => [...el.querySelectorAll("button")].every(button => button.getBoundingClientRect().right <= innerWidth && button.getBoundingClientRect().left >= 0))).toBe(true)
}
if (section === "gre") {
await expect(dialog.getByLabel("Сервер (MikroTik)")).toHaveValue("2")
await dialog.getByLabel("Комментарий").fill("Проверенный контекст")
} else {
await expect(dialog).toContainText("Резервный роутер")
await expect(dialog).toContainText("vni=200")
}
await page.screenshot({ path: `docs/ux-ui/screens/${section}-${section === "gre" ? "form" : "export"}-${device}.png`, animations: "disabled" })
if (section === "gre") {
await dialog.getByRole("button", { name: "Сохранить", exact: true }).focus()
await page.keyboard.press("Enter")
await expect(dialog).toHaveCount(0)
expect(writes).toBe(1)
} else { await page.keyboard.press("Escape"); expect(writes).toBe(0) }
expect(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth)).toBe(true)
})
}