625 lines
25 KiB
TypeScript
625 lines
25 KiB
TypeScript
"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<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)`
|
|
}
|
|
|
|
// ─── 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
|
|
}, [])
|
|
|
|
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<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([])
|
|
}
|
|
}
|
|
|
|
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>
|
|
)
|
|
}
|
|
|
|
// ─── 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<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) { setLiveServers([]); return }
|
|
let cancelled = false
|
|
setServersLoading(true)
|
|
fetch(`${backendUrl}/api/servers`)
|
|
.then(r => r.json() as Promise<BackendServer[]>)
|
|
.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<string>("")
|
|
|
|
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<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 (
|
|
<div className="flex flex-col h-full">
|
|
<PageHeader
|
|
crumbs={[{ label: "Инструменты" }, { label: "Терминал" }]}
|
|
actions={
|
|
<Button variant="outline" size="sm" onClick={() => {
|
|
setRefreshKey(k => k + 1)
|
|
if (isLive) setSelectedUid("")
|
|
}}>
|
|
<RefreshCwIcon className="size-4" />Переподключить
|
|
</Button>
|
|
}
|
|
/>
|
|
|
|
<div className="flex-1 overflow-hidden p-6">
|
|
<div className="grid grid-cols-[220px_1fr] gap-5 h-full">
|
|
|
|
{/* ── sidebar ── */}
|
|
<div className="flex flex-col gap-4 overflow-y-auto min-h-0">
|
|
|
|
{/* server picker */}
|
|
<div>
|
|
<div className="flex items-center gap-2 mb-2">
|
|
<p className="text-xs font-semibold text-muted-foreground uppercase tracking-wider">Узел</p>
|
|
{isLive && serversLoading && (
|
|
<Loader2Icon className="size-3 animate-spin text-muted-foreground" />
|
|
)}
|
|
{isLive && !serversLoading && (
|
|
<span className="inline-flex items-center gap-1 text-[10px] font-medium rounded border border-emerald-500/30 bg-emerald-500/10 text-emerald-400 px-1.5 py-0.5">
|
|
<span className="size-1 rounded-full bg-emerald-400" />LIVE
|
|
</span>
|
|
)}
|
|
</div>
|
|
|
|
{isLive && !serversLoading && liveServers.length === 0 && (
|
|
<p className="text-xs text-muted-foreground px-2.5">
|
|
Нет доступных серверов
|
|
</p>
|
|
)}
|
|
|
|
<div className="space-y-1">
|
|
{termServers.map(s => {
|
|
const isOffline = s.status === "offline"
|
|
const isSelected = s.uid === selectedUid
|
|
return (
|
|
<button
|
|
key={s.uid}
|
|
disabled={isOffline || !s.enabled}
|
|
onClick={() => { setSelectedUid(s.uid); setRefreshKey(k => k + 1) }}
|
|
className={cn(
|
|
"w-full text-left rounded-md px-2.5 py-2 text-xs transition-colors",
|
|
"flex items-center gap-2",
|
|
isSelected
|
|
? "bg-primary text-primary-foreground"
|
|
: "hover:bg-muted",
|
|
(isOffline || !s.enabled) && "opacity-40 cursor-not-allowed",
|
|
)}
|
|
>
|
|
<span className={cn(
|
|
"inline-block size-1.5 rounded-full shrink-0",
|
|
s.status === "online" ? "bg-emerald-500" :
|
|
s.status === "degraded" ? "bg-amber-400" :
|
|
s.status === null ? "bg-sky-400" : "bg-red-500",
|
|
)} />
|
|
{s.country && <Flag code={s.country} />}
|
|
<span className="truncate font-mono">{s.name}</span>
|
|
</button>
|
|
)
|
|
})}
|
|
</div>
|
|
</div>
|
|
|
|
{/* quick commands */}
|
|
<div>
|
|
<p className="text-xs font-semibold text-muted-foreground uppercase tracking-wider mb-2">
|
|
Быстрые команды
|
|
</p>
|
|
<div className="space-y-1">
|
|
{QUICK_CMDS.map(({ cmd, label }) => (
|
|
<button
|
|
key={cmd}
|
|
className="w-full text-left rounded-md px-2.5 py-1.5 text-[11px] font-mono text-muted-foreground hover:bg-muted hover:text-foreground transition-colors truncate block"
|
|
onClick={() => injectCommand(cmd)}
|
|
title={cmd}
|
|
>
|
|
{label}
|
|
</button>
|
|
))}
|
|
</div>
|
|
</div>
|
|
|
|
{/* hints */}
|
|
<div className="mt-auto text-[10px] text-muted-foreground/50 space-y-0.5 px-0.5">
|
|
<p>↑↓ — история команд</p>
|
|
<p>Ctrl+L — очистить экран</p>
|
|
{isLive
|
|
? <p className="text-sky-400/60">Команды выполняются на роутере</p>
|
|
: <p>Режим: mock-данные</p>
|
|
}
|
|
</div>
|
|
</div>
|
|
|
|
{/* ── terminal ── */}
|
|
{selected ? (
|
|
<Terminal
|
|
key={termKey}
|
|
server={selected}
|
|
isLive={isLive}
|
|
backendUrl={backendUrl}
|
|
/>
|
|
) : (
|
|
<div className="flex items-center justify-center bg-[#0d1117] rounded-lg border border-[#30363d] text-[#8b949e] text-sm font-mono">
|
|
{serversLoading ? "Загрузка серверов…" : "Выберите сервер"}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|