feat(serial-console): add SerialConsolePage component with WebSocket support

This commit is contained in:
Jaime Idolpx 2026-06-10 20:01:42 -04:00
parent 1234ba30d9
commit 05be758754
3 changed files with 204 additions and 3 deletions

View File

@ -52,6 +52,8 @@
"@types/parallax-js": "^3.1.3", "@types/parallax-js": "^3.1.3",
"@types/react-syntax-highlighter": "^15.5.13", "@types/react-syntax-highlighter": "^15.5.13",
"@uiw/react-codemirror": "^4.25.10", "@uiw/react-codemirror": "^4.25.10",
"@xterm/addon-fit": "^0.11.0",
"@xterm/xterm": "^6.0.0",
"canvas-confetti": "1.9.4", "canvas-confetti": "1.9.4",
"class-variance-authority": "0.7.1", "class-variance-authority": "0.7.1",
"clsx": "2.1.1", "clsx": "2.1.1",

View File

@ -1,5 +1,5 @@
import { lazy, Suspense, useEffect, useState } from 'react'; import { lazy, Suspense, useEffect, useState } from 'react';
import { Cpu, Settings, Wifi, Network, HardDrive, Activity, Search, Wrench, User, LogOut, Bell, FileText, AppWindow, Folder, Edit, Eye, Database, Upload, Download, Code2, LayoutList, Image, ChevronLeft, Loader2, Terminal, Link, Printer, Maximize2, Minimize2 } from 'lucide-react'; import { Cpu, Settings, Wifi, Network, HardDrive, Activity, Search, Wrench, User, LogOut, Bell, FileText, AppWindow, Folder, Edit, Eye, Database, Upload, Download, Code2, LayoutList, Image, ChevronLeft, Loader2, Terminal, Link, Printer, Maximize2, Minimize2, Info } from 'lucide-react';
import { Toaster, toast } from 'sonner'; import { Toaster, toast } from 'sonner';
import StatusPage from './components/StatusPage'; import StatusPage from './components/StatusPage';
import DevicesPage from './components/DevicesPage'; import DevicesPage from './components/DevicesPage';
@ -17,7 +17,9 @@ import { WsProvider } from './ws';
// Three.js lives only in RealityOverridePage — keep lazy so it doesn't load on startup. // Three.js lives only in RealityOverridePage — keep lazy so it doesn't load on startup.
// CodeMirror/syntax-highlighter/ReactMarkdown live in MediaViewerEditor — lazy-loaded // CodeMirror/syntax-highlighter/ReactMarkdown live in MediaViewerEditor — lazy-loaded
// inside MediaManager when the user first opens a file to view or edit. // inside MediaManager when the user first opens a file to view or edit.
// xterm.js lives only in SerialConsolePage — keep lazy.
const RealityOverridePage = lazy(() => import('./components/RealityOverridePage')); const RealityOverridePage = lazy(() => import('./components/RealityOverridePage'));
const SerialConsolePage = lazy(() => import('./components/SerialConsolePage'));
type Page = 'status' | 'devices' | 'iec' | 'network' | 'general' | 'tools' | 'apps' | AppId; type Page = 'status' | 'devices' | 'iec' | 'network' | 'general' | 'tools' | 'apps' | AppId;
@ -176,7 +178,7 @@ export default function App() {
config={config} config={config}
setConfig={setConfig} setConfig={setConfig}
/>, />,
'serial-console': <AppPage title="Serial Console" onBack={() => setCurrentPage('apps')} />, 'serial-console': <SerialConsolePage onBack={() => setCurrentPage('apps')} />,
'directory-editor': <AppPage title="Directory Editor" onBack={() => setCurrentPage('apps')} />, 'directory-editor': <AppPage title="Directory Editor" onBack={() => setCurrentPage('apps')} />,
'sector-editor': <AppPage title="Sector Editor" onBack={() => setCurrentPage('apps')} />, 'sector-editor': <AppPage title="Sector Editor" onBack={() => setCurrentPage('apps')} />,
'bam-editor': <AppPage title="BAM Editor" onBack={() => setCurrentPage('apps')} />, 'bam-editor': <AppPage title="BAM Editor" onBack={() => setCurrentPage('apps')} />,
@ -273,7 +275,7 @@ function AppPage({ title, onBack }: { title: string; onBack: () => void }) {
className="w-full px-4 py-2 text-left hover:bg-neutral-50 flex items-center gap-2" className="w-full px-4 py-2 text-left hover:bg-neutral-50 flex items-center gap-2"
> >
<Settings className="w-4 h-4 text-[#4d4d4d]" /> <Settings className="w-4 h-4 text-[#4d4d4d]" />
Settings Preferences
</button> </button>
<button <button
onClick={() => setShowProfileMenu(false)} onClick={() => setShowProfileMenu(false)}
@ -289,6 +291,13 @@ function AppPage({ title, onBack }: { title: string; onBack: () => void }) {
<FileText className="w-4 h-4 text-[#4d4d4d]" /> <FileText className="w-4 h-4 text-[#4d4d4d]" />
Documentation Documentation
</button> </button>
<button
onClick={() => setShowProfileMenu(false)}
className="w-full px-4 py-2 text-left hover:bg-neutral-50 flex items-center gap-2"
>
<Info className="w-4 h-4 text-[#4d4d4d]" />
About Meatloaf
</button>
<div className="border-t border-neutral-200 my-2" /> <div className="border-t border-neutral-200 my-2" />
<button <button
onClick={() => setShowProfileMenu(false)} onClick={() => setShowProfileMenu(false)}

View File

@ -0,0 +1,190 @@
import { useEffect, useRef, useState } from 'react';
import { Terminal } from '@xterm/xterm';
import { FitAddon } from '@xterm/addon-fit';
import '@xterm/xterm/css/xterm.css';
import { ChevronLeft, Plug, Radio, RotateCcw, Unplug } from 'lucide-react';
interface Props { onBack: () => void; }
type ConnStatus = 'disconnected' | 'connecting' | 'connected' | 'error';
export default function SerialConsolePage({ onBack }: Props) {
const containerRef = useRef<HTMLDivElement>(null);
const termRef = useRef<Terminal | null>(null);
const fitRef = useRef<FitAddon | null>(null);
const wsRef = useRef<WebSocket | null>(null);
const [status, setStatus] = useState<ConnStatus>('disconnected');
// Initialize xterm on mount
useEffect(() => {
const el = containerRef.current;
if (!el) return;
const term = new Terminal({
cursorBlink: true,
fontSize: 14,
fontFamily: '"Cascadia Code","Fira Code",Menlo,Monaco,"Courier New",monospace',
theme: {
background: '#0a0a0a',
foreground: '#d4d4d4',
cursor: '#d4d4d4',
selectionBackground: '#264f78',
},
scrollback: 5000,
convertEol: true,
});
const fit = new FitAddon();
term.loadAddon(fit);
term.open(el);
fit.fit();
termRef.current = term;
fitRef.current = fit;
term.writeln('\x1b[2m── Meatloaf Serial Console ──\x1b[0m');
term.writeln('\x1b[2mPress Connect to open a session.\x1b[0m');
term.writeln('');
term.onData((data) => {
if (wsRef.current?.readyState === WebSocket.OPEN) wsRef.current.send(data);
});
const onResize = () => fitRef.current?.fit();
window.addEventListener('resize', onResize);
return () => {
window.removeEventListener('resize', onResize);
term.dispose();
termRef.current = null;
fitRef.current = null;
};
}, []);
// Close WebSocket on unmount
useEffect(() => {
return () => {
if (wsRef.current) {
wsRef.current.onopen = wsRef.current.onmessage = wsRef.current.onerror = wsRef.current.onclose = null;
wsRef.current.close();
wsRef.current = null;
}
};
}, []);
const writeln = (msg: string) => termRef.current?.writeln(msg);
const connect = () => {
if (wsRef.current) {
wsRef.current.onopen = wsRef.current.onmessage = wsRef.current.onerror = wsRef.current.onclose = null;
wsRef.current.close();
wsRef.current = null;
}
const url = `ws://${window.location.hostname}/console`;
setStatus('connecting');
writeln(`\x1b[2mConnecting to ${url}\x1b[0m`);
const ws = new WebSocket(url);
ws.binaryType = 'arraybuffer';
wsRef.current = ws;
ws.onopen = () => {
setStatus('connected');
writeln('\x1b[32mConnected.\x1b[0m');
};
ws.onmessage = (ev) => {
if (!termRef.current) return;
if (ev.data instanceof ArrayBuffer) termRef.current.write(new Uint8Array(ev.data));
else termRef.current.write(String(ev.data));
};
let hadError = false;
ws.onerror = () => {
hadError = true;
writeln('\x1b[31mConnection error.\x1b[0m');
};
ws.onclose = (ev) => {
setStatus(hadError ? 'error' : 'disconnected');
wsRef.current = null;
if (!hadError) writeln(`\x1b[2mSession ended${ev.reason ? `: ${ev.reason}` : ''}.\x1b[0m`);
};
};
const disconnect = () => {
if (wsRef.current) {
wsRef.current.onopen = wsRef.current.onmessage = wsRef.current.onerror = wsRef.current.onclose = null;
wsRef.current.close();
wsRef.current = null;
}
setStatus('disconnected');
writeln('\x1b[2mDisconnected.\x1b[0m');
};
const busy = status === 'connecting' || status === 'connected';
const statusColor =
status === 'connected' ? 'text-green-400' :
status === 'connecting' ? 'text-amber-400' :
status === 'error' ? 'text-red-400' : 'text-neutral-500';
const statusLabel =
status === 'connected' ? 'Connected' :
status === 'connecting' ? 'Connecting…' :
status === 'error' ? 'Error' : 'Disconnected';
return (
<div className="fixed inset-0 bg-[#0a0a0a] z-40 flex flex-col">
{/* Header */}
<div
className="flex-none flex items-center gap-2 px-3 py-2 bg-neutral-900 border-b border-neutral-800"
style={{ paddingTop: 'max(0.5rem, env(safe-area-inset-top))' }}
>
<button
onClick={() => { disconnect(); onBack(); }}
className="p-1.5 rounded hover:bg-neutral-800 text-neutral-400 hover:text-white transition"
title="Back"
>
<ChevronLeft className="w-5 h-5" />
</button>
<span className="text-sm font-medium text-neutral-200">Serial Console</span>
<div className="flex-1" />
<div className={`flex items-center gap-1.5 text-xs ${statusColor}`}>
<Radio className="w-3.5 h-3.5" />
<span className="hidden sm:inline">{statusLabel}</span>
</div>
<button
onClick={() => termRef.current?.clear()}
className="p-1.5 rounded hover:bg-neutral-800 text-neutral-400 hover:text-neutral-200 transition"
title="Clear terminal"
>
<RotateCcw className="w-4 h-4" />
</button>
<button
onClick={busy ? disconnect : connect}
className={`flex items-center gap-1.5 px-3 py-1 rounded text-xs font-medium transition ${
busy
? 'bg-red-900/60 hover:bg-red-800 text-red-300'
: 'bg-green-900/60 hover:bg-green-800 text-green-300'
}`}
>
{busy ? <Unplug className="w-3.5 h-3.5" /> : <Plug className="w-3.5 h-3.5" />}
<span>{busy ? 'Disconnect' : 'Connect'}</span>
</button>
</div>
{/* Terminal */}
<div
ref={containerRef}
className="flex-1 min-h-0"
style={{ paddingBottom: 'env(safe-area-inset-bottom)' }}
/>
</div>
);
}