Files
JBD_BMS_Tools/components/Terminal.tsx

152 lines
5.7 KiB
TypeScript

import React, { useState, useRef, useEffect } from 'react';
import { serialService } from '../services/serialService';
import { Terminal as TerminalIcon, Send, Trash2, ArrowUp, ArrowDown } from 'lucide-react';
interface LogEntry {
id: number;
type: 'tx' | 'rx' | 'info' | 'error';
timestamp: string;
data: string;
}
const Terminal: React.FC = () => {
const [logs, setLogs] = useState<LogEntry[]>([]);
const [input, setInput] = useState('');
const [autoScroll, setAutoScroll] = useState(true);
const logsContainerRef = useRef<HTMLDivElement>(null);
const MAX_LOGS = 200;
useEffect(() => {
// Subscribe to serial service logs
serialService.setLogCallback((type, data) => {
setLogs(prev => {
const newLog: LogEntry = {
id: Date.now() + Math.random(), // Ensure unique key for fast updates
type,
timestamp: new Date().toLocaleTimeString([], { hour12: false, hour: "2-digit", minute: "2-digit", second: "2-digit" }),
data
};
// NEWEST FIRST: Prepend new log to the beginning of the array
const newLogs = [newLog, ...prev];
if (newLogs.length > MAX_LOGS) {
return newLogs.slice(0, MAX_LOGS);
}
return newLogs;
});
});
return () => {
serialService.setLogCallback(null);
};
}, []);
useEffect(() => {
// If auto-scroll is on, force scroll to top when logs change
if (autoScroll && logsContainerRef.current) {
logsContainerRef.current.scrollTop = 0;
}
}, [logs, autoScroll]);
const handleSend = async () => {
if (!input.trim()) return;
// Remove spaces and validate hex
const hexStr = input.replace(/\s+/g, '');
if (!/^[0-9A-Fa-f]+$/.test(hexStr) || hexStr.length % 2 !== 0) {
// Local error log (prepended)
setLogs(prev => [{
id: Date.now(),
type: 'error',
timestamp: new Date().toLocaleTimeString(),
data: '유효하지 않은 HEX 포맷입니다.'
}, ...prev]);
return;
}
const bytes = new Uint8Array(hexStr.match(/.{1,2}/g)!.map(byte => parseInt(byte, 16)));
try {
await serialService.sendRaw(bytes);
// readRaw also triggers callback inside service
await serialService.readRaw();
} catch (e: any) {
// Errors from service are logged via callback usually
}
setInput('');
};
return (
<div className="flex flex-col h-full bg-gray-50">
<div className="flex justify-between items-center p-3 border-b border-gray-200 bg-white">
<div className="flex items-center gap-2 overflow-hidden">
<TerminalIcon size={18} className="text-gray-500 shrink-0" />
<span className="font-semibold text-gray-700 truncate">Terminal / Logs</span>
</div>
<div className="flex items-center gap-2">
<button
onClick={() => setAutoScroll(!autoScroll)}
className={`p-1.5 rounded transition-colors ${autoScroll ? 'text-green-600 bg-green-100' : 'text-gray-400 hover:text-gray-600'}`}
title={autoScroll ? "Auto-scroll: ON (Top)" : "Auto-scroll: OFF"}
>
{/* Icon indicates scroll direction focus */}
<ArrowUp size={16} className={autoScroll ? "" : "opacity-50"} />
</button>
<button onClick={() => setLogs([])} className="p-1.5 text-gray-400 hover:text-red-600 hover:bg-red-100 rounded transition-colors" title="Clear Logs">
<Trash2 size={16} />
</button>
</div>
</div>
<div className="flex-1 bg-white overflow-hidden flex flex-col font-mono text-xs shadow-inner">
{/* Input Area (Top) or Bottom? Keeping input at bottom is standard, logs flow down from top */}
<div
ref={logsContainerRef}
className="flex-1 overflow-y-auto p-2 space-y-1 scroll-smooth"
>
{logs.length === 0 && <div className="text-gray-500 text-center mt-4 italic"> ... (Logs)</div>}
{logs.map(log => (
<div key={log.id} className="flex gap-2 animate-in fade-in slide-in-from-top-1 hover:bg-gray-100 p-0.5 rounded border-b border-gray-100 last:border-0">
<span className="text-gray-500 shrink-0 select-none">[{log.timestamp}]</span>
<div className="break-all flex-1">
{log.type === 'tx' && <span className="text-blue-600 font-bold mr-1">TX</span>}
{log.type === 'rx' && <span className="text-green-600 font-bold mr-1">RX</span>}
{log.type === 'error' && <span className="text-red-500 font-bold mr-1">ERR</span>}
{log.type === 'info' && <span className="text-gray-500 font-bold mr-1">INF</span>}
<span className={`${log.type === 'error' ? 'text-red-600' : 'text-gray-800'}`}>
{log.data}
</span>
</div>
</div>
))}
</div>
<div className="p-2 border-t border-gray-200 bg-gray-50">
<div className="flex gap-2">
<input
type="text"
value={input}
onChange={(e) => setInput(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && handleSend()}
placeholder="HEX (e.g. DD A5 03...)"
className="flex-1 bg-white border border-gray-300 rounded px-2 py-1.5 text-gray-900 focus:outline-none focus:border-blue-500 font-mono text-xs"
/>
<button
onClick={handleSend}
className="bg-blue-600 hover:bg-blue-500 text-white px-3 py-1.5 rounded flex items-center gap-1 transition-colors text-xs font-bold"
>
<Send size={14} />
</button>
</div>
</div>
</div>
</div>
);
};
export default Terminal;