"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 = { "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 " 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([{id:0,kind:"info",text:isLive?"REST: каждая команда — отдельный запрос. Результат появится после ответа API.":"Демо: команды возвращают синтетические данные."}]) 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),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(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){ 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 ─────────────────────────────────────────────────────────── 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 Загружаем терминал…

}>
} 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. Команда отправляется только после нажатия «Выполнить».}
}