fix(network): исправить keepalive GRE и каталог IPsec

This commit is contained in:
Denozordec committed 2026-10-13 21:15:49 +07:00
1 parent 3f3f06f147
commit 727e35f985
28 files changed
+1722 -37

No files matched your search

+7 -3
View File
@@ -357,6 +357,10 @@ function GrePageContent({ selectedServerId, selectedTunnelId, allTunnelsHref, on
setFormError("Для IPsec нужен PSK не короче 8 символов") setFormError("Для IPsec нужен PSK не короче 8 символов")
return return
} }
if (![tForm.keepaliveInterval, tForm.keepaliveRetries].every(value => Number.isInteger(value) && value >= 1 && value <= 4294967295)) {
setFormError("Keepalive и число попыток: целые значения от 1 до 4294967295. Ноль не поддерживается этим API.")
return
}
if (!beginOperation(tForm.serverId)) { setFormError("Устройство не готово к изменению GRE; повторите опрос"); return } if (!beginOperation(tForm.serverId)) { setFormError("Устройство не готово к изменению GRE; повторите опрос"); return }
formLock.current = true formLock.current = true
try { try {
@@ -885,11 +889,11 @@ function GrePageContent({ selectedServerId, selectedTunnelId, allTunnelsHref, on
<FormField label="MTU" hint="По умолч. 1476"> <FormField label="MTU" hint="По умолч. 1476">
<Input aria-label="MTU" 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>
<FormField label="Keepalive, с" hint="0 = откл."> <FormField label="Keepalive, с" hint="Целое число, минимум 1 с">
<Input aria-label="Keepalive, с" type="number" className="font-mono" value={tForm.keepaliveInterval} onChange={(e) => setT("keepaliveInterval", Number(e.target.value))} /> <Input aria-label="Keepalive, с" type="number" min={1} max={4294967295} step={1} className="font-mono" value={tForm.keepaliveInterval} onChange={(e) => setT("keepaliveInterval", Number(e.target.value))} />
</FormField> </FormField>
<FormField label="Попытки"> <FormField label="Попытки">
<Input aria-label="Попытки" type="number" className="font-mono" value={tForm.keepaliveRetries} onChange={(e) => setT("keepaliveRetries", Number(e.target.value))} /> <Input aria-label="Попытки" type="number" min={1} max={4294967295} step={1} className="font-mono" value={tForm.keepaliveRetries} onChange={(e) => setT("keepaliveRetries", Number(e.target.value))} />
</FormField> </FormField>
</div> </div>
<FormField label="DSCP"> <FormField label="DSCP">
+2 -2
View File
@@ -125,7 +125,7 @@ interface ApiGreTunnelRow {
localInnerIp: string localInnerIp: string
remoteInnerIp: string remoteInnerIp: string
poolId: string poolId: string
ipsec: null ipsec: { secret: "" } | null
mtu: number mtu: number
keepaliveInterval: number keepaliveInterval: number
keepaliveRetries: number keepaliveRetries: number
@@ -147,7 +147,7 @@ function apiGreToGreTunnel(t: ApiGreTunnelRow): GreTunnel {
localInnerIp: t.localInnerIp, localInnerIp: t.localInnerIp,
remoteInnerIp: t.remoteInnerIp, remoteInnerIp: t.remoteInnerIp,
poolId: t.poolId || "live", poolId: t.poolId || "live",
ipsec: null, ipsec: t.ipsec,
mtu: t.mtu, mtu: t.mtu,
keepaliveInterval: t.keepaliveInterval, keepaliveInterval: t.keepaliveInterval,
keepaliveRetries: t.keepaliveRetries, keepaliveRetries: t.keepaliveRetries,
+1 -1
View File
@@ -29,7 +29,7 @@
"test:scheduler": "tsx src/services/scheduler-running.test.ts && tsx src/services/scheduler-recorded-run.test.ts && tsx src/routes/scheduler.test.ts", "test:scheduler": "tsx src/services/scheduler-running.test.ts && tsx src/services/scheduler-recorded-run.test.ts && tsx src/routes/scheduler.test.ts",
"test:backups": "tsx src/services/backup-scheduler-run.test.ts && tsx src/services/backup-restore.test.ts && tsx src/services/backup-deletion.test.ts && tsx src/services/backup-dto.test.ts && tsx src/services/backup-storage-update.test.ts && tsx src/services/backup-job-execution.test.ts && tsx src/routes/backups-operations.test.ts && tsx src/services/s3-backup-client.test.ts", "test:backups": "tsx src/services/backup-scheduler-run.test.ts && tsx src/services/backup-restore.test.ts && tsx src/services/backup-deletion.test.ts && tsx src/services/backup-dto.test.ts && tsx src/services/backup-storage-update.test.ts && tsx src/services/backup-job-execution.test.ts && tsx src/routes/backups-operations.test.ts && tsx src/services/s3-backup-client.test.ts",
"test:ipsec": "tsx src/services/ipsec-config.test.ts && tsx src/services/ipsec-state.test.ts && tsx src/routes/ipsec-history.test.ts && tsx src/services/ipsec-restore.test.ts && tsx src/services/ipsec-operation.test.ts && tsx src/routes/ipsec-mutation.test.ts && tsx src/routes/ipsec-nat.test.ts && tsx src/routes/ipsec-group.test.ts", "test:ipsec": "tsx src/services/ipsec-config.test.ts && tsx src/services/ipsec-state.test.ts && tsx src/routes/ipsec-history.test.ts && tsx src/services/ipsec-restore.test.ts && tsx src/services/ipsec-operation.test.ts && tsx src/routes/ipsec-mutation.test.ts && tsx src/routes/ipsec-nat.test.ts && tsx src/routes/ipsec-group.test.ts",
"test:live-maps": "tsx src/services/gre-read.test.ts && tsx src/services/ospf-route-parse.test.ts && tsx src/services/vxlan-live.test.ts && tsx src/routes/vxlan.test.ts && tsx src/services/containers-live.test.ts && tsx src/services/firewall-poll.test.ts && tsx src/services/gre-catalog-poll.test.ts && tsx src/routes/filters-gre-catalog.test.ts && tsx src/routes/bgp-catalog.test.ts && tsx src/services/bgp-catalog-parse.test.ts && tsx src/routes/ospf-catalog.test.ts && tsx src/services/ospf-observed-values.test.ts", "test:live-maps": "tsx src/services/gre-keepalive.test.ts && tsx src/services/gre-read.test.ts && tsx src/services/ospf-route-parse.test.ts && tsx src/services/vxlan-live.test.ts && tsx src/routes/vxlan.test.ts && tsx src/services/containers-live.test.ts && tsx src/services/firewall-poll.test.ts && tsx src/services/gre-catalog-poll.test.ts && tsx src/routes/filters-gre-catalog.test.ts && tsx src/routes/bgp-catalog.test.ts && tsx src/services/bgp-catalog-parse.test.ts && tsx src/routes/ospf-catalog.test.ts && tsx src/services/ospf-observed-values.test.ts",
"test": "node scripts/test.mjs", "test": "node scripts/test.mjs",
"test:evolution": "tsx src/services/evobgp-catalog.test.ts && tsx src/routes/evobgp-catalog.test.ts && tsx src/routes/evobgp-settings.test.ts && tsx src/plugins/auth.evolution-settings.test.ts", "test:evolution": "tsx src/services/evobgp-catalog.test.ts && tsx src/routes/evobgp-catalog.test.ts && tsx src/routes/evobgp-settings.test.ts && tsx src/plugins/auth.evolution-settings.test.ts",
"test:netflow-purge": "tsx src/db/operation-lock.test.ts && tsx src/services/traffic-flow-purge-execution.test.ts && tsx src/routes/traffic-flow-purge.test.ts", "test:netflow-purge": "tsx src/db/operation-lock.test.ts && tsx src/services/traffic-flow-purge-execution.test.ts && tsx src/routes/traffic-flow-purge.test.ts",
@@ -0,0 +1,18 @@
import assert from 'node:assert/strict'
import fs from 'node:fs'
import {createServer} from 'node:http'
import {randomBytes} from 'node:crypto'
const database=process.env.MMAPP_OWN_SQL_DB,url=new URL(process.env.DATABASE_URL)
assert(/^mm_accept_166_\d+$/.test(database??''));assert.equal(url.pathname,'/'+database);assert.equal(url.username,database)
process.env.AUTH_REQUIRED='true';process.env.AUTH_JWT_SECRET=randomBytes(32).toString('hex');process.env.AUTH_ISSUER='https://own210.invalid';process.env.AUTH_PORTAL_URL='https://own210.invalid'
const {pool,closePool}=await import('/app/backend/dist/db/index.js'),{initDatabase}=await import('/app/backend/dist/db/bootstrap.js'),{buildApp}=await import('/app/backend/dist/index.js')
assert.equal((await pool.query('SELECT current_database() AS name')).rows[0].name,database);assert(Object.values((await pool.query('SELECT rolsuper,rolcreatedb,rolcreaterole,rolbypassrls FROM pg_roles WHERE rolname=current_user')).rows[0]).every(v=>v===false))
const rows=[],writes=[],cases=[];let app,serverId,nativeCalls=0;
const native=createServer(async(req,res)=>{nativeCalls++;let text='';for await(const chunk of req)text+=chunk;const body=text?JSON.parse(text):{};res.setHeader('content-type','application/json');if(req.method==='GET'){res.end(JSON.stringify(req.url.endsWith('/interface/gre')?rows:[]));return}writes.push({method:req.method,path:req.url,body});if(req.method==='PUT'){const row={...body,'.id':'*'+(rows.length+1),running:'false'};rows.push(row);res.end(JSON.stringify(row));return}if(req.method==='PATCH'){const id=decodeURIComponent(req.url.split('/').pop()),row=rows.find(r=>r['.id']===id);assert(row);Object.assign(row,body);res.end(JSON.stringify(row));return}res.statusCode=500;res.end('{}')});await new Promise(r=>native.listen(0,'127.0.0.1',r));
const report={date:'2026-10-13',block:'12.5.210',database,cases,scope:'Production compiled GRE create/patch using isolated PostgreSQL and native HTTP fixture',passed:false};
try{await initDatabase();serverId=Number((await pool.query("INSERT INTO servers(name,host,port,use_ssl,enabled,username,password) VALUES('own210-native','127.0.0.1',$1,false,true,'fixture','fixture') RETURNING id",[native.address().port])).rows[0].id);app=await buildApp({logger:false,startScheduler:false});await app.ready();const token=app.jwt.sign({sub:'own210-writer',apps:['mm'],permissions:['mm:network:write'],iss:process.env.AUTH_ISSUER},{expiresIn:'5m'}),headers={authorization:'Bearer '+token};
for(const field of ['keepaliveInterval','keepaliveRetries'])for(const value of [0,-1,1.5,4294967296]){const before=nativeCalls;const r=await app.inject({method:'POST',url:'/api/gre/tunnels',headers,payload:{serverId:String(serverId),name:'invalid',remoteAddress:'192.0.2.2',[field]:value}});assert.equal(r.statusCode,400);assert.equal(nativeCalls,before);cases.push({field,value,status:400,nativeCalls:0})}
for(const params of [{},{keepaliveInterval:60},{keepaliveRetries:3},{keepaliveInterval:90,keepaliveRetries:7}]){const name='own210-'+rows.length;const r=await app.inject({method:'POST',url:'/api/gre/tunnels',headers,payload:{serverId:String(serverId),name,remoteAddress:'192.0.2.2',enabled:false,...params}});assert.equal(r.statusCode,201);const expected=(params.keepaliveInterval??10)+'s,'+(params.keepaliveRetries??10);assert.equal(rows.at(-1).keepalive,expected);cases.push({operation:'create',params,keepalive:expected,status:201})}
rows[0].keepalive='1m,3';const patch=await app.inject({method:'PATCH',url:'/api/gre/tunnels',headers,payload:{serverId:String(serverId),rosId:rows[0]['.id'],comment:'own210 updated'}});assert.equal(patch.statusCode,200);assert.equal(rows[0].keepalive,'1m,3');assert.equal(rows[0].comment,'own210 updated');cases.push({operation:'patch-other-field',nativeKeepalivePreserved:'1m,3',status:200});const read=await app.inject({method:'GET',url:'/api/gre/tunnels?serverId='+serverId,headers});assert.equal(read.statusCode,200);assert.equal(read.json().tunnels.find(t=>t.name===rows[0].name).keepaliveInterval,60);cases.push({operation:'read-minutes',seconds:60,status:200});report.passed=true;report.nativeWrites=writes.length;
}catch(e){report.errorName=e.name;fs.writeFileSync('/app/backend/acceptance/gre210-private.log',String(e.stack??e),{mode:0o600})}finally{if(app)await app.close();native.closeAllConnections();await new Promise(r=>native.close(r));await closePool();fs.writeFileSync('/app/backend/acceptance/gre210-keepalive-result.json',JSON.stringify(report,null,2));console.log(JSON.stringify(report))}if(!report.passed)process.exitCode=1;
+1 -11
View File
@@ -5,6 +5,7 @@ import { db } from "../db/index.js"
import { filterRules, recursiveRoutes, servers } from "../db/schema.js" import { filterRules, recursiveRoutes, servers } from "../db/schema.js"
import { MikrotikClient } from "../services/mikrotik.js" import { MikrotikClient } from "../services/mikrotik.js"
import { pollGreCatalog } from "../services/gre-catalog-poll.js" import { pollGreCatalog } from "../services/gre-catalog-poll.js"
import { parseKeepalive } from "../services/gre-live.js"
import { parseDbServerId } from "../utils/server-id.js" import { parseDbServerId } from "../utils/server-id.js"
import { appendEvent } from "../modules/events/service/events-service.js" import { appendEvent } from "../modules/events/service/events-service.js"
import { managedComment } from "../managed-markers.js" import { managedComment } from "../managed-markers.js"
@@ -74,17 +75,6 @@ interface ApiFilterRule {
description: string description: string
} }
function parseKeepalive(value: string | undefined): { interval: number; retries: number } {
if (!value || value.toLowerCase() === "none") return { interval: 0, retries: 0 }
const [intervalRaw, retriesRaw] = value.split(",")
const interval = Number.parseInt((intervalRaw ?? "").trim(), 10)
const retries = Number.parseInt((retriesRaw ?? "").trim(), 10)
return {
interval: Number.isFinite(interval) ? interval : 0,
retries: Number.isFinite(retries) ? retries : 0,
}
}
function parseDscp(value: string | undefined): "inherit" | number { function parseDscp(value: string | undefined): "inherit" | number {
if (!value || value === "inherit") return "inherit" if (!value || value === "inherit") return "inherit"
const n = Number.parseInt(value, 10) const n = Number.parseInt(value, 10)
+8 -6
View File
@@ -43,8 +43,8 @@ const TunnelWriteSchema = z.object({
comment: z.string().optional(), comment: z.string().optional(),
enabled: z.boolean().optional(), enabled: z.boolean().optional(),
mtu: z.number().optional(), mtu: z.number().optional(),
keepaliveInterval: z.number().optional(), keepaliveInterval: z.number().int().min(1).max(4294967295).optional(),
keepaliveRetries: z.number().optional(), keepaliveRetries: z.number().int().min(1).max(4294967295).optional(),
dscp: z.union([z.literal("inherit"), z.number(), z.string()]).optional(), dscp: z.union([z.literal("inherit"), z.number(), z.string()]).optional(),
clampTcpMss: z.boolean().optional(), clampTcpMss: z.boolean().optional(),
allowFastPath: z.boolean().optional(), allowFastPath: z.boolean().optional(),
@@ -84,9 +84,7 @@ function tunnelFromBody(body: z.infer<typeof TunnelWriteSchema> & { keepaliveInt
: typeof body.dscp === "number" : typeof body.dscp === "number"
? String(body.dscp) ? String(body.dscp)
: body.dscp : body.dscp
const keepalive = body.keepaliveInterval === undefined && body.keepaliveRetries === undefined const keepalive = formatKeepalive(body.keepaliveInterval ?? 10, body.keepaliveRetries ?? 10)
? undefined
: formatKeepalive(body.keepaliveInterval ?? 0, body.keepaliveRetries ?? 10)
return canonicalGreSnapshot({ return canonicalGreSnapshot({
tunnels: [{ tunnels: [{
name: body.name, name: body.name,
@@ -97,7 +95,7 @@ function tunnelFromBody(body: z.infer<typeof TunnelWriteSchema> & { keepaliveInt
comment: body.remoteInnerIp === undefined ? body.comment ?? "" : writeGreComment(body.comment ?? "", body.remoteInnerIp), comment: body.remoteInnerIp === undefined ? body.comment ?? "" : writeGreComment(body.comment ?? "", body.remoteInnerIp),
disabled: body.enabled === false, disabled: body.enabled === false,
mtu: body.mtu ?? 1476, mtu: body.mtu ?? 1476,
keepalive: keepalive ?? "0", keepalive,
dscp, dscp,
clampTcpMss: body.clampTcpMss, clampTcpMss: body.clampTcpMss,
allowFastPath: body.allowFastPath, allowFastPath: body.allowFastPath,
@@ -173,6 +171,10 @@ const greRoutes: FastifyPluginAsyncZod = async (app) => {
allowFastPath: body.allowFastPath ?? live.allowFastPath, allowFastPath: body.allowFastPath ?? live.allowFastPath,
ipsecSecret: body.ipsecSecret ?? live.ipsecSecret, ipsecSecret: body.ipsecSecret ?? live.ipsecSecret,
}) })
// A patch to another field must not reformat an observed native timer.
if (body.keepaliveInterval === undefined && body.keepaliveRetries === undefined) {
merged.keepalive = original.keepalive
}
merged.innerAddresses = original.innerAddresses?.map(a => ({ ...a })) merged.innerAddresses = original.innerAddresses?.map(a => ({ ...a }))
if (body.localInnerIp !== undefined && body.localInnerIp !== original.localInnerIp) { if (body.localInnerIp !== undefined && body.localInnerIp !== original.localInnerIp) {
merged.innerAddresses = (merged.innerAddresses ?? []).filter(a => a.address !== original.localInnerIp) merged.innerAddresses = (merged.innerAddresses ?? []).filter(a => a.address !== original.localInnerIp)
@@ -0,0 +1,9 @@
import assert from "node:assert/strict"
import { parseKeepalive } from "./gre-live.js"
for (const [native, seconds] of [["1m,3", 60], ["1m30s,3", 90], ["00:01:00,3", 60], ["1h2m3s,3", 3723], ["10s,3", 10], ["10,3", 10]] as const) {
assert.deepEqual(parseKeepalive(native), { interval: seconds, retries: 3 })
}
assert.deepEqual(parseKeepalive("invalid,3"), { interval: 0, retries: 3 })
assert.deepEqual(parseKeepalive("10s,3garbage"), { interval: 10, retries: 0 })
console.log("GRE keepalive: native units and clock parsed without prefix truncation")
+8 -4
View File
@@ -3,6 +3,7 @@ import { db } from "../db/index.js"
import { servers } from "../db/schema.js" import { servers } from "../db/schema.js"
import { MikrotikClient } from "./mikrotik.js" import { MikrotikClient } from "./mikrotik.js"
import { readGreComment } from "./gre-comment.js" import { readGreComment } from "./gre-comment.js"
import { observedDurationMs, observedInteger } from "./ospf-observed-values.js"
import { import {
canonicalGreSnapshot, canonicalGreSnapshot,
greAddressPath, greAddressPath,
@@ -69,11 +70,14 @@ export interface LiveGreTunnel {
export function parseKeepalive(value: string | undefined): { interval: number; retries: number } { export function parseKeepalive(value: string | undefined): { interval: number; retries: number } {
if (!value || value.toLowerCase() === "none") return { interval: 0, retries: 0 } if (!value || value.toLowerCase() === "none") return { interval: 0, retries: 0 }
const [intervalRaw, retriesRaw] = value.split(",") const [intervalRaw, retriesRaw] = value.split(",")
const interval = Number.parseInt((intervalRaw ?? "").trim(), 10) const intervalText = intervalRaw?.trim()
const retries = Number.parseInt((retriesRaw ?? "").trim(), 10) const interval = intervalText && /^\d+$/.test(intervalText)
? observedInteger(intervalText)
: (observedDurationMs(intervalText) ?? 0) / 1000
const retries = observedInteger(retriesRaw)
return { return {
interval: Number.isFinite(interval) ? interval : 0, interval: interval ?? 0,
retries: Number.isFinite(retries) ? retries : 0, retries: retries ?? 0,
} }
} }
+18 -1
View File
@@ -1,6 +1,6 @@
# UX/UI: протокол внешней приёмки 12.5 # UX/UI: протокол внешней приёмки 12.5
Актуальное продолжение 2026-10-13: блок 209 завершён. GRE PSK скрыт от читателя; каталог Filters сохраняет признак настроенного IPsec без ключа. Пройдены 12 SQL/API-проверок, профиль live-maps, 4 E2E и 4 live UI-сценария. Два собственных отключённых GRE на CHR созданы и удалены. Backend/frontend: ux-gre209, 473/527 исходных файлов и 1048 compiled artifacts совпали. Следующий блок 210 — keepalive при создании GRE без явных параметров. Этап 12.5 открыт. Актуальное продолжение 2026-10-13: блок 210 завершён. GRE создаётся с default keepalive 10s,10; минутные значения читаются в секундах, PATCH другого поля сохраняет native timer. Валидатор каталога IPsec исправлен для Filters/карты/диагностики. Пройдены SQL/API14, E2E13 и live GRE/Filters UI4; собственные интерфейсы удалены. Backend/frontend ux-gre210, source475/527 и compiled1048 совпали. Срочный следующий блок 211 — auth/RBAC для закодированных URL; baseline анонимного обхода подтверждён. Этап 12.5 открыт.
IPsec78: реальный PSK responder established, mode-config198.18.78.10 и online=true подтверждены API; точные собственные объекты удалены. Сертификатный/DN online, late failure и history restore остаются открытыми. [Отчёт](ux-ui/acceptance/ipsec-mode-config-2026-10-09.json), [журнал](ux-ui-progress.md). IPsec78: реальный PSK responder established, mode-config198.18.78.10 и online=true подтверждены API; точные собственные объекты удалены. Сертификатный/DN online, late failure и history restore остаются открытыми. [Отчёт](ux-ui/acceptance/ipsec-mode-config-2026-10-09.json), [журнал](ux-ui-progress.md).
@@ -2539,3 +2539,20 @@ Backend/frontend ux-gre209 развёрнуты; 473/527 source files и 1048 co
Следующий блок 210: исправить создание GRE с опущенным keepalive, проверить фактический RouterOS ответ и прежнее редактирование. Этап 12.5 открыт; внешние ограничения в актуальной матрице сохраняются. Следующий блок 210: исправить создание GRE с опущенным keepalive, проверить фактический RouterOS ответ и прежнее редактирование. Этап 12.5 открыт; внешние ограничения в актуальной матрице сохраняются.
## 12.5.210 — GRE keepalive и совместимость публичного каталога (2026-10-13)
На обоих CHR собственные отключённые интерфейсы подтвердили: native REST отклоняет 0, none, пустую строку, disabled, no и ноль попыток. Дробные интервалы принимаются, но сокращаются RouterOS до целых секунд. Поэтому форма и write schema требуют целые значения 1..4294967295 вместо обещания «0 = откл.». Неверный ввод сохраняет форму и не отправляет запрос. Официальный default 10s,10: [MikroTik GRE](https://help.mikrotik.com/docs/spaces/ROS/pages/24805531/GRE).
Опущенные параметры создания теперь дают 10s,10; один заданный параметр сохраняется, второй получает default. Основной GET и каталог Filters используют общий разбор native единиц/clock: 1m = 60с, 1m30s = 90с. PATCH без параметров keepalive сохраняет исходную строку snapshot, не преобразует таймер при изменении комментария/PSK. Legacy snapshots не переписаны; произвольные legacy/unknown timers и их экспорт не приняты этим блоком.
Аудит потребителей выявил несовместимость 209: shared schema Filters допускала только ipsec:null. Теперь она принимает {secret:""} или null, отвергает реальный ключ. Карта сохраняет configured IPsec; Filters и диагностика используют обновлённую схему. ReUI primitives и theme не менялись.
Проверки: 24 direct native syntax cases на отключённых собственных GRE, с очисткой; production SQL/API14 на собственной ограниченной DB/role, включая 8 invalid cases до native requests, четыре создания, сохранение native timer и чтение минут. SQL DB/role/container удалены. Профиль live-maps, schema unit, 5 network-map units, TypeScript/backend build/ESLint прошли. 13 уникальных E2E: GRE формы/PSK/валидация9 и Filters gateway/retry4. Копии fixtures имеют configured IPsec; прежние повторные действия сохранены.
Runtime ux-gre210: backend healthy, source475/527 и compiled1048 matched. На каждом CHR приложение создало отключённый GRE без keepalive и с явным allowFastPath=false для PSK; native default10s,10 подтверждён. После native 1m,3 GET показывает60с, PATCH комментария сохраняет1m,3, explicit0 даёт400 без изменения native. Два UI GRE и два UI Filters на1440/320 проверили форму, отсутствие запроса при invalid input и доступность собственного GRE с IPsec в каталоге шлюзов. Оба интерфейса удалены, отсутствие проверено. Пароли не менялись; это не data-plane test.
Два первых create с PSK и опущенным allowFastPath получили502: текущий API default FastPath несовместим с PSK. Это отдельное обнаруженное ограничение, не скрытое успешным повтором; дальнейшая проверка задала false явно. Следующий приоритет211 — подтверждённый обход auth через encoded API prefix. После него исправить FastPath/PSK default и экспорт недопустимого/неизвестного keepalive.
Протоколы: [native syntax](ux-ui/acceptance/gre210-native-range-2026-10-13.json), [дополнительный синтаксис](ux-ui/acceptance/gre210-native-range-extra-2026-10-13.json), [SQL/API14](ux-ui/acceptance/gre210-keepalive-result.json), [профили](ux-ui/acceptance/gre210-profile-2026-10-13.json), [E2E13](ux-ui/acceptance/gre210-e2e-2026-10-13.json), [native/UI/cleanup](ux-ui/acceptance/gre210-native-live-2026-10-13.json), [source](ux-ui/acceptance/source210-result.json), [compiled](ux-ui/acceptance/compiled210-result.json), [deployment](ux-ui/acceptance/gre210-deployment-2026-10-13.json).
+1 -1
View File
@@ -1,6 +1,6 @@
# План реализации UX/UI по пятичасовым окнам # План реализации UX/UI по пятичасовым окнам
Актуальное продолжение 2026-10-13: блок 209 завершён. GRE PSK скрыт от читателя; каталог Filters сохраняет признак настроенного IPsec без ключа. Пройдены 12 SQL/API-проверок, профиль live-maps, 4 E2E и 4 live UI-сценария. Два собственных отключённых GRE на CHR созданы и удалены. Backend/frontend: ux-gre209, 473/527 исходных файлов и 1048 compiled artifacts совпали. Следующий блок 210 — keepalive при создании GRE без явных параметров. Этап 12.5 открыт. Актуальное продолжение 2026-10-13: блок 210 завершён. GRE создаётся с default keepalive 10s,10; минутные значения читаются в секундах, PATCH другого поля сохраняет native timer. Валидатор каталога IPsec исправлен для Filters/карты/диагностики. Пройдены SQL/API14, E2E13 и live GRE/Filters UI4; собственные интерфейсы удалены. Backend/frontend ux-gre210, source475/527 и compiled1048 совпали. Срочный следующий блок 211 — auth/RBAC для закодированных URL; baseline анонимного обхода подтверждён. Этап 12.5 открыт.
Общий план проекта MikrotikManager. Основан на согласованном плане из задачи; точка продолжения обновлена 2026-10-13. Источник фактического прогресса, проверок и ограничений — [журнал реализации](ux-ui-progress.md). Этот документ задаёт объём и критерии завершения, журнал фиксирует результат. Общий план проекта MikrotikManager. Основан на согласованном плане из задачи; точка продолжения обновлена 2026-10-13. Источник фактического прогресса, проверок и ограничений — [журнал реализации](ux-ui-progress.md). Этот документ задаёт объём и критерии завершения, журнал фиксирует результат.
+17 -1
View File
@@ -1,6 +1,6 @@
# UX/UI: журнал реализации # UX/UI: журнал реализации
Актуальное продолжение 2026-10-13: блок 209 завершён. GRE PSK скрыт от читателя; каталог Filters сохраняет признак настроенного IPsec без ключа. Пройдены 12 SQL/API-проверок, профиль live-maps, 4 E2E и 4 live UI-сценария. Два собственных отключённых GRE на CHR созданы и удалены. Backend/frontend: ux-gre209, 473/527 исходных файлов и 1048 compiled artifacts совпали. Следующий блок 210 — keepalive при создании GRE без явных параметров. Этап 12.5 открыт. Актуальное продолжение 2026-10-13: блок 210 завершён. GRE создаётся с default keepalive 10s,10; минутные значения читаются в секундах, PATCH другого поля сохраняет native timer. Валидатор каталога IPsec исправлен для Filters/карты/диагностики. Пройдены SQL/API14, E2E13 и live GRE/Filters UI4; собственные интерфейсы удалены. Backend/frontend ux-gre210, source475/527 и compiled1048 совпали. Срочный следующий блок 211 — auth/RBAC для закодированных URL; baseline анонимного обхода подтверждён. Этап 12.5 открыт.
Общий план: [UX/UI по этапам и пятичасовым окнам](ux-ui-plan.md). План задаёт объём, этот журнал фиксирует выполненные блоки и ограничения. Записи расположены от новых к старым; подробная история этапа12 также сохранена в [ux-ui-stage-12.md](ux-ui-stage-12.md). Общий план: [UX/UI по этапам и пятичасовым окнам](ux-ui-plan.md). План задаёт объём, этот журнал фиксирует выполненные блоки и ограничения. Записи расположены от новых к старым; подробная история этапа12 также сохранена в [ux-ui-stage-12.md](ux-ui-stage-12.md).
@@ -38,6 +38,22 @@
| 11Б | Оповещения, сбор данных, интеграции, релизы и настройки | Завершён в границах локальной приёмки 2026-10-03; ограничения и evidence в11Б.7 | | 11Б | Оповещения, сбор данных, интеграции, релизы и настройки | Завершён в границах локальной приёмки 2026-10-03; ограничения и evidence в11Б.7 |
| 12 | Итоговая доступность, адаптивность, производительность и регрессия | Локальная приёмка завершена в границах12.3.83; внешняя12.5 в работе, выполненные блоки1–196 и ограничения ниже. Runtime backend/frontend ux-peak196. | | 12 | Итоговая доступность, адаптивность, производительность и регрессия | Локальная приёмка завершена в границах12.3.83; внешняя12.5 в работе, выполненные блоки1–196 и ограничения ниже. Runtime backend/frontend ux-peak196. |
## 12.5.210 — GRE keepalive и совместимость публичного каталога (2026-10-13)
На обоих CHR собственные отключённые интерфейсы подтвердили: native REST отклоняет 0, none, пустую строку, disabled, no и ноль попыток. Дробные интервалы принимаются, но сокращаются RouterOS до целых секунд. Поэтому форма и write schema требуют целые значения 1..4294967295 вместо обещания «0 = откл.». Неверный ввод сохраняет форму и не отправляет запрос. Официальный default 10s,10: [MikroTik GRE](https://help.mikrotik.com/docs/spaces/ROS/pages/24805531/GRE).
Опущенные параметры создания теперь дают 10s,10; один заданный параметр сохраняется, второй получает default. Основной GET и каталог Filters используют общий разбор native единиц/clock: 1m = 60с, 1m30s = 90с. PATCH без параметров keepalive сохраняет исходную строку snapshot, не преобразует таймер при изменении комментария/PSK. Legacy snapshots не переписаны; произвольные legacy/unknown timers и их экспорт не приняты этим блоком.
Аудит потребителей выявил несовместимость 209: shared schema Filters допускала только ipsec:null. Теперь она принимает {secret:""} или null, отвергает реальный ключ. Карта сохраняет configured IPsec; Filters и диагностика используют обновлённую схему. ReUI primitives и theme не менялись.
Проверки: 24 direct native syntax cases на отключённых собственных GRE, с очисткой; production SQL/API14 на собственной ограниченной DB/role, включая 8 invalid cases до native requests, четыре создания, сохранение native timer и чтение минут. SQL DB/role/container удалены. Профиль live-maps, schema unit, 5 network-map units, TypeScript/backend build/ESLint прошли. 13 уникальных E2E: GRE формы/PSK/валидация9 и Filters gateway/retry4. Копии fixtures имеют configured IPsec; прежние повторные действия сохранены.
Runtime ux-gre210: backend healthy, source475/527 и compiled1048 matched. На каждом CHR приложение создало отключённый GRE без keepalive и с явным allowFastPath=false для PSK; native default10s,10 подтверждён. После native 1m,3 GET показывает60с, PATCH комментария сохраняет1m,3, explicit0 даёт400 без изменения native. Два UI GRE и два UI Filters на1440/320 проверили форму, отсутствие запроса при invalid input и доступность собственного GRE с IPsec в каталоге шлюзов. Оба интерфейса удалены, отсутствие проверено. Пароли не менялись; это не data-plane test.
Два первых create с PSK и опущенным allowFastPath получили502: текущий API default FastPath несовместим с PSK. Это отдельное обнаруженное ограничение, не скрытое успешным повтором; дальнейшая проверка задала false явно. Следующий приоритет211 — подтверждённый обход auth через encoded API prefix. После него исправить FastPath/PSK default и экспорт недопустимого/неизвестного keepalive.
Протоколы: [native syntax](ux-ui/acceptance/gre210-native-range-2026-10-13.json), [дополнительный синтаксис](ux-ui/acceptance/gre210-native-range-extra-2026-10-13.json), [SQL/API14](ux-ui/acceptance/gre210-keepalive-result.json), [профили](ux-ui/acceptance/gre210-profile-2026-10-13.json), [E2E13](ux-ui/acceptance/gre210-e2e-2026-10-13.json), [native/UI/cleanup](ux-ui/acceptance/gre210-native-live-2026-10-13.json), [source](ux-ui/acceptance/source210-result.json), [compiled](ux-ui/acceptance/compiled210-result.json), [deployment](ux-ui/acceptance/gre210-deployment-2026-10-13.json).
## 12.5.209 — доступ к GRE PSK и достоверность каталога IPsec (2026-10-13) ## 12.5.209 — доступ к GRE PSK и достоверность каталога IPsec (2026-10-13)
В production baseline читатель получал PSK через GET /gre/tunnels. Каталог /filters/gre-tunnels ключ не раскрывал, но всегда возвращал ipsec:null, скрывая сам факт настройки IPsec. Основной GET теперь копирует публичный DTO и очищает secret при отсутствии mm:network:write/admin; алгоритмы и наличие объекта IPsec сохраняются. Internal snapshot и право редактирования для writer/admin не меняются. is_admin без permission не даёт доступ к ключу. Legacy AUTH_REQUIRED=false сохранён. Общий каталог Filters возвращает только признак IPsec и пустой secret для всех ролей. В production baseline читатель получал PSK через GET /gre/tunnels. Каталог /filters/gre-tunnels ключ не раскрывал, но всегда возвращал ipsec:null, скрывая сам факт настройки IPsec. Основной GET теперь копирует публичный DTO и очищает secret при отсутствии mm:network:write/admin; алгоритмы и наличие объекта IPsec сохраняются. Internal snapshot и право редактирования для writer/admin не меняются. is_admin без permission не даёт доступ к ключу. Legacy AUTH_REQUIRED=false сохранён. Общий каталог Filters возвращает только признак IPsec и пустой secret для всех ролей.
+21 -4
View File
@@ -1,10 +1,10 @@
# Оставшаяся внешняя приёмка — 2026-10-13 # Оставшаяся внешняя приёмка — 2026-10-13
Актуальное продолжение 2026-10-13: блок 209 завершён. GRE PSK скрыт от читателя; каталог Filters сохраняет признак настроенного IPsec без ключа. Пройдены 12 SQL/API-проверок, профиль live-maps, 4 E2E и 4 live UI-сценария. Два собственных отключённых GRE на CHR созданы и удалены. Backend/frontend: ux-gre209, 473/527 исходных файлов и 1048 compiled artifacts совпали. Следующий блок 210 — keepalive при создании GRE без явных параметров. Этап 12.5 открыт. Актуальное продолжение 2026-10-13: блок 210 завершён. GRE создаётся с default keepalive 10s,10; минутные значения читаются в секундах, PATCH другого поля сохраняет native timer. Валидатор каталога IPsec исправлен для Filters/карты/диагностики. Пройдены SQL/API14, E2E13 и live GRE/Filters UI4; собственные интерфейсы удалены. Backend/frontend ux-gre210, source475/527 и compiled1048 совпали. Срочный следующий блок 211 — auth/RBAC для закодированных URL; baseline анонимного обхода подтверждён. Этап 12.5 открыт.
Это актуальная матрица после 12.5.209. Исторические записи сохраняют версии и ограничения на момент выполнения; поздние проверки ниже уточняют их. Этап 12.5 открыт. Успех в двух CHR и одном backend не означает приёмку всех конфигураций, интеграций или полной регрессии. Это актуальная матрица после 12.5.210. Исторические записи сохраняют версии и ограничения на момент выполнения; поздние проверки ниже уточняют их. Этап 12.5 открыт. Успех в двух CHR и одном backend не означает приёмку всех конфигураций, интеграций или полной регрессии.
Стенд: Debian13/Compose, PostgreSQL18.6, два CHR RouterOS7.24.5. Backend/frontend `ux-gre209`; production lab с195 использует AUTH_REQUIRED=true и Portal1.3.1. Подписанные JWT/RBAC проверялись в изолированных процессах. Все собственные объекты112 удалены, исходные13 коллекций×2 (включая logging/certificates/files/routes) проверены; пароли не менялись. Стенд: Debian13/Compose, PostgreSQL18.6, два CHR RouterOS7.24.5. Backend/frontend `ux-gre210`; production lab с195 использует AUTH_REQUIRED=true и Portal1.3.1. Подписанные JWT/RBAC проверялись в изолированных процессах. Все собственные объекты112 удалены, исходные13 коллекций×2 (включая logging/certificates/files/routes) проверены; пароли не менялись.
| Раздел | Подтверждено | Осталось / условие завершения | | Раздел | Подтверждено | Осталось / условие завершения |
| --- | --- | --- | | --- | --- | --- |
@@ -26,7 +26,7 @@
## Следующая последовательность — 2026-10-13 ## Следующая последовательность — 2026-10-13
1. **210 — GRE keepalive:** исправить значение по умолчанию создания, которое RouterOS отклоняет; проверить отсутствие неожиданных изменений при PATCH. 1. **211 — canonical auth path:** закрыть обход JWT/RBAC через encoded API prefix; actual anonymous baseline200 сохранён без raw body/секретов. После него — FastPath/PSK default и экспорт неизвестного keepalive.
2. **Профильный контроль:** для новых изменений выполнять затронутые SQL/API/UI проверки. Общая регрессия192 выполнена; не повторять её без изменений общей основы или нового основания. 2. **Профильный контроль:** для новых изменений выполнять затронутые SQL/API/UI проверки. Общая регрессия192 выполнена; не повторять её без изменений общей основы или нового основания.
3. **Внешние зависимости:** Portal issuer/login/revoke, Telegram bot/chat/разрешённое сообщение, клиентские ОС, независимые WAN/public IPv6 и scale/multi-instance — отдельные группы по таблице выше. Ограничения текущего стенда не превращать в обещание приёмки всех конфигураций. 3. **Внешние зависимости:** Portal issuer/login/revoke, Telegram bot/chat/разрешённое сообщение, клиентские ОС, независимые WAN/public IPv6 и scale/multi-instance — отдельные группы по таблице выше. Ограничения текущего стенда не превращать в обещание приёмки всех конфигураций.
@@ -1457,3 +1457,20 @@ Backend/frontend ux-gre209 развёрнуты; 473/527 source files и 1048 co
Следующий блок 210: исправить создание GRE с опущенным keepalive, проверить фактический RouterOS ответ и прежнее редактирование. Этап 12.5 открыт; внешние ограничения в актуальной матрице сохраняются. Следующий блок 210: исправить создание GRE с опущенным keepalive, проверить фактический RouterOS ответ и прежнее редактирование. Этап 12.5 открыт; внешние ограничения в актуальной матрице сохраняются.
## 12.5.210 — GRE keepalive и совместимость публичного каталога (2026-10-13)
На обоих CHR собственные отключённые интерфейсы подтвердили: native REST отклоняет 0, none, пустую строку, disabled, no и ноль попыток. Дробные интервалы принимаются, но сокращаются RouterOS до целых секунд. Поэтому форма и write schema требуют целые значения 1..4294967295 вместо обещания «0 = откл.». Неверный ввод сохраняет форму и не отправляет запрос. Официальный default 10s,10: [MikroTik GRE](https://help.mikrotik.com/docs/spaces/ROS/pages/24805531/GRE).
Опущенные параметры создания теперь дают 10s,10; один заданный параметр сохраняется, второй получает default. Основной GET и каталог Filters используют общий разбор native единиц/clock: 1m = 60с, 1m30s = 90с. PATCH без параметров keepalive сохраняет исходную строку snapshot, не преобразует таймер при изменении комментария/PSK. Legacy snapshots не переписаны; произвольные legacy/unknown timers и их экспорт не приняты этим блоком.
Аудит потребителей выявил несовместимость 209: shared schema Filters допускала только ipsec:null. Теперь она принимает {secret:""} или null, отвергает реальный ключ. Карта сохраняет configured IPsec; Filters и диагностика используют обновлённую схему. ReUI primitives и theme не менялись.
Проверки: 24 direct native syntax cases на отключённых собственных GRE, с очисткой; production SQL/API14 на собственной ограниченной DB/role, включая 8 invalid cases до native requests, четыре создания, сохранение native timer и чтение минут. SQL DB/role/container удалены. Профиль live-maps, schema unit, 5 network-map units, TypeScript/backend build/ESLint прошли. 13 уникальных E2E: GRE формы/PSK/валидация9 и Filters gateway/retry4. Копии fixtures имеют configured IPsec; прежние повторные действия сохранены.
Runtime ux-gre210: backend healthy, source475/527 и compiled1048 matched. На каждом CHR приложение создало отключённый GRE без keepalive и с явным allowFastPath=false для PSK; native default10s,10 подтверждён. После native 1m,3 GET показывает60с, PATCH комментария сохраняет1m,3, explicit0 даёт400 без изменения native. Два UI GRE и два UI Filters на1440/320 проверили форму, отсутствие запроса при invalid input и доступность собственного GRE с IPsec в каталоге шлюзов. Оба интерфейса удалены, отсутствие проверено. Пароли не менялись; это не data-plane test.
Два первых create с PSK и опущенным allowFastPath получили502: текущий API default FastPath несовместим с PSK. Это отдельное обнаруженное ограничение, не скрытое успешным повтором; дальнейшая проверка задала false явно. Следующий приоритет211 — подтверждённый обход auth через encoded API prefix. После него исправить FastPath/PSK default и экспорт недопустимого/неизвестного keepalive.
Протоколы: [native syntax](ux-ui/acceptance/gre210-native-range-2026-10-13.json), [дополнительный синтаксис](ux-ui/acceptance/gre210-native-range-extra-2026-10-13.json), [SQL/API14](ux-ui/acceptance/gre210-keepalive-result.json), [профили](ux-ui/acceptance/gre210-profile-2026-10-13.json), [E2E13](ux-ui/acceptance/gre210-e2e-2026-10-13.json), [native/UI/cleanup](ux-ui/acceptance/gre210-native-live-2026-10-13.json), [source](ux-ui/acceptance/source210-result.json), [compiled](ux-ui/acceptance/compiled210-result.json), [deployment](ux-ui/acceptance/gre210-deployment-2026-10-13.json).
+18 -1
View File
@@ -1,6 +1,6 @@
# Этап 12: итоговая приёмка UX/UI # Этап 12: итоговая приёмка UX/UI
Актуальное продолжение 2026-10-13: блок 209 завершён. GRE PSK скрыт от читателя; каталог Filters сохраняет признак настроенного IPsec без ключа. Пройдены 12 SQL/API-проверок, профиль live-maps, 4 E2E и 4 live UI-сценария. Два собственных отключённых GRE на CHR созданы и удалены. Backend/frontend: ux-gre209, 473/527 исходных файлов и 1048 compiled artifacts совпали. Следующий блок 210 — keepalive при создании GRE без явных параметров. Этап 12.5 открыт. Актуальное продолжение 2026-10-13: блок 210 завершён. GRE создаётся с default keepalive 10s,10; минутные значения читаются в секундах, PATCH другого поля сохраняет native timer. Валидатор каталога IPsec исправлен для Filters/карты/диагностики. Пройдены SQL/API14, E2E13 и live GRE/Filters UI4; собственные интерфейсы удалены. Backend/frontend ux-gre210, source475/527 и compiled1048 совпали. Срочный следующий блок 211 — auth/RBAC для закодированных URL; baseline анонимного обхода подтверждён. Этап 12.5 открыт.
План: [ux-ui-plan.md](ux-ui-plan.md). Фактические результаты: [ux-ui-progress.md](ux-ui-progress.md). Подготовлен после сводных проверок 11Б; завершение каждого блока требует локального коммита без push. План: [ux-ui-plan.md](ux-ui-plan.md). Фактические результаты: [ux-ui-progress.md](ux-ui-progress.md). Подготовлен после сводных проверок 11Б; завершение каждого блока требует локального коммита без push.
@@ -3846,3 +3846,20 @@ Backend/frontend ux-gre209 развёрнуты; 473/527 source files и 1048 co
Следующий блок 210: исправить создание GRE с опущенным keepalive, проверить фактический RouterOS ответ и прежнее редактирование. Этап 12.5 открыт; внешние ограничения в актуальной матрице сохраняются. Следующий блок 210: исправить создание GRE с опущенным keepalive, проверить фактический RouterOS ответ и прежнее редактирование. Этап 12.5 открыт; внешние ограничения в актуальной матрице сохраняются.
## 12.5.210 — GRE keepalive и совместимость публичного каталога (2026-10-13)
На обоих CHR собственные отключённые интерфейсы подтвердили: native REST отклоняет 0, none, пустую строку, disabled, no и ноль попыток. Дробные интервалы принимаются, но сокращаются RouterOS до целых секунд. Поэтому форма и write schema требуют целые значения 1..4294967295 вместо обещания «0 = откл.». Неверный ввод сохраняет форму и не отправляет запрос. Официальный default 10s,10: [MikroTik GRE](https://help.mikrotik.com/docs/spaces/ROS/pages/24805531/GRE).
Опущенные параметры создания теперь дают 10s,10; один заданный параметр сохраняется, второй получает default. Основной GET и каталог Filters используют общий разбор native единиц/clock: 1m = 60с, 1m30s = 90с. PATCH без параметров keepalive сохраняет исходную строку snapshot, не преобразует таймер при изменении комментария/PSK. Legacy snapshots не переписаны; произвольные legacy/unknown timers и их экспорт не приняты этим блоком.
Аудит потребителей выявил несовместимость 209: shared schema Filters допускала только ipsec:null. Теперь она принимает {secret:""} или null, отвергает реальный ключ. Карта сохраняет configured IPsec; Filters и диагностика используют обновлённую схему. ReUI primitives и theme не менялись.
Проверки: 24 direct native syntax cases на отключённых собственных GRE, с очисткой; production SQL/API14 на собственной ограниченной DB/role, включая 8 invalid cases до native requests, четыре создания, сохранение native timer и чтение минут. SQL DB/role/container удалены. Профиль live-maps, schema unit, 5 network-map units, TypeScript/backend build/ESLint прошли. 13 уникальных E2E: GRE формы/PSK/валидация9 и Filters gateway/retry4. Копии fixtures имеют configured IPsec; прежние повторные действия сохранены.
Runtime ux-gre210: backend healthy, source475/527 и compiled1048 matched. На каждом CHR приложение создало отключённый GRE без keepalive и с явным allowFastPath=false для PSK; native default10s,10 подтверждён. После native 1m,3 GET показывает60с, PATCH комментария сохраняет1m,3, explicit0 даёт400 без изменения native. Два UI GRE и два UI Filters на1440/320 проверили форму, отсутствие запроса при invalid input и доступность собственного GRE с IPsec в каталоге шлюзов. Оба интерфейса удалены, отсутствие проверено. Пароли не менялись; это не data-plane test.
Два первых create с PSK и опущенным allowFastPath получили502: текущий API default FastPath несовместим с PSK. Это отдельное обнаруженное ограничение, не скрытое успешным повтором; дальнейшая проверка задала false явно. Следующий приоритет211 — подтверждённый обход auth через encoded API prefix. После него исправить FastPath/PSK default и экспорт недопустимого/неизвестного keepalive.
Протоколы: [native syntax](ux-ui/acceptance/gre210-native-range-2026-10-13.json), [дополнительный синтаксис](ux-ui/acceptance/gre210-native-range-extra-2026-10-13.json), [SQL/API14](ux-ui/acceptance/gre210-keepalive-result.json), [профили](ux-ui/acceptance/gre210-profile-2026-10-13.json), [E2E13](ux-ui/acceptance/gre210-e2e-2026-10-13.json), [native/UI/cleanup](ux-ui/acceptance/gre210-native-live-2026-10-13.json), [source](ux-ui/acceptance/source210-result.json), [compiled](ux-ui/acceptance/compiled210-result.json), [deployment](ux-ui/acceptance/gre210-deployment-2026-10-13.json).
+18 -1
View File
@@ -1,6 +1,6 @@
# Тестовый сервер UX/UI — 2026-10-10 # Тестовый сервер UX/UI — 2026-10-10
Актуальное продолжение 2026-10-13: блок 209 завершён. GRE PSK скрыт от читателя; каталог Filters сохраняет признак настроенного IPsec без ключа. Пройдены 12 SQL/API-проверок, профиль live-maps, 4 E2E и 4 live UI-сценария. Два собственных отключённых GRE на CHR созданы и удалены. Backend/frontend: ux-gre209, 473/527 исходных файлов и 1048 compiled artifacts совпали. Следующий блок 210 — keepalive при создании GRE без явных параметров. Этап 12.5 открыт. Актуальное продолжение 2026-10-13: блок 210 завершён. GRE создаётся с default keepalive 10s,10; минутные значения читаются в секундах, PATCH другого поля сохраняет native timer. Валидатор каталога IPsec исправлен для Filters/карты/диагностики. Пройдены SQL/API14, E2E13 и live GRE/Filters UI4; собственные интерфейсы удалены. Backend/frontend ux-gre210, source475/527 и compiled1048 совпали. Срочный следующий блок 211 — auth/RBAC для закодированных URL; baseline анонимного обхода подтверждён. Этап 12.5 открыт.
| Параметр | Проверенное состояние | | Параметр | Проверенное состояние |
| --- | --- | | --- | --- |
@@ -2270,3 +2270,20 @@ Backend/frontend ux-gre209 развёрнуты; 473/527 source files и 1048 co
Следующий блок 210: исправить создание GRE с опущенным keepalive, проверить фактический RouterOS ответ и прежнее редактирование. Этап 12.5 открыт; внешние ограничения в актуальной матрице сохраняются. Следующий блок 210: исправить создание GRE с опущенным keepalive, проверить фактический RouterOS ответ и прежнее редактирование. Этап 12.5 открыт; внешние ограничения в актуальной матрице сохраняются.
## 12.5.210 — GRE keepalive и совместимость публичного каталога (2026-10-13)
На обоих CHR собственные отключённые интерфейсы подтвердили: native REST отклоняет 0, none, пустую строку, disabled, no и ноль попыток. Дробные интервалы принимаются, но сокращаются RouterOS до целых секунд. Поэтому форма и write schema требуют целые значения 1..4294967295 вместо обещания «0 = откл.». Неверный ввод сохраняет форму и не отправляет запрос. Официальный default 10s,10: [MikroTik GRE](https://help.mikrotik.com/docs/spaces/ROS/pages/24805531/GRE).
Опущенные параметры создания теперь дают 10s,10; один заданный параметр сохраняется, второй получает default. Основной GET и каталог Filters используют общий разбор native единиц/clock: 1m = 60с, 1m30s = 90с. PATCH без параметров keepalive сохраняет исходную строку snapshot, не преобразует таймер при изменении комментария/PSK. Legacy snapshots не переписаны; произвольные legacy/unknown timers и их экспорт не приняты этим блоком.
Аудит потребителей выявил несовместимость 209: shared schema Filters допускала только ipsec:null. Теперь она принимает {secret:""} или null, отвергает реальный ключ. Карта сохраняет configured IPsec; Filters и диагностика используют обновлённую схему. ReUI primitives и theme не менялись.
Проверки: 24 direct native syntax cases на отключённых собственных GRE, с очисткой; production SQL/API14 на собственной ограниченной DB/role, включая 8 invalid cases до native requests, четыре создания, сохранение native timer и чтение минут. SQL DB/role/container удалены. Профиль live-maps, schema unit, 5 network-map units, TypeScript/backend build/ESLint прошли. 13 уникальных E2E: GRE формы/PSK/валидация9 и Filters gateway/retry4. Копии fixtures имеют configured IPsec; прежние повторные действия сохранены.
Runtime ux-gre210: backend healthy, source475/527 и compiled1048 matched. На каждом CHR приложение создало отключённый GRE без keepalive и с явным allowFastPath=false для PSK; native default10s,10 подтверждён. После native 1m,3 GET показывает60с, PATCH комментария сохраняет1m,3, explicit0 даёт400 без изменения native. Два UI GRE и два UI Filters на1440/320 проверили форму, отсутствие запроса при invalid input и доступность собственного GRE с IPsec в каталоге шлюзов. Оба интерфейса удалены, отсутствие проверено. Пароли не менялись; это не data-plane test.
Два первых create с PSK и опущенным allowFastPath получили502: текущий API default FastPath несовместим с PSK. Это отдельное обнаруженное ограничение, не скрытое успешным повтором; дальнейшая проверка задала false явно. Следующий приоритет211 — подтверждённый обход auth через encoded API prefix. После него исправить FastPath/PSK default и экспорт недопустимого/неизвестного keepalive.
Протоколы: [native syntax](ux-ui/acceptance/gre210-native-range-2026-10-13.json), [дополнительный синтаксис](ux-ui/acceptance/gre210-native-range-extra-2026-10-13.json), [SQL/API14](ux-ui/acceptance/gre210-keepalive-result.json), [профили](ux-ui/acceptance/gre210-profile-2026-10-13.json), [E2E13](ux-ui/acceptance/gre210-e2e-2026-10-13.json), [native/UI/cleanup](ux-ui/acceptance/gre210-native-live-2026-10-13.json), [source](ux-ui/acceptance/source210-result.json), [compiled](ux-ui/acceptance/compiled210-result.json), [deployment](ux-ui/acceptance/gre210-deployment-2026-10-13.json).
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,35 @@
{
"date": "2026-10-13",
"block": "12.5.210",
"runtime": "ux-gre210",
"services": {
"backend": {
"image": "mmapp-test/backend:ux-gre210",
"imageId": "sha256:9bce7d7505145a6479cf2920240abacc5e38aa1aafacfd7c69f1fcaaf3a900a7",
"running": true,
"health": "healthy"
},
"frontend": {
"image": "mmapp-test/frontend:ux-gre210",
"imageId": "sha256:b4b1280f729e89de83b30bd242d37bda831306720b3c78cb9eef6048c3e3178e",
"running": true,
"health": null
}
},
"rollbackTags": {
"MMAPP_IMAGE_TAG": "ux-gre209",
"BACKEND_IMAGE_TAG": "ux-gre209"
},
"chrPasswordsChanged": false,
"source": [
{
"kind": "backend",
"matched": 475
},
{
"kind": "frontend",
"matched": 527
}
],
"compiledMatched": 1048
}
@@ -0,0 +1,30 @@
{
"date": "2026-10-13",
"block": "12.5.210",
"uniqueCases": 13,
"groups": [
{
"scope": "GRE keepalive, PSK and form receipts",
"stats": {
"startTime": "2026-10-13T13:54:18.301Z",
"duration": 22078.386000000002,
"expected": 9,
"skipped": 0,
"unexpected": 0,
"flaky": 0
}
},
{
"scope": "Filters gateway catalog masked-IPsec and original retry",
"stats": {
"startTime": "2026-10-13T13:59:12.012Z",
"duration": 15935.284,
"expected": 4,
"skipped": 0,
"unexpected": 0,
"flaky": 0
}
}
],
"apiMocked": true
}
@@ -0,0 +1,100 @@
{
"date": "2026-10-13",
"block": "12.5.210",
"database": "mm_accept_166_1791900297484",
"cases": [
{
"field": "keepaliveInterval",
"value": 0,
"status": 400,
"nativeCalls": 0
},
{
"field": "keepaliveInterval",
"value": -1,
"status": 400,
"nativeCalls": 0
},
{
"field": "keepaliveInterval",
"value": 1.5,
"status": 400,
"nativeCalls": 0
},
{
"field": "keepaliveInterval",
"value": 4294967296,
"status": 400,
"nativeCalls": 0
},
{
"field": "keepaliveRetries",
"value": 0,
"status": 400,
"nativeCalls": 0
},
{
"field": "keepaliveRetries",
"value": -1,
"status": 400,
"nativeCalls": 0
},
{
"field": "keepaliveRetries",
"value": 1.5,
"status": 400,
"nativeCalls": 0
},
{
"field": "keepaliveRetries",
"value": 4294967296,
"status": 400,
"nativeCalls": 0
},
{
"operation": "create",
"params": {},
"keepalive": "10s,10",
"status": 201
},
{
"operation": "create",
"params": {
"keepaliveInterval": 60
},
"keepalive": "60s,10",
"status": 201
},
{
"operation": "create",
"params": {
"keepaliveRetries": 3
},
"keepalive": "10s,3",
"status": 201
},
{
"operation": "create",
"params": {
"keepaliveInterval": 90,
"keepaliveRetries": 7
},
"keepalive": "90s,7",
"status": 201
},
{
"operation": "patch-other-field",
"nativeKeepalivePreserved": "1m,3",
"status": 200
},
{
"operation": "read-minutes",
"seconds": 60,
"status": 200
}
],
"scope": "Production compiled GRE create/patch using isolated PostgreSQL and native HTTP fixture",
"passed": true,
"nativeWrites": 5,
"databaseAndRoleRemoved": true
}
@@ -0,0 +1,65 @@
{
"date": "2026-10-13",
"block": "12.5.210",
"runtime": "ux-gre210",
"cases": [
{
"host": "194.226.121.150",
"defaultCreateStatus": 201,
"defaultNative": "10s,10",
"minutesReadAsSeconds": 60,
"patchOtherFieldPreservesNative": "1m,3",
"invalidIntervalStatus": 400,
"invalidPatchLeavesNativeUnchanged": true,
"catalogSeconds": 60
},
{
"host": "104.165.244.7",
"defaultCreateStatus": 201,
"defaultNative": "10s,10",
"minutesReadAsSeconds": 60,
"patchOtherFieldPreservesNative": "1m,3",
"invalidIntervalStatus": 400,
"invalidPatchLeavesNativeUnchanged": true,
"catalogSeconds": 60
}
],
"uiCases": [
{
"section": "gre",
"width": 1440,
"actualNativeRead": true,
"minutesFormSeconds": 60,
"invalidInputPreservesForm": true,
"mutationRequests": 0,
"pageErrors": 0
},
{
"section": "filters",
"width": 1440,
"actualNativeRead": true,
"configuredGreCatalogAccepted": true,
"mutationRequests": 0,
"pageErrors": 0
},
{
"section": "gre",
"width": 320,
"actualNativeRead": true,
"minutesFormSeconds": 60,
"invalidInputPreservesForm": true,
"mutationRequests": 0,
"pageErrors": 0
},
{
"section": "filters",
"width": 320,
"actualNativeRead": true,
"configuredGreCatalogAccepted": true,
"mutationRequests": 0,
"pageErrors": 0
}
],
"scope": "Disabled own native GRE; defaults, unit parsing, patch preservation and UI validation; not data-plane",
"cleanupCompleted": true
}
@@ -0,0 +1,116 @@
{
"date": "2026-10-13",
"block": "12.5.210",
"cases": [
{
"host": "194.226.121.150",
"value": "0",
"status": 400,
"observed": "10s,3",
"error": "value of keepalive-interval is out of range (00:00:01 .. 7101w3d06:28:15)"
},
{
"host": "194.226.121.150",
"value": "none",
"status": 400,
"observed": "10s,3",
"error": "invalid time value for argument keepalive-interval"
},
{
"host": "194.226.121.150",
"value": "",
"status": 400,
"observed": "10s,3",
"error": "invalid time value for argument keepalive-interval"
},
{
"host": "194.226.121.150",
"value": "0s,0",
"status": 400,
"observed": "10s,3",
"error": "value of keepalive-interval is out of range (00:00:01 .. 7101w3d06:28:15)"
},
{
"host": "194.226.121.150",
"value": "1s,0",
"status": 400,
"observed": "10s,3",
"error": "value of keepalive-retries out of range (1..4294967295)"
},
{
"host": "194.226.121.150",
"value": "1m,3",
"status": 200,
"observed": "1m,3"
},
{
"host": "194.226.121.150",
"value": "500ms,3",
"status": 400,
"observed": "10s,3",
"error": "value of keepalive-interval is out of range (00:00:01 .. 7101w3d06:28:15)"
},
{
"host": "194.226.121.150",
"value": "00:01:00,3",
"status": 200,
"observed": "1m,3"
},
{
"host": "104.165.244.7",
"value": "0",
"status": 400,
"observed": "10s,3",
"error": "value of keepalive-interval is out of range (00:00:01 .. 7101w3d06:28:15)"
},
{
"host": "104.165.244.7",
"value": "none",
"status": 400,
"observed": "10s,3",
"error": "invalid time value for argument keepalive-interval"
},
{
"host": "104.165.244.7",
"value": "",
"status": 400,
"observed": "10s,3",
"error": "invalid time value for argument keepalive-interval"
},
{
"host": "104.165.244.7",
"value": "0s,0",
"status": 400,
"observed": "10s,3",
"error": "value of keepalive-interval is out of range (00:00:01 .. 7101w3d06:28:15)"
},
{
"host": "104.165.244.7",
"value": "1s,0",
"status": 400,
"observed": "10s,3",
"error": "value of keepalive-retries out of range (1..4294967295)"
},
{
"host": "104.165.244.7",
"value": "1m,3",
"status": 200,
"observed": "1m,3"
},
{
"host": "104.165.244.7",
"value": "500ms,3",
"status": 400,
"observed": "10s,3",
"error": "value of keepalive-interval is out of range (00:00:01 .. 7101w3d06:28:15)"
},
{
"host": "104.165.244.7",
"value": "00:01:00,3",
"status": 200,
"observed": "1m,3"
}
],
"scope": "Direct native syntax exploration on disabled owned interfaces; application fixes not deployed",
"cleanupCompleted": true
}
@@ -0,0 +1,60 @@
{
"date": "2026-10-13",
"block": "12.5.210",
"cases": [
{
"host": "194.226.121.150",
"value": "disabled",
"status": 400,
"observed": "10s,3",
"error": "invalid time value for argument keepalive-interval"
},
{
"host": "194.226.121.150",
"value": "no",
"status": 400,
"observed": "10s,3",
"error": "invalid time value for argument keepalive-interval"
},
{
"host": "194.226.121.150",
"value": "1.5s,3",
"status": 200,
"observed": "1s,3"
},
{
"host": "194.226.121.150",
"value": "1s500ms,3",
"status": 200,
"observed": "1s,3"
},
{
"host": "104.165.244.7",
"value": "disabled",
"status": 400,
"observed": "10s,3",
"error": "invalid time value for argument keepalive-interval"
},
{
"host": "104.165.244.7",
"value": "no",
"status": 400,
"observed": "10s,3",
"error": "invalid time value for argument keepalive-interval"
},
{
"host": "104.165.244.7",
"value": "1.5s,3",
"status": 200,
"observed": "1s,3"
},
{
"host": "104.165.244.7",
"value": "1s500ms,3",
"status": 200,
"observed": "1s,3"
}
],
"scope": "Direct native syntax exploration on disabled owned interfaces; application fixes not deployed",
"cleanupCompleted": false
}
@@ -0,0 +1,14 @@
{
"date": "2026-10-13",
"block": "12.5.210",
"commands": [
"npm --prefix backend run test:live-maps",
"npx tsx lib/filters-response.test.ts",
"npx tsx lib/network-map-response.test.ts"
],
"passed": true,
"networkMapUnitCases": 5,
"frontendTypecheckPassed": true,
"backendBuildPassed": true,
"eslintErrors": 0
}
@@ -0,0 +1,14 @@
{
"date": "2026-10-13",
"block": "12.5.210",
"kinds": [
{
"kind": "backend",
"matched": 475
},
{
"kind": "frontend",
"matched": 527
}
]
}
+7
View File
@@ -0,0 +1,7 @@
import assert from 'node:assert/strict'
import {filtersGreResponseSchema} from './filters-response'
const t={id:'*1',serverId:'1',name:'own210',localAddress:'192.0.2.1',remoteAddress:'192.0.2.2',localInnerIp:'10.0.0.1/30',remoteInnerIp:'10.0.0.2/30',poolId:'',ipsec:{secret:''},mtu:1476,keepaliveInterval:60,keepaliveRetries:3,dscp:'inherit',clampTcpMss:true,allowFastPath:false,comment:'',enabled:true,status:'up'}
assert.equal(filtersGreResponseSchema.safeParse({tunnels:[t]}).success,true)
assert.equal(filtersGreResponseSchema.safeParse({tunnels:[{...t,ipsec:null}]}).success,true)
assert.equal(filtersGreResponseSchema.safeParse({tunnels:[{...t,ipsec:{secret:'must-not-be-in-catalog'}}]}).success,false)
console.log('Filters GRE catalog: configured IPsec accepted without key material')
+2 -1
View File
@@ -15,7 +15,8 @@ export const filtersMutationSchema = z.object({ ok: z.literal(true), rules: filt
export const filtersGreResponseSchema = z.object({ export const filtersGreResponseSchema = z.object({
tunnels: z.array(z.object({ tunnels: z.array(z.object({
id: z.string().min(1), serverId: z.string().min(1), name: z.string(), localAddress: z.string(), remoteAddress: z.string(), id: z.string().min(1), serverId: z.string().min(1), name: z.string(), localAddress: z.string(), remoteAddress: z.string(),
localInnerIp: z.string(), remoteInnerIp: z.string(), poolId: z.string(), ipsec: z.null(), mtu: z.number().int().nonnegative(), localInnerIp: z.string(), remoteInnerIp: z.string(), poolId: z.string(),
ipsec: z.object({ secret: z.literal("") }).nullable(), mtu: z.number().int().nonnegative(),
keepaliveInterval: z.number().int().nonnegative(), keepaliveRetries: z.number().int().nonnegative(), keepaliveInterval: z.number().int().nonnegative(), keepaliveRetries: z.number().int().nonnegative(),
dscp: z.union([z.literal("inherit"), z.number().int().min(0).max(63)]), clampTcpMss: z.boolean(), allowFastPath: z.boolean(), dscp: z.union([z.literal("inherit"), z.number().int().min(0).max(63)]), clampTcpMss: z.boolean(), allowFastPath: z.boolean(),
comment: z.string(), enabled: z.boolean(), status: z.enum(["up", "down", "degraded"]), comment: z.string(), enabled: z.boolean(), status: z.enum(["up", "down", "degraded"]),
+5
View File
@@ -2,6 +2,11 @@ import {test} from "node:test"
import assert from "node:assert/strict" import assert from "node:assert/strict"
import {validateNetworkMapSnapshot} from "./network-map-response" import {validateNetworkMapSnapshot} from "./network-map-response"
const server={id:1,name:"Router",host:"192.0.2.1",type:"home-router",country:"RU",enabled:true,status:null,latency:0} const server={id:1,name:"Router",host:"192.0.2.1",type:"home-router",country:"RU",enabled:true,status:null,latency:0}
test("network map retains configured GRE IPsec without key material",()=>{
const tunnel={id:"*1",serverId:"1",name:"own210",localAddress:"192.0.2.1",remoteAddress:"192.0.2.2",localInnerIp:"10.0.0.1/30",remoteInnerIp:"10.0.0.2/30",poolId:"",ipsec:{secret:""},mtu:1476,keepaliveInterval:60,keepaliveRetries:3,dscp:"inherit",clampTcpMss:true,allowFastPath:false,comment:"",enabled:true,status:"up"}
const result=validateNetworkMapSnapshot([server],{tunnels:[tunnel],failures:[]},{probes:[]})
assert.deepEqual(result.tunnels[0].ipsec,{secret:""})
})
test("network map requires complete envelopes and preserves zero/null",()=>{ test("network map requires complete envelopes and preserves zero/null",()=>{
const result=validateNetworkMapSnapshot([server],{tunnels:[],failures:[]},{probes:[]}) const result=validateNetworkMapSnapshot([server],{tunnels:[],failures:[]},{probes:[]})
assert.equal(result.servers[0].latency,0);assert.equal(result.servers[0].status,null) assert.equal(result.servers[0].latency,0);assert.equal(result.servers[0].status,null)
+61
View File
@@ -0,0 +1,61 @@
import { test, expect } from "@playwright/test"
for (const kind of ["gre", "recursive"] as const) {
test(`Filters: каталог ${kind} с masked IPsec сохраняется при сбое и блокирует маршрут/копирование`, async ({ page }) => {
let phase = 0, writes = 0, catalogReads = 0
const tunnel = (id: string) => ({ id: "t" + id, serverId: id, name: "GRE-" + id, localAddress: "192.0.2." + id, remoteAddress: "198.51.100.1", localInnerIp: "10.0.0.1/30", remoteInnerIp: "10.0.0.2/30", poolId: "", ipsec: { secret: "" }, mtu: 1476, keepaliveInterval: 10, keepaliveRetries: 3, dscp: "inherit", clampTcpMss: true, allowFastPath: true, comment: "", enabled: true, status: "up" })
const rec = { id: "rec1", dstAddress: "10.0.0.2/32", gateway: "198.51.100.1", distance: 1, routingTable: "main", comment: "Рекурсивный шлюз", disabled: false }
const rule = { id: "rule-1", community: "65001:101", gateway: "10.0.0.2", gatewayTunnelId: kind === "gre" ? "t1" : "rec:rec1", action: "route", description: "Маршрут" }
await page.route("**/health", route => route.fulfill({ json: { ok: true } }))
await page.route("**/api/**", route => {
const url = new URL(route.request().url()), path = url.pathname, id = url.searchParams.get("serverId") ?? "1"
if (path === "/api/filters/rules" && route.request().method() === "PUT") { writes++; return route.fulfill({ json: { ok: true, rules: route.request().postDataJSON().rules } }) }
const catalog = kind === "gre" ? "/api/filters/gre-tunnels" : "/api/recursive-routes"
if (path === catalog) catalogReads++
if (path === catalog && phase === 1) return route.fulfill({ status: 503, json: { error: "Каталог недоступен тест" } })
if (path === catalog && phase === 2) return route.fulfill({ json: kind === "gre" ? { tunnels: [{ ...tunnel(id), name: null }] } : { routes: [rec], stale: true } })
return route.fulfill({ json: path === "/api/filters/rules" ? { rulesets: [{ serverId: "1", rules: [rule] }, { serverId: "2", rules: [] }], live: true, greTunnels: [] }
: path === "/api/servers" ? [1, 2].map(i => ({ id: i, name: "Роутер " + i, host: "192.0.2." + i, type: "exit-node", enabled: true, country: "RU", site: "TEST", asn: "", status: "online", latency: 1 }))
: path === "/api/filters/gre-tunnels" ? { tunnels: kind === "gre" && phase !== 3 ? [tunnel(id)] : [] }
: path === "/api/recursive-routes" ? { routes: kind === "recursive" && phase !== 3 ? [rec] : [], stale: false }
: path === "/api/auth/config" ? { required: false } : {} })
})
await page.goto("/filters")
const row = page.locator("tr[data-row-id]").first()
await expect(row).toContainText(kind === "gre" ? "GRE-1" : rec.dstAddress)
phase = 1
await page.getByRole("button", { name: "Обновить", exact: true }).click()
await expect(page.getByRole("alert").filter({ hasText: "Каталог недоступен тест" })).toBeVisible()
await expect(row).toContainText(kind === "gre" ? "GRE-1" : rec.dstAddress)
await row.hover()
await row.getByRole("button", { name: "Редактировать правило", exact: true }).click()
const dialog = page.getByRole("dialog")
await dialog.getByRole("textbox", { name: "Описание правила" }).fill("Сохранить маршрут")
await dialog.getByRole("button", { name: "Сохранить", exact: true }).click()
await expect(dialog.getByRole("alert")).toContainText("Шлюзы устройства не подтверждены")
expect(writes).toBe(0)
await dialog.getByRole("button", { name: "Отмена", exact: true }).click()
await page.getByRole("button", { name: "Копировать", exact: true }).click()
await dialog.getByRole("button", { name: /Роутер 2/ }).click()
const copy = dialog.getByRole("button", { name: "Копировать 1 правил", exact: true })
await expect(copy).toBeDisabled()
phase = 2
let priorReads = catalogReads
await dialog.getByRole("button", { name: "Обновить шлюзы", exact: true }).click()
await expect.poll(() => catalogReads).toBeGreaterThanOrEqual(priorReads + 2)
await expect(copy).toBeDisabled()
await expect(dialog.getByRole("alert")).toContainText("сопоставление шлюзов не подтверждены")
phase = 3
priorReads = catalogReads
await dialog.getByRole("button", { name: "Обновить шлюзы", exact: true }).click()
await expect.poll(() => catalogReads).toBeGreaterThanOrEqual(priorReads + 2)
await page.evaluate(() => new Promise<void>(resolve => requestAnimationFrame(() => resolve())))
await expect(copy).toBeDisabled()
phase = 4
await dialog.getByRole("button", { name: "Обновить шлюзы", exact: true }).click()
await expect(copy).toBeEnabled()
await expect(dialog.getByRole("alert")).toHaveCount(0)
await copy.click()
await expect(dialog).toHaveCount(0)
expect(writes).toBe(1)
})
}
+8
View File
@@ -0,0 +1,8 @@
import {test,expect} from '@playwright/test'
for(const width of [1440,320])test(`GRE: неверный keepalive сохраняет форму без запроса ${width}`,async({page})=>{
let writes=0;const tunnel={id:'*1',serverId:'1',name:'own210-gre',localAddress:'192.0.2.1',remoteAddress:'192.0.2.2',localInnerIp:'',remoteInnerIp:'',poolId:'live',ipsec:null,mtu:1476,keepaliveInterval:60,keepaliveRetries:3,dscp:'inherit',clampTcpMss:true,allowFastPath:false,comment:'own210',enabled:false,status:'down'}
await page.route('**/health',r=>r.fulfill({json:{ok:true}}));await page.route('**/api/**',r=>{if(r.request().method()!=='GET')writes++;const p=new URL(r.request().url()).pathname;return r.fulfill({json:p==='/api/auth/config'?{required:false}:p==='/api/servers'?[{id:1,name:'Own210 router',host:'192.0.2.1',enabled:true,country:'RU'}]:p==='/api/gre/tunnels'?{tunnels:[tunnel]}:{}})})
await page.setViewportSize({width,height:1000});await page.goto('/gre?server=1');await page.getByRole('button',{name:'Действия: own210-gre',exact:true}).click();await page.getByRole('menuitem',{name:'Редактировать',exact:true}).click();const form=page.getByRole('dialog');await form.getByRole('button',{name:'Дополнительно',exact:true}).click();await expect(form.getByLabel('Keepalive, с')).toHaveValue('60');await expect(form).not.toContainText('0 = откл.');await form.getByLabel('Комментарий').fill('сохранённый ввод');
for(const field of ['Keepalive, с','Попытки']){await form.getByLabel(field).fill('0');await form.getByRole('button',{name:'Сохранить',exact:true}).click();await expect(form.getByRole('alert')).toContainText('целые значения от 1');await expect(form.getByLabel('Комментарий')).toHaveValue('сохранённый ввод');await form.getByLabel(field).fill(field==='Попытки'?'3':'60')}
expect(writes).toBe(0);expect(await page.evaluate(()=>document.documentElement.scrollWidth<=innerWidth+1)).toBe(true)
})