314 lines
23 KiB
TypeScript
314 lines
23 KiB
TypeScript
"use client"
|
|
|
|
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 {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 {
|
|
Frame,
|
|
FrameDescription,
|
|
FrameFooter,
|
|
FrameHeader,
|
|
FramePanel,
|
|
FrameTitle,
|
|
} from "@/components/reui/frame"
|
|
|
|
import { ServerRailLayout, ServerRailMobileButton } from "@/components/server-rail-layout"
|
|
|
|
import {
|
|
TrashIcon, RefreshCwIcon,
|
|
} from "lucide-react"
|
|
import { cn } from "@/lib/utils"
|
|
|
|
// ─── types ────────────────────────────────────────────────────────────────────
|
|
|
|
/** Unified server representation for the terminal sidebar */
|
|
interface TermServer {
|
|
uid: string // unique key (mock id or String(backend id))
|
|
backendId: number | null // null in mock mode
|
|
name: string
|
|
host: string
|
|
country: string
|
|
status: "online" | "offline" | "degraded" | null
|
|
enabled: boolean
|
|
rosVersion: string | null
|
|
identityName: string | null
|
|
}
|
|
|
|
interface TermLine {
|
|
id: number
|
|
kind: "output" | "prompt" | "error" | "info"
|
|
text: string
|
|
}
|
|
|
|
// ─── mock CLI responses ───────────────────────────────────────────────────────
|
|
|
|
type CmdFn = (args: string[], serverName: string) => string
|
|
|
|
const MOCK_COMMANDS: Record<string, CmdFn> = {
|
|
"ip address print": () =>
|
|
"Flags: X - disabled, I - invalid, D - dynamic\n" +
|
|
" # ADDRESS NETWORK INTERFACE\n" +
|
|
" 0 10.0.0.1/24 10.0.0.0 ether1-wan\n" +
|
|
" 1 192.168.88.1/24 192.168.88.0 bridge-lan\n" +
|
|
" 2 10.200.0.1/30 10.200.0.0 gre-msk-spb\n" +
|
|
" 3 10.200.0.5/30 10.200.0.4 gre-msk-fra\n" +
|
|
" 4 D 169.254.0.0/16 169.254.0.0 lo0",
|
|
|
|
"ip route print": () =>
|
|
"Flags: A - active, D - dynamic, C - connect, S - static, b - bgp, o - ospf\n" +
|
|
" # DST-ADDRESS PREF-SRC GATEWAY DIST\n" +
|
|
" 0 ADS 0.0.0.0/0 10.0.0.254 1\n" +
|
|
" 1 Ab 1.1.1.0/24 10.0.1.1 20\n" +
|
|
" 2 Ab 8.8.8.0/24 10.0.1.1 20\n" +
|
|
" 3 ADC 10.0.0.0/24 10.0.0.1 0\n" +
|
|
" 4 ADC 10.200.0.0/30 10.200.0.1 0",
|
|
|
|
"interface print": () =>
|
|
"Flags: X - disabled, D - dynamic, R - running\n" +
|
|
" # NAME TYPE MTU\n" +
|
|
" 0 R ether1-wan ether 1500\n" +
|
|
" 1 R ether2-lan ether 1500\n" +
|
|
" 2 R bridge-lan bridge 1500\n" +
|
|
" 3 R gre-msk-spb gre 1476\n" +
|
|
" 4 R gre-msk-fra gre 1476\n" +
|
|
" 5 gre-msk-ams gre 1476",
|
|
|
|
"system resource print": () =>
|
|
" uptime: 12d 4h 22m 18s\n" +
|
|
" version: 7.14.3 (stable)\n" +
|
|
" free-memory: 512.0 MiB\n" +
|
|
" total-memory: 2048.0 MiB\n" +
|
|
" cpu: Intel(R) Xeon(R)\n" +
|
|
" cpu-count: 4\n" +
|
|
" cpu-frequency: 3200 MHz\n" +
|
|
" cpu-load: 12%\n" +
|
|
" free-hdd-space: 8192.0 MiB\n" +
|
|
" total-hdd-space: 16384.0 MiB\n" +
|
|
" architecture-name: x86\n" +
|
|
" board-name: CHR\n" +
|
|
" platform: MikroTik",
|
|
|
|
"system identity print": (_, n) => ` name: ${n}`,
|
|
|
|
"ip firewall filter print": () =>
|
|
"Flags: X - disabled, I - invalid, D - dynamic\n\n" +
|
|
" 0 \n ;;; Accept established connections\n" +
|
|
" chain=input action=accept connection-state=established,related\n\n" +
|
|
" 1 \n ;;; YouTube bypass traffic\n" +
|
|
" chain=forward action=accept src-address-list=youtube-bypass protocol=tcp dst-port=443\n" +
|
|
" packets=188421 bytes=14283698176\n\n" +
|
|
" 2 \n ;;; Block SSH from WAN\n" +
|
|
" chain=input action=drop in-interface=ether1-wan protocol=tcp dst-port=22\n" +
|
|
" packets=882412 bytes=54618112",
|
|
|
|
"routing bgp session print": () =>
|
|
"Flags: E - established\n" +
|
|
" # NAME REMOTE-AS REMOTE-ADDRESS STATE UPTIME\n" +
|
|
" 0 E peer-spb-01 65002 10.0.1.1 established 12d 4h 22m\n" +
|
|
" 1 E peer-fra-01 65003 10.0.2.1 established 9d 12h 11m\n" +
|
|
" 2 peer-ams-01 65004 10.0.3.1 active —",
|
|
|
|
"routing ospf neighbor print": () =>
|
|
"Flags: V - virtual\n" +
|
|
" # ROUTER-ID STATE CHANGES ADJACENCY INTERFACE\n" +
|
|
" 0 10.0.0.7 Full 8 12d 4h 18m gre-msk-spb\n" +
|
|
" 1 10.0.1.1 Full 3 9d 11h 42m gre-msk-fra",
|
|
|
|
"routing bfd session print": () =>
|
|
" # LOCAL-ADDRESS REMOTE-ADDRESS STATE UPTIME\n" +
|
|
" 0 10.200.0.1%gre-msk-spb 10.200.0.2%gre-msk-spb up 12d 4h\n" +
|
|
" 1 10.200.1.1%gre-msk-fra 10.200.1.2%gre-msk-fra up 9d 11h",
|
|
|
|
"log print": () =>
|
|
"may/01 08:14:22 ospf,debug,packet GRE-MSK-FRA: hello received, RouterID: 10.0.2.1\n" +
|
|
"may/01 08:14:21 bgp,debug peer-spb-01 sending UPDATE\n" +
|
|
"may/01 08:14:18 system,info user admin logged from 10.10.0.5\n" +
|
|
"may/01 08:12:44 firewall,info forward: in:ether2-lan out:gre-msk-fra proto TCP\n" +
|
|
"may/01 08:11:03 script,info backup-script: backup saved to /backup/mt.rsc\n" +
|
|
"may/01 08:09:17 system,warning cpu load is 85% on cpu0",
|
|
|
|
"ping": (args) => {
|
|
const host = args[0] || "8.8.8.8"
|
|
const count = parseInt(args.find(a => a.startsWith("count="))?.split("=")[1] || "4")
|
|
const rtt = Math.round(10 + Math.random() * 50)
|
|
const lines = [`PING ${host}`]
|
|
for (let i = 0; i < Math.min(count, 5); i++) {
|
|
const t = rtt + Math.round((Math.random() - 0.5) * 8)
|
|
lines.push(` seq=${i} ttl=56 time=${t}ms`)
|
|
}
|
|
lines.push(` sent=${count} received=${count} packet-loss=0%`)
|
|
return lines.join("\n")
|
|
},
|
|
}
|
|
|
|
function mockResolve(raw: string, serverName: string): string {
|
|
const trimmed = raw.trim()
|
|
if (!trimmed) return ""
|
|
|
|
if (trimmed === "?" || trimmed === "help")
|
|
return "ip address print | ip route print | interface print\n" +
|
|
"system resource print | ip firewall filter print\n" +
|
|
"routing bgp session print | routing ospf neighbor print\n" +
|
|
"routing bfd session print | log print | ping <host>"
|
|
|
|
if (trimmed === "clear" || trimmed === "/clear") return "__CLEAR__"
|
|
if (trimmed === "quit" || trimmed === "exit") return "Connection closed."
|
|
|
|
const key = Object.keys(MOCK_COMMANDS).find(k =>
|
|
trimmed === k || trimmed.toLowerCase().startsWith(k + " ")
|
|
)
|
|
if (key) {
|
|
const rest = trimmed.slice(key.length).trim().split(/\s+/)
|
|
return MOCK_COMMANDS[key](rest, serverName)
|
|
}
|
|
|
|
return `bad command name ${trimmed.split(" ")[0]} (line 1 column 1)`
|
|
}
|
|
|
|
|
|
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: каждая команда — отдельный запрос. Результат появится после ответа API.":"Демо: команды возвращают синтетические данные."}])
|
|
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),pendingFocus=useRef(false)
|
|
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])
|
|
// Focus only after React has committed the re-enabled input.
|
|
useEffect(()=>{
|
|
if(executing||!pendingFocus.current)return
|
|
pendingFocus.current=false
|
|
if(reader.current.alive&&!inputRef.current?.disabled)inputRef.current?.focus()
|
|
},[executing,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){pendingFocus.current=true;setExecuting(false);reader.current.controller=null}
|
|
}
|
|
}
|
|
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 ───────────────────────────────────────────────────────────
|
|
|
|
const QUICK_CMDS = [
|
|
{ cmd: "system resource print", label: "system resource print" },
|
|
{ cmd: "interface print", label: "interface print" },
|
|
{ cmd: "ip address print", label: "ip address print" },
|
|
{ cmd: "ip route print", label: "ip route print" },
|
|
{ cmd: "ip firewall filter print", label: "ip firewall filter print" },
|
|
{ cmd: "routing bgp session print", label: "routing bgp session print" },
|
|
{ cmd: "routing ospf neighbor print", label: "routing ospf neighbor print" },
|
|
{ cmd: "routing ospf area print", label: "routing ospf area print" },
|
|
{ cmd: "routing bfd session print", label: "routing bfd session print" },
|
|
{ cmd: "log print", label: "log print" },
|
|
]
|
|
|
|
// ─── page ─────────────────────────────────────────────────────────────────────
|
|
|
|
/** Convert mock server list to TermServer[] */
|
|
function mockServersToTermServers(): TermServer[] {
|
|
return mockServers.map(s => ({
|
|
uid: s.id,
|
|
backendId: null,
|
|
name: s.name,
|
|
host: s.host,
|
|
country: s.country,
|
|
status: s.status==="unknown"?null:s.status,
|
|
enabled: s.enabled,
|
|
rosVersion: s.os ?? null,
|
|
identityName: s.name,
|
|
}))
|
|
}
|
|
|
|
|
|
export default function TerminalPage(){
|
|
return <Suspense fallback={<p role="status" className="p-4">Загружаем терминал…</p>}><TerminalRoute/></Suspense>
|
|
}
|
|
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>
|
|
}
|