{isLive?(readAt?"Каталог · ответ "+readAt+" (время браузера)":"Каталог ещё не подтверждён."):"Демо: команды не отправляются на устройство."} REST выполняет только поддерживаемые диагностические команды.
{!canRead&&isLive&&Нет доступа к каталогуТребуется mm:servers:read.}{error&&{loaded?"Каталог не обновлён":"Устройства пока неизвестны"}{error} {loaded?"Сохранён предыдущий каталог; выполнение приостановлено до успешного обновления.":"Повторите загрузку устройств."}}{unavailable&&Устройство терминала недоступно{matches.length>1?"Host неоднозначен: выберите точное устройство.":"Указанный server/host отсутствует в текущем каталоге."}}
}>
+ {selected?:{loading?"Загружаем устройства…":loaded&&!termServers.length?"Устройств пока нет":"Выберите устройство для терминала"}Выбор сохраняется в URL. Команда отправляется только после нажатия «Выполнить».}
+
}
diff --git a/docs/ux-ui-progress.md b/docs/ux-ui-progress.md
index 78903726..22d0c174 100644
--- a/docs/ux-ui-progress.md
+++ b/docs/ux-ui-progress.md
@@ -26,7 +26,7 @@
| 7 | Серверы и мониторинг: детали, формы, URL-контекст, права | Завершён в границах локальной приёмки 2026-09-23; внешние ограничения ниже |
| 8 | Трафик и статистика | Завершён 2026-09-28 в границах локальной приёмки; ограничения в блоке 8.13 |
| 9 | VPN, Firewall и Filters | Завершён в границах локальной приёмки 2026-09-30; блоки 9.1–9.64, ограничения в 9.64 |
-| 10 | Маршрутизация, диагностика, карта и терминал | В работе: 10.1–10.54; оптимизатор и карта приняты локально, далее терминал |
+| 10 | Маршрутизация, диагностика, карта и терминал | В работе: 10.1–10.55; оптимизатор и карта приняты локально, терминал в работе |
| 11А | Пользователи, контейнеры, сертификаты и резервные копии | Не начат |
| 11Б | Оповещения, сбор данных, интеграции, релизы и настройки | Не начат |
| 12 | Итоговая доступность, адаптивность, производительность и регрессия | Не начат |
@@ -34,6 +34,16 @@
+
+## Блок 10.55 — достоверный REST терминал и URL (2026-10-01)
+
+- Frame/ServerRailLayout сохранены; нет фиктивного Connected/admin prompt/LIVE/SSH или обещания Tab completion. Устройство выбирается явно, server/host восстанавливаются из URL; неизвестный/неоднозначный host не выбирает другое устройство. History/reload/query/hash сохраняются. Неверный URL останавливает чтение каталога.
+- Каталог и output проверяются строго; первоначальная ошибка отличается от пустого списка. Фоновый отказ сохраняет каталог/черновик и приостанавливает новые команды до успешного обновления. Ключ источника/device/host и abort cleanup запрещают прежний вывод; запрос защищён немедленным lock.
+- При API/legacy error или таймауте команда остаётся в поле; успех показан только после валидного output, пустой вывод подписан. Поле/кнопка остаются видимыми во время ожидания, после завершения фокус возвращается. Быстрые команды только подставляют; clear — локальная очистка без API, история до 100 команд/вывод до 1000 строк.
+- Frontend права соответствуют текущему backend: каталог mm:servers:read, POST exec mm:servers:write. Терминал в permissionForPath связан с servers:read. Правила RouterOS команд не расширены.
+- 12 E2E, 3 unit, TypeScript и lint прошли; clear добавлен затем проверен повтором quick/history E2E и lint. ReUI checklist прочитан. Production/screenshots — после backend/source/адаптивной приёмки терминала.
+- Далее 10.56: backend exec не должен маскировать BFD/API отказ пустым успехом; DI route тесты. Реальная сеть/JWT/БД не проверены.
+
## Блок 10.54 — Pointer/touch и локальная приёмка карты (2026-10-01)
- Pointer Events/capture удерживают drag за пределами canvas; основной указатель отделён от остальных, cancel/lost capture завершают жест без залипания. Touch-action отключает прокрутку только на SVG; детали продолжают прокручиваться. Фокусируемый canvas поддерживает стрелки, zoom/fit имеют клавиатурные альтернативы.
diff --git a/docs/ux-ui-stage-10.md b/docs/ux-ui-stage-10.md
index 349a49a5..c67f7b5f 100644
--- a/docs/ux-ui-stage-10.md
+++ b/docs/ux-ui-stage-10.md
@@ -310,3 +310,13 @@ Shared контракт и backend observed исходные значения/д
- Карта принята локально: 39 основных + 12 source E2E, 10 unit, TypeScript, lint и production frontend build прошли. ReUI Frame checklist повторён, адаптивные снимки обновлены. Touch проверен через реальный Chromium input protocol; физических телефонов в приёмке нет.
- Границы: HTTP фикстуры не подтверждают RouterOS/JWT/БД; WAN пунктир остаётся моделью, Flow свежесть — время браузерного ответа; SVG флаги зависят от существующего CDN. Фильтр сохраняет приглушённые узлы как контекст, pan/zoom/drag локальные. SVG XML валиден, сторонние SVG viewers отдельно не проверены.
- Далее 10.55: терминал — API-контракт, выбранное устройство/host из URL, достоверность выполнения и ошибки/повтор/права. Этап 10 ещё не завершён.
+
+
+## Блок 10.55 — достоверный REST терминал и URL (2026-10-01)
+
+- Frame/ServerRailLayout сохранены; нет фиктивного Connected/admin prompt/LIVE/SSH или обещания Tab completion. Устройство выбирается явно, server/host восстанавливаются из URL; неизвестный/неоднозначный host не выбирает другое устройство. History/reload/query/hash сохраняются. Неверный URL останавливает чтение каталога.
+- Каталог и output проверяются строго; первоначальная ошибка отличается от пустого списка. Фоновый отказ сохраняет каталог/черновик и приостанавливает новые команды до успешного обновления. Ключ источника/device/host и abort cleanup запрещают прежний вывод; запрос защищён немедленным lock.
+- При API/legacy error или таймауте команда остаётся в поле; успех показан только после валидного output, пустой вывод подписан. Поле/кнопка остаются видимыми во время ожидания, после завершения фокус возвращается. Быстрые команды только подставляют; clear — локальная очистка без API, история до 100 команд/вывод до 1000 строк.
+- Frontend права соответствуют текущему backend: каталог mm:servers:read, POST exec mm:servers:write. Терминал в permissionForPath связан с servers:read. Правила RouterOS команд не расширены.
+- 12 E2E, 3 unit, TypeScript и lint прошли; clear добавлен затем проверен повтором quick/history E2E и lint. ReUI checklist прочитан. Production/screenshots — после backend/source/адаптивной приёмки терминала.
+- Далее 10.56: backend exec не должен маскировать BFD/API отказ пустым успехом; DI route тесты. Реальная сеть/JWT/БД не проверены.
diff --git a/lib/auth.ts b/lib/auth.ts
index add91bdb..4390486b 100644
--- a/lib/auth.ts
+++ b/lib/auth.ts
@@ -227,6 +227,7 @@ export function permissionForPath(pathname: string): string | null {
if (pathname === "/" || pathname.startsWith("/dashboard")) {
return "mm:dashboard:read"
}
+ if (pathname.startsWith("/terminal")) return "mm:servers:read"
if (pathname.startsWith("/servers")) return "mm:servers:read"
if (pathname.startsWith("/filters") || pathname.startsWith("/gre")) {
return "mm:filters:read"
diff --git a/lib/terminal-response.test.ts b/lib/terminal-response.test.ts
new file mode 100644
index 00000000..a7b41a1e
--- /dev/null
+++ b/lib/terminal-response.test.ts
@@ -0,0 +1,6 @@
+import {test} from "node:test"
+import assert from "node:assert/strict"
+import {terminalServersSchema,readTerminalOutput,readTerminalUrl,patchTerminalUrl} from "./terminal-response"
+test("terminal catalog requires explicit state and unique IDs",()=>{const row={id:1,name:"",host:"192.0.2.1",country:"RU",enabled:true,status:null};assert.equal(terminalServersSchema.parse([row])[0].status,null);for(const raw of [{},[row,row],[{...row,status:undefined}],[{...row,enabled:undefined}]])assert.equal(terminalServersSchema.safeParse(raw).success,false)})
+test("terminal output distinguishes empty success, malformed and legacy errors",()=>{assert.equal(readTerminalOutput({output:""}),"");assert.equal(readTerminalOutput({output:"0\nok"}),"0\nok");for(const raw of [{},{error:"fail"},{output:null},{output:"error: refused"},{output:"bad command name remove"},{output:"ok",error:"fail"}])assert.throws(()=>readTerminalOutput(raw))})
+test("terminal URL refuses ambiguous/malformed context, preserves unrelated parameters",()=>{for(const q of ["server=1&host=x","server=1&server=2","host=","server=bad%20id"])assert.ok(readTerminalUrl(new URLSearchParams(q)).error);assert.equal(readTerminalUrl(new URLSearchParams("host=192.0.2.1")).host,"192.0.2.1");assert.equal(patchTerminalUrl(new URLSearchParams("host=x&ref=map"),"2"),"/terminal?ref=map&server=2")})
diff --git a/lib/terminal-response.ts b/lib/terminal-response.ts
new file mode 100644
index 00000000..31c902ea
--- /dev/null
+++ b/lib/terminal-response.ts
@@ -0,0 +1,24 @@
+import {z} from "zod"
+export const terminalServersSchema=z.array(z.object({
+ id:z.number().int().positive(),name:z.string(),host:z.string().min(1),country:z.string(),enabled:z.boolean(),
+ status:z.enum(["online","offline","degraded"]).nullable(),os:z.string().nullable().optional(),identityName:z.string().nullable().optional(),
+})).refine(rows=>new Set(rows.map(row=>row.id)).size===rows.length)
+const response=z.object({output:z.string().max(1024*1024)}).strict()
+export function readTerminalOutput(raw:unknown) {
+ const data=response.safeParse(raw)
+ if(!data.success)throw new Error("Некорректный ответ API: вывод команды не подтверждён.")
+ if(/^(error:|bad command name |Usage:)/i.test(data.data.output))throw new Error(data.data.output)
+ return data.data.output
+}
+export function readTerminalUrl(params:URLSearchParams) {
+ const server=params.get("server"),host=params.get("host")
+ const error=["server","host"].some(key=>params.getAll(key).length>1)?"В ссылке повторяется устройство терминала."
+ :server!==null&&host!==null?"В ссылке одновременно указаны server и host."
+ :server!==null&&!/^[a-zA-Z0-9_.:-]{1,64}$/.test(server)?"В ссылке указан некорректный сервер."
+ :host!==null&&(!host.trim()||host.length>256||/[\u0000-\u001f]/.test(host))?"В ссылке указан некорректный host.":null
+ return {server,host,error}
+}
+export function patchTerminalUrl(params:URLSearchParams,server:string|null){
+ const next=new URLSearchParams(params);next.delete("server");next.delete("host");if(server)next.set("server",server)
+ return "/terminal"+(next.size?"?"+next.toString():"")
+}
diff --git a/tests/e2e/terminal-data.spec.ts b/tests/e2e/terminal-data.spec.ts
new file mode 100644
index 00000000..1303c087
--- /dev/null
+++ b/tests/e2e/terminal-data.spec.ts
@@ -0,0 +1,48 @@
+import {test,expect,type Page} from "@playwright/test"
+const devices=[{id:1,name:"Первый",host:"192.0.2.1",country:"RU",enabled:true,status:"online",os:null},{id:2,name:"Второй",host:"192.0.2.2",country:"RU",enabled:true,status:null,os:"7.20"}]
+async function routes(page:Page){await page.route("**/health",r=>r.fulfill({json:{ok:true}}));await page.route("**/api/**",r=>r.fulfill({json:new URL(r.request().url()).pathname==="/api/servers"?devices:new URL(r.request().url()).pathname==="/api/auth/config"?{required:false}:{}}))}
+test.beforeEach(async({page})=>routes(page))
+const command=(page:Page)=>page.getByRole("textbox",{name:/^Команда ·/})
+for(const malformed of [false,true])test("Терминал: первоначальный отказ каталога и повтор "+malformed,async({page})=>{
+ let fail=true;await page.route("**/api/servers",r=>r.fulfill(fail?(malformed?{json:{}}:{status:403,json:{error:"Каталог запрещён"}}):{json:devices}))
+ await page.goto("/terminal?server=1");await expect(page.getByRole("alert").filter({hasText:"Устройства пока неизвестны"})).toBeVisible();await expect(command(page)).toHaveCount(0)
+ fail=false;await page.getByRole("button",{name:"Обновить устройства",exact:true}).click();await expect(command(page)).toBeEnabled();await expect(page.getByRole("region",{name:"Вывод терминала"})).not.toContainText("Connected")
+})
+for(const q of ["server=99","host=missing.example"])test("Терминал: неизвестный URL не выбирает другой сервер "+q,async({page})=>{await page.goto("/terminal?"+q);await expect(page.getByRole("alert").filter({hasText:"Устройство терминала недоступно"})).toBeVisible();await expect(command(page)).toHaveCount(0)})
+test("Терминал: host из карты, точная история server и reload",async({page})=>{
+ await page.goto("/terminal?host=192.0.2.2&ref=map#return");await expect(command(page)).toHaveAccessibleName("Команда · Второй")
+ await page.getByRole("option",{name:/Первый/}).click();await expect(page).toHaveURL(/ref=map&server=1#return$/);await expect(command(page)).toHaveAccessibleName("Команда · Первый")
+ await page.reload();await expect(command(page)).toHaveAccessibleName("Команда · Первый");await page.goBack();await expect(command(page)).toHaveAccessibleName("Команда · Второй")
+})
+for(const payload of [{status:403,json:{error:"Команда запрещена"}},{json:{}},{json:{output:"error: BFD недоступен"}}])test("Терминал: отказ сохраняет команду "+JSON.stringify(payload),async({page})=>{
+ let fail=true;await page.route("**/api/servers/1/exec",r=>r.fulfill(fail?payload:{json:{output:"получен реальный ответ"}}))
+ await page.goto("/terminal?server=1");await command(page).fill("routing bfd session print");await page.getByRole("button",{name:"Выполнить",exact:true}).click()
+ await expect(page.getByRole("alert").filter({hasText:"Команда сохранена"})).toBeVisible();await expect(command(page)).toHaveValue("routing bfd session print");await expect(command(page)).toBeEnabled()
+ fail=false;await page.getByRole("button",{name:"Выполнить",exact:true}).click();await expect(page.getByRole("region",{name:"Вывод терминала"})).toContainText("получен реальный ответ");await expect(command(page)).toHaveValue("");await expect(command(page)).toBeFocused()
+})
+test("Терминал: один запрос, поздний ответ прежнего устройства не смешивается",async({page})=>{
+ let calls=0,release!:()=>void;const gate=new Promise(resolve=>release=resolve)
+ await page.route("**/api/servers/1/exec",async r=>{calls++;await gate;await r.fulfill({json:{output:"прежний секретный вывод"}}).catch(()=>{})})
+ await page.goto("/terminal?server=1");await command(page).fill("interface print");await command(page).press("Enter");await page.keyboard.press("Enter")
+ await expect.poll(()=>calls).toBe(1);await expect(page.getByRole("button",{name:"Ожидание…"})).toBeDisabled()
+ await page.getByRole("option",{name:/Второй/}).click();await expect(command(page)).toHaveAccessibleName("Команда · Второй");release()
+ await expect(page.getByRole("region",{name:"Вывод терминала"})).not.toContainText("прежний секретный вывод");await expect(command(page)).toBeEnabled()
+})
+test("Терминал: фоновый отказ сохраняет черновик и блокирует выполнение",async({page})=>{
+ let fail=false;await page.route("**/api/servers",r=>r.fulfill(fail?{status:503,json:{error:"Каталог недоступен"}}:{json:devices}))
+ await page.goto("/terminal?server=1");await command(page).fill("ip route print");fail=true;await page.getByRole("button",{name:"Обновить устройства",exact:true}).click()
+ await expect(page.getByRole("alert").filter({hasText:"Каталог не обновлён"})).toBeVisible();await expect(command(page)).toHaveValue("ip route print");await expect(command(page)).toBeDisabled()
+ fail=false;await page.getByRole("button",{name:"Обновить устройства",exact:true}).click();await expect(command(page)).toBeEnabled();await expect(command(page)).toHaveValue("ip route print")
+})
+test("Терминал: быстрый шаблон подставляет, история работает без повторной отправки",async({page})=>{
+ let calls=0;await page.route("**/api/servers/1/exec",r=>{calls++;return r.fulfill({json:{output:""}})})
+ await page.goto("/terminal?server=1");await page.getByRole("combobox",{name:"Быстрая команда"}).selectOption("interface print");await expect(command(page)).toHaveValue("interface print");expect(calls).toBe(0)
+ await command(page).press("Enter");await expect(command(page)).toHaveValue("");await expect(page.getByRole("region",{name:"Вывод терминала"})).toContainText("Пустой вывод.")
+ await command(page).press("ArrowUp");await expect(command(page)).toHaveValue("interface print");await command(page).press("ArrowDown");await expect(command(page)).toHaveValue("");expect(calls).toBe(1)
+ await command(page).fill("clear");await command(page).press("Enter");await expect(page.getByRole("region",{name:"Вывод терминала"})).toContainText("Вывод очищен.");expect(calls).toBe(1)
+})
+test("Терминал: reader видит устройство, выполнение запрещено",async({page})=>{
+ await page.addInitScript(()=>localStorage.setItem("mmapp_token","header."+btoa(JSON.stringify({sub:"test",apps:["mm"],permissions:["mm:servers:read"],exp:Math.floor(Date.now()/1000)+3600}))+".signature"))
+ await page.route("**/api/auth/config",r=>r.fulfill({json:{required:true}}))
+ await page.goto("/terminal?server=1");await expect(command(page)).toBeDisabled();await expect(page.getByRole("status").filter({hasText:"Для выполнения требуется mm:servers:write"})).toBeVisible()
+})