Docker images / prepare-release (push) Successful in 9s
Docker images / backend-image (push) Successful in 1m43s
Docker images / frontend-image (push) Successful in 2m58s
Docker images / notify-webhook (push) Skipped
Docker images / updater-image (push) Successful in 39s
Docker images / publish-release (push) Successful in 8s
Replaced existing KPI display implementations across multiple pages with the new KpiStatGrid component for a more consistent and visually appealing presentation of statistics. Updated the Backups, BGP, Certificates, Communities, Containers, Dashboard, and Data Collection pages to utilize the KpiStatGrid, improving the overall user experience and maintainability of the codebase. Additionally, added new dependencies in package.json for required libraries.
624 lines
23 KiB
TypeScript
624 lines
23 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 type { ServerStatus } from "@/lib/data"
|
|
import { useDataSource } from "@/lib/data-source"
|
|
import { requestJson } from "@/shared/api/http-client"
|
|
import { Badge } from "@/components/reui/badge"
|
|
import {
|
|
Frame,
|
|
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,
|
|
} 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
|
|
}, [])
|
|
|
|
// 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([])
|
|
}
|
|
}
|
|
|
|
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, 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>
|
|
)
|
|
}
|