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:
parent
ec4fb1155b
commit
f774e0f4f1
1
.gitignore
vendored
1
.gitignore
vendored
@ -4,3 +4,4 @@ dist/
|
|||||||
target/
|
target/
|
||||||
.env
|
.env
|
||||||
*.log
|
*.log
|
||||||
|
ca.crt
|
||||||
|
|||||||
@ -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",
|
||||||
|
|||||||
@ -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',
|
||||||
}),
|
}),
|
||||||
|
|||||||
@ -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
6
apps/web/next-env.d.ts
vendored
Normal 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.
|
||||||
@ -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>
|
||||||
|
|||||||
127
apps/web/src/app/(dashboard)/sessions/[id]/page.tsx
Normal file
127
apps/web/src/app/(dashboard)/sessions/[id]/page.tsx
Normal 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,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
← 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} — {session.workspace.path || 'default'}
|
||||||
|
{' '}•{' '}
|
||||||
|
<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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -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>
|
||||||
|
|||||||
7
apps/web/src/components/terminal-wrapper.tsx
Normal file
7
apps/web/src/components/terminal-wrapper.tsx
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import dynamic from 'next/dynamic';
|
||||||
|
|
||||||
|
const TerminalViewer = dynamic(() => import('./terminal'), { ssr: false });
|
||||||
|
|
||||||
|
export default TerminalViewer;
|
||||||
106
apps/web/src/components/terminal.tsx
Normal file
106
apps/web/src/components/terminal.tsx
Normal 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',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
8
apps/web/src/lib/socket.ts
Normal file
8
apps/web/src/lib/socket.ts
Normal 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'],
|
||||||
|
});
|
||||||
23
apps/web/src/lib/use-socket.ts
Normal file
23
apps/web/src/lib/use-socket.ts
Normal 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 };
|
||||||
|
}
|
||||||
@ -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
7192
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
Loading…
Reference in New Issue
Block a user