Fix dependencies, SSL config, terminal UI, WebSocket client

- Fix @nestjs/typeorm version (^11.0.0), add socket.io
- Add SSL CA cert support for Timeweb Cloud PostgreSQL
- Add terminal component (xterm.js) with WebSocket streaming
- Add session detail page with live terminal view
- Add socket.io client utilities for real-time updates

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
claude 2026-02-18 18:20:28 +05:00
parent ec4fb1155b
commit f774e0f4f1
14 changed files with 7605 additions and 31 deletions

1
.gitignore vendored
View File

@ -4,3 +4,4 @@ dist/
target/ target/
.env .env
*.log *.log
ca.crt

View File

@ -9,16 +9,16 @@
"start:prod": "node dist/main" "start:prod": "node dist/main"
}, },
"dependencies": { "dependencies": {
"@nestjs/common": "^11.0.0", "@nestjs/common": "^11.1.0",
"@nestjs/config": "^4.0.0", "@nestjs/config": "^4.0.0",
"@nestjs/core": "^11.0.0", "@nestjs/core": "^11.1.0",
"@nestjs/platform-express": "^11.0.0", "@nestjs/platform-express": "^11.1.0",
"@nestjs/websockets": "^11.0.0", "@nestjs/websockets": "^11.1.0",
"@nestjs/platform-socket.io": "^11.0.0", "@nestjs/platform-socket.io": "^11.1.0",
"@nestjs/jwt": "^11.0.0", "@nestjs/jwt": "^11.0.0",
"@nestjs/passport": "^11.0.0", "@nestjs/passport": "^11.0.0",
"@nestjs/typeorm": "^0.3.0", "@nestjs/typeorm": "^11.0.0",
"typeorm": "^0.3.0", "typeorm": "^0.3.20",
"pg": "^8.13.0", "pg": "^8.13.0",
"passport": "^0.7.0", "passport": "^0.7.0",
"passport-jwt": "^4.0.0", "passport-jwt": "^4.0.0",
@ -26,7 +26,8 @@
"class-validator": "^0.14.0", "class-validator": "^0.14.0",
"class-transformer": "^0.5.0", "class-transformer": "^0.5.0",
"reflect-metadata": "^0.2.0", "reflect-metadata": "^0.2.0",
"rxjs": "^7.8.0" "rxjs": "^7.8.0",
"socket.io": "^4.8.0"
}, },
"devDependencies": { "devDependencies": {
"@nestjs/cli": "^11.0.0", "@nestjs/cli": "^11.0.0",

View File

@ -1,6 +1,8 @@
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm'; import { TypeOrmModule } from '@nestjs/typeorm';
import { ConfigModule, ConfigService } from '@nestjs/config'; import { ConfigModule, ConfigService } from '@nestjs/config';
import { readFileSync } from 'fs';
import { join } from 'path';
import { AuthModule } from './auth/auth.module'; import { AuthModule } from './auth/auth.module';
import { UsersModule } from './users/users.module'; import { UsersModule } from './users/users.module';
import { MachinesModule } from './machines/machines.module'; import { MachinesModule } from './machines/machines.module';
@ -9,6 +11,18 @@ import { WorkspacesModule } from './workspaces/workspaces.module';
import { SessionsModule } from './sessions/sessions.module'; import { SessionsModule } from './sessions/sessions.module';
import { AgentGateway } from './gateway/agent.gateway'; import { AgentGateway } from './gateway/agent.gateway';
function buildSslConfig(config: ConfigService): false | object {
if (config.get('DATABASE_SSL', 'true') !== 'true') return false;
const caPath = config.get('DATABASE_CA_CERT', '');
if (caPath) {
const resolved = caPath.startsWith('/') || caPath.match(/^[A-Z]:/i)
? caPath
: join(process.cwd(), caPath);
return { rejectUnauthorized: true, ca: readFileSync(resolved).toString() };
}
return { rejectUnauthorized: false };
}
@Module({ @Module({
imports: [ imports: [
ConfigModule.forRoot({ isGlobal: true }), ConfigModule.forRoot({ isGlobal: true }),
@ -22,9 +36,7 @@ import { AgentGateway } from './gateway/agent.gateway';
username: config.get('DATABASE_USER', 'gen_user'), username: config.get('DATABASE_USER', 'gen_user'),
password: config.get('DATABASE_PASSWORD', ''), password: config.get('DATABASE_PASSWORD', ''),
database: config.get('DATABASE_NAME', 'default_db'), database: config.get('DATABASE_NAME', 'default_db'),
ssl: config.get('DATABASE_SSL', 'true') === 'true' ssl: buildSslConfig(config),
? { rejectUnauthorized: true }
: false,
autoLoadEntities: true, autoLoadEntities: true,
synchronize: config.get('NODE_ENV') !== 'production', synchronize: config.get('NODE_ENV') !== 'production',
}), }),

View File

@ -16,7 +16,7 @@ export class SessionsService {
} }
async findById(id: string) { async findById(id: string) {
const session = await this.repo.findOne({ where: { id }, relations: ['workspace'] }); const session = await this.repo.findOne({ where: { id }, relations: ['workspace', 'workspace.machine'] });
if (!session) throw new NotFoundException('Session not found'); if (!session) throw new NotFoundException('Session not found');
return session; return session;
} }

6
apps/web/next-env.d.ts vendored Normal file
View File

@ -0,0 +1,6 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
/// <reference path="./.next/types/routes.d.ts" />
// NOTE: This file should not be edited
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.

View File

@ -1,7 +1,7 @@
'use client'; 'use client';
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { useParams } from 'next/navigation'; import { useParams, useRouter } from 'next/navigation';
import { api } from '@/lib/api'; import { api } from '@/lib/api';
interface Workspace { interface Workspace {
@ -21,12 +21,14 @@ interface Project {
export default function ProjectDetailPage() { export default function ProjectDetailPage() {
const params = useParams(); const params = useParams();
const router = useRouter();
const [project, setProject] = useState<Project | null>(null); const [project, setProject] = useState<Project | null>(null);
const [machines, setMachines] = useState<any[]>([]); const [machines, setMachines] = useState<any[]>([]);
const [showAdd, setShowAdd] = useState(false); const [showAdd, setShowAdd] = useState(false);
const [machineId, setMachineId] = useState(''); const [machineId, setMachineId] = useState('');
const [path, setPath] = useState(''); const [path, setPath] = useState('');
const [gitUrl, setGitUrl] = useState(''); const [gitUrl, setGitUrl] = useState('');
const [launchingWs, setLaunchingWs] = useState<string | null>(null);
const load = () => { const load = () => {
api.get(`/projects/${params.id}`).then(setProject).catch(() => {}); api.get(`/projects/${params.id}`).then(setProject).catch(() => {});
@ -41,6 +43,16 @@ export default function ProjectDetailPage() {
load(); load();
}; };
const openTerminal = async (ws: Workspace) => {
setLaunchingWs(ws.id);
try {
const session = await api.post('/sessions', { workspaceId: ws.id });
router.push(`/sessions/${session.id}`);
} catch {
setLaunchingWs(null);
}
};
if (!project) return <div>Loading...</div>; if (!project) return <div>Loading...</div>;
return ( return (
@ -82,12 +94,27 @@ export default function ProjectDetailPage() {
<div style={{ fontSize: 13, color: 'var(--text-muted)' }}>{ws.path || 'No path set'}</div> <div style={{ fontSize: 13, color: 'var(--text-muted)' }}>{ws.path || 'No path set'}</div>
{ws.gitUrl && <div style={{ fontSize: 13, color: 'var(--text-muted)' }}>{ws.gitUrl} @ {ws.branch}</div>} {ws.gitUrl && <div style={{ fontSize: 13, color: 'var(--text-muted)' }}>{ws.gitUrl} @ {ws.branch}</div>}
</div> </div>
<div style={{ <div style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
padding: '4px 12px', borderRadius: 20, fontSize: 12, fontWeight: 600, {ws.machine?.status === 'online' && (
background: ws.machine?.status === 'online' ? 'rgba(34,197,94,0.15)' : 'rgba(239,68,68,0.15)', <button
color: ws.machine?.status === 'online' ? 'var(--success)' : 'var(--danger)', onClick={() => openTerminal(ws)}
}}> disabled={launchingWs === ws.id}
{ws.machine?.status === 'online' ? '\u25CF Online' : '\u25CB Offline'} style={{
padding: '4px 12px', borderRadius: 8, fontSize: 12, fontWeight: 600,
background: 'var(--primary)', color: '#fff', border: 'none', cursor: 'pointer',
opacity: launchingWs === ws.id ? 0.6 : 1,
}}
>
{launchingWs === ws.id ? 'Opening...' : 'Terminal'}
</button>
)}
<div style={{
padding: '4px 12px', borderRadius: 20, fontSize: 12, fontWeight: 600,
background: ws.machine?.status === 'online' ? 'rgba(34,197,94,0.15)' : 'rgba(239,68,68,0.15)',
color: ws.machine?.status === 'online' ? 'var(--success)' : 'var(--danger)',
}}>
{ws.machine?.status === 'online' ? '\u25CF Online' : '\u25CB Offline'}
</div>
</div> </div>
</div> </div>
</div> </div>

View File

@ -0,0 +1,127 @@
'use client';
import { useEffect, useRef, useState } from 'react';
import { useParams, useRouter } from 'next/navigation';
import { api } from '@/lib/api';
import { useSocket } from '@/lib/use-socket';
import TerminalViewer from '@/components/terminal-wrapper';
interface Session {
id: string;
status: string;
command: string;
startedAt: string;
stoppedAt: string | null;
workspace: {
id: string;
path: string;
machine: { id: string; name: string; status: string };
};
}
export default function SessionDetailPage() {
const params = useParams();
const router = useRouter();
const { socket, connected } = useSocket();
const [session, setSession] = useState<Session | null>(null);
const [error, setError] = useState('');
const startedRef = useRef(false);
useEffect(() => {
api.get(`/sessions/${params.id}`).then(setSession).catch((e) => setError(e.message));
}, [params.id]);
useEffect(() => {
if (!session || !connected || startedRef.current) return;
startedRef.current = true;
const ageMs = Date.now() - new Date(session.startedAt).getTime();
if (session.status === 'running' && ageMs < 10_000) {
socket.emit('session:start', {
sessionId: session.id,
machineId: session.workspace.machine.id,
command: session.command,
});
}
}, [session, connected, socket]);
useEffect(() => {
if (!session) return;
const onStatus = (data: { status: string }) => {
setSession((prev) => (prev ? { ...prev, status: data.status } : prev));
};
socket.on(`session:status:${session.id}`, onStatus);
return () => { socket.off(`session:status:${session.id}`, onStatus); };
}, [session, socket]);
const handleStop = () => {
if (!session) return;
socket.emit('session:stop', {
sessionId: session.id,
machineId: session.workspace.machine.id,
});
};
if (error) return <div style={{ color: 'var(--danger)', padding: 40 }}>Error: {error}</div>;
if (!session) return <div style={{ padding: 40 }}>Loading...</div>;
return (
<div style={{ display: 'flex', flexDirection: 'column', height: 'calc(100vh - 48px)' }}>
<div style={{
display: 'flex', justifyContent: 'space-between', alignItems: 'center',
marginBottom: 16, flexShrink: 0,
}}>
<div>
<div style={{ display: 'flex', alignItems: 'center', gap: 12, marginBottom: 4 }}>
<button
onClick={() => router.push('/sessions')}
style={{
background: 'none', border: 'none', color: 'var(--text-muted)',
cursor: 'pointer', fontSize: 14, padding: 0,
}}
>
&larr; Sessions
</button>
<h2 style={{ fontSize: 20, margin: 0 }}>{session.command || 'Terminal'}</h2>
</div>
<div style={{ fontSize: 13, color: 'var(--text-muted)' }}>
{session.workspace.machine.name} &mdash; {session.workspace.path || 'default'}
{' '}&bull;{' '}
<span style={{
color: connected ? 'var(--success)' : 'var(--danger)',
}}>
{connected ? 'Connected' : 'Disconnected'}
</span>
</div>
</div>
<div style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
<span style={{
padding: '4px 12px', borderRadius: 20, fontSize: 12, fontWeight: 600,
background: session.status === 'running' ? 'rgba(34,197,94,0.15)' : 'rgba(136,136,136,0.15)',
color: session.status === 'running' ? 'var(--success)' : 'var(--text-muted)',
}}>
{session.status}
</span>
{session.status === 'running' && (
<button
onClick={handleStop}
style={{
padding: '6px 16px', background: 'var(--danger)', color: '#fff',
border: 'none', borderRadius: 8, cursor: 'pointer', fontSize: 13,
}}
>
Stop
</button>
)}
</div>
</div>
<div style={{ flex: 1, minHeight: 0 }}>
<TerminalViewer
sessionId={session.id}
machineId={session.workspace.machine.id}
readOnly={session.status !== 'running'}
/>
</div>
</div>
);
}

View File

@ -1,13 +1,18 @@
'use client'; 'use client';
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { useRouter } from 'next/navigation';
import { api } from '@/lib/api'; import { api } from '@/lib/api';
export default function SessionsPage() { export default function SessionsPage() {
const router = useRouter();
const [sessions, setSessions] = useState<any[]>([]); const [sessions, setSessions] = useState<any[]>([]);
const [workspaces, setWorkspaces] = useState<any[]>([]); const [workspaces, setWorkspaces] = useState<any[]>([]);
const [showNew, setShowNew] = useState(false);
const [newWsId, setNewWsId] = useState('');
const [newCmd, setNewCmd] = useState('');
useEffect(() => { const load = () => {
api.get('/projects').then(async (projects: any[]) => { api.get('/projects').then(async (projects: any[]) => {
const allWs: any[] = []; const allWs: any[] = [];
for (const p of projects) { for (const p of projects) {
@ -22,17 +27,57 @@ export default function SessionsPage() {
} }
setSessions(allSessions); setSessions(allSessions);
}).catch(() => {}); }).catch(() => {});
}, []); };
useEffect(() => { load(); }, []);
const createSession = async () => {
if (!newWsId) return;
const session = await api.post('/sessions', { workspaceId: newWsId, command: newCmd || undefined });
setShowNew(false);
setNewWsId('');
setNewCmd('');
router.push(`/sessions/${session.id}`);
};
return ( return (
<div> <div>
<h2 style={{ fontSize: 24, marginBottom: 24 }}>Sessions</h2> <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 24 }}>
<h2 style={{ fontSize: 24, margin: 0 }}>Sessions</h2>
<button onClick={() => setShowNew(!showNew)} style={{
padding: '6px 12px', background: 'var(--primary)', color: '#fff',
border: 'none', borderRadius: 8, cursor: 'pointer', fontSize: 13,
}}>+ New Session</button>
</div>
{showNew && (
<div style={{ background: 'var(--bg-card)', border: '1px solid var(--border)', borderRadius: 12, padding: 20, marginBottom: 16 }}>
<select value={newWsId} onChange={e => setNewWsId(e.target.value)}
style={{ width: '100%', padding: '8px 12px', marginBottom: 8, background: 'var(--bg)', border: '1px solid var(--border)', borderRadius: 8, color: 'var(--text)', fontSize: 14 }}>
<option value="">Select workspace...</option>
{workspaces.map((ws: any) => (
<option key={ws.id} value={ws.id}>{ws.machine?.name} {ws.path || 'default'}</option>
))}
</select>
<input placeholder="Command (optional, default: cmd.exe)" value={newCmd} onChange={e => setNewCmd(e.target.value)}
style={{ width: '100%', padding: '8px 12px', marginBottom: 8, background: 'var(--bg)', border: '1px solid var(--border)', borderRadius: 8, color: 'var(--text)', fontSize: 14 }} />
<button onClick={createSession} style={{ padding: '8px 16px', background: 'var(--primary)', color: '#fff', border: 'none', borderRadius: 8, cursor: 'pointer' }}>Create & Connect</button>
</div>
)}
<div style={{ display: 'grid', gap: 12 }}> <div style={{ display: 'grid', gap: 12 }}>
{sessions.map(s => ( {sessions.map(s => (
<div key={s.id} style={{ <div
background: 'var(--bg-card)', border: '1px solid var(--border)', borderRadius: 12, padding: 20, key={s.id}
display: 'flex', justifyContent: 'space-between', alignItems: 'center', onClick={() => router.push(`/sessions/${s.id}`)}
}}> style={{
background: 'var(--bg-card)', border: '1px solid var(--border)', borderRadius: 12, padding: 20,
display: 'flex', justifyContent: 'space-between', alignItems: 'center',
cursor: 'pointer', transition: 'border-color 0.15s',
}}
onMouseEnter={e => (e.currentTarget.style.borderColor = 'var(--primary)')}
onMouseLeave={e => (e.currentTarget.style.borderColor = 'var(--border)')}
>
<div> <div>
<div style={{ fontWeight: 600, marginBottom: 4 }}>{s.command || 'Session'}</div> <div style={{ fontWeight: 600, marginBottom: 4 }}>{s.command || 'Session'}</div>
<div style={{ fontSize: 13, color: 'var(--text-muted)' }}>{s.machineName} {s.workspacePath}</div> <div style={{ fontSize: 13, color: 'var(--text-muted)' }}>{s.machineName} {s.workspacePath}</div>

View File

@ -0,0 +1,7 @@
'use client';
import dynamic from 'next/dynamic';
const TerminalViewer = dynamic(() => import('./terminal'), { ssr: false });
export default TerminalViewer;

View File

@ -0,0 +1,106 @@
'use client';
import { useEffect, useRef } from 'react';
import { Terminal } from '@xterm/xterm';
import { FitAddon } from '@xterm/addon-fit';
import '@xterm/xterm/css/xterm.css';
import { socket } from '@/lib/socket';
interface TerminalViewerProps {
sessionId: string;
machineId: string;
readOnly?: boolean;
}
export default function TerminalViewer({ sessionId, machineId, readOnly }: TerminalViewerProps) {
const containerRef = useRef<HTMLDivElement>(null);
const termRef = useRef<Terminal | null>(null);
useEffect(() => {
if (!containerRef.current) return;
const term = new Terminal({
cursorBlink: !readOnly,
fontSize: 14,
fontFamily: 'Menlo, Monaco, "Courier New", monospace',
theme: {
background: '#0a0a0a',
foreground: '#e5e5e5',
cursor: '#3b82f6',
selectionBackground: 'rgba(59,130,246,0.3)',
black: '#0a0a0a',
brightBlack: '#666666',
red: '#ef4444',
brightRed: '#f87171',
green: '#22c55e',
brightGreen: '#4ade80',
yellow: '#eab308',
brightYellow: '#facc15',
blue: '#3b82f6',
brightBlue: '#60a5fa',
magenta: '#a855f7',
brightMagenta: '#c084fc',
cyan: '#06b6d4',
brightCyan: '#22d3ee',
white: '#e5e5e5',
brightWhite: '#ffffff',
},
});
const fitAddon = new FitAddon();
term.loadAddon(fitAddon);
term.open(containerRef.current);
fitAddon.fit();
termRef.current = term;
// Subscribe to session output
socket.emit('session:subscribe', { sessionId });
const onOutput = (data: { output: string }) => {
term.write(data.output);
};
socket.on(`session:output:${sessionId}`, onOutput);
const onStatus = (data: { status: string }) => {
if (data.status === 'stopped' || data.status === 'error') {
term.write('\r\n\x1b[90m--- Session ended ---\x1b[0m\r\n');
term.options.cursorBlink = false;
}
};
socket.on(`session:status:${sessionId}`, onStatus);
// Send input to agent
if (!readOnly) {
term.onData((data) => {
socket.emit('session:input', { sessionId, machineId, input: data });
});
}
// Resize handling
const observer = new ResizeObserver(() => {
fitAddon.fit();
});
observer.observe(containerRef.current);
return () => {
observer.disconnect();
socket.off(`session:output:${sessionId}`, onOutput);
socket.off(`session:status:${sessionId}`, onStatus);
term.dispose();
};
}, [sessionId, machineId, readOnly]);
return (
<div
ref={containerRef}
style={{
width: '100%',
height: '100%',
minHeight: 400,
borderRadius: 8,
overflow: 'hidden',
background: '#0a0a0a',
}}
/>
);
}

View File

@ -0,0 +1,8 @@
import { io } from 'socket.io-client';
const WS_URL = process.env.NEXT_PUBLIC_WS_URL || 'http://localhost:3001/ws';
export const socket = io(WS_URL, {
autoConnect: false,
transports: ['websocket'],
});

View File

@ -0,0 +1,23 @@
import { useEffect, useState } from 'react';
import { socket } from './socket';
export function useSocket() {
const [connected, setConnected] = useState(socket.connected);
useEffect(() => {
if (!socket.connected) socket.connect();
const onConnect = () => setConnected(true);
const onDisconnect = () => setConnected(false);
socket.on('connect', onConnect);
socket.on('disconnect', onDisconnect);
return () => {
socket.off('connect', onConnect);
socket.off('disconnect', onDisconnect);
};
}, []);
return { socket, connected };
}

View File

@ -1,7 +1,11 @@
{ {
"compilerOptions": { "compilerOptions": {
"target": "ES2017", "target": "ES2017",
"lib": ["dom", "dom.iterable", "esnext"], "lib": [
"dom",
"dom.iterable",
"esnext"
],
"allowJs": true, "allowJs": true,
"skipLibCheck": true, "skipLibCheck": true,
"strict": true, "strict": true,
@ -13,9 +17,24 @@
"isolatedModules": true, "isolatedModules": true,
"jsx": "preserve", "jsx": "preserve",
"incremental": true, "incremental": true,
"plugins": [{ "name": "next" }], "plugins": [
"paths": { "@/*": ["./src/*"] } {
"name": "next"
}
],
"paths": {
"@/*": [
"./src/*"
]
}
}, },
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx"], "include": [
"exclude": ["node_modules"] "**/*.ts",
"**/*.tsx",
"next-env.d.ts",
".next/types/**/*.ts"
],
"exclude": [
"node_modules"
]
} }

7192
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff