mirror of
https://github.com/khoaliber/dockhand.git
synced 2026-03-09 05:39:05 +00:00
Initial commit
This commit is contained in:
349
routes/terminal/+page.svelte
Normal file
349
routes/terminal/+page.svelte
Normal file
@@ -0,0 +1,349 @@
|
||||
<script lang="ts">
|
||||
import { onMount, onDestroy } from 'svelte';
|
||||
import { page } from '$app/stores';
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
import { Input } from '$lib/components/ui/input';
|
||||
import { Label } from '$lib/components/ui/label';
|
||||
import * as Select from '$lib/components/ui/select';
|
||||
import { Search, ChevronDown, Terminal as TerminalIcon, Unplug, RefreshCw, Trash2, Copy, Shell, User } from 'lucide-svelte';
|
||||
import PageHeader from '$lib/components/PageHeader.svelte';
|
||||
import type { ContainerInfo } from '$lib/types';
|
||||
import { currentEnvironment, environments, appendEnvParam } from '$lib/stores/environment';
|
||||
import Terminal from './Terminal.svelte';
|
||||
import { NoEnvironment } from '$lib/components/ui/empty-state';
|
||||
|
||||
// Track if we've handled the initial container from URL
|
||||
let initialContainerHandled = $state(false);
|
||||
|
||||
let containers = $state<ContainerInfo[]>([]);
|
||||
let selectedContainer = $state<ContainerInfo | null>(null);
|
||||
let envId = $state<number | null>(null);
|
||||
|
||||
// Terminal component reference
|
||||
let terminalComponent: ReturnType<typeof Terminal> | undefined;
|
||||
let connected = $state(false);
|
||||
|
||||
// Shell/user options
|
||||
let selectedShell = $state('/bin/bash');
|
||||
let selectedUser = $state('root');
|
||||
let terminalFontSize = $state(14);
|
||||
|
||||
const shellOptions = [
|
||||
{ value: '/bin/bash', label: 'Bash' },
|
||||
{ value: '/bin/sh', label: 'Shell (sh)' },
|
||||
{ value: '/bin/zsh', label: 'Zsh' },
|
||||
{ value: '/bin/ash', label: 'Ash (Alpine)' }
|
||||
];
|
||||
|
||||
const userOptions = [
|
||||
{ value: 'root', label: 'root' },
|
||||
{ value: 'nobody', label: 'nobody' },
|
||||
{ value: '', label: 'Container default' }
|
||||
];
|
||||
|
||||
const fontSizeOptions = [10, 12, 14, 16, 18];
|
||||
|
||||
// Searchable dropdown state
|
||||
let searchQuery = $state('');
|
||||
let dropdownOpen = $state(false);
|
||||
|
||||
// Subscribe to environment changes
|
||||
currentEnvironment.subscribe((env) => {
|
||||
envId = env?.id ?? null;
|
||||
if (env) {
|
||||
fetchContainers();
|
||||
}
|
||||
});
|
||||
|
||||
// Filtered containers based on search
|
||||
let filteredContainers = $derived(() => {
|
||||
if (!searchQuery.trim()) return containers;
|
||||
const query = searchQuery.toLowerCase();
|
||||
return containers.filter(c =>
|
||||
c.name.toLowerCase().includes(query) ||
|
||||
c.image.toLowerCase().includes(query)
|
||||
);
|
||||
});
|
||||
|
||||
async function fetchContainers() {
|
||||
try {
|
||||
const response = await fetch(appendEnvParam('/api/containers', envId));
|
||||
const allContainers = await response.json();
|
||||
// Only show running containers
|
||||
containers = allContainers.filter((c: ContainerInfo) => c.state === 'running');
|
||||
|
||||
// If selected container is no longer running, clear selection
|
||||
if (selectedContainer && !containers.find((c) => c.id === selectedContainer?.id)) {
|
||||
clearSelection();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch containers:', error);
|
||||
}
|
||||
}
|
||||
|
||||
function selectContainer(container: ContainerInfo) {
|
||||
// Disconnect from previous container
|
||||
if (selectedContainer && terminalComponent) {
|
||||
terminalComponent.dispose();
|
||||
}
|
||||
selectedContainer = container;
|
||||
searchQuery = '';
|
||||
dropdownOpen = false;
|
||||
}
|
||||
|
||||
function clearSelection() {
|
||||
if (terminalComponent) {
|
||||
terminalComponent.dispose();
|
||||
}
|
||||
selectedContainer = null;
|
||||
searchQuery = '';
|
||||
connected = false;
|
||||
}
|
||||
|
||||
function handleInputFocus() {
|
||||
dropdownOpen = true;
|
||||
}
|
||||
|
||||
function handleInputBlur(e: FocusEvent) {
|
||||
setTimeout(() => {
|
||||
dropdownOpen = false;
|
||||
}, 200);
|
||||
}
|
||||
|
||||
function handleInputKeydown(e: KeyboardEvent) {
|
||||
if (e.key === 'Enter') {
|
||||
const filtered = filteredContainers();
|
||||
if (filtered.length > 0) {
|
||||
selectContainer(filtered[0]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Change font size
|
||||
function changeFontSize(newSize: number) {
|
||||
terminalFontSize = newSize;
|
||||
terminalComponent?.setFontSize(newSize);
|
||||
}
|
||||
|
||||
// Handle window resize
|
||||
function handleResize() {
|
||||
terminalComponent?.fit();
|
||||
}
|
||||
|
||||
onMount(async () => {
|
||||
await fetchContainers();
|
||||
|
||||
// Check for container ID in URL query parameter
|
||||
const urlContainerId = $page.url.searchParams.get('container');
|
||||
if (urlContainerId && !initialContainerHandled) {
|
||||
initialContainerHandled = true;
|
||||
const container = containers.find(c => c.id === urlContainerId || c.id.startsWith(urlContainerId));
|
||||
if (container) {
|
||||
selectContainer(container);
|
||||
}
|
||||
}
|
||||
|
||||
const containerInterval = setInterval(fetchContainers, 10000);
|
||||
|
||||
// Poll connected state from terminal component
|
||||
const connectedPollInterval = setInterval(() => {
|
||||
if (terminalComponent) {
|
||||
connected = terminalComponent.getConnected();
|
||||
}
|
||||
}, 500);
|
||||
|
||||
window.addEventListener('resize', handleResize);
|
||||
|
||||
return () => {
|
||||
clearInterval(containerInterval);
|
||||
clearInterval(connectedPollInterval);
|
||||
};
|
||||
});
|
||||
|
||||
onDestroy(() => {
|
||||
window.removeEventListener('resize', handleResize);
|
||||
terminalComponent?.dispose();
|
||||
});
|
||||
</script>
|
||||
|
||||
{#if $environments.length === 0 || !$currentEnvironment}
|
||||
<div class="flex flex-col flex-1 min-h-0 h-full">
|
||||
<PageHeader icon={TerminalIcon} title="Shell" class="h-9 mb-3" />
|
||||
<NoEnvironment />
|
||||
</div>
|
||||
{:else}
|
||||
<div class="flex flex-col flex-1 min-h-0 h-full gap-3">
|
||||
<!-- Header with container selector -->
|
||||
<div class="flex items-center gap-4 flex-wrap">
|
||||
<PageHeader icon={TerminalIcon} title="Shell" />
|
||||
<div class="relative flex-1 max-w-md min-w-[200px]">
|
||||
<!-- Search input - always visible, shows selected container or placeholder -->
|
||||
<div class="relative">
|
||||
<Search class="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground" />
|
||||
<Input
|
||||
type="text"
|
||||
placeholder={selectedContainer ? selectedContainer.name : "Search running containers..."}
|
||||
bind:value={searchQuery}
|
||||
onfocus={handleInputFocus}
|
||||
onblur={handleInputBlur}
|
||||
onkeydown={handleInputKeydown}
|
||||
class="pl-10 pr-10 h-9 {selectedContainer && !searchQuery ? 'text-foreground' : ''}"
|
||||
/>
|
||||
<ChevronDown class="absolute right-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground" />
|
||||
</div>
|
||||
|
||||
<!-- Dropdown -->
|
||||
{#if dropdownOpen}
|
||||
<div class="absolute top-full left-0 right-0 mt-1 border rounded-md bg-popover shadow-lg z-50 max-h-64 overflow-auto">
|
||||
{#if filteredContainers().length === 0}
|
||||
<div class="px-3 py-2 text-sm text-muted-foreground">
|
||||
{containers.length === 0 ? 'No running containers' : 'No matches found'}
|
||||
</div>
|
||||
{:else}
|
||||
{#each filteredContainers() as container}
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => selectContainer(container)}
|
||||
class="w-full px-3 py-2 text-left text-sm hover:bg-muted transition-colors flex items-center gap-2 {selectedContainer?.id === container.id ? 'bg-muted' : ''}"
|
||||
>
|
||||
<span class="font-medium truncate">{container.name}</span>
|
||||
<span class="text-muted-foreground text-xs truncate">({container.image})</span>
|
||||
{#if selectedContainer?.id === container.id}
|
||||
<span class="ml-auto text-xs text-primary">connected</span>
|
||||
{/if}
|
||||
</button>
|
||||
{/each}
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if selectedContainer}
|
||||
<Button size="sm" variant="ghost" onclick={clearSelection} class="h-9 px-3 text-sm text-muted-foreground hover:text-foreground">
|
||||
<Unplug class="w-4 h-4 mr-1.5" />
|
||||
Disconnect
|
||||
</Button>
|
||||
{/if}
|
||||
|
||||
{#if !selectedContainer}
|
||||
<div class="flex items-center gap-2">
|
||||
<Label class="text-sm text-muted-foreground">Shell:</Label>
|
||||
<Select.Root type="single" bind:value={selectedShell}>
|
||||
<Select.Trigger class="h-9 w-36">
|
||||
<Shell class="w-4 h-4 mr-2 text-muted-foreground" />
|
||||
<span>{shellOptions.find(o => o.value === selectedShell)?.label || 'Select'}</span>
|
||||
</Select.Trigger>
|
||||
<Select.Content>
|
||||
{#each shellOptions as option}
|
||||
<Select.Item value={option.value} label={option.label}>
|
||||
<Shell class="w-4 h-4 mr-2 text-muted-foreground" />
|
||||
{option.label}
|
||||
</Select.Item>
|
||||
{/each}
|
||||
</Select.Content>
|
||||
</Select.Root>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<Label class="text-sm text-muted-foreground">User:</Label>
|
||||
<Select.Root type="single" bind:value={selectedUser}>
|
||||
<Select.Trigger class="h-9 w-40">
|
||||
<User class="w-4 h-4 mr-2 text-muted-foreground" />
|
||||
<span>{userOptions.find(o => o.value === selectedUser)?.label || 'Select'}</span>
|
||||
</Select.Trigger>
|
||||
<Select.Content>
|
||||
{#each userOptions as option}
|
||||
<Select.Item value={option.value} label={option.label}>
|
||||
<User class="w-4 h-4 mr-2 text-muted-foreground" />
|
||||
{option.label}
|
||||
</Select.Item>
|
||||
{/each}
|
||||
</Select.Content>
|
||||
</Select.Root>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Shell output - full height -->
|
||||
<div class="flex-1 min-h-0 border rounded-lg bg-zinc-950 overflow-hidden flex flex-col">
|
||||
{#if !selectedContainer}
|
||||
<div class="flex items-center justify-center h-full text-muted-foreground">
|
||||
<div class="text-center">
|
||||
<TerminalIcon class="w-12 h-12 mx-auto mb-3 opacity-50" />
|
||||
<p>Select a container to open shell</p>
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
<!-- Header bar inside black area -->
|
||||
<div class="flex items-center justify-between px-3 py-1.5 border-b border-zinc-800 bg-zinc-900/50 shrink-0">
|
||||
<div class="flex items-center gap-2">
|
||||
{#if connected}
|
||||
<span class="inline-flex items-center gap-1 text-xs text-green-500">
|
||||
<span class="w-1.5 h-1.5 rounded-full bg-green-500 animate-pulse"></span>
|
||||
Connected
|
||||
</span>
|
||||
{:else}
|
||||
<span class="text-xs text-zinc-500">Disconnected</span>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="flex items-center gap-3">
|
||||
<Select.Root type="single" value={String(terminalFontSize)} onValueChange={(v) => changeFontSize(Number(v))}>
|
||||
<Select.Trigger class="!h-5 !py-0 w-14 bg-zinc-800 border-zinc-700 text-xs text-zinc-300 px-1.5 [&_svg]:size-3">
|
||||
<span>{terminalFontSize}px</span>
|
||||
</Select.Trigger>
|
||||
<Select.Content>
|
||||
{#each fontSizeOptions as size}
|
||||
<Select.Item value={String(size)} label="{size}px" class="pe-2 [&>span:first-child]:hidden">{size}px</Select.Item>
|
||||
{/each}
|
||||
</Select.Content>
|
||||
</Select.Root>
|
||||
<button
|
||||
onclick={() => terminalComponent?.copyOutput()}
|
||||
class="p-1 rounded hover:bg-zinc-800 transition-colors"
|
||||
title="Copy output"
|
||||
>
|
||||
<Copy class="w-3 h-3 text-zinc-500 hover:text-zinc-300" />
|
||||
</button>
|
||||
<button
|
||||
onclick={() => terminalComponent?.clear()}
|
||||
class="p-1 rounded hover:bg-zinc-800 transition-colors"
|
||||
title="Clear (Cmd+L)"
|
||||
>
|
||||
<Trash2 class="w-3 h-3 text-zinc-500 hover:text-zinc-300" />
|
||||
</button>
|
||||
<button
|
||||
onclick={() => terminalComponent?.reconnect()}
|
||||
class="p-1 rounded hover:bg-zinc-800 transition-colors"
|
||||
title="Reconnect"
|
||||
>
|
||||
<RefreshCw class="w-3 h-3 text-zinc-500 hover:text-zinc-300" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex-1 min-h-0 w-full">
|
||||
{#key selectedContainer.id}
|
||||
<Terminal
|
||||
bind:this={terminalComponent}
|
||||
containerId={selectedContainer.id}
|
||||
containerName={selectedContainer.name}
|
||||
shell={selectedShell}
|
||||
user={selectedUser}
|
||||
{envId}
|
||||
fontSize={terminalFontSize}
|
||||
/>
|
||||
{/key}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
:global(.xterm) {
|
||||
height: 100%;
|
||||
padding: 8px;
|
||||
}
|
||||
|
||||
:global(.xterm-viewport) {
|
||||
overflow-y: auto !important;
|
||||
}
|
||||
</style>
|
||||
285
routes/terminal/Terminal.svelte
Normal file
285
routes/terminal/Terminal.svelte
Normal file
@@ -0,0 +1,285 @@
|
||||
<script lang="ts">
|
||||
import { onMount, onDestroy } from 'svelte';
|
||||
import { themeStore } from '$lib/stores/theme';
|
||||
import { getMonospaceFont } from '$lib/themes';
|
||||
|
||||
// Dynamic imports for browser-only xterm
|
||||
let TerminalClass: any;
|
||||
let FitAddon: any;
|
||||
let WebLinksAddon: any;
|
||||
let xtermLoaded = $state(false);
|
||||
|
||||
interface Props {
|
||||
containerId: string;
|
||||
containerName: string;
|
||||
shell: string;
|
||||
user: string;
|
||||
envId: number | null;
|
||||
fontSize?: number;
|
||||
autoConnect?: boolean;
|
||||
}
|
||||
|
||||
let { containerId, containerName, shell, user, envId, fontSize = 13, autoConnect = true }: Props = $props();
|
||||
|
||||
let terminal: any = null;
|
||||
let fitAddon: any = null;
|
||||
let ws: WebSocket | null = null;
|
||||
let terminalRef: HTMLDivElement;
|
||||
|
||||
let connected = $state(false);
|
||||
let error = $state<string | null>(null);
|
||||
|
||||
// Expose these via bindable props
|
||||
export function getConnected() { return connected; }
|
||||
export function getError() { return error; }
|
||||
|
||||
export function clear() {
|
||||
terminal?.clear();
|
||||
terminal?.focus();
|
||||
}
|
||||
|
||||
export function focus() {
|
||||
terminal?.focus();
|
||||
}
|
||||
|
||||
export function fit() {
|
||||
fitAddon?.fit();
|
||||
}
|
||||
|
||||
export async function copyOutput(): Promise<string> {
|
||||
if (!terminal) return '';
|
||||
const buffer = terminal.buffer.active;
|
||||
let text = '';
|
||||
for (let i = 0; i < buffer.length; i++) {
|
||||
const line = buffer.getLine(i);
|
||||
if (line) {
|
||||
text += line.translateToString(true) + '\n';
|
||||
}
|
||||
}
|
||||
try {
|
||||
await navigator.clipboard.writeText(text.trim());
|
||||
} catch {
|
||||
// Ignore clipboard errors
|
||||
}
|
||||
terminal.focus();
|
||||
return text.trim();
|
||||
}
|
||||
|
||||
export function setFontSize(size: number) {
|
||||
if (terminal) {
|
||||
terminal.options.fontSize = size;
|
||||
fitAddon?.fit();
|
||||
}
|
||||
}
|
||||
|
||||
export function reconnect() {
|
||||
if (ws) {
|
||||
ws.close();
|
||||
ws = null;
|
||||
}
|
||||
connected = false;
|
||||
terminal?.writeln('\x1b[90m\r\nReconnecting...\x1b[0m');
|
||||
connect();
|
||||
}
|
||||
|
||||
export function disconnect() {
|
||||
if (ws) {
|
||||
ws.close();
|
||||
ws = null;
|
||||
}
|
||||
connected = false;
|
||||
}
|
||||
|
||||
export function dispose() {
|
||||
if (ws) ws.close();
|
||||
if (terminal) terminal.dispose();
|
||||
ws = null;
|
||||
terminal = null;
|
||||
fitAddon = null;
|
||||
}
|
||||
|
||||
function getTerminalFontFamily(): string {
|
||||
const fontMeta = getMonospaceFont($themeStore.terminalFont);
|
||||
return fontMeta?.family || 'ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace';
|
||||
}
|
||||
|
||||
function initTerminal() {
|
||||
if (!terminalRef || !xtermLoaded || terminal) return;
|
||||
|
||||
terminal = new TerminalClass({
|
||||
cursorBlink: true,
|
||||
fontFamily: getTerminalFontFamily(),
|
||||
fontSize,
|
||||
theme: {
|
||||
background: '#0c0c0c',
|
||||
foreground: '#cccccc',
|
||||
cursor: '#ffffff',
|
||||
cursorAccent: '#000000',
|
||||
selectionBackground: '#264f78',
|
||||
black: '#0c0c0c',
|
||||
red: '#c50f1f',
|
||||
green: '#13a10e',
|
||||
yellow: '#c19c00',
|
||||
blue: '#0037da',
|
||||
magenta: '#881798',
|
||||
cyan: '#3a96dd',
|
||||
white: '#cccccc',
|
||||
brightBlack: '#767676',
|
||||
brightRed: '#e74856',
|
||||
brightGreen: '#16c60c',
|
||||
brightYellow: '#f9f1a5',
|
||||
brightBlue: '#3b78ff',
|
||||
brightMagenta: '#b4009e',
|
||||
brightCyan: '#61d6d6',
|
||||
brightWhite: '#f2f2f2'
|
||||
}
|
||||
});
|
||||
|
||||
fitAddon = new FitAddon();
|
||||
terminal.loadAddon(fitAddon);
|
||||
terminal.loadAddon(new WebLinksAddon());
|
||||
|
||||
terminal.open(terminalRef);
|
||||
fitAddon.fit();
|
||||
|
||||
// Handle Ctrl+L to clear terminal
|
||||
terminal.attachCustomKeyEventHandler((e: KeyboardEvent) => {
|
||||
if ((e.ctrlKey || e.metaKey) && e.key === 'l') {
|
||||
e.preventDefault();
|
||||
clear();
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
|
||||
// Handle terminal input
|
||||
terminal.onData((data: string) => {
|
||||
if (ws && ws.readyState === WebSocket.OPEN) {
|
||||
ws.send(JSON.stringify({ type: 'input', data }));
|
||||
}
|
||||
});
|
||||
|
||||
// Handle resize
|
||||
terminal.onResize(({ cols, rows }: { cols: number; rows: number }) => {
|
||||
if (ws && ws.readyState === WebSocket.OPEN) {
|
||||
ws.send(JSON.stringify({ type: 'resize', cols, rows }));
|
||||
}
|
||||
});
|
||||
|
||||
if (autoConnect) {
|
||||
connect();
|
||||
}
|
||||
}
|
||||
|
||||
function connect() {
|
||||
if (!terminal) return;
|
||||
|
||||
error = null;
|
||||
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||
const wsHost = window.location.hostname;
|
||||
// In dev mode (vite), connect directly to the WS server on port 5174
|
||||
// In production, connect to the same port as the app
|
||||
const isDev = import.meta.env.DEV;
|
||||
const portPart = isDev ? ':5174' : (window.location.port ? `:${window.location.port}` : '');
|
||||
let wsUrl = `${protocol}//${wsHost}${portPart}/api/containers/${containerId}/exec?shell=${encodeURIComponent(shell)}&user=${encodeURIComponent(user)}`;
|
||||
if (envId) {
|
||||
wsUrl += `&envId=${envId}`;
|
||||
}
|
||||
|
||||
terminal.writeln(`\x1b[90mConnecting to ${containerName}...\x1b[0m`);
|
||||
terminal.writeln(`\x1b[90mShell: ${shell}, User: ${user || 'default'}\x1b[0m`);
|
||||
terminal.writeln('');
|
||||
|
||||
ws = new WebSocket(wsUrl);
|
||||
|
||||
ws.onopen = () => {
|
||||
connected = true;
|
||||
terminal?.focus();
|
||||
if (fitAddon && terminal) {
|
||||
const dims = fitAddon.proposeDimensions();
|
||||
if (dims) {
|
||||
ws?.send(JSON.stringify({ type: 'resize', cols: dims.cols, rows: dims.rows }));
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
ws.onmessage = (event) => {
|
||||
try {
|
||||
const msg = JSON.parse(event.data);
|
||||
if (msg.type === 'output') {
|
||||
terminal?.write(msg.data);
|
||||
} else if (msg.type === 'error') {
|
||||
error = msg.message;
|
||||
terminal?.writeln(`\x1b[31mError: ${msg.message}\x1b[0m`);
|
||||
} else if (msg.type === 'exit') {
|
||||
terminal?.writeln('\x1b[90m\r\nSession ended.\x1b[0m');
|
||||
connected = false;
|
||||
}
|
||||
} catch {
|
||||
terminal?.write(event.data);
|
||||
}
|
||||
};
|
||||
|
||||
ws.onerror = () => {
|
||||
error = 'Connection error';
|
||||
terminal?.writeln('\x1b[31mConnection error\x1b[0m');
|
||||
};
|
||||
|
||||
ws.onclose = () => {
|
||||
connected = false;
|
||||
terminal?.writeln('\x1b[90mDisconnected.\x1b[0m');
|
||||
};
|
||||
}
|
||||
|
||||
function handleResize() {
|
||||
fitAddon?.fit();
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
if (xtermLoaded && terminalRef && !terminal) {
|
||||
setTimeout(initTerminal, 50);
|
||||
}
|
||||
});
|
||||
|
||||
// Update font when terminal font preference changes
|
||||
$effect(() => {
|
||||
if (terminal && $themeStore.terminalFont) {
|
||||
const fontFamily = getTerminalFontFamily();
|
||||
terminal.options.fontFamily = fontFamily;
|
||||
fitAddon?.fit();
|
||||
}
|
||||
});
|
||||
|
||||
onMount(async () => {
|
||||
window.addEventListener('resize', handleResize);
|
||||
|
||||
const xtermModule = await import('@xterm/xterm');
|
||||
const fitModule = await import('@xterm/addon-fit');
|
||||
const webLinksModule = await import('@xterm/addon-web-links');
|
||||
|
||||
TerminalClass = xtermModule.Terminal || xtermModule.default?.Terminal;
|
||||
FitAddon = fitModule.FitAddon || fitModule.default?.FitAddon;
|
||||
WebLinksAddon = webLinksModule.WebLinksAddon || webLinksModule.default?.WebLinksAddon;
|
||||
|
||||
await import('@xterm/xterm/css/xterm.css');
|
||||
xtermLoaded = true;
|
||||
});
|
||||
|
||||
onDestroy(() => {
|
||||
window.removeEventListener('resize', handleResize);
|
||||
dispose();
|
||||
});
|
||||
</script>
|
||||
|
||||
<div bind:this={terminalRef} class="h-full w-full terminal-container"></div>
|
||||
|
||||
<style>
|
||||
.terminal-container :global(.xterm) {
|
||||
height: 100%;
|
||||
padding: 4px;
|
||||
}
|
||||
|
||||
.terminal-container :global(.xterm-viewport) {
|
||||
overflow-y: auto !important;
|
||||
}
|
||||
</style>
|
||||
343
routes/terminal/TerminalEmulator.svelte
Normal file
343
routes/terminal/TerminalEmulator.svelte
Normal file
@@ -0,0 +1,343 @@
|
||||
<script lang="ts">
|
||||
import { onMount, onDestroy } from 'svelte';
|
||||
import { RefreshCw, Copy, Trash2, Type } from 'lucide-svelte';
|
||||
import * as Select from '$lib/components/ui/select';
|
||||
|
||||
// Dynamic imports for browser-only xterm
|
||||
let Terminal: any;
|
||||
let FitAddon: any;
|
||||
let WebLinksAddon: any;
|
||||
let xtermLoaded = $state(false);
|
||||
|
||||
interface Props {
|
||||
containerId: string;
|
||||
containerName: string;
|
||||
shell: string;
|
||||
user: string;
|
||||
envId: number | null;
|
||||
class?: string;
|
||||
}
|
||||
|
||||
let { containerId, containerName, shell, user, envId, class: className = '' }: Props = $props();
|
||||
|
||||
// Single session for this terminal instance
|
||||
let terminal: any = null;
|
||||
let fitAddon: any = null;
|
||||
let ws: WebSocket | null = null;
|
||||
let sessionInitialized = $state(false);
|
||||
|
||||
let terminalRef: HTMLDivElement;
|
||||
let connected = $state(false);
|
||||
let error = $state<string | null>(null);
|
||||
|
||||
// Font size options
|
||||
let fontSize = $state(13);
|
||||
const fontSizeOptions = [10, 12, 13, 14, 16];
|
||||
|
||||
// Clear terminal
|
||||
function clearTerminal() {
|
||||
if (terminal) {
|
||||
terminal.clear();
|
||||
terminal.focus();
|
||||
}
|
||||
}
|
||||
|
||||
// Copy terminal output
|
||||
async function copyOutput() {
|
||||
if (terminal) {
|
||||
const buffer = terminal.buffer.active;
|
||||
let text = '';
|
||||
for (let i = 0; i < buffer.length; i++) {
|
||||
const line = buffer.getLine(i);
|
||||
if (line) {
|
||||
text += line.translateToString(true) + '\n';
|
||||
}
|
||||
}
|
||||
try {
|
||||
await navigator.clipboard.writeText(text.trim());
|
||||
} catch (err) {
|
||||
console.error('Failed to copy:', err);
|
||||
}
|
||||
terminal.focus();
|
||||
}
|
||||
}
|
||||
|
||||
// Update font size
|
||||
function updateFontSize(newSize: number) {
|
||||
fontSize = newSize;
|
||||
if (terminal) {
|
||||
terminal.options.fontSize = newSize;
|
||||
fitAddon?.fit();
|
||||
}
|
||||
}
|
||||
|
||||
function initTerminal() {
|
||||
if (!terminalRef || !xtermLoaded) return;
|
||||
|
||||
// If we already have a terminal, just re-attach it
|
||||
if (terminal && sessionInitialized) {
|
||||
terminal.open(terminalRef);
|
||||
fitAddon?.fit();
|
||||
terminal.focus();
|
||||
return;
|
||||
}
|
||||
|
||||
// Create new terminal
|
||||
terminal = new Terminal({
|
||||
cursorBlink: true,
|
||||
fontFamily: 'Menlo, Monaco, "Courier New", monospace',
|
||||
fontSize: fontSize,
|
||||
theme: {
|
||||
background: '#0c0c0c',
|
||||
foreground: '#cccccc',
|
||||
cursor: '#ffffff',
|
||||
cursorAccent: '#000000',
|
||||
selectionBackground: '#264f78',
|
||||
black: '#0c0c0c',
|
||||
red: '#c50f1f',
|
||||
green: '#13a10e',
|
||||
yellow: '#c19c00',
|
||||
blue: '#0037da',
|
||||
magenta: '#881798',
|
||||
cyan: '#3a96dd',
|
||||
white: '#cccccc',
|
||||
brightBlack: '#767676',
|
||||
brightRed: '#e74856',
|
||||
brightGreen: '#16c60c',
|
||||
brightYellow: '#f9f1a5',
|
||||
brightBlue: '#3b78ff',
|
||||
brightMagenta: '#b4009e',
|
||||
brightCyan: '#61d6d6',
|
||||
brightWhite: '#f2f2f2'
|
||||
}
|
||||
});
|
||||
|
||||
fitAddon = new FitAddon();
|
||||
terminal.loadAddon(fitAddon);
|
||||
terminal.loadAddon(new WebLinksAddon());
|
||||
|
||||
terminal.open(terminalRef);
|
||||
fitAddon.fit();
|
||||
|
||||
// Handle Ctrl+L to clear terminal
|
||||
terminal.attachCustomKeyEventHandler((e: KeyboardEvent) => {
|
||||
if ((e.ctrlKey || e.metaKey) && e.key === 'l') {
|
||||
e.preventDefault();
|
||||
clearTerminal();
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
|
||||
// Handle terminal input
|
||||
terminal.onData((data: string) => {
|
||||
if (ws && ws.readyState === WebSocket.OPEN) {
|
||||
ws.send(JSON.stringify({ type: 'input', data }));
|
||||
}
|
||||
});
|
||||
|
||||
// Handle resize
|
||||
terminal.onResize(({ cols, rows }: { cols: number; rows: number }) => {
|
||||
if (ws && ws.readyState === WebSocket.OPEN) {
|
||||
ws.send(JSON.stringify({ type: 'resize', cols, rows }));
|
||||
}
|
||||
});
|
||||
|
||||
sessionInitialized = true;
|
||||
|
||||
// Connect to container
|
||||
connect();
|
||||
}
|
||||
|
||||
function connect() {
|
||||
if (!terminal) return;
|
||||
|
||||
error = null;
|
||||
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||
let wsUrl = `${protocol}//${window.location.host}/api/containers/${containerId}/exec?shell=${encodeURIComponent(shell)}&user=${encodeURIComponent(user)}`;
|
||||
if (envId) {
|
||||
wsUrl += `&envId=${envId}`;
|
||||
}
|
||||
|
||||
terminal.writeln(`\x1b[90mConnecting to ${containerName}...\x1b[0m`);
|
||||
terminal.writeln(`\x1b[90mShell: ${shell}, User: ${user || 'default'}\x1b[0m`);
|
||||
terminal.writeln('');
|
||||
|
||||
ws = new WebSocket(wsUrl);
|
||||
|
||||
ws.onopen = () => {
|
||||
connected = true;
|
||||
terminal?.focus();
|
||||
// Send initial resize
|
||||
if (fitAddon && terminal) {
|
||||
const dims = fitAddon.proposeDimensions();
|
||||
if (dims) {
|
||||
ws?.send(JSON.stringify({ type: 'resize', cols: dims.cols, rows: dims.rows }));
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
ws.onmessage = (event) => {
|
||||
try {
|
||||
const msg = JSON.parse(event.data);
|
||||
if (msg.type === 'output') {
|
||||
terminal?.write(msg.data);
|
||||
} else if (msg.type === 'error') {
|
||||
error = msg.message;
|
||||
terminal?.writeln(`\x1b[31mError: ${msg.message}\x1b[0m`);
|
||||
} else if (msg.type === 'exit') {
|
||||
terminal?.writeln('\x1b[90m\r\nSession ended.\x1b[0m');
|
||||
connected = false;
|
||||
}
|
||||
} catch (e) {
|
||||
terminal?.write(event.data);
|
||||
}
|
||||
};
|
||||
|
||||
ws.onerror = () => {
|
||||
error = 'Connection error';
|
||||
terminal?.writeln('\x1b[31mConnection error\x1b[0m');
|
||||
};
|
||||
|
||||
ws.onclose = () => {
|
||||
connected = false;
|
||||
terminal?.writeln('\x1b[90mDisconnected.\x1b[0m');
|
||||
};
|
||||
}
|
||||
|
||||
function reconnect() {
|
||||
if (ws) {
|
||||
ws.close();
|
||||
ws = null;
|
||||
}
|
||||
connected = false;
|
||||
terminal?.writeln('\x1b[90m\r\nReconnecting...\x1b[0m');
|
||||
connect();
|
||||
}
|
||||
|
||||
// Handle window resize
|
||||
function handleResize() {
|
||||
if (fitAddon && terminal) {
|
||||
fitAddon.fit();
|
||||
}
|
||||
}
|
||||
|
||||
// Initialize terminal when DOM is ready
|
||||
$effect(() => {
|
||||
if (xtermLoaded && terminalRef && !sessionInitialized) {
|
||||
setTimeout(() => {
|
||||
initTerminal();
|
||||
}, 50);
|
||||
}
|
||||
});
|
||||
|
||||
// Fit terminal on mount
|
||||
$effect(() => {
|
||||
if (sessionInitialized && fitAddon && terminal) {
|
||||
setTimeout(() => {
|
||||
fitAddon?.fit();
|
||||
terminal?.focus();
|
||||
}, 50);
|
||||
}
|
||||
});
|
||||
|
||||
onMount(async () => {
|
||||
window.addEventListener('resize', handleResize);
|
||||
|
||||
// Dynamically load xterm modules
|
||||
const xtermModule = await import('@xterm/xterm');
|
||||
const fitModule = await import('@xterm/addon-fit');
|
||||
const webLinksModule = await import('@xterm/addon-web-links');
|
||||
|
||||
Terminal = xtermModule.Terminal || xtermModule.default?.Terminal;
|
||||
FitAddon = fitModule.FitAddon || fitModule.default?.FitAddon;
|
||||
WebLinksAddon = webLinksModule.WebLinksAddon || webLinksModule.default?.WebLinksAddon;
|
||||
|
||||
await import('@xterm/xterm/css/xterm.css');
|
||||
xtermLoaded = true;
|
||||
});
|
||||
|
||||
onDestroy(() => {
|
||||
window.removeEventListener('resize', handleResize);
|
||||
// Clean up
|
||||
if (ws) ws.close();
|
||||
if (terminal) terminal.dispose();
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="flex flex-col bg-zinc-950 rounded-lg border border-zinc-800 overflow-hidden {className}">
|
||||
<!-- Header bar -->
|
||||
<div class="flex items-center justify-between px-3 py-1.5 border-b border-zinc-800 bg-zinc-900/50 shrink-0">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-xs text-zinc-400">Terminal:</span>
|
||||
<span class="text-xs text-zinc-200 font-medium">{containerName}</span>
|
||||
{#if connected}
|
||||
<span class="inline-flex items-center gap-1 text-xs text-green-500">
|
||||
<span class="w-1.5 h-1.5 rounded-full bg-green-500 animate-pulse"></span>
|
||||
Connected
|
||||
</span>
|
||||
{:else}
|
||||
<span class="text-xs text-zinc-500">Disconnected</span>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<!-- Font size -->
|
||||
<Select.Root type="single" value={String(fontSize)} onValueChange={(v) => updateFontSize(Number(v))}>
|
||||
<Select.Trigger class="h-6 w-16 bg-zinc-800 border-zinc-700 text-xs text-zinc-300 px-1.5">
|
||||
<Type class="w-3 h-3 mr-1 text-zinc-400" />
|
||||
<span>{fontSize}px</span>
|
||||
</Select.Trigger>
|
||||
<Select.Content>
|
||||
{#each fontSizeOptions as size}
|
||||
<Select.Item value={String(size)} label="{size}px">
|
||||
<Type class="w-3 h-3 mr-1.5 text-muted-foreground" />
|
||||
{size}px
|
||||
</Select.Item>
|
||||
{/each}
|
||||
</Select.Content>
|
||||
</Select.Root>
|
||||
<!-- Clear -->
|
||||
<button
|
||||
onclick={clearTerminal}
|
||||
class="p-1 rounded hover:bg-zinc-800 transition-colors"
|
||||
title="Clear terminal (Ctrl+L)"
|
||||
>
|
||||
<Trash2 class="w-3 h-3 text-zinc-500 hover:text-zinc-300" />
|
||||
</button>
|
||||
<!-- Copy -->
|
||||
<button
|
||||
onclick={copyOutput}
|
||||
class="p-1 rounded hover:bg-zinc-800 transition-colors"
|
||||
title="Copy output"
|
||||
>
|
||||
<Copy class="w-3 h-3 text-zinc-500 hover:text-zinc-300" />
|
||||
</button>
|
||||
<!-- Reconnect -->
|
||||
{#if !connected}
|
||||
<button
|
||||
onclick={reconnect}
|
||||
class="flex items-center gap-1 px-1.5 py-0.5 rounded text-xs bg-amber-500/20 ring-1 ring-amber-500/50 text-amber-400 hover:bg-amber-500/30 transition-colors"
|
||||
title="Reconnect"
|
||||
>
|
||||
<RefreshCw class="w-3 h-3" />
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Terminal content -->
|
||||
<div class="flex-1 overflow-hidden p-1">
|
||||
<div bind:this={terminalRef} class="h-full w-full"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
:global(.xterm) {
|
||||
height: 100%;
|
||||
padding: 4px;
|
||||
}
|
||||
|
||||
:global(.xterm-viewport) {
|
||||
overflow-y: auto !important;
|
||||
}
|
||||
</style>
|
||||
242
routes/terminal/TerminalPanel.svelte
Normal file
242
routes/terminal/TerminalPanel.svelte
Normal file
@@ -0,0 +1,242 @@
|
||||
<script lang="ts">
|
||||
import { onMount, onDestroy } from 'svelte';
|
||||
import { X, GripHorizontal, RefreshCw, Copy, Trash2 } from 'lucide-svelte';
|
||||
import * as Select from '$lib/components/ui/select';
|
||||
import Terminal from './Terminal.svelte';
|
||||
|
||||
interface Props {
|
||||
containerId: string;
|
||||
containerName: string;
|
||||
shell: string;
|
||||
user: string;
|
||||
visible: boolean;
|
||||
envId: number | null;
|
||||
fillHeight?: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
let { containerId, containerName, shell, user, visible, envId, fillHeight = false, onClose }: Props = $props();
|
||||
|
||||
let terminalComponent: ReturnType<typeof Terminal>;
|
||||
let panelRef: HTMLDivElement;
|
||||
let connected = $state(false);
|
||||
|
||||
// Font size options
|
||||
let fontSize = $state(13);
|
||||
const fontSizeOptions = [10, 12, 13, 14, 16];
|
||||
|
||||
// Panel height with localStorage persistence
|
||||
const STORAGE_KEY = 'dockhand-terminal-panel-height';
|
||||
const SETTINGS_STORAGE_KEY = 'dockhand-terminal-settings';
|
||||
const DEFAULT_HEIGHT = 300;
|
||||
const MIN_HEIGHT = 150;
|
||||
const MAX_HEIGHT = 600;
|
||||
|
||||
let panelHeight = $state(DEFAULT_HEIGHT);
|
||||
let isDragging = $state(false);
|
||||
|
||||
// Load saved settings from localStorage
|
||||
function loadSettings() {
|
||||
if (typeof window !== 'undefined') {
|
||||
// Load height
|
||||
const savedHeight = localStorage.getItem(STORAGE_KEY);
|
||||
if (savedHeight) {
|
||||
const h = parseInt(savedHeight);
|
||||
if (!isNaN(h) && h >= MIN_HEIGHT && h <= MAX_HEIGHT) {
|
||||
panelHeight = h;
|
||||
}
|
||||
}
|
||||
// Load other settings
|
||||
const savedSettings = localStorage.getItem(SETTINGS_STORAGE_KEY);
|
||||
if (savedSettings) {
|
||||
try {
|
||||
const settings = JSON.parse(savedSettings);
|
||||
if (settings.fontSize !== undefined && fontSizeOptions.includes(settings.fontSize)) {
|
||||
fontSize = settings.fontSize;
|
||||
}
|
||||
} catch {
|
||||
// ignore parsing errors
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Save height to localStorage
|
||||
function saveHeight() {
|
||||
if (typeof window !== 'undefined') {
|
||||
localStorage.setItem(STORAGE_KEY, String(panelHeight));
|
||||
}
|
||||
}
|
||||
|
||||
// Save settings to localStorage
|
||||
function saveSettings() {
|
||||
if (typeof window !== 'undefined') {
|
||||
localStorage.setItem(SETTINGS_STORAGE_KEY, JSON.stringify({
|
||||
fontSize
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
// Drag handle functionality
|
||||
function startDrag(e: MouseEvent) {
|
||||
e.preventDefault();
|
||||
isDragging = true;
|
||||
document.addEventListener('mousemove', handleDrag);
|
||||
document.addEventListener('mouseup', stopDrag);
|
||||
}
|
||||
|
||||
function handleDrag(e: MouseEvent) {
|
||||
if (!isDragging || !panelRef) return;
|
||||
const newHeight = window.innerHeight - e.clientY;
|
||||
panelHeight = Math.max(MIN_HEIGHT, Math.min(MAX_HEIGHT, newHeight));
|
||||
}
|
||||
|
||||
function stopDrag() {
|
||||
isDragging = false;
|
||||
document.removeEventListener('mousemove', handleDrag);
|
||||
document.removeEventListener('mouseup', stopDrag);
|
||||
saveHeight();
|
||||
// Fit terminal after resize
|
||||
setTimeout(() => terminalComponent?.fit(), 50);
|
||||
}
|
||||
|
||||
// Update font size
|
||||
function updateFontSize(newSize: number) {
|
||||
fontSize = newSize;
|
||||
terminalComponent?.setFontSize(newSize);
|
||||
saveSettings();
|
||||
}
|
||||
|
||||
function handleClose() {
|
||||
terminalComponent?.dispose();
|
||||
connected = false;
|
||||
onClose();
|
||||
}
|
||||
|
||||
// Poll connected state from terminal component
|
||||
$effect(() => {
|
||||
if (terminalComponent) {
|
||||
const interval = setInterval(() => {
|
||||
connected = terminalComponent.getConnected();
|
||||
}, 500);
|
||||
return () => clearInterval(interval);
|
||||
}
|
||||
});
|
||||
|
||||
// Fit terminal and focus when becoming visible
|
||||
$effect(() => {
|
||||
if (visible && terminalComponent) {
|
||||
setTimeout(() => {
|
||||
terminalComponent?.fit();
|
||||
terminalComponent?.focus();
|
||||
}, 50);
|
||||
}
|
||||
});
|
||||
|
||||
onMount(() => {
|
||||
loadSettings();
|
||||
});
|
||||
|
||||
onDestroy(() => {
|
||||
// Clean up drag listeners if component destroyed while dragging
|
||||
document.removeEventListener('mousemove', handleDrag);
|
||||
document.removeEventListener('mouseup', stopDrag);
|
||||
});
|
||||
</script>
|
||||
|
||||
<!-- Always keep mounted, use fixed off-screen to hide while preserving xterm dimensions -->
|
||||
<div
|
||||
bind:this={panelRef}
|
||||
class="border rounded-lg bg-zinc-950 flex flex-col w-full"
|
||||
class:fixed={!visible}
|
||||
class:invisible={!visible}
|
||||
class:pointer-events-none={!visible}
|
||||
class:h-full={fillHeight}
|
||||
style="{fillHeight ? '' : `height: ${panelHeight}px;`} {!visible ? 'left: -9999px;' : ''}"
|
||||
>
|
||||
<!-- Drag handle -->
|
||||
<div
|
||||
role="separator"
|
||||
aria-orientation="horizontal"
|
||||
class="h-2 cursor-ns-resize flex items-center justify-center hover:bg-zinc-800 transition-colors rounded-t-lg"
|
||||
onmousedown={startDrag}
|
||||
>
|
||||
<GripHorizontal class="w-8 h-4 text-zinc-600" />
|
||||
</div>
|
||||
|
||||
<!-- Header -->
|
||||
<div class="flex items-center justify-between px-3 py-1.5 border-b border-zinc-800 bg-zinc-900/50">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-xs text-zinc-400">Terminal:</span>
|
||||
<span class="text-xs text-zinc-200 font-medium">{containerName}</span>
|
||||
{#if connected}
|
||||
<span class="inline-flex items-center gap-1 text-xs text-green-500">
|
||||
<span class="w-1.5 h-1.5 rounded-full bg-green-500 animate-pulse"></span>
|
||||
Connected
|
||||
</span>
|
||||
{:else}
|
||||
<span class="text-xs text-zinc-500">Disconnected</span>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<!-- Font size -->
|
||||
<Select.Root type="single" value={String(fontSize)} onValueChange={(v) => updateFontSize(Number(v))}>
|
||||
<Select.Trigger size="sm" class="!h-5 !py-0 w-14 bg-zinc-800 border-zinc-700 text-xs text-zinc-300 px-1.5 [&_svg]:size-3">
|
||||
<span>{fontSize}px</span>
|
||||
</Select.Trigger>
|
||||
<Select.Content>
|
||||
{#each fontSizeOptions as size}
|
||||
<Select.Item value={String(size)} label="{size}px" class="pe-2 [&>span:first-child]:hidden">{size}px</Select.Item>
|
||||
{/each}
|
||||
</Select.Content>
|
||||
</Select.Root>
|
||||
<!-- Clear -->
|
||||
<button
|
||||
onclick={() => terminalComponent?.clear()}
|
||||
class="p-1 rounded hover:bg-zinc-800 transition-colors"
|
||||
title="Clear terminal (Ctrl+L)"
|
||||
>
|
||||
<Trash2 class="w-3 h-3 text-zinc-500 hover:text-zinc-300" />
|
||||
</button>
|
||||
<!-- Copy -->
|
||||
<button
|
||||
onclick={() => terminalComponent?.copyOutput()}
|
||||
class="p-1 rounded hover:bg-zinc-800 transition-colors"
|
||||
title="Copy output"
|
||||
>
|
||||
<Copy class="w-3 h-3 text-zinc-500 hover:text-zinc-300" />
|
||||
</button>
|
||||
<!-- Reconnect -->
|
||||
{#if !connected}
|
||||
<button
|
||||
onclick={() => terminalComponent?.reconnect()}
|
||||
class="flex items-center gap-1 px-1.5 py-0.5 rounded text-xs bg-amber-500/20 ring-1 ring-amber-500/50 text-amber-400 hover:bg-amber-500/30 transition-colors"
|
||||
title="Reconnect"
|
||||
>
|
||||
<RefreshCw class="w-3 h-3" />
|
||||
</button>
|
||||
{/if}
|
||||
<!-- Close -->
|
||||
<button
|
||||
onclick={handleClose}
|
||||
class="p-1 rounded hover:bg-zinc-800 transition-colors"
|
||||
title="Close terminal"
|
||||
>
|
||||
<X class="w-3 h-3 text-zinc-500 hover:text-zinc-300" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Terminal content -->
|
||||
<div class="flex-1 overflow-hidden p-1">
|
||||
<Terminal
|
||||
bind:this={terminalComponent}
|
||||
{containerId}
|
||||
{containerName}
|
||||
{shell}
|
||||
{user}
|
||||
{envId}
|
||||
{fontSize}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
251
routes/terminal/[id]/+page.svelte
Normal file
251
routes/terminal/[id]/+page.svelte
Normal file
@@ -0,0 +1,251 @@
|
||||
<script lang="ts">
|
||||
import { page } from '$app/stores';
|
||||
import { onMount, onDestroy } from 'svelte';
|
||||
import { Terminal as TerminalIcon } from 'lucide-svelte';
|
||||
|
||||
// Dynamic imports for browser-only xterm
|
||||
let Terminal: any;
|
||||
let FitAddon: any;
|
||||
let WebLinksAddon: any;
|
||||
let xtermLoaded = $state(false);
|
||||
|
||||
let terminalRef: HTMLDivElement | undefined;
|
||||
let terminal: any = null;
|
||||
let fitAddon: any = null;
|
||||
let ws: WebSocket | null = null;
|
||||
let connected = $state(false);
|
||||
let error = $state<string | null>(null);
|
||||
let containerName = $state('');
|
||||
|
||||
// Get params from URL
|
||||
let containerId = $derived($page.params.id);
|
||||
let shell = $derived($page.url.searchParams.get('shell') || '/bin/bash');
|
||||
let user = $derived($page.url.searchParams.get('user') || 'root');
|
||||
let name = $derived($page.url.searchParams.get('name') || 'Container');
|
||||
|
||||
function initTerminal() {
|
||||
if (!terminalRef || terminal || !xtermLoaded) return;
|
||||
|
||||
containerName = name;
|
||||
|
||||
terminal = new Terminal({
|
||||
cursorBlink: true,
|
||||
fontFamily: 'Menlo, Monaco, "Courier New", monospace',
|
||||
fontSize: 14,
|
||||
theme: {
|
||||
background: '#0c0c0c',
|
||||
foreground: '#cccccc',
|
||||
cursor: '#ffffff',
|
||||
cursorAccent: '#000000',
|
||||
selectionBackground: '#264f78',
|
||||
black: '#0c0c0c',
|
||||
red: '#c50f1f',
|
||||
green: '#13a10e',
|
||||
yellow: '#c19c00',
|
||||
blue: '#0037da',
|
||||
magenta: '#881798',
|
||||
cyan: '#3a96dd',
|
||||
white: '#cccccc',
|
||||
brightBlack: '#767676',
|
||||
brightRed: '#e74856',
|
||||
brightGreen: '#16c60c',
|
||||
brightYellow: '#f9f1a5',
|
||||
brightBlue: '#3b78ff',
|
||||
brightMagenta: '#b4009e',
|
||||
brightCyan: '#61d6d6',
|
||||
brightWhite: '#f2f2f2'
|
||||
}
|
||||
});
|
||||
|
||||
fitAddon = new FitAddon();
|
||||
terminal.loadAddon(fitAddon);
|
||||
terminal.loadAddon(new WebLinksAddon());
|
||||
|
||||
terminal.open(terminalRef);
|
||||
fitAddon.fit();
|
||||
|
||||
// Handle terminal input
|
||||
terminal.onData((data: string) => {
|
||||
if (ws && ws.readyState === WebSocket.OPEN) {
|
||||
ws.send(JSON.stringify({ type: 'input', data }));
|
||||
}
|
||||
});
|
||||
|
||||
// Handle resize
|
||||
terminal.onResize(({ cols, rows }: { cols: number; rows: number }) => {
|
||||
if (ws && ws.readyState === WebSocket.OPEN) {
|
||||
ws.send(JSON.stringify({ type: 'resize', cols, rows }));
|
||||
}
|
||||
});
|
||||
|
||||
// Connect to container
|
||||
connect();
|
||||
}
|
||||
|
||||
function connect() {
|
||||
if (!terminal) return;
|
||||
|
||||
error = null;
|
||||
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||
const wsUrl = `${protocol}//${window.location.host}/api/containers/${containerId}/exec?shell=${encodeURIComponent(shell)}&user=${encodeURIComponent(user)}`;
|
||||
|
||||
terminal.writeln(`\x1b[90mConnecting to ${name}...\x1b[0m`);
|
||||
terminal.writeln(`\x1b[90mShell: ${shell}, User: ${user || 'default'}\x1b[0m`);
|
||||
terminal.writeln('');
|
||||
|
||||
ws = new WebSocket(wsUrl);
|
||||
|
||||
ws.onopen = () => {
|
||||
connected = true;
|
||||
document.title = `Terminal - ${name}`;
|
||||
terminal?.focus();
|
||||
// Send initial resize
|
||||
if (fitAddon && terminal) {
|
||||
const dims = fitAddon.proposeDimensions();
|
||||
if (dims) {
|
||||
ws?.send(JSON.stringify({ type: 'resize', cols: dims.cols, rows: dims.rows }));
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
ws.onmessage = (event) => {
|
||||
try {
|
||||
const msg = JSON.parse(event.data);
|
||||
if (msg.type === 'output') {
|
||||
terminal?.write(msg.data);
|
||||
} else if (msg.type === 'error') {
|
||||
error = msg.message;
|
||||
terminal?.writeln(`\x1b[31mError: ${msg.message}\x1b[0m`);
|
||||
} else if (msg.type === 'exit') {
|
||||
terminal?.writeln('\x1b[90m\r\nSession ended.\x1b[0m');
|
||||
connected = false;
|
||||
// Close the window after a brief delay so user sees the message
|
||||
setTimeout(() => {
|
||||
window.close();
|
||||
}, 500);
|
||||
}
|
||||
} catch (e) {
|
||||
terminal?.write(event.data);
|
||||
}
|
||||
};
|
||||
|
||||
ws.onerror = (e) => {
|
||||
console.error('WebSocket error:', e);
|
||||
error = 'Connection error';
|
||||
terminal?.writeln('\x1b[31mConnection error\x1b[0m');
|
||||
};
|
||||
|
||||
ws.onclose = () => {
|
||||
connected = false;
|
||||
terminal?.writeln('\x1b[90mDisconnected.\x1b[0m');
|
||||
};
|
||||
}
|
||||
|
||||
function disconnect() {
|
||||
if (ws) {
|
||||
ws.close();
|
||||
ws = null;
|
||||
}
|
||||
}
|
||||
|
||||
function cleanup() {
|
||||
disconnect();
|
||||
if (terminal) {
|
||||
terminal.dispose();
|
||||
terminal = null;
|
||||
}
|
||||
fitAddon = null;
|
||||
}
|
||||
|
||||
// Handle window resize
|
||||
function handleResize() {
|
||||
if (fitAddon && terminal) {
|
||||
fitAddon.fit();
|
||||
}
|
||||
}
|
||||
|
||||
onMount(async () => {
|
||||
window.addEventListener('resize', handleResize);
|
||||
|
||||
// Dynamically load xterm modules (browser only)
|
||||
const xtermModule = await import('@xterm/xterm');
|
||||
const fitModule = await import('@xterm/addon-fit');
|
||||
const webLinksModule = await import('@xterm/addon-web-links');
|
||||
|
||||
// Handle both ESM and CommonJS exports
|
||||
Terminal = xtermModule.Terminal || xtermModule.default?.Terminal;
|
||||
FitAddon = fitModule.FitAddon || fitModule.default?.FitAddon;
|
||||
WebLinksAddon = webLinksModule.WebLinksAddon || webLinksModule.default?.WebLinksAddon;
|
||||
|
||||
// Load CSS
|
||||
await import('@xterm/xterm/css/xterm.css');
|
||||
xtermLoaded = true;
|
||||
|
||||
// Initialize terminal after xterm is loaded
|
||||
setTimeout(() => {
|
||||
initTerminal();
|
||||
}, 100);
|
||||
});
|
||||
|
||||
onDestroy(() => {
|
||||
window.removeEventListener('resize', handleResize);
|
||||
cleanup();
|
||||
});
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Terminal - {containerName || 'Loading...'}</title>
|
||||
</svelte:head>
|
||||
|
||||
<div class="h-screen w-screen flex flex-col bg-[#0c0c0c]">
|
||||
<!-- Header -->
|
||||
<div class="flex items-center justify-between px-4 py-2 bg-zinc-900 border-b border-zinc-800 flex-shrink-0">
|
||||
<div class="flex items-center gap-2">
|
||||
<TerminalIcon class="w-4 h-4 text-zinc-400" />
|
||||
<span class="text-sm text-zinc-200 font-medium">{containerName}</span>
|
||||
{#if connected}
|
||||
<span class="inline-flex items-center gap-1 text-xs text-green-500">
|
||||
<span class="w-2 h-2 rounded-full bg-green-500 animate-pulse"></span>
|
||||
Connected
|
||||
</span>
|
||||
{:else if error}
|
||||
<span class="text-xs text-red-500">{error}</span>
|
||||
{:else}
|
||||
<span class="text-xs text-zinc-500">Connecting...</span>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="flex items-center gap-2 text-xs text-zinc-500">
|
||||
<span>Shell: {shell}</span>
|
||||
<span>|</span>
|
||||
<span>User: {user}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Terminal -->
|
||||
<div class="flex-1 p-2 overflow-hidden">
|
||||
{#if xtermLoaded}
|
||||
<div bind:this={terminalRef} class="h-full w-full"></div>
|
||||
{:else}
|
||||
<div class="h-full w-full flex items-center justify-center">
|
||||
<span class="text-zinc-500">Loading terminal...</span>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
:global(body) {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
:global(.xterm) {
|
||||
height: 100%;
|
||||
padding: 8px;
|
||||
}
|
||||
|
||||
:global(.xterm-viewport) {
|
||||
overflow-y: auto !important;
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user