mirror of
https://github.com/Dvorinka/Containr.git
synced 2026-10-28 15:51:26 +00:00
feat: interactive terminal — WS-bridged docker exec PTY (#46)
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>
This commit is contained in:
1 parent
5eac4a382b
commit
612520bbff
13 files changed
+570
-75
No files matched your search
Generated
+17
@@ -33,6 +33,8 @@
|
||||
"@tailwindcss/typography": "^0.5.19",
|
||||
"@tanstack/react-query": "^5.66.0",
|
||||
"@types/dompurify": "^3.0.5",
|
||||
"@xterm/addon-fit": "^0.11.0",
|
||||
"@xterm/xterm": "^6.0.0",
|
||||
"@xyflow/react": "^12.10.0",
|
||||
"autoprefixer": "^10.4.24",
|
||||
"axios": "^1.7.9",
|
||||
@@ -4262,6 +4264,21 @@
|
||||
"url": "https://opencollective.com/vitest"
|
||||
}
|
||||
},
|
||||
"node_modules/@xterm/addon-fit": {
|
||||
"version": "0.11.0",
|
||||
"resolved": "https://registry.npmjs.org/@xterm/addon-fit/-/addon-fit-0.11.0.tgz",
|
||||
"integrity": "sha512-jYcgT6xtVYhnhgxh3QgYDnnNMYTcf8ElbxxFzX0IZo+vabQqSPAjC3c1wJrKB5E19VwQei89QCiZZP86DCPF7g==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@xterm/xterm": {
|
||||
"version": "6.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@xterm/xterm/-/xterm-6.0.0.tgz",
|
||||
"integrity": "sha512-TQwDdQGtwwDt+2cgKDLn0IRaSxYu1tSUjgKarSDkUM0ZNiSRXFpjxEsvc/Zgc5kq5omJ+V0a8/kIM2WD3sMOYg==",
|
||||
"license": "MIT",
|
||||
"workspaces": [
|
||||
"addons/*"
|
||||
]
|
||||
},
|
||||
"node_modules/@xyflow/react": {
|
||||
"version": "12.10.1",
|
||||
"resolved": "https://registry.npmjs.org/@xyflow/react/-/react-12.10.1.tgz",
|
||||
|
||||
@@ -44,6 +44,8 @@
|
||||
"@tailwindcss/typography": "^0.5.19",
|
||||
"@tanstack/react-query": "^5.66.0",
|
||||
"@types/dompurify": "^3.0.5",
|
||||
"@xterm/addon-fit": "^0.11.0",
|
||||
"@xterm/xterm": "^6.0.0",
|
||||
"@xyflow/react": "^12.10.0",
|
||||
"autoprefixer": "^10.4.24",
|
||||
"axios": "^1.7.9",
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -26,7 +26,6 @@ import {
|
||||
deleteScalingPolicy,
|
||||
getServiceScalingState,
|
||||
manualScaleService,
|
||||
execInService,
|
||||
rollbackDeployment,
|
||||
cancelDeployment,
|
||||
updateServiceVariables,
|
||||
@@ -59,6 +58,7 @@ import { getCurrentUserProfile } from '@/lib/api-client';
|
||||
import { parseDotenv, validateVariableRows, type VariableDraft } from '../variable-utils';
|
||||
import { formatBytes, formatDate, formatRelative, seededMetric } from '@/lib/time';
|
||||
import { EnhancedMetricCard, LineAreaChart, DonutChart } from '@/shared/components';
|
||||
import { ServiceTerminal } from '@/features/service/components/ServiceTerminal';
|
||||
import {
|
||||
Activity,
|
||||
FileText,
|
||||
@@ -338,21 +338,6 @@ export function ServiceDetailPage() {
|
||||
},
|
||||
});
|
||||
|
||||
// --- Console ---
|
||||
const [consoleInput, setConsoleInput] = useState('');
|
||||
const [consoleHistory, setConsoleHistory] = useState<
|
||||
Array<{ command: string; output: string; exitCode: number; error: string }>
|
||||
>([]);
|
||||
const execMutation = useMutation({
|
||||
mutationFn: (command: string) => execInService(serviceId, command),
|
||||
onSuccess: (result, command) => {
|
||||
setConsoleHistory((prev) => [
|
||||
{ command, output: result.output ?? '', exitCode: result.exit_code ?? 0, error: result.error ?? '' },
|
||||
...prev,
|
||||
]);
|
||||
setConsoleInput('');
|
||||
},
|
||||
});
|
||||
const triggerCronMutation = useMutation({
|
||||
mutationFn: (id: string) => triggerCronJob(id),
|
||||
onSuccess: () => {
|
||||
@@ -1927,64 +1912,10 @@ export function ServiceDetailPage() {
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-[var(--text-primary)]">Console</h2>
|
||||
<p className="text-sm text-[var(--text-secondary)]">One-off commands via docker exec — 30s limit, 64KB output cap</p>
|
||||
<p className="text-sm text-[var(--text-secondary)]">Interactive shell inside the service container</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form
|
||||
className="flex items-center gap-2 mb-4"
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
const cmd = consoleInput.trim();
|
||||
if (cmd && !execMutation.isPending) execMutation.mutate(cmd);
|
||||
}}
|
||||
>
|
||||
<span className="text-sm font-mono text-[var(--accent-primary)]">$</span>
|
||||
<input
|
||||
value={consoleInput}
|
||||
onChange={(e) => setConsoleInput(e.target.value)}
|
||||
placeholder="e.g. env | sort | head -20"
|
||||
disabled={execMutation.isPending}
|
||||
className="flex-1 px-3 py-2 rounded-[var(--radius-md)] bg-[var(--surface-muted)] border border-[var(--border-subtle)] text-sm font-mono text-[var(--text-primary)] disabled:opacity-50"
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={execMutation.isPending || !consoleInput.trim()}
|
||||
className="px-4 py-2 rounded-[var(--radius-md)] bg-[var(--accent-primary)] text-[var(--accent-on)] text-sm font-medium disabled:opacity-50"
|
||||
>
|
||||
{execMutation.isPending ? 'Running…' : 'Run'}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
{execMutation.isError && (
|
||||
<p className="mb-4 text-xs text-[var(--error)]">
|
||||
{(execMutation.error as Error)?.message ?? 'Command failed'}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{consoleHistory.length === 0 ? (
|
||||
<p className="text-xs text-[var(--text-tertiary)]">
|
||||
Commands run inside the service's running container as its default user. No interactive shell — stdout/stderr is captured and returned.
|
||||
</p>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{consoleHistory.map((entry, i) => (
|
||||
<div key={i} className="rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-muted)] p-3">
|
||||
<div className="flex items-center justify-between mb-1">
|
||||
<p className="text-xs font-mono text-[var(--text-primary)]">$ {entry.command}</p>
|
||||
<span className={`text-xs font-mono ${entry.exitCode === 0 && !entry.error ? 'text-[var(--success)]' : 'text-[var(--error)]'}`}>
|
||||
{entry.error ? 'error' : `exit ${entry.exitCode}`}
|
||||
</span>
|
||||
</div>
|
||||
{(entry.output || entry.error) && (
|
||||
<pre className="text-xs font-mono text-[var(--text-secondary)] whitespace-pre-wrap break-all max-h-64 overflow-y-auto">
|
||||
{entry.output}{entry.error ? `\n${entry.error}` : ''}
|
||||
</pre>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<ServiceTerminal serviceId={serviceId} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
@@ -1056,6 +1056,66 @@ export interface paths {
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/services/{id}/terminal": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
/**
|
||||
* Interactive terminal (WebSocket)
|
||||
* @description Upgrades to a WebSocket bridged to a `docker exec` PTY in the service's running container (bash if present, else sh). Cookie/bearer auth as usual. Client→server JSON frames: `{"type":"stdin","data":"..."}`, `{"type":"resize","cols":N,"rows":N}`. Server→client frames: `{"type":"stdout","data":"..."}`, `{"type":"exit","code":N}`, `{"type":"error","message":"..."}`.
|
||||
*/
|
||||
get: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path: {
|
||||
id: string;
|
||||
};
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
/** @description Switching protocols — WebSocket session established */
|
||||
101: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content?: never;
|
||||
};
|
||||
/** @description Service not found or not visible */
|
||||
404: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content?: never;
|
||||
};
|
||||
/** @description Service has no running container */
|
||||
409: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content?: never;
|
||||
};
|
||||
/** @description Docker unavailable */
|
||||
503: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content?: never;
|
||||
};
|
||||
};
|
||||
};
|
||||
put?: never;
|
||||
post?: never;
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/internal/wake/{id}": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
|
||||
Reference in new issue
Block a user