fix(ui): подтверждать выполнение команд терминала
This commit is contained in:
1 parent
bc20e5b9f4
commit
a2eae189be
7 files changed
+205
-422
No files matched your search
+105
-421
@@ -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<TermLine[]>([])
|
||||
const [input, setInput] = useState("")
|
||||
const [history, setHistory] = useState<string[]>([])
|
||||
const [_histIdx, setHistIdx] = useState(-1)
|
||||
const [_idCtr, setIdCtr] = useState(0)
|
||||
const [executing, setExecuting] = useState(false)
|
||||
|
||||
const inputRef = useRef<HTMLInputElement>(null)
|
||||
const scrollRef = useRef<HTMLDivElement>(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<HTMLInputElement>) => {
|
||||
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<TermLine[]>([{id:0,kind:"info",text:isLive?"REST: каждое выполнение — отдельный запрос; соединение с устройством пока не проверено.":"Демо: команды возвращают синтетические данные."}])
|
||||
const [input,setInput]=useState(""),[history,setHistory]=useState<string[]>([]),[histIdx,setHistIdx]=useState(-1),[executing,setExecuting]=useState(false),[error,setError]=useState<string|null>(null),[completed,setCompleted]=useState<string|null>(null)
|
||||
const nextId=useRef(1),lock=useRef(false),reader=useRef<{alive:boolean;controller:AbortController|null}>({alive:true,controller:null})
|
||||
const inputRef=useRef<HTMLInputElement>(null),scrollRef=useRef<HTMLDivElement>(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<unknown>(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 (
|
||||
<div
|
||||
className="flex flex-col bg-[#0d1117] rounded-lg border border-[#30363d] overflow-hidden font-mono text-xs h-full"
|
||||
onClick={() => inputRef.current?.focus()}
|
||||
>
|
||||
{/* title bar */}
|
||||
<div className="flex items-center gap-2 px-3 py-2 border-b border-[#30363d] bg-[#161b22] shrink-0">
|
||||
<CircleIcon className="size-3 text-red-500 fill-red-500" />
|
||||
<CircleIcon className="size-3 text-amber-400 fill-amber-400" />
|
||||
<CircleIcon className="size-3 text-emerald-500 fill-emerald-500" />
|
||||
<span className="mx-auto text-[#8b949e] text-[11px]">
|
||||
{server.name} — {isLive ? "RouterOS REST" : "SSH Terminal (mock)"}
|
||||
</span>
|
||||
{isLive && (
|
||||
<span className="inline-flex items-center gap-1 rounded px-1.5 py-0.5 text-[10px] font-medium border border-emerald-500/30 bg-emerald-500/10 text-emerald-400">
|
||||
<span className="size-1 rounded-full bg-emerald-400" />LIVE
|
||||
</span>
|
||||
)}
|
||||
<button
|
||||
onClick={e => { e.stopPropagation(); setLines([]) }}
|
||||
className="text-[#8b949e] hover:text-white transition-colors ml-1"
|
||||
title="Очистить"
|
||||
>
|
||||
<TrashIcon className="size-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* output */}
|
||||
<div ref={scrollRef} className="flex-1 overflow-y-auto p-3 space-y-0.5 min-h-0">
|
||||
{lines.map((line, i) => (
|
||||
<div key={`${line.id}-${i}`} className={cn("whitespace-pre-wrap break-all leading-5", lineColor(line.kind))}>
|
||||
{line.text}
|
||||
</div>
|
||||
))}
|
||||
|
||||
{/* executing indicator */}
|
||||
{executing && (
|
||||
<div className="flex items-center gap-2 text-amber-400 opacity-80">
|
||||
<Loader2Icon className="size-3 animate-spin" />
|
||||
<span>executing…</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* input line */}
|
||||
{!executing && (
|
||||
<div className="flex items-center gap-0">
|
||||
<span className="text-emerald-400 select-none shrink-0">{prompt}</span>
|
||||
<input
|
||||
ref={inputRef}
|
||||
value={input}
|
||||
onChange={e => 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}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
function handleKey(e:React.KeyboardEvent<HTMLInputElement>){
|
||||
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 <Frame spacing="sm" className="h-full min-h-0 min-w-0">
|
||||
<FramePanel className="flex flex-1 flex-col min-h-0 min-w-0 p-0">
|
||||
<FrameHeader className="border-b shrink-0 flex-row items-start justify-between gap-2">
|
||||
<div className="min-w-0"><FrameTitle className="[overflow-wrap:anywhere]">{server.name}</FrameTitle><FrameDescription className="[overflow-wrap:anywhere]">{server.host} · {isLive?"RouterOS REST":"Демо"} · версия {server.rosVersion??"неизвестна"}</FrameDescription></div>
|
||||
<Button type="button" variant="ghost" size="icon-sm" aria-label="Очистить вывод терминала" onClick={()=>setLines([])}><TrashIcon aria-hidden="true" className="size-4"/></Button>
|
||||
</FrameHeader>
|
||||
<div ref={scrollRef} role="region" aria-label="Вывод терминала" tabIndex={0} onScroll={e=>{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=><div key={line.id} className={cn("whitespace-pre-wrap [overflow-wrap:anywhere] leading-5",line.kind==="error"?"text-destructive":line.kind==="prompt"?"text-primary":line.kind==="info"?"text-muted-foreground":"text-foreground")}>{line.text}</div>):<p className="text-muted-foreground">Вывод очищен. Следующая команда добавит новый результат.</p>}
|
||||
</div>
|
||||
<FrameFooter className="shrink-0 border-t flex flex-col items-stretch gap-2">
|
||||
<p role="status" className="text-xs text-muted-foreground">{executing?"Выполняем команду; ожидаем ответ…":completed??"Команда ещё не выполнена."}</p>
|
||||
{error&&<p role="alert" className="text-xs text-destructive [overflow-wrap:anywhere]">{error} Команда сохранена для повторного выполнения.</p>}
|
||||
{blocked&&<p role="status" className="text-xs text-muted-foreground">{blocked}</p>}
|
||||
<label className="text-xs" htmlFor="terminal-command">Команда · {server.name}</label>
|
||||
<div className="flex gap-2 min-w-0"><Input ref={inputRef} id="terminal-command" value={input} maxLength={1024} onChange={e=>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}/><Button type="button" size="sm" disabled={executing||!!blocked||!input.trim()} onClick={()=>void submit()}>{executing?"Ожидание…":"Выполнить"}</Button></div>
|
||||
<div className="flex flex-col sm:flex-row gap-2 text-xs text-muted-foreground"><select aria-label="Быстрая команда" value="" disabled={executing||!!blocked} className="min-w-0 rounded-md border bg-background px-2 py-1" onChange={e=>{setInput(e.target.value);setHistIdx(-1);inputRef.current?.focus()}}><option value="">Подставить команду…</option>{QUICK_CMDS.map(item=><option key={item.cmd} value={item.cmd}>{item.label}</option>)}</select><p>↑↓ — история · Ctrl+L — очистить · до 1024 символов</p></div>
|
||||
</FrameFooter>
|
||||
</FramePanel>
|
||||
</Frame>
|
||||
}
|
||||
|
||||
// ─── 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 <Suspense fallback={<p role="status" className="p-4">Загружаем терминал…</p>}><TerminalRoute/></Suspense>
|
||||
}
|
||||
|
||||
export default function TerminalPage() {
|
||||
const { mode, backendUrl, prefsHydrated } = useDataSource()
|
||||
const isLive = mode === "live"
|
||||
|
||||
// Server list state
|
||||
const [liveServers, setLiveServers] = useState<TermServer[]>([])
|
||||
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<BackendServer[]>(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<string>("")
|
||||
|
||||
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<ServerTileItem[]>(() => {
|
||||
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
|
||||
? <Badge variant="success-light" size="xs">LIVE</Badge>
|
||||
: undefined
|
||||
|
||||
function QuickCmds() {
|
||||
return (
|
||||
<Frame dense spacing="sm" className="min-h-0 shrink-0">
|
||||
<FramePanel className="flex max-h-56 flex-col gap-0 p-0">
|
||||
<FrameHeader className="border-b px-3 py-2">
|
||||
<FrameTitle className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
|
||||
Быстрые команды
|
||||
</FrameTitle>
|
||||
</FrameHeader>
|
||||
<ScrollArea className="min-h-0 flex-1">
|
||||
<div className="flex flex-col gap-0.5 p-1.5">
|
||||
{QUICK_CMDS.map(({ cmd, label }) => (
|
||||
<button
|
||||
key={cmd}
|
||||
type="button"
|
||||
className="truncate rounded-md px-2 py-1.5 text-left font-mono text-[11px] text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
|
||||
onClick={() => injectCommand(cmd)}
|
||||
title={cmd}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
<FrameFooter className="border-t text-[10px] text-muted-foreground/70">
|
||||
<p>↑↓ — история команд</p>
|
||||
<p>Ctrl+L — очистить экран</p>
|
||||
{isLive
|
||||
? <p className="text-info">Команды выполняются на роутере</p>
|
||||
: <p>Режим: mock-данные</p>}
|
||||
</FrameFooter>
|
||||
</FramePanel>
|
||||
</Frame>
|
||||
)
|
||||
}
|
||||
|
||||
function injectCommand(cmd: string) {
|
||||
const el = document.querySelector<HTMLInputElement>(".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 (
|
||||
<ServerRailLayout
|
||||
items={railItems}
|
||||
selectedId={selected?.uid ?? selectedUid}
|
||||
onSelect={handleSelectServer}
|
||||
showAll={false}
|
||||
showCount={false}
|
||||
showType={false}
|
||||
headerRight={railHeaderRight}
|
||||
loading={serversLoading}
|
||||
extra={<QuickCmds />}
|
||||
contentClassName="overflow-hidden p-3 md:p-4"
|
||||
header={
|
||||
<PageHeader
|
||||
crumbs={[{ label: "Инструменты" }, { label: "Терминал" }]}
|
||||
actions={
|
||||
<>
|
||||
<ServerRailMobileButton />
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
setRefreshKey((k) => k + 1)
|
||||
if (isLive) setSelectedUid("")
|
||||
}}
|
||||
>
|
||||
<RefreshCwIcon className="size-4" />
|
||||
Переподключить
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
}
|
||||
>
|
||||
{selected ? (
|
||||
<Terminal
|
||||
key={termKey}
|
||||
server={selected}
|
||||
isLive={isLive}
|
||||
backendUrl={backendUrl}
|
||||
/>
|
||||
) : (
|
||||
<div className="flex h-full items-center justify-center rounded-lg border border-[#30363d] bg-[#0d1117] font-mono text-sm text-[#8b949e]">
|
||||
{serversLoading ? "Загрузка серверов…" : "Выберите сервер"}
|
||||
</div>
|
||||
)}
|
||||
</ServerRailLayout>
|
||||
)
|
||||
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 <Alert variant="destructive"><AlertTitle>Некорректная ссылка терминала</AlertTitle><AlertDescription>{url.error}<Button type="button" variant="outline" onClick={reset}>Сбросить контекст терминала</Button></AlertDescription></Alert>
|
||||
if(!prefsHydrated)return <p role="status" className="p-4">Загружаем источник…</p>
|
||||
return <TerminalContent key={JSON.stringify([mode,backendUrl])}/>
|
||||
}
|
||||
function TerminalContent(){
|
||||
const {mode,backendUrl}=useDataSource(),isLive=mode==="live",params=useSearchParams(),url=readTerminalUrl(new URLSearchParams(params.toString()))
|
||||
const [liveServers,setLiveServers]=useState<TermServer[]>([]),[loading,setLoading]=useState(false),[error,setError]=useState<string|null>(null),[loaded,setLoaded]=useState(false),[readAt,setReadAt]=useState<string|null>(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<unknown>(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 <ServerRailLayout items={items} selectedId={selected?.uid??url.server??""} onSelect={select} preserveSelection showAll={false} showCount={false} showType={false} loading={loading} contentClassName="min-h-0 min-w-0 overflow-hidden p-2 md:p-3"
|
||||
header={<PageHeader crumbs={[{label:"Инструменты"},{label:"Терминал"}]} actions={<><ServerRailMobileButton/>{isLive&&<Button type="button" variant="outline" size="sm" disabled={loading||!canRead} onClick={()=>setRefresh(value=>value+1)}><RefreshCwIcon aria-hidden="true" className="size-4"/>Обновить устройства</Button>}</>}/>}
|
||||
banner={<div className="px-3 py-2 space-y-2"><p role="status" className="text-xs text-muted-foreground">{isLive?(readAt?"Каталог · ответ "+readAt+" (время браузера)":"Каталог ещё не подтверждён."):"Демо: команды не отправляются на устройство."} REST выполняет только поддерживаемые диагностические команды.</p>{!canRead&&isLive&&<Alert variant="destructive"><AlertTitle>Нет доступа к каталогу</AlertTitle><AlertDescription>Требуется mm:servers:read.</AlertDescription></Alert>}{error&&<Alert variant="destructive"><AlertTitle>{loaded?"Каталог не обновлён":"Устройства пока неизвестны"}</AlertTitle><AlertDescription>{error} {loaded?"Сохранён предыдущий каталог; выполнение приостановлено до успешного обновления.":"Повторите загрузку устройств."}</AlertDescription></Alert>}{unavailable&&<Alert variant="destructive"><AlertTitle>Устройство терминала недоступно</AlertTitle><AlertDescription>{matches.length>1?"Host неоднозначен: выберите точное устройство.":"Указанный server/host отсутствует в текущем каталоге."}<Button type="button" variant="outline" onClick={()=>select("")}>Сбросить выбор устройства</Button></AlertDescription></Alert>}</div>}>
|
||||
{selected?<Terminal key={JSON.stringify([selected.uid,selected.host])} server={selected} isLive={isLive} backendUrl={backendUrl} canExecute={canExecute} blockedReason={blocked?"Каталог не подтверждён; выполнение приостановлено до успешного обновления.":undefined}/>:<Frame spacing="sm" className="h-full min-h-0"><FramePanel className="h-full flex flex-col gap-2 items-center justify-center p-4 text-center"><FrameTitle>{loading?"Загружаем устройства…":loaded&&!termServers.length?"Устройств пока нет":"Выберите устройство для терминала"}</FrameTitle><FrameDescription>Выбор сохраняется в URL. Команда отправляется только после нажатия «Выполнить».</FrameDescription></FramePanel></Frame>}
|
||||
</ServerRailLayout>
|
||||
}
|
||||
+11
-1
@@ -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 имеют клавиатурные альтернативы.
|
||||
|
||||
@@ -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/БД не проверены.
|
||||
@@ -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"
|
||||
|
||||
@@ -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")})
|
||||
@@ -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():"")
|
||||
}
|
||||
@@ -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<void>(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()
|
||||
})
|
||||
Reference in new issue
Block a user