"use client" import { useState, useRef, useEffect, useCallback, useMemo } from "react" import { PageHeader } from "@/components/page-header" import { Button } from "@/components/ui/button" import { servers as mockServers } from "@/lib/data" import { Flag } from "@/components/flag" import { useDataSource } from "@/lib/data-source" import { TrashIcon, RefreshCwIcon, CircleIcon, Loader2Icon, } 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)` } // ─── 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 }, []) const addLines = useCallback((texts: string[], kind: TermLine["kind"] = "output") => { setLines(prev => [ ...prev, ...texts.map(text => ({ id: 0, kind, text })), ].map((l, i, arr) => ({ ...l, id: arr.length - texts.length + i })) // Note: IDs don't need to be perfect unique here since we append ) setIdCtr(prev => prev + texts.length) }, []) // 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 res = await fetch(`${backendUrl}/api/servers/${server.backendId}/exec`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ command: cmd }), }) const data = await res.json() as { output?: string; error?: string } 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([]) } } 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} />
)}
) } // ─── 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 as TermServer["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() { const { mode, backendUrl, backendStatus } = useDataSource() const isLive = mode === "live" && backendStatus === true // 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) { setLiveServers([]); return } let cancelled = false setServersLoading(true) fetch(`${backendUrl}/api/servers`) .then(r => r.json() as Promise) .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]) 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(() => { if (!selectedUid && defaultUid) setSelectedUid(defaultUid) }, [defaultUid, selectedUid]) // Reset selection when switching modes useEffect(() => { setSelectedUid("") }, [isLive]) const selected = termServers.find(s => s.uid === selectedUid) ?? termServers[0] const termKey = `${selectedUid}-${refreshKey}-${isLive ? "live" : "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 (
{ setRefreshKey(k => k + 1) if (isLive) setSelectedUid("") }}> Переподключить } />
{/* ── sidebar ── */}
{/* server picker */}

Узел

{isLive && serversLoading && ( )} {isLive && !serversLoading && ( LIVE )}
{isLive && !serversLoading && liveServers.length === 0 && (

Нет доступных серверов

)}
{termServers.map(s => { const isOffline = s.status === "offline" const isSelected = s.uid === selectedUid return ( ) })}
{/* quick commands */}

Быстрые команды

{QUICK_CMDS.map(({ cmd, label }) => ( ))}
{/* hints */}

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

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

{isLive ?

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

:

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

}
{/* ── terminal ── */} {selected ? ( ) : (
{serversLoading ? "Загрузка серверов…" : "Выберите сервер"}
)}
) }