mirror of
https://github.com/Dvorinka/Containr.git
synced 2026-10-28 15:51:26 +00:00
GET /services/:id/terminal upgrades to a WebSocket attached to a `docker exec` PTY in the service's running replica. JSON frames both ways: stdin/resize out, stdout/exit/error in. Shell probe tries each candidate (bash→sh) since `command -v` needs a shell to run; distroless images get a clean "no shell" error instead of an OCI failure dump. - docker.Client gains ExecAttach + ExecResize wrappers - Frontend: xterm.js replaces the one-off console section — live PTY, fit-addon resizes forwarded over the socket - CLI: `containr shell <id>` bridges the local TTY (raw mode, SIGWINCH resize forwarding); `containr exec` keeps the one-off command path - Access mirrors service logs: project manage scope required Generated with [Devin](https://devin.ai) Co-authored-by: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
108 lines
3.3 KiB
TypeScript
108 lines
3.3 KiB
TypeScript
import { useEffect, useRef, useState } from 'react';
|
|
import { Terminal } from '@xterm/xterm';
|
|
import { FitAddon } from '@xterm/addon-fit';
|
|
import '@xterm/xterm/css/xterm.css';
|
|
import { Loader2 } from 'lucide-react';
|
|
import { getApiBaseUrl } from '@/lib/api-client';
|
|
|
|
type Frame = {
|
|
type: 'stdin' | 'resize' | 'stdout' | 'exit' | 'error' | 'ping' | 'pong';
|
|
data?: string;
|
|
cols?: number;
|
|
rows?: number;
|
|
code?: number;
|
|
message?: string;
|
|
};
|
|
|
|
// Interactive shell: WebSocket → docker exec pty in the service container.
|
|
export function ServiceTerminal({ serviceId }: { serviceId: string }) {
|
|
const hostRef = useRef<HTMLDivElement>(null);
|
|
const [status, setStatus] = useState<'connecting' | 'live' | 'ended' | 'error'>('connecting');
|
|
|
|
useEffect(() => {
|
|
if (!hostRef.current) return;
|
|
|
|
const term = new Terminal({
|
|
fontFamily: 'var(--font-mono, ui-monospace, monospace)',
|
|
fontSize: 13,
|
|
cursorBlink: true,
|
|
convertEol: false,
|
|
theme: {
|
|
background: '#0a0a0c',
|
|
foreground: '#d4d4d8',
|
|
cursor: '#a1a1aa',
|
|
selectionBackground: '#3f3f46',
|
|
},
|
|
});
|
|
const fit = new FitAddon();
|
|
term.loadAddon(fit);
|
|
term.open(hostRef.current);
|
|
fit.fit();
|
|
|
|
const wsUrl = getApiBaseUrl().replace(/^http/, 'ws');
|
|
const ws = new WebSocket(`${wsUrl}/services/${serviceId}/terminal`);
|
|
|
|
ws.onopen = () => {
|
|
setStatus('live');
|
|
const { cols, rows } = term;
|
|
const frame: Frame = { type: 'resize', cols, rows };
|
|
ws.send(JSON.stringify(frame));
|
|
};
|
|
ws.onmessage = (ev) => {
|
|
let f: Frame;
|
|
try {
|
|
f = JSON.parse(ev.data);
|
|
} catch {
|
|
return;
|
|
}
|
|
if (f.type === 'stdout' && f.data) term.write(f.data);
|
|
else if (f.type === 'exit') {
|
|
setStatus('ended');
|
|
term.write(`\r\n\x1b[90m[session ended — exit ${f.code ?? 0}]\x1b[0m\r\n`);
|
|
} else if (f.type === 'error') {
|
|
setStatus('error');
|
|
term.write(`\r\n\x1b[31m${f.message ?? 'terminal error'}\x1b[0m\r\n`);
|
|
}
|
|
};
|
|
ws.onerror = () => setStatus((s) => (s === 'live' ? s : 'error'));
|
|
ws.onclose = () => setStatus((s) => (s === 'error' ? s : 'ended'));
|
|
|
|
const dataSub = term.onData((data) => {
|
|
if (ws.readyState === WebSocket.OPEN) {
|
|
ws.send(JSON.stringify({ type: 'stdin', data } satisfies Frame));
|
|
}
|
|
});
|
|
|
|
const observer = new ResizeObserver(() => {
|
|
fit.fit();
|
|
if (ws.readyState === WebSocket.OPEN) {
|
|
const { cols, rows } = term;
|
|
ws.send(JSON.stringify({ type: 'resize', cols, rows } satisfies Frame));
|
|
}
|
|
});
|
|
observer.observe(hostRef.current);
|
|
|
|
return () => {
|
|
observer.disconnect();
|
|
dataSub.dispose();
|
|
ws.close();
|
|
term.dispose();
|
|
};
|
|
}, [serviceId]);
|
|
|
|
return (
|
|
<div className="relative">
|
|
{status === 'connecting' && (
|
|
<div className="absolute inset-0 z-10 flex items-center justify-center bg-[#0a0a0c]/80 rounded-[var(--radius-md)]">
|
|
<Loader2 size={18} className="animate-spin text-[var(--accent-primary)]" />
|
|
</div>
|
|
)}
|
|
<div
|
|
ref={hostRef}
|
|
className="rounded-[var(--radius-md)] border border-[var(--border-subtle)] overflow-hidden p-2"
|
|
style={{ background: '#0a0a0c', height: 420 }}
|
|
/>
|
|
</div>
|
|
);
|
|
}
|