diff --git a/backend/src/routes/speed-test.test.ts b/backend/src/routes/speed-test.test.ts index 10ef96d6..87c7238e 100644 --- a/backend/src/routes/speed-test.test.ts +++ b/backend/src/routes/speed-test.test.ts @@ -3,7 +3,7 @@ import Fastify from "fastify" import { SignJWT } from "jose" import { speedTestCommandSchema, type SpeedTestCommand } from "@mmapp/contracts" import { createSpeedTestRoute } from "./speed-test.js" -import { SpeedProbeEndpointsChanged } from "../services/speed-run-persistence.js" +import { SpeedProbeEndpointsChanged, SpeedProbeContextChanged } from "../services/speed-run-persistence.js" const commands: SpeedTestCommand[] = [], failures: Array = [] let lookups = 0, missing = false, problem: Error | null = null const result = (c: SpeedTestCommand) => ({ runId: c.runId ?? "ad-hoc", srcInterface: c.srcInterface || null, dstInterface: c.dstInterface || null, address: "192.0.2.2", srcAddress: "192.0.2.1", dstAddress: "192.0.2.2", srcInterfaceAddress: null, dstInterfaceAddress: null, protocol: c.protocol, direction: c.direction, durationSec: c.durationSec, txAvgMbps: null, rxAvgMbps: 0, txSeries: [], rxSeries: [0], raw: [{ status: "done testing" }], afterBtPing: { rttMs: null, lossPct: null, error: null } }) @@ -25,7 +25,7 @@ const explicit = await send({ srcServerId: "1", dstServerId: "2", runId: " run-2 assert.equal(explicit.statusCode, 200); assert.equal(commands[1].runId, "run-2"); assert.equal(commands[1].probeId, "probe"); assert.equal(commands[1].srcInterface, "ether1"); assert.equal(commands[1].protocol, "udp"); assert.equal(commands[1].direction, "receive"); assert.equal(commands[1].durationSec, 3); assert.equal("lastTxAvgMbps" in commands[1], false) assert.equal(speedTestCommandSchema.parse({ ...base, runId: " ", probeId: "" }).probeId, undefined) missing = true; assert.equal((await send(base)).statusCode, 404); assert.equal(commands.length, 2); assert.equal(failures.length, 0); missing = false -for (const [error, code] of [[new SpeedProbeEndpointsChanged(), 409], [Error("На интерфейсе назначения нет активного IP"), 400], [Error("Server not found"), 400], [Error("authentication failed"), 502], [Error("socket hang up"), 502]] as const) { +for (const [error, code] of [[new SpeedProbeEndpointsChanged(), 409], [new SpeedProbeContextChanged(), 409], [Error("На интерфейсе назначения нет активного IP"), 400], [Error("Server not found"), 400], [Error("authentication failed"), 502], [Error("socket hang up"), 502]] as const) { problem = error; const response = await send({ ...base, runId: "failed", direction: "transmit", durationSec: 20 }); assert.equal(response.statusCode, code); assert.equal(failures.at(-1)?.runId, "failed"); assert.equal(failures.at(-1)?.durationSec, 20); assert.equal(failures.at(-1)?.direction, "transmit") } await app.close(); console.log("Speed-test HTTP:26 invalid inputs before lookups/native/history; defaults/ad-hoc/trim/nullable/zero/404/409/400/502") diff --git a/backend/src/routes/speed-test.ts b/backend/src/routes/speed-test.ts index 6079335d..22e37a61 100644 --- a/backend/src/routes/speed-test.ts +++ b/backend/src/routes/speed-test.ts @@ -1,7 +1,7 @@ import type { FastifyPluginAsyncZod } from "@fastify/type-provider-zod" import { speedTestCommandSchema, type SpeedTestCommand } from "@mmapp/contracts" import type { SpeedTestRunResult } from "../services/uptime-speed-service.js" -import { SpeedProbeEndpointsChanged } from "../services/speed-run-persistence.js" +import { SpeedProbeContextChanged } from "../services/speed-run-persistence.js" export function createSpeedTestRoute(deps: { serverExists: (id: number) => Promise @@ -23,7 +23,7 @@ export function createSpeedTestRoute(deps: { const message = /timed out|socket hang up|ECONNRESET/i.test(raw) ? "BTTest timeout/socket hang up: проверь доступность destination, /tool bandwidth-server, firewall и корректность интерфейсов" : raw - if (error instanceof SpeedProbeEndpointsChanged) return reply.code(409).send({ error: message }) + if (error instanceof SpeedProbeContextChanged) return reply.code(409).send({ error: message }) if (/активного IP|Server not found/i.test(raw)) return reply.code(400).send({ error: message }) return reply.code(502).send({ error: message }) } diff --git a/backend/src/services/speed-run-persistence.test.ts b/backend/src/services/speed-run-persistence.test.ts index 52cf5716..0670adb5 100644 --- a/backend/src/services/speed-run-persistence.test.ts +++ b/backend/src/services/speed-run-persistence.test.ts @@ -1,6 +1,6 @@ import { test } from "node:test" import assert from "node:assert/strict" -import { assertSpeedProbeEndpoints, matchesSpeedProbeEndpoints, persistSpeedRun, SpeedProbeEndpointsChanged } from "./speed-run-persistence.js" +import { assertSpeedProbeEndpoints, matchesSpeedProbeEndpoints, persistSpeedRun, SpeedProbeEndpointsChanged, assertSpeedProbeContext, matchesSpeedProbeContext, SpeedProbeContextChanged, type SpeedProbeContext } from "./speed-run-persistence.js" const target = { id: "probe", srcServerId: 1, dstServerId: 2 } type State = { probe: (typeof target & { average: number }) | null; runs: Array<{ probeId: string | null; average: number }> } function harness(failure?: "update" | "insert" | "commit") { @@ -64,3 +64,36 @@ test("ad-hoc run writes history without touching any probe", async () => { assert.equal(h.updates(), 0) assert.deepEqual(h.state().runs, [{ probeId: null, average: 0 }]) }) + +const context: SpeedProbeContext = { ...target, srcInterface: "ether1", dstInterface: "ether2", protocol: "tcp", direction: "both", durationSec: 10 } +const edits: Partial[] = [{ srcInterface: "ether3" }, { dstInterface: "ether3" }, { protocol: "udp" }, { direction: "receive" }, { durationSec: 20 }] +test("preflight validates full context and ignores enable/order/telemetry changes", () => { + assert.doesNotThrow(() => assertSpeedProbeContext({ ...context, enabled: false, sortOrder: 9, lastTxAvgMbps: 0 }, context)) + for (const edit of edits) assert.throws(() => assertSpeedProbeContext({ ...context, ...edit }, context), SpeedProbeContextChanged) + assert.throws(() => assertSpeedProbeContext(null, context), SpeedProbeEndpointsChanged) +}) +for (const edit of edits) test(`changed ${Object.keys(edit)[0]} cannot publish success or failure into new context`, async () => { + for (const status of ["done", "error"]) { + const probe = { ...context, ...edit, average: 7, status: "done" } + const runs: Array<{ probeId: string | null; status: string }> = [] + await persistSpeedRun(context, { + transaction: async work => work(probe), + updateProbe: async (_tx, expected) => { + if (!matchesSpeedProbeContext(probe, expected)) return false + probe.average = 0; probe.status = status; return true + }, + insertRun: async (_tx, probeId) => { runs.push({ probeId, status }) }, + }) + assert.equal(probe.average, 7); assert.equal(probe.status, "done") + assert.deepEqual(runs, [{ probeId: null, status }]) + } +}) +test("matching full context publishes explicit zero with associated history", async () => { + const probe = { ...context, average: 7 }; let associated: string | null = null + await persistSpeedRun(context, { + transaction: async work => work(probe), + updateProbe: async (_tx, expected) => { assert.equal(matchesSpeedProbeContext(probe, expected), true); probe.average = 0; return true }, + insertRun: async (_tx, probeId) => { associated = probeId }, + }) + assert.equal(probe.average, 0); assert.equal(associated, context.id) +}) diff --git a/backend/src/services/speed-run-persistence.ts b/backend/src/services/speed-run-persistence.ts index dd5c2a74..9d128d10 100644 --- a/backend/src/services/speed-run-persistence.ts +++ b/backend/src/services/speed-run-persistence.ts @@ -1,6 +1,12 @@ export interface SpeedProbeEndpoints { id: string; srcServerId: number; dstServerId: number } -export class SpeedProbeEndpointsChanged extends Error { +export interface SpeedProbeContext extends SpeedProbeEndpoints { + srcInterface: string; dstInterface: string; protocol: "tcp" | "udp"; direction: "both" | "transmit" | "receive"; durationSec: number +} +export class SpeedProbeContextChanged extends Error { + constructor(message = "Параметры speed-пробы изменились. Обновите список проб перед запуском") { super(message) } +} +export class SpeedProbeEndpointsChanged extends SpeedProbeContextChanged { constructor() { super("Пара устройств speed-пробы изменилась. Обновите список проб перед запуском") } } export function matchesSpeedProbeEndpoints(row: unknown, target: SpeedProbeEndpoints): boolean { @@ -12,10 +18,21 @@ export function assertSpeedProbeEndpoints(row: unknown, target: SpeedProbeEndpoi if (!matchesSpeedProbeEndpoints(row, target)) throw new SpeedProbeEndpointsChanged() } +export function matchesSpeedProbeContext(row: unknown, target: SpeedProbeContext): boolean { + if (!matchesSpeedProbeEndpoints(row, target)) return false + const probe = row as Partial + return probe.srcInterface === target.srcInterface && probe.dstInterface === target.dstInterface + && probe.protocol === target.protocol && probe.direction === target.direction && probe.durationSec === target.durationSec +} +export function assertSpeedProbeContext(row: unknown, target: SpeedProbeContext): void { + assertSpeedProbeEndpoints(row, target) + if (!matchesSpeedProbeContext(row, target)) throw new SpeedProbeContextChanged() +} + /** Changed/deleted probes retain their snapshot; history keeps the actual run without a stale association. */ -export async function persistSpeedRun(target: SpeedProbeEndpoints | null, deps: { +export async function persistSpeedRun(target: T | null, deps: { transaction: (work: (context: C) => Promise) => Promise - updateProbe: (context: C, target: SpeedProbeEndpoints) => Promise + updateProbe: (context: C, target: T) => Promise insertRun: (context: C, associatedProbeId: string | null) => Promise }): Promise { await deps.transaction(async context => { diff --git a/backend/src/services/uptime-speed-service.ts b/backend/src/services/uptime-speed-service.ts index 17990652..25c3873a 100644 --- a/backend/src/services/uptime-speed-service.ts +++ b/backend/src/services/uptime-speed-service.ts @@ -8,12 +8,14 @@ import { withBtestNodeLocks } from "./btest-node-locks.js" import { MikrotikClient } from "./mikrotik.js" import { parsePing } from "./uptime-collector.js" import { assertBandwidthTestCompleted } from "./bandwidth-test-status.js" -import { assertSpeedProbeEndpoints, persistSpeedRun, type SpeedProbeEndpoints } from "./speed-run-persistence.js" +import { assertSpeedProbeContext, persistSpeedRun, type SpeedProbeContext } from "./speed-run-persistence.js" import { readBandwidthTestRates } from "./bandwidth-test-rates.js" type SpeedWriteTransaction = Parameters[0]>[0] -function speedProbeEndpointsWhere(target: SpeedProbeEndpoints) { - return and(eq(uptimeSpeedProbes.id, target.id), eq(uptimeSpeedProbes.srcServerId, target.srcServerId), eq(uptimeSpeedProbes.dstServerId, target.dstServerId)) +function speedProbeContextWhere(target: SpeedProbeContext) { + return and(eq(uptimeSpeedProbes.id, target.id), eq(uptimeSpeedProbes.srcServerId, target.srcServerId), eq(uptimeSpeedProbes.dstServerId, target.dstServerId), + eq(uptimeSpeedProbes.srcInterface, target.srcInterface), eq(uptimeSpeedProbes.dstInterface, target.dstInterface), + eq(uptimeSpeedProbes.protocol, target.protocol), eq(uptimeSpeedProbes.direction, target.direction), eq(uptimeSpeedProbes.durationSec, target.durationSec)) } function firstUsableIpOnInterface(rows: RosIpAddress[], iface: string): string { @@ -84,16 +86,17 @@ export async function executeBandwidthSpeedTestInner(params: SpeedTestRunParams) const src = (await db.select().from(servers).where(eq(servers.id, srcId)).limit(1))[0] const dst = (await db.select().from(servers).where(eq(servers.id, dstId)).limit(1))[0] if (!src || !dst) throw new Error("Server not found") - if (probeId) { - const probe = (await db.select().from(uptimeSpeedProbes).where(eq(uptimeSpeedProbes.id, probeId)).limit(1))[0] - assertSpeedProbeEndpoints(probe, { id: probeId, srcServerId: srcId, dstServerId: dstId }) - } const protocol = params.protocol === "udp" ? "udp" : "tcp" const direction = params.direction === "transmit" || params.direction === "receive" ? params.direction : "both" const durationSec = Math.max(3, Number.parseInt(String(params.durationSec ?? ""), 10) || 10) const srcInterface = String(params.srcInterface ?? "").trim() const dstInterface = String(params.dstInterface ?? "").trim() + const target: SpeedProbeContext = { id: probeId, srcServerId: srcId, dstServerId: dstId, srcInterface, dstInterface, protocol, direction, durationSec } + if (probeId) { + const probe = (await db.select().from(uptimeSpeedProbes).where(eq(uptimeSpeedProbes.id, probeId)).limit(1))[0] + assertSpeedProbeContext(probe, target) + } const srcClient = MikrotikClient.fromServer(src) const dstAddressRows = dstInterface ? await MikrotikClient.fromServer(dst).getIpAddresses().catch(() => []) @@ -138,7 +141,7 @@ export async function executeBandwidthSpeedTestInner(params: SpeedTestRunParams) } const runId = String(params.runId ?? "").trim() || `sr-${Date.now()}-${Math.random().toString(36).slice(2, 8)}` - await persistSpeedRun(probeId ? { id: probeId, srcServerId: srcId, dstServerId: dstId } : null, { + await persistSpeedRun(probeId ? target : null, { transaction: work => db.transaction(work), updateProbe: async (tx, target) => { const rows = await tx.update(uptimeSpeedProbes).set({ @@ -152,7 +155,7 @@ export async function executeBandwidthSpeedTestInner(params: SpeedTestRunParams) lastPingAt: pingNow, lastPingError: afterBtPing.error ?? "", updatedAt: pingNow, - }).where(speedProbeEndpointsWhere(target)).returning({ id: uptimeSpeedProbes.id }) + }).where(speedProbeContextWhere(target)).returning({ id: uptimeSpeedProbes.id }) return rows.length === 1 }, insertRun: async (tx, associatedProbeId) => { @@ -224,7 +227,13 @@ export async function recordSpeedTestFailure(params: { }) { const probeId = String(params.probeId ?? "").trim() const message = humanizeSpeedError(params.message) - await persistSpeedRun(probeId ? { id: probeId, srcServerId: params.srcServerId, dstServerId: params.dstServerId } : null, { + await persistSpeedRun(probeId ? { + id: probeId, srcServerId: params.srcServerId, dstServerId: params.dstServerId, + srcInterface: String(params.srcInterface ?? "").trim(), dstInterface: String(params.dstInterface ?? "").trim(), + protocol: params.protocol === "udp" ? "udp" : "tcp", + direction: params.direction === "transmit" || params.direction === "receive" ? params.direction : "both", + durationSec: Math.max(3, Number.parseInt(String(params.durationSec ?? ""), 10) || 10), + } : null, { transaction: work => db.transaction(work), updateProbe: async (tx, target) => { const rows = await tx.update(uptimeSpeedProbes).set({ @@ -232,7 +241,7 @@ export async function recordSpeedTestFailure(params: { lastStatus: "error", lastError: message, updatedAt: new Date().toISOString(), - }).where(speedProbeEndpointsWhere(target)).returning({ id: uptimeSpeedProbes.id }) + }).where(speedProbeContextWhere(target)).returning({ id: uptimeSpeedProbes.id }) return rows.length === 1 }, insertRun: async (tx, associatedProbeId) => { diff --git a/docs/ux-ui-external-acceptance.md b/docs/ux-ui-external-acceptance.md index b62fbd69..863b7759 100644 --- a/docs/ux-ui-external-acceptance.md +++ b/docs/ux-ui-external-acceptance.md @@ -63,7 +63,7 @@ SELECT min(resolution_version), max(resolution_version) FROM alert_outbox; | ROS-5 | OSPF preview → действие → перечитывание templates | Preview соответствует исправленным RX/DL и TX/UL. Применение подтверждается фактическим cost; частичный отказ не становится полным успехом | | ROS-6 | WireGuard/IPsec/GRE/VXLAN/Firewall/BGP/маршруты | Проблема→устройство→форма→подтверждённое действие→возврат сохраняет контекст. Права и повторное нажатие проверены; фактическая конфигурация и connectivity сверены | | ROS-9 | Смена пары в каталоге и конкурентное завершение native test | Новый snapshot unknown, старый run сохранён с actual endpoints; enable/reorder сохраняют измеренный0. Проверить row locks/условный UPDATE в реальном PG | -| ROS-8 | Смена пары/удаление speed-пробы до запуска и во время теста | Preflight409 до команды; при поздней смене новый snapshot не перезаписан, actual run сохранён без stale probe association | +| ROS-8 | Смена пары, интерфейсов, протокола, направления, длительности или удаление speed-пробы до запуска и во время теста | Preflight409 до команды; при поздней смене новый snapshot не перезаписан, actual run сохранён без stale probe association | | ROS-7 | Удаление/отсутствие доступа/частичный отказ одного устройства | Ошибка не стирает форму, последний снимок и объекты остальных устройств; неизвестные метрики не превращаются в0 | Для ROS-1/ROS-2 зафиксировать версию RouterOS и очищенный сырой ответ. Строковый status подтверждается по [официальному CLI reference](https://manual.mikrotik.com/docs/cli-reference/tool/bandwidth-test/); несовместимый REST shape требует отдельной коррекции контракта. Тест скорости способен нагрузить канал — длительность и endpoints выбираются для выделенного стенда. Команды и правила маршрутизации этой UX/UI-серией не расширялись. diff --git a/docs/ux-ui-plan.md b/docs/ux-ui-plan.md index 6194b0fc..deae5cc2 100644 --- a/docs/ux-ui-plan.md +++ b/docs/ux-ui-plan.md @@ -1,6 +1,6 @@ # План реализации UX/UI по пятичасовым окнам -Актуальное продолжение 2026-10-07: 12.3.79 завершён: строгий POST speed-test, 26 отказов до DB/native, HTTP/RBAC и 16 pure checks; contracts/backend/frontend build прошли. Общие main1176(579e816)/context221(a3fcba0) относятся к указанным HEAD. Следующий12.3.80: принадлежность результата полным параметрам пробы. Внешняя12.5 не выполнена. +Актуальное продолжение 2026-10-07: 12.3.80 завершён: preflight и conditional UPDATE сверяют полные параметры speed-пробы; 23 pure, HTTP409/RBAC и backend build прошли. Следующий12.3.81: сброс observations при изменении параметров в каталоге/UI. Общие main1176/context221 относятся к прежним указанным HEAD. Внешняя12.5 не выполнена. Общий план проекта MikrotikManager. Основан на согласованном плане из задачи; точка продолжения обновлена 2026-10-04. Источник фактического прогресса, проверок и ограничений — [журнал реализации](ux-ui-progress.md). Этот документ задаёт объём и критерии завершения, журнал фиксирует результат. diff --git a/docs/ux-ui-progress.md b/docs/ux-ui-progress.md index 510fff6b..9309f2b3 100644 --- a/docs/ux-ui-progress.md +++ b/docs/ux-ui-progress.md @@ -1,6 +1,6 @@ # UX/UI: журнал реализации -Актуальное продолжение 2026-10-07: 12.3.79 завершён: строгий POST speed-test, 26 отказов до DB/native, HTTP/RBAC и 16 pure checks; contracts/backend/frontend build прошли. Общие main1176(579e816)/context221(a3fcba0) относятся к указанным HEAD. Следующий12.3.80: принадлежность результата полным параметрам пробы. Внешняя12.5 не выполнена. +Актуальное продолжение 2026-10-07: 12.3.80 завершён: preflight и conditional UPDATE сверяют полные параметры speed-пробы; 23 pure, HTTP409/RBAC и backend build прошли. Следующий12.3.81: сброс observations при изменении параметров в каталоге/UI. Общие main1176/context221 относятся к прежним указанным HEAD. Внешняя12.5 не выполнена. Общий план: [UX/UI по этапам и пятичасовым окнам](ux-ui-plan.md). План задаёт объём, этот журнал фиксирует выполненные блоки и ограничения. Записи расположены от новых к старым; подробная история этапа12 также сохранена в [ux-ui-stage-12.md](ux-ui-stage-12.md). @@ -32,7 +32,15 @@ | 10 | Маршрутизация, диагностика, карта и терминал | Завершён в границах локальной приёмки 2026-10-01; блоки 10.1–10.71, внешние ограничения в 10.71 | | 11А | Пользователи, контейнеры, сертификаты и резервные копии | Завершён в границах локальной приёмки 2026-10-01; 11А.1–11А.33, внешние ограничения в 11А.33 | | 11Б | Оповещения, сбор данных, интеграции, релизы и настройки | Завершён в границах локальной приёмки 2026-10-03; ограничения и evidence в11Б.7 | -| 12 | Итоговая доступность, адаптивность, производительность и регрессия | В работе. Локальные блоки до12.3.79 завершены. Общая main1176/1176 на579e816 и context221/221 наa3fcba0 прошли; последующий функциональный delta проверен73browser/8context. Следующий12.3.80; внешняя12.5 не выполнена. | +| 12 | Итоговая доступность, адаптивность, производительность и регрессия | В работе. Локальные блоки до12.3.80 завершены. Общая main1176/1176 на579e816 и context221/221 наa3fcba0 прошли; последующий функциональный delta проверен73browser/8context. Следующий12.3.81; внешняя12.5 не выполнена. | + +## 12.3.80 — принадлежность результата параметрам speed-пробы (2026-10-07) + +Preflight перед созданием RouterOS client проверяет id, оба устройства, оба интерфейса, протокол, направление и длительность. Изменение параметров возвращает409. Условный UPDATE успеха и ошибки сверяет те же поля; поздний результат сохраняется в истории с probeId=null и не перезаписывает изменённый snapshot. Enable/order/telemetry не относятся к параметрам измерения. Сетевые команды, locks и права сохранены. + +23 pure rate/status/persistence проверки прошли: 7 новых проверяют каждый изменённый параметр для late success/error, неизменный контекст с явным0 и preflight. HTTP/DI проверил409 как для пары, так и для параметров, прежние26 invalid inputs/defaults/RBAC; backend/contracts build passed. Реального PostgreSQL/RouterOS не было: source review условного SQL и DI не заменяют12.5. Это сравнение параметров, не версия строки и не защита от ABA изменения с возвратом тех же значений. + +Следующий12.3.81: каталог и frontend пока сбрасывают observations только при смене пары; распространить правило на все параметры измерения, сохраняя историю и enable/reorder. ## 12.3.79 — строгие параметры команды speed-test (2026-10-07) diff --git a/docs/ux-ui-stage-12.md b/docs/ux-ui-stage-12.md index e66fccf4..58cf4ab0 100644 --- a/docs/ux-ui-stage-12.md +++ b/docs/ux-ui-stage-12.md @@ -903,3 +903,10 @@ POST /uptime/speed-test проверяет runtime DTO до поиска уст Следующий12.3.80: текущая привязка защищает пару устройств, но не изменение интерфейсов, протокола, направления и длительности во время теста; проверить принадлежность snapshot полным параметрам без изменения сетевых команд. +## 12.3.80 — принадлежность результата параметрам speed-пробы (2026-10-07) + +Preflight перед созданием RouterOS client проверяет id, оба устройства, оба интерфейса, протокол, направление и длительность. Изменение параметров возвращает409. Условный UPDATE успеха и ошибки сверяет те же поля; поздний результат сохраняется в истории с probeId=null и не перезаписывает изменённый snapshot. Enable/order/telemetry не относятся к параметрам измерения. Сетевые команды, locks и права сохранены. + +23 pure rate/status/persistence проверки прошли: 7 новых проверяют каждый изменённый параметр для late success/error, неизменный контекст с явным0 и preflight. HTTP/DI проверил409 как для пары, так и для параметров, прежние26 invalid inputs/defaults/RBAC; backend/contracts build passed. Реального PostgreSQL/RouterOS не было: source review условного SQL и DI не заменяют12.5. Это сравнение параметров, не версия строки и не защита от ABA изменения с возвратом тех же значений. + +Следующий12.3.81: каталог и frontend пока сбрасывают observations только при смене пары; распространить правило на все параметры измерения, сохраняя историю и enable/reorder.