From a2eae189bef2c5f4d3bd98318383de38fc663345 Mon Sep 17 00:00:00 2001 From: Denozordec Date: Thu, 1 Oct 2026 03:36:14 +0700 Subject: [PATCH] =?UTF-8?q?fix(ui):=20=D0=BF=D0=BE=D0=B4=D1=82=D0=B2=D0=B5?= =?UTF-8?q?=D1=80=D0=B6=D0=B4=D0=B0=D1=82=D1=8C=20=D0=B2=D1=8B=D0=BF=D0=BE?= =?UTF-8?q?=D0=BB=D0=BD=D0=B5=D0=BD=D0=B8=D0=B5=20=D0=BA=D0=BE=D0=BC=D0=B0?= =?UTF-8?q?=D0=BD=D0=B4=20=D1=82=D0=B5=D1=80=D0=BC=D0=B8=D0=BD=D0=B0=D0=BB?= =?UTF-8?q?=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/(main)/terminal/page.tsx | 526 +++++++------------------------- docs/ux-ui-progress.md | 12 +- docs/ux-ui-stage-10.md | 10 + lib/auth.ts | 1 + lib/terminal-response.test.ts | 6 + lib/terminal-response.ts | 24 ++ tests/e2e/terminal-data.spec.ts | 48 +++ 7 files changed, 205 insertions(+), 422 deletions(-) create mode 100644 lib/terminal-response.test.ts create mode 100644 lib/terminal-response.ts create mode 100644 tests/e2e/terminal-data.spec.ts diff --git a/app/(main)/terminal/page.tsx b/app/(main)/terminal/page.tsx index b350b235..34751c31 100644 --- a/app/(main)/terminal/page.tsx +++ b/app/(main)/terminal/page.tsx @@ -1,27 +1,30 @@ "use client" -import { useState, useRef, useEffect, useCallback, useMemo } from "react" +import { useState, useRef, useEffect, Suspense } from "react" import { PageHeader } from "@/components/page-header" import { Button } from "@/components/ui/button" import { servers as mockServers } from "@/lib/data" -import type { ServerStatus } from "@/lib/data" +import {useSearchParams} from "next/navigation" +import {can} from "@/lib/auth" +import {terminalServersSchema,readTerminalOutput,readTerminalUrl,patchTerminalUrl} from "@/lib/terminal-response" +import {Alert,AlertTitle,AlertDescription} from "@/components/ui/alert" +import {Input} from "@/components/ui/input" import { useDataSource } from "@/lib/data-source" import { requestJson } from "@/shared/api/http-client" -import { Badge } from "@/components/reui/badge" + import { Frame, + FrameDescription, FrameFooter, FrameHeader, FramePanel, FrameTitle, } from "@/components/reui/frame" -import { ScrollArea } from "@/components/ui/scroll-area" + import { ServerRailLayout, ServerRailMobileButton } from "@/components/server-rail-layout" + import { - type ServerTileItem, -} from "@/components/server-tile-rail" -import { - TrashIcon, RefreshCwIcon, CircleIcon, Loader2Icon, + TrashIcon, RefreshCwIcon, } from "lucide-react" import { cn } from "@/lib/utils" @@ -171,231 +174,66 @@ function mockResolve(raw: string, serverName: string): string { return `bad command name ${trimmed.split(" ")[0]} (line 1 column 1)` } -// ─── MOTD builders ──────────────────────────────────────────────────────────── -function mockMotd(server: TermServer): string { - return [ - "", - " MMM MMM KKK TTTTTTTTTTT KKK", - " MMM MMMM MMM III KKK KKK RRR OOOOOO TTT TTT II KKK KKK", - " MMM MMM III KKK KKK RRR RRROOOOO TTT TTT II KKK KKK", - "", - ` MikroTik RouterOS ${server.rosVersion ?? "7.14.3"} (c) 1999-${new Date().getFullYear()} https://www.mikrotik.com/`, - "", - "[?] Gives the list of available commands", - "[Tab] Completes the command/word", - "[/] Move up to base level", - "[..] Move up one level", - "", - ].join("\n") -} - -function liveMotd(server: TermServer): string { - return [ - "", - ` MikroTik RouterOS ${server.rosVersion ?? "7.x"} (c) 1999-${new Date().getFullYear()} https://www.mikrotik.com/`, - "", - ` Connected to ${server.name} (${server.host})`, - "", - "[?] Gives the list of available commands — type help or ?", - "[Tab] Completes the command/word", - "[/] Move up to base level", - "", - ].join("\n") -} - -// ─── terminal component ─────────────────────────────────────────────────────── - -function Terminal({ - server, - isLive, - backendUrl, -}: { - server: TermServer - isLive: boolean - backendUrl: string -}) { - const [lines, setLines] = useState([]) - const [input, setInput] = useState("") - const [history, setHistory] = useState([]) - const [_histIdx, setHistIdx] = useState(-1) - const [_idCtr, setIdCtr] = useState(0) - const [executing, setExecuting] = useState(false) - - const inputRef = useRef(null) - const scrollRef = useRef(null) - - const nextId = useCallback((): number => { - let id = 0 - setIdCtr(prev => { id = prev + 1; return prev + 1 }) - return id - }, []) - - // Initialize MOTD on mount / server change - useEffect(() => { - const motd = isLive ? liveMotd(server) : mockMotd(server) - const init = motd.split("\n").map((text, i) => ({ id: i, kind: "output" as const, text })) - setLines(init) - setIdCtr(init.length) - setInput("") - setHistory([]) - setHistIdx(-1) - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [server.uid, isLive]) - - // Auto-scroll - useEffect(() => { - scrollRef.current?.scrollTo({ top: scrollRef.current.scrollHeight, behavior: "smooth" }) - }, [lines]) - - const prompt = `[admin@${server.identityName ?? server.name}] > ` - - const submit = useCallback(async () => { - if (executing) return - const cmd = input.trim() - - setLines(prev => [...prev, { id: nextId(), kind: "prompt", text: prompt + input }]) - setInput("") - setHistIdx(-1) - - if (!cmd) return - - setHistory(h => [cmd, ...h.filter(x => x !== cmd)].slice(0, 100)) - - // local always-available commands - const lower = cmd.toLowerCase() - if (lower === "clear" || lower === "/clear") { setLines([]); return } - if (lower === "quit" || lower === "exit") { - setLines(prev => [...prev, { id: nextId(), kind: "info", text: "Connection closed." }]) - return - } - - if (isLive && server.backendId !== null) { - setExecuting(true) - try { - const data = await requestJson<{ output?: string; error?: string }>( - backendUrl, - `/api/servers/${server.backendId}/exec`, - { - method: "POST", - body: JSON.stringify({ command: cmd }), - }, - ) - const text = data.output ?? data.error ?? "(empty response)" - const kind: TermLine["kind"] = text.startsWith("error:") ? "error" : "output" - text.split("\n").forEach(line => - setLines(prev => [...prev, { id: nextId(), kind, text: line }]) - ) - } catch (err) { - setLines(prev => [...prev, { id: nextId(), kind: "error", text: `network error: ${String(err)}` }]) - } finally { - setExecuting(false) - } - } else { - // mock mode - const result = mockResolve(cmd, server.name) - if (result === "__CLEAR__") { setLines([]); return } - if (result) { - result.split("\n").forEach(line => - setLines(prev => [...prev, { id: nextId(), kind: "output", text: line }]) - ) - } - } - }, [executing, input, nextId, prompt, isLive, server, backendUrl]) - - const handleKey = (e: React.KeyboardEvent) => { - if (e.key === "Enter") { - e.preventDefault(); void submit() - } else if (e.key === "ArrowUp") { - e.preventDefault() - setHistIdx(i => { - const next = Math.min(i + 1, history.length - 1) - if (history[next] !== undefined) setInput(history[next]) - return next - }) - } else if (e.key === "ArrowDown") { - e.preventDefault() - setHistIdx(i => { - const next = i - 1 - if (next < 0) { setInput(""); return -1 } - if (history[next] !== undefined) setInput(history[next]) - return next - }) - } else if (e.key === "l" && e.ctrlKey) { - e.preventDefault(); setLines([]) - } +function Terminal({server,isLive,backendUrl,canExecute,blockedReason}:{server:TermServer;isLive:boolean;backendUrl:string;canExecute:boolean;blockedReason?:string}) { + const [lines,setLines]=useState([{id:0,kind:"info",text:isLive?"REST: каждое выполнение — отдельный запрос; соединение с устройством пока не проверено.":"Демо: команды возвращают синтетические данные."}]) + const [input,setInput]=useState(""),[history,setHistory]=useState([]),[histIdx,setHistIdx]=useState(-1),[executing,setExecuting]=useState(false),[error,setError]=useState(null),[completed,setCompleted]=useState(null) + const nextId=useRef(1),lock=useRef(false),reader=useRef<{alive:boolean;controller:AbortController|null}>({alive:true,controller:null}) + const inputRef=useRef(null),scrollRef=useRef(null),followOutput=useRef(true) + useEffect(()=>{const state=reader.current;state.alive=true;return()=>{state.alive=false;state.controller?.abort()}},[]) + useEffect(()=>{if(followOutput.current)scrollRef.current?.scrollTo({top:scrollRef.current.scrollHeight,behavior:"auto"})},[lines]) + function append(kind:TermLine["kind"],text:string) { + const added=text.split("\n").map(text=>({id:nextId.current++,kind,text})) + setLines(previous=>{const merged=[...previous.filter(line=>line.id!==-1),...added];return merged.length>1000||previous.some(line=>line.id===-1)?[{id:-1,kind:"info",text:"Показаны последние 1000 строк; ранний вывод удалён."},...merged.slice(-1000)]:merged}) + } + async function submit(){ + const command=input.trim();if(lock.current||!command||!canExecute||!server.enabled||blockedReason)return + if(command.toLowerCase()==="clear"||command.toLowerCase()==="/clear"){setLines([]);setInput("");return} + lock.current=true;setExecuting(true);setError(null);setCompleted(null);setHistIdx(-1) + append("prompt",server.name+" > "+command) + setHistory(previous=>[command,...previous.filter(item=>item!==command)].slice(0,100)) + const controller=new AbortController();reader.current.controller=controller + let timedOut=false;const timer=window.setTimeout(()=>{timedOut=true;controller.abort()},60000) + try { + const output=isLive?readTerminalOutput(await requestJson(backendUrl,"/api/servers/"+server.backendId+"/exec",{method:"POST",body:JSON.stringify({command}),signal:controller.signal})):mockResolve(command,server.name) + if(!reader.current.alive)return + if(output==="__CLEAR__")setLines([]);else append("output",output||"Пустой вывод.") + setInput("");setCompleted((isLive?"Ответ API получен":"Результат демо")+" · "+new Date().toLocaleTimeString("ru-RU"));followOutput.current=true + } catch(err) { + if(!reader.current.alive)return + const message=timedOut?"Время ожидания истекло. Результат запроса не подтверждён.":err instanceof Error?err.message:"Не удалось получить вывод команды." + append("error",message);setError(message) + } finally { + window.clearTimeout(timer);lock.current=false + if(reader.current.alive){setExecuting(false);reader.current.controller=null;requestAnimationFrame(()=>inputRef.current?.focus())} } - - const lineColor = (kind: TermLine["kind"]) => { - if (kind === "error") return "text-red-400" - if (kind === "prompt") return "text-emerald-400" - if (kind === "info") return "text-sky-400" - return "text-[#c9d1d9]" - } - - return ( -
inputRef.current?.focus()} - > - {/* title bar */} -
- - - - - {server.name} — {isLive ? "RouterOS REST" : "SSH Terminal (mock)"} - - {isLive && ( - - LIVE - - )} - -
- - {/* output */} -
- {lines.map((line, i) => ( -
- {line.text} -
- ))} - - {/* executing indicator */} - {executing && ( -
- - executing… -
- )} - - {/* input line */} - {!executing && ( -
- {prompt} - setInput(e.target.value)} - onKeyDown={handleKey} - className="terminal-input-active flex-1 bg-transparent outline-none text-[#c9d1d9] caret-emerald-400" - autoComplete="off" - autoCorrect="off" - autoCapitalize="off" - spellCheck={false} - /> -
- )} -
-
- ) + } + function handleKey(e:React.KeyboardEvent){ + if(e.key==="Enter"){e.preventDefault();void submit()} + else if(e.key==="ArrowUp"){e.preventDefault();const index=Math.min(histIdx+1,history.length-1);setHistIdx(index);if(history[index]!==undefined)setInput(history[index])} + else if(e.key==="ArrowDown"){e.preventDefault();const index=histIdx-1;setHistIdx(Math.max(-1,index));setInput(index<0?"":history[index]??"")} + else if(e.key.toLowerCase()==="l"&&e.ctrlKey){e.preventDefault();setLines([])} + } + const blocked=blockedReason??(!server.enabled?"Устройство отключено в каталоге.":!canExecute?"Для выполнения требуется mm:servers:write.":null) + return + + +
{server.name}{server.host} · {isLive?"RouterOS REST":"Демо"} · версия {server.rosVersion??"неизвестна"}
+ +
+
{const el=e.currentTarget;followOutput.current=el.scrollHeight-el.scrollTop-el.clientHeight<32}} className="flex-1 min-h-0 min-w-0 overflow-auto p-3 font-mono text-xs focus-visible:outline-2 focus-visible:outline-ring"> + {lines.length?lines.map(line=>
{line.text}
):

Вывод очищен. Следующая команда добавит новый результат.

} +
+ +

{executing?"Выполняем команду; ожидаем ответ…":completed??"Команда ещё не выполнена."}

+ {error&&

{error} Команда сохранена для повторного выполнения.

} + {blocked&&

{blocked}

} + +
setInput(e.target.value)} onKeyDown={handleKey} disabled={executing||!!blocked} className="flex-1 min-w-0 font-mono text-xs" autoComplete="off" autoCorrect="off" autoCapitalize="off" spellCheck={false}/>
+

↑↓ — история · Ctrl+L — очистить · до 1024 символов

+
+
+ } // ─── quick commands ─────────────────────────────────────────────────────────── @@ -423,201 +261,47 @@ function mockServersToTermServers(): TermServer[] { name: s.name, host: s.host, country: s.country, - status: s.status as TermServer["status"], + status: s.status==="unknown"?null:s.status, enabled: s.enabled, rosVersion: s.os ?? null, identityName: s.name, })) } -/** Backend ServerRead shape (only fields we need) */ -interface BackendServer { - id: number - name: string - host: string - country: string - status: "online" | "offline" | null - enabled: boolean - os: string | null - identityName: string | null + +export default function TerminalPage(){ + return Загружаем терминал…

}>
} - -export default function TerminalPage() { - const { mode, backendUrl, prefsHydrated } = useDataSource() - const isLive = mode === "live" - - // Server list state - const [liveServers, setLiveServers] = useState([]) - const [serversLoading, setServersLoading] = useState(false) - const [refreshKey, setRefreshKey] = useState(0) // force terminal remount on reconnect - - // Load servers from backend when in live mode - useEffect(() => { - if (!isLive || !prefsHydrated) return - let cancelled = false - queueMicrotask(() => { - if (cancelled) return - setServersLoading(true) - void requestJson(backendUrl, "/api/servers") - .then((data) => { - if (cancelled) return - setLiveServers(data.map(s => ({ - uid: String(s.id), - backendId: s.id, - name: s.name || s.host, - host: s.host, - country: s.country || "", - status: s.status, - enabled: s.enabled, - rosVersion: s.os, - identityName: s.identityName, - }))) - setServersLoading(false) - }) - .catch(() => { if (!cancelled) setServersLoading(false) }) - }) - return () => { cancelled = true } - }, [isLive, backendUrl, refreshKey, prefsHydrated]) - - const termServers: TermServer[] = isLive ? liveServers : mockServersToTermServers() - - // Select first available server by default - const [selectedUid, setSelectedUid] = useState("") - - const defaultUid = useMemo(() => { - const first = termServers.find(s => s.status !== "offline" && s.enabled) - ?? termServers[0] - return first?.uid ?? "" - }, [termServers]) - - useEffect(() => { - queueMicrotask(() => { - if (!selectedUid && defaultUid) setSelectedUid(defaultUid) - }) - }, [defaultUid, selectedUid]) - - // Reset selection when switching modes - useEffect(() => { - queueMicrotask(() => setSelectedUid("")) - }, [isLive]) - - const selected = termServers.find(s => s.uid === selectedUid) ?? termServers[0] - - const termKey = `${selectedUid}-${refreshKey}-${isLive ? "live" : "mock"}` - - const railItems = useMemo(() => { - return termServers.map((s) => ({ - id: s.uid, - name: s.name, - host: s.host, - country: s.country || undefined, - status: (s.status ?? undefined) as ServerStatus | undefined, - enabled: s.enabled, - selectable: s.enabled && s.status !== "offline", - title: [s.name, s.host].filter(Boolean).join(" · "), - })) - }, [termServers]) - - const handleSelectServer = useCallback((id: string) => { - setSelectedUid(id) - setRefreshKey((k) => k + 1) - }, []) - - const railHeaderRight = isLive && !serversLoading - ? LIVE - : undefined - - function QuickCmds() { - return ( - - - - - Быстрые команды - - - -
- {QUICK_CMDS.map(({ cmd, label }) => ( - - ))} -
-
- -

↑↓ — история команд

-

Ctrl+L — очистить экран

- {isLive - ?

Команды выполняются на роутере

- :

Режим: mock-данные

} -
-
- - ) - } - - function injectCommand(cmd: string) { - const el = document.querySelector(".terminal-input-active") - if (!el) return - const nativeSetter = Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, "value")?.set - nativeSetter?.call(el, cmd) - el.dispatchEvent(new Event("input", { bubbles: true })) - el.focus() - } - - return ( - } - contentClassName="overflow-hidden p-3 md:p-4" - header={ - - - - - } - /> - } - > - {selected ? ( - - ) : ( -
- {serversLoading ? "Загрузка серверов…" : "Выберите сервер"} -
- )} -
- ) +function TerminalRoute(){ + const {mode,backendUrl,prefsHydrated}=useDataSource(),params=useSearchParams(),url=readTerminalUrl(new URLSearchParams(params.toString())) + function reset(){const href=patchTerminalUrl(new URLSearchParams(params.toString()),null)+window.location.hash;window.history.replaceState(null,"",href)} + if(url.error)return Некорректная ссылка терминала{url.error} + if(!prefsHydrated)return

Загружаем источник…

+ return +} +function TerminalContent(){ + const {mode,backendUrl}=useDataSource(),isLive=mode==="live",params=useSearchParams(),url=readTerminalUrl(new URLSearchParams(params.toString())) + const [liveServers,setLiveServers]=useState([]),[loading,setLoading]=useState(false),[error,setError]=useState(null),[loaded,setLoaded]=useState(false),[readAt,setReadAt]=useState(null),[refresh,setRefresh]=useState(0) + const canRead=can("mm:servers:read"),canExecute=!isLive||can("mm:servers:write") + useEffect(()=>{ + if(!isLive||!canRead)return + let cancelled=false;const controller=new AbortController() + queueMicrotask(()=>{if(cancelled)return;setLoading(true);void requestJson(backendUrl,"/api/servers",{signal:controller.signal}).then(raw=>{ + const rows=terminalServersSchema.parse(raw);if(cancelled)return + setLiveServers(rows.map(row=>({uid:String(row.id),backendId:row.id,name:row.name||row.host,host:row.host,country:row.country,status:row.status,enabled:row.enabled,rosVersion:row.os??null,identityName:row.identityName??null})));setLoaded(true);setReadAt(new Date().toLocaleTimeString("ru-RU"));setError(null) + }).catch(err=>{if(!cancelled)setError(err instanceof Error?(err.name==="ZodError"?"Некорректный каталог устройств.":err.message):"Не удалось загрузить устройства.")}).finally(()=>{if(!cancelled)setLoading(false)})}) + return()=>{cancelled=true;controller.abort()} + },[isLive,backendUrl,canRead,refresh]) + const termServers=isLive?liveServers:mockServersToTermServers() + const matches=url.server?termServers.filter(server=>server.uid===url.server):url.host?termServers.filter(server=>server.host===url.host):[] + const selected=matches.length===1?matches[0]:null + const unavailable=(loaded||!isLive)&&(url.server||url.host)&&!selected + const items=termServers.map(server=>({id:server.uid,name:server.name,host:server.host,country:server.country||undefined,status:server.status??"unknown" as const,enabled:server.enabled,selectable:true,title:[server.name,server.host].join(" · ")})) + function select(id:string){const href=patchTerminalUrl(new URLSearchParams(params.toString()),id||null)+window.location.hash;window.history.pushState(null,"",href)} + const blocked=!!error||loading||isLive&&!loaded + return {isLive&&}}/>} + banner={

{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() +})