mc-manager/src/components/ConsoleViewer.tsx
2026-02-07 15:55:48 -08:00

137 lines
4.2 KiB
TypeScript

'use client'
import { useEffect, useRef, useState } from 'react'
import { Send, Trash2 } from 'lucide-react'
interface ConsoleViewerProps {
serverId: string
readOnly?: boolean
}
export default function ConsoleViewer({ serverId, readOnly = false }: ConsoleViewerProps) {
const [lines, setLines] = useState<string[]>([])
const [command, setCommand] = useState('')
const [sending, setSending] = useState(false)
const containerRef = useRef<HTMLDivElement>(null)
const inputRef = useRef<HTMLInputElement>(null)
// Auto-scroll to bottom on new lines
useEffect(() => {
if (containerRef.current) {
containerRef.current.scrollTop = containerRef.current.scrollHeight
}
}, [lines])
// SSE log streaming
useEffect(() => {
const eventSource = new EventSource(`/api/servers/${serverId}/console`)
eventSource.onmessage = (event) => {
try {
const data = JSON.parse(event.data)
const line = data.line || event.data
setLines(prev => [...prev.slice(-500), line])
} catch {
setLines(prev => [...prev.slice(-500), event.data])
}
}
eventSource.onerror = () => {
eventSource.close()
}
return () => {
eventSource.close()
}
}, [serverId])
const handleSendCommand = async () => {
if (!command.trim() || sending) return
const cmd = command.trim()
// Show the command in the console
setLines(prev => [...prev.slice(-500), `> ${cmd}`])
setCommand('')
inputRef.current?.focus()
setSending(true)
try {
const res = await fetch(`/api/servers/${serverId}/console`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify({ command: cmd }),
})
if (!res.ok) {
const data = await res.json()
setLines(prev => [...prev.slice(-500), `§c Error: ${data.error || 'Command failed'}`])
}
// Output arrives via SSE log stream — no need to parse response
} catch (error) {
console.error('Failed to send command:', error)
setLines(prev => [...prev.slice(-500), '§c Error: Failed to send command'])
} finally {
setSending(false)
}
}
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === 'Enter') {
e.preventDefault()
handleSendCommand()
}
}
return (
<div className="flex flex-col h-full bg-gray-950 rounded-lg border border-gray-700/50 overflow-hidden">
{/* Console Output */}
<div
ref={containerRef}
className="flex-1 overflow-y-auto p-4 font-mono text-xs sm:text-sm text-gray-300 space-y-0.5 min-h-[300px] sm:min-h-[400px]"
>
{lines.length === 0 ? (
<p className="text-gray-600 italic">Waiting for server output...</p>
) : (
lines.map((line, i) => (
<div key={i} className="whitespace-pre-wrap break-all hover:bg-gray-900/50">
{line}
</div>
))
)}
</div>
{/* Command Input */}
{!readOnly && (
<div className="flex items-center gap-2 p-3 border-t border-gray-700/50 bg-gray-900/50">
<span className="text-cyan-500 font-mono text-sm">&gt;</span>
<input
ref={inputRef}
type="text"
value={command}
onChange={e => setCommand(e.target.value)}
onKeyDown={handleKeyDown}
placeholder="Type a command..."
className="flex-1 bg-transparent text-gray-100 font-mono text-sm placeholder-gray-600 focus:outline-none"
/>
<button
onClick={() => setLines([])}
className="p-1.5 rounded text-gray-500 hover:text-gray-300 hover:bg-gray-800 transition-colors"
aria-label="Clear console"
>
<Trash2 size={16} />
</button>
<button
onClick={handleSendCommand}
disabled={!command.trim() || sending}
className="p-1.5 rounded text-cyan-400 hover:text-cyan-300 hover:bg-gray-800 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
aria-label="Send command"
>
<Send size={16} />
</button>
</div>
)}
</div>
)
}