Compare commits

...

2 Commits

Author SHA1 Message Date
f774e0f4f1 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>
2026-02-18 18:20:28 +05:00
ec4fb1155b Implement core platform: auth, projects, machines, sessions, agent
Full-stack implementation of the Reckue Dev platform:

API: JWT auth, CRUD for users/projects/machines/workspaces/sessions,
WebSocket gateway for real-time agent communication.

Web: Login/register, dashboard with stats, project/machine/session
management pages, sidebar navigation, dark theme.

Agent: Rust WebSocket client with PTY terminal management, heartbeat,
reconnection logic, Socket.IO protocol support.

Deploy: Updated docker-compose and env configuration.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-18 17:34:04 +05:00
62 changed files with 9670 additions and 36 deletions

1
.gitignore vendored
View File

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

View File

@ -3,6 +3,10 @@ name = "reckue-agent"
version = "0.1.0"
edition = "2021"
[[bin]]
name = "reckue-agent"
path = "src/main.rs"
[dependencies]
tokio = { version = "1", features = ["full"] }
tokio-tungstenite = { version = "0.24", features = ["native-tls"] }
@ -10,5 +14,9 @@ serde = { version = "1", features = ["derive"] }
serde_json = "1"
uuid = { version = "1", features = ["v4"] }
tracing = "0.1"
tracing-subscriber = "0.3"
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
portable-pty = "0.8"
futures-util = "0.3"
url = "2"
native-tls = "0.2"
clap = { version = "4", features = ["derive"] }

27
apps/agent/src/config.rs Normal file
View File

@ -0,0 +1,27 @@
use clap::Parser;
#[derive(Parser, Debug, Clone)]
#[command(name = "reckue-agent", about = "Reckue Dev Agent")]
pub struct Config {
/// WebSocket server URL
#[arg(long, env = "RECKUE_SERVER_URL", default_value = "ws://localhost:3001/ws")]
pub server_url: String,
/// Machine authentication token
#[arg(long, env = "RECKUE_TOKEN")]
pub token: String,
/// Machine name
#[arg(long, env = "RECKUE_MACHINE_NAME", default_value_t = hostname())]
pub name: String,
/// Heartbeat interval in seconds
#[arg(long, env = "RECKUE_HEARTBEAT_INTERVAL", default_value_t = 30)]
pub heartbeat_interval: u64,
}
fn hostname() -> String {
std::env::var("COMPUTERNAME")
.or_else(|_| std::env::var("HOSTNAME"))
.unwrap_or_else(|_| "unknown".to_string())
}

View File

@ -0,0 +1,187 @@
use futures_util::{SinkExt, StreamExt};
use tokio::sync::mpsc;
use tokio_tungstenite::{connect_async, tungstenite::Message};
use tracing::{error, info, warn};
use crate::config::Config;
use crate::messages::{parse_socketio, AgentMessage, ServerMessage};
use crate::pty_manager::PtyManager;
pub async fn run_agent(config: Config) {
let pty_manager = PtyManager::new();
let mut retry_delay = 1u64;
loop {
info!("Connecting to {}...", config.server_url);
match connect_and_run(&config, &pty_manager).await {
Ok(()) => {
info!("Connection closed normally");
retry_delay = 1;
}
Err(e) => {
error!("Connection error: {}", e);
}
}
let delay = retry_delay.min(60);
warn!("Reconnecting in {}s...", delay);
tokio::time::sleep(tokio::time::Duration::from_secs(delay)).await;
retry_delay = (retry_delay * 2).min(60);
}
}
async fn connect_and_run(config: &Config, pty_manager: &PtyManager) -> Result<(), String> {
// Socket.IO handshake: first GET /socket.io/?EIO=4&transport=polling
// Then upgrade to WebSocket with /socket.io/?EIO=4&transport=websocket
let ws_url = format!(
"{}/socket.io/?EIO=4&transport=websocket",
config.server_url.replace("ws://", "ws://").replace("wss://", "wss://")
);
let (ws_stream, _) = connect_async(&ws_url)
.await
.map_err(|e| format!("WebSocket connect failed: {}", e))?;
info!("Connected to server");
let (mut write, mut read) = ws_stream.split();
// Socket.IO handshake: send "40" to connect to namespace
write
.send(Message::Text("40/ws,".to_string()))
.await
.map_err(|e| format!("Handshake failed: {}", e))?;
// Send register
let register = AgentMessage::Register {
token: config.token.clone(),
hostname: config.name.clone(),
os: std::env::consts::OS.to_string(),
};
// Wait for namespace connect confirmation
// Then send register event on /ws namespace
let register_msg = format!("42/ws,{}", &register.to_socketio()[2..]);
write
.send(Message::Text(register_msg))
.await
.map_err(|e| format!("Register failed: {}", e))?;
info!("Registered as {}", config.name);
// Channel for PTY output
let (output_tx, mut output_rx) = mpsc::unbounded_channel::<(String, String)>();
// Heartbeat task
let heartbeat_interval = config.heartbeat_interval;
let (heartbeat_tx, mut heartbeat_rx) = mpsc::unbounded_channel::<()>();
tokio::spawn(async move {
loop {
tokio::time::sleep(tokio::time::Duration::from_secs(heartbeat_interval)).await;
if heartbeat_tx.send(()).is_err() {
break;
}
}
});
loop {
tokio::select! {
// Receive from server
msg = read.next() => {
match msg {
Some(Ok(Message::Text(text))) => {
// Handle Socket.IO ping/pong
if text == "2" {
let _ = write.send(Message::Text("3".to_string())).await;
continue;
}
// Parse /ws namespace messages
let clean = if text.starts_with("42/ws,") {
format!("42{}", &text[6..])
} else {
text.clone()
};
if let Some(server_msg) = parse_socketio(&clean) {
match server_msg {
ServerMessage::Registered { machine_id } => {
info!("Registered with machine_id: {}", machine_id);
}
ServerMessage::SessionStart { session_id, command } => {
info!("Starting session {}: {}", session_id, command);
let tx = output_tx.clone();
match pty_manager.start_session(session_id.clone(), &command, tx).await {
Ok(()) => {
let status_msg = AgentMessage::SessionStatus {
session_id,
status: "running".to_string(),
};
let msg = format!("42/ws,{}", &status_msg.to_socketio()[2..]);
let _ = write.send(Message::Text(msg)).await;
}
Err(e) => error!("Failed to start session: {}", e),
}
}
ServerMessage::SessionStop { session_id } => {
info!("Stopping session {}", session_id);
let _ = pty_manager.stop_session(&session_id).await;
let status_msg = AgentMessage::SessionStatus {
session_id,
status: "stopped".to_string(),
};
let msg = format!("42/ws,{}", &status_msg.to_socketio()[2..]);
let _ = write.send(Message::Text(msg)).await;
}
ServerMessage::SessionInput { session_id, input } => {
if let Err(e) = pty_manager.write_to_session(&session_id, &input).await {
error!("Failed to write to session: {}", e);
}
}
ServerMessage::Error { message } => {
error!("Server error: {}", message);
return Err(message);
}
}
}
}
Some(Ok(Message::Close(_))) | None => {
return Ok(());
}
Some(Err(e)) => {
return Err(format!("WebSocket error: {}", e));
}
_ => {}
}
}
// PTY output -> send to server
Some((session_id, output)) = output_rx.recv() => {
if output.is_empty() {
// Session ended
let status_msg = AgentMessage::SessionStatus {
session_id,
status: "stopped".to_string(),
};
let msg = format!("42/ws,{}", &status_msg.to_socketio()[2..]);
let _ = write.send(Message::Text(msg)).await;
} else {
let output_msg = AgentMessage::SessionOutput {
session_id,
output,
};
let msg = format!("42/ws,{}", &output_msg.to_socketio()[2..]);
let _ = write.send(Message::Text(msg)).await;
}
}
// Heartbeat
Some(()) = heartbeat_rx.recv() => {
let hb = AgentMessage::Heartbeat {
timestamp: format!("{}", std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_secs()),
};
let msg = format!("42/ws,{}", &hb.to_socketio()[2..]);
let _ = write.send(Message::Text(msg)).await;
}
}
}
}

View File

@ -1,11 +1,27 @@
mod config;
mod connection;
mod messages;
mod pty_manager;
use clap::Parser;
use tracing::info;
use config::Config;
use connection::run_agent;
#[tokio::main]
async fn main() {
tracing_subscriber::init();
info!("Reckue Agent starting...");
// TODO: WebSocket connection to Control Plane
// TODO: Machine registration + heartbeats
// TODO: PTY session manager
info!("Agent ready");
tracing_subscriber::fmt()
.with_env_filter(
tracing_subscriber::EnvFilter::try_from_default_env()
.unwrap_or_else(|_| "reckue_agent=info".into()),
)
.init();
let config = Config::parse();
info!("Reckue Agent v{}", env!("CARGO_PKG_VERSION"));
info!("Machine: {}", config.name);
info!("Server: {}", config.server_url);
run_agent(config).await;
}

105
apps/agent/src/messages.rs Normal file
View File

@ -0,0 +1,105 @@
use serde::{Deserialize, Serialize};
/// Messages sent from Agent to Server
#[derive(Debug, Serialize)]
#[serde(tag = "type")]
pub enum AgentMessage {
#[serde(rename = "agent:register")]
Register {
token: String,
hostname: String,
os: String,
},
#[serde(rename = "agent:heartbeat")]
Heartbeat {
timestamp: String,
},
#[serde(rename = "agent:session_output")]
SessionOutput {
#[serde(rename = "sessionId")]
session_id: String,
output: String,
},
#[serde(rename = "agent:session_status")]
SessionStatus {
#[serde(rename = "sessionId")]
session_id: String,
status: String,
},
}
/// Messages received from Server
#[derive(Debug, Deserialize)]
#[serde(tag = "type")]
pub enum ServerMessage {
#[serde(rename = "agent:registered")]
Registered {
#[serde(rename = "machineId")]
machine_id: String,
},
#[serde(rename = "command:session_start")]
SessionStart {
#[serde(rename = "sessionId")]
session_id: String,
#[serde(default = "default_shell")]
command: String,
},
#[serde(rename = "command:session_stop")]
SessionStop {
#[serde(rename = "sessionId")]
session_id: String,
},
#[serde(rename = "command:session_input")]
SessionInput {
#[serde(rename = "sessionId")]
session_id: String,
input: String,
},
#[serde(rename = "error")]
Error {
message: String,
},
}
fn default_shell() -> String {
if cfg!(windows) {
"cmd.exe".to_string()
} else {
"/bin/bash".to_string()
}
}
/// Socket.IO-like packet for encoding/decoding
/// socket.io protocol: "42" prefix + JSON array [event, data]
impl AgentMessage {
pub fn to_socketio(&self) -> String {
let event = match self {
AgentMessage::Register { .. } => "agent:register",
AgentMessage::Heartbeat { .. } => "agent:heartbeat",
AgentMessage::SessionOutput { .. } => "agent:session_output",
AgentMessage::SessionStatus { .. } => "agent:session_status",
};
let data = serde_json::to_value(self).unwrap();
// Remove the "type" field from data since it's the event name
let mut obj = data.as_object().unwrap().clone();
obj.remove("type");
format!("42{}", serde_json::to_string(&serde_json::json!([event, obj])).unwrap())
}
}
pub fn parse_socketio(msg: &str) -> Option<ServerMessage> {
// Socket.IO messages: "42[event, data]"
if !msg.starts_with("42") {
return None;
}
let json_str = &msg[2..];
let arr: serde_json::Value = serde_json::from_str(json_str).ok()?;
let arr = arr.as_array()?;
if arr.len() < 2 {
return None;
}
let event = arr[0].as_str()?;
let mut data = arr[1].as_object()?.clone();
data.insert("type".to_string(), serde_json::Value::String(event.to_string()));
serde_json::from_value(serde_json::Value::Object(data)).ok()
}

View File

@ -0,0 +1,113 @@
use portable_pty::{native_pty_system, CommandBuilder, PtyPair, PtySize};
use std::collections::HashMap;
use std::io::{Read, Write};
use std::sync::Arc;
use tokio::sync::{mpsc, Mutex};
use tracing::{error, info};
pub struct PtySession {
pair: PtyPair,
writer: Box<dyn Write + Send>,
}
pub struct PtyManager {
sessions: Arc<Mutex<HashMap<String, PtySession>>>,
}
impl PtyManager {
pub fn new() -> Self {
Self {
sessions: Arc::new(Mutex::new(HashMap::new())),
}
}
pub async fn start_session(
&self,
session_id: String,
command: &str,
output_tx: mpsc::UnboundedSender<(String, String)>, // (session_id, output)
) -> Result<(), String> {
let pty_system = native_pty_system();
let pair = pty_system
.openpty(PtySize {
rows: 24,
cols: 80,
pixel_width: 0,
pixel_height: 0,
})
.map_err(|e| format!("Failed to open PTY: {}", e))?;
let cmd = CommandBuilder::new(command);
let _child = pair
.slave
.spawn_command(cmd)
.map_err(|e| format!("Failed to spawn: {}", e))?;
let writer = pair
.master
.take_writer()
.map_err(|e| format!("Failed to get writer: {}", e))?;
let mut reader = pair
.master
.try_clone_reader()
.map_err(|e| format!("Failed to get reader: {}", e))?;
let sid = session_id.clone();
// Spawn reader thread
std::thread::spawn(move || {
let mut buf = [0u8; 4096];
loop {
match reader.read(&mut buf) {
Ok(0) => {
let _ = output_tx.send((sid.clone(), String::new()));
break;
}
Ok(n) => {
let text = String::from_utf8_lossy(&buf[..n]).to_string();
if output_tx.send((sid.clone(), text)).is_err() {
break;
}
}
Err(e) => {
error!("PTY read error: {}", e);
break;
}
}
}
});
let session = PtySession { pair, writer };
self.sessions.lock().await.insert(session_id.clone(), session);
info!("PTY session started: {}", session_id);
Ok(())
}
pub async fn write_to_session(&self, session_id: &str, data: &str) -> Result<(), String> {
let mut sessions = self.sessions.lock().await;
if let Some(session) = sessions.get_mut(session_id) {
session
.writer
.write_all(data.as_bytes())
.map_err(|e| format!("Write error: {}", e))?;
session
.writer
.flush()
.map_err(|e| format!("Flush error: {}", e))?;
Ok(())
} else {
Err("Session not found".to_string())
}
}
pub async fn stop_session(&self, session_id: &str) -> Result<(), String> {
let mut sessions = self.sessions.lock().await;
if sessions.remove(session_id).is_some() {
info!("PTY session stopped: {}", session_id);
Ok(())
} else {
Err("Session not found".to_string())
}
}
}

View File

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

View File

@ -1,8 +1,53 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { ConfigModule, ConfigService } from '@nestjs/config';
import { readFileSync } from 'fs';
import { join } from 'path';
import { AuthModule } from './auth/auth.module';
import { UsersModule } from './users/users.module';
import { MachinesModule } from './machines/machines.module';
import { ProjectsModule } from './projects/projects.module';
import { WorkspacesModule } from './workspaces/workspaces.module';
import { SessionsModule } from './sessions/sessions.module';
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({
imports: [],
controllers: [],
providers: [],
imports: [
ConfigModule.forRoot({ isGlobal: true }),
TypeOrmModule.forRootAsync({
imports: [ConfigModule],
inject: [ConfigService],
useFactory: (config: ConfigService) => ({
type: 'postgres',
host: config.get('DATABASE_HOST', 'localhost'),
port: config.get<number>('DATABASE_PORT', 5432),
username: config.get('DATABASE_USER', 'gen_user'),
password: config.get('DATABASE_PASSWORD', ''),
database: config.get('DATABASE_NAME', 'default_db'),
ssl: buildSslConfig(config),
autoLoadEntities: true,
synchronize: config.get('NODE_ENV') !== 'production',
}),
}),
AuthModule,
UsersModule,
MachinesModule,
ProjectsModule,
WorkspacesModule,
SessionsModule,
],
providers: [AgentGateway],
})
export class AppModule {}

View File

@ -0,0 +1,24 @@
import { Controller, Post, Body, UseGuards, Request } from '@nestjs/common';
import { AuthService } from './auth.service';
import { JwtAuthGuard } from './jwt-auth.guard';
@Controller('auth')
export class AuthController {
constructor(private authService: AuthService) {}
@Post('register')
register(@Body() body: { email: string; password: string; name: string }) {
return this.authService.register(body.email, body.password, body.name);
}
@Post('login')
login(@Body() body: { email: string; password: string }) {
return this.authService.login(body.email, body.password);
}
@Post('refresh')
@UseGuards(JwtAuthGuard)
refresh(@Request() req: any) {
return this.authService.refresh(req.user.sub);
}
}

View File

@ -0,0 +1,27 @@
import { Module } from '@nestjs/common';
import { JwtModule } from '@nestjs/jwt';
import { PassportModule } from '@nestjs/passport';
import { ConfigModule, ConfigService } from '@nestjs/config';
import { AuthController } from './auth.controller';
import { AuthService } from './auth.service';
import { JwtStrategy } from './jwt.strategy';
import { UsersModule } from '../users/users.module';
@Module({
imports: [
UsersModule,
PassportModule,
JwtModule.registerAsync({
imports: [ConfigModule],
inject: [ConfigService],
useFactory: (config: ConfigService) => ({
secret: config.get('JWT_SECRET', 'dev-secret-change-me'),
signOptions: { expiresIn: '24h' },
}),
}),
],
controllers: [AuthController],
providers: [AuthService, JwtStrategy],
exports: [AuthService, JwtModule],
})
export class AuthModule {}

View File

@ -0,0 +1,41 @@
import { Injectable, UnauthorizedException } from '@nestjs/common';
import { JwtService } from '@nestjs/jwt';
import * as bcrypt from 'bcrypt';
import { UsersService } from '../users/users.service';
@Injectable()
export class AuthService {
constructor(
private usersService: UsersService,
private jwtService: JwtService,
) {}
async register(email: string, password: string, name: string) {
const hash = await bcrypt.hash(password, 10);
const user = await this.usersService.create({ email, password: hash, name });
return this.generateTokens(user);
}
async login(email: string, password: string) {
const user = await this.usersService.findByEmail(email);
if (!user || !(await bcrypt.compare(password, user.password))) {
throw new UnauthorizedException('Invalid credentials');
}
return this.generateTokens(user);
}
async refresh(userId: string) {
const user = await this.usersService.findById(userId);
if (!user) throw new UnauthorizedException();
return this.generateTokens(user);
}
private generateTokens(user: any) {
const payload = { sub: user.id, email: user.email, role: user.role };
return {
accessToken: this.jwtService.sign(payload),
refreshToken: this.jwtService.sign(payload, { expiresIn: '7d' }),
user: { id: user.id, email: user.email, name: user.name, role: user.role },
};
}
}

View File

@ -0,0 +1,5 @@
import { Injectable } from '@nestjs/common';
import { AuthGuard } from '@nestjs/passport';
@Injectable()
export class JwtAuthGuard extends AuthGuard('jwt') {}

View File

@ -0,0 +1,19 @@
import { Injectable } from '@nestjs/common';
import { PassportStrategy } from '@nestjs/passport';
import { ExtractJwt, Strategy } from 'passport-jwt';
import { ConfigService } from '@nestjs/config';
@Injectable()
export class JwtStrategy extends PassportStrategy(Strategy) {
constructor(config: ConfigService) {
super({
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
ignoreExpiration: false,
secretOrKey: config.get('JWT_SECRET', 'dev-secret-change-me'),
});
}
validate(payload: any) {
return { sub: payload.sub, email: payload.email, role: payload.role };
}
}

View File

@ -0,0 +1,28 @@
import { Entity, PrimaryGeneratedColumn, Column, CreateDateColumn } from 'typeorm';
@Entity('audit_log')
export class AuditLog {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column({ nullable: true })
userId: string;
@Column()
action: string;
@Column({ nullable: true })
entityType: string;
@Column({ nullable: true })
entityId: string;
@Column({ type: 'jsonb', nullable: true })
details: Record<string, any>;
@Column({ nullable: true })
ip: string;
@CreateDateColumn()
timestamp: Date;
}

View File

@ -0,0 +1,6 @@
export { User } from './user.entity';
export { Machine } from './machine.entity';
export { Project } from './project.entity';
export { Workspace } from './workspace.entity';
export { Session } from './session.entity';
export { AuditLog } from './audit-log.entity';

View File

@ -0,0 +1,35 @@
import { Entity, PrimaryGeneratedColumn, Column, CreateDateColumn, UpdateDateColumn, OneToMany } from 'typeorm';
import { Workspace } from './workspace.entity';
@Entity('machines')
export class Machine {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column()
name: string;
@Column()
hostname: string;
@Column({ nullable: true })
os: string;
@Column({ default: 'offline' })
status: string;
@Column({ unique: true })
token: string;
@Column({ type: 'timestamp', nullable: true })
lastHeartbeat: Date;
@OneToMany(() => Workspace, workspace => workspace.machine)
workspaces: Workspace[];
@CreateDateColumn()
createdAt: Date;
@UpdateDateColumn()
updatedAt: Date;
}

View File

@ -0,0 +1,31 @@
import { Entity, PrimaryGeneratedColumn, Column, CreateDateColumn, UpdateDateColumn, ManyToOne, OneToMany, JoinColumn } from 'typeorm';
import { User } from './user.entity';
import { Workspace } from './workspace.entity';
@Entity('projects')
export class Project {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column()
name: string;
@Column({ type: 'text', default: '' })
description: string;
@Column()
ownerId: string;
@ManyToOne(() => User, user => user.projects)
@JoinColumn({ name: 'ownerId' })
owner: User;
@OneToMany(() => Workspace, workspace => workspace.project)
workspaces: Workspace[];
@CreateDateColumn()
createdAt: Date;
@UpdateDateColumn()
updatedAt: Date;
}

View File

@ -0,0 +1,30 @@
import { Entity, PrimaryGeneratedColumn, Column, CreateDateColumn, UpdateDateColumn, ManyToOne, JoinColumn } from 'typeorm';
import { Workspace } from './workspace.entity';
@Entity('sessions')
export class Session {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column()
workspaceId: string;
@Column({ default: 'stopped' })
status: string;
@Column({ nullable: true })
command: string;
@ManyToOne(() => Workspace, workspace => workspace.sessions)
@JoinColumn({ name: 'workspaceId' })
workspace: Workspace;
@CreateDateColumn()
startedAt: Date;
@Column({ type: 'timestamp', nullable: true })
stoppedAt: Date;
@UpdateDateColumn()
updatedAt: Date;
}

View File

@ -0,0 +1,29 @@
import { Entity, PrimaryGeneratedColumn, Column, CreateDateColumn, UpdateDateColumn, OneToMany } from 'typeorm';
import { Project } from './project.entity';
@Entity('users')
export class User {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column({ unique: true })
email: string;
@Column()
name: string;
@Column()
password: string;
@Column({ default: 'developer' })
role: string;
@OneToMany(() => Project, project => project.owner)
projects: Project[];
@CreateDateColumn()
createdAt: Date;
@UpdateDateColumn()
updatedAt: Date;
}

View File

@ -0,0 +1,42 @@
import { Entity, PrimaryGeneratedColumn, Column, CreateDateColumn, UpdateDateColumn, ManyToOne, OneToMany, JoinColumn } from 'typeorm';
import { Project } from './project.entity';
import { Machine } from './machine.entity';
import { Session } from './session.entity';
@Entity('workspaces')
export class Workspace {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column()
projectId: string;
@Column()
machineId: string;
@Column({ default: '' })
path: string;
@Column({ default: '' })
gitUrl: string;
@Column({ default: 'main' })
branch: string;
@ManyToOne(() => Project, project => project.workspaces)
@JoinColumn({ name: 'projectId' })
project: Project;
@ManyToOne(() => Machine, machine => machine.workspaces)
@JoinColumn({ name: 'machineId' })
machine: Machine;
@OneToMany(() => Session, session => session.workspace)
sessions: Session[];
@CreateDateColumn()
createdAt: Date;
@UpdateDateColumn()
updatedAt: Date;
}

View File

@ -0,0 +1,140 @@
import {
WebSocketGateway,
WebSocketServer,
OnGatewayConnection,
OnGatewayDisconnect,
SubscribeMessage,
MessageBody,
ConnectedSocket,
} from '@nestjs/websockets';
import { Server, Socket } from 'socket.io';
import { MachinesService } from '../machines/machines.service';
import { SessionsService } from '../sessions/sessions.service';
@WebSocketGateway({ cors: true, namespace: '/ws' })
export class AgentGateway implements OnGatewayConnection, OnGatewayDisconnect {
@WebSocketServer()
server: Server;
// machineId -> socketId
private agents = new Map<string, string>();
// socketId -> machineId
private sockets = new Map<string, string>();
// sessionId -> set of browser socket ids
private sessionViewers = new Map<string, Set<string>>();
constructor(
private machinesService: MachinesService,
private sessionsService: SessionsService,
) {}
handleConnection(client: Socket) {
console.log(`Client connected: ${client.id}`);
}
async handleDisconnect(client: Socket) {
const machineId = this.sockets.get(client.id);
if (machineId) {
this.agents.delete(machineId);
this.sockets.delete(client.id);
await this.machinesService.updateStatus(machineId, 'offline');
this.server.emit('machine:status', { machineId, status: 'offline' });
}
// Remove from session viewers
for (const [, viewers] of this.sessionViewers) {
viewers.delete(client.id);
}
console.log(`Client disconnected: ${client.id}`);
}
@SubscribeMessage('agent:register')
async handleRegister(
@ConnectedSocket() client: Socket,
@MessageBody() data: { token: string; hostname: string; os: string },
) {
const machine = await this.machinesService.findByToken(data.token);
if (!machine) {
client.emit('error', { message: 'Invalid token' });
client.disconnect();
return;
}
this.agents.set(machine.id, client.id);
this.sockets.set(client.id, machine.id);
await this.machinesService.updateStatus(machine.id, 'online');
this.server.emit('machine:status', { machineId: machine.id, status: 'online' });
client.emit('agent:registered', { machineId: machine.id });
}
@SubscribeMessage('agent:heartbeat')
async handleHeartbeat(@ConnectedSocket() client: Socket) {
const machineId = this.sockets.get(client.id);
if (machineId) await this.machinesService.heartbeat(machineId);
}
@SubscribeMessage('agent:session_output')
handleSessionOutput(
@MessageBody() data: { sessionId: string; output: string },
) {
// Forward to all browser clients watching this session
this.server.emit(`session:output:${data.sessionId}`, { output: data.output });
}
@SubscribeMessage('agent:session_status')
async handleSessionStatus(
@MessageBody() data: { sessionId: string; status: string },
) {
await this.sessionsService.updateStatus(data.sessionId, data.status);
this.server.emit(`session:status:${data.sessionId}`, { status: data.status });
}
// Browser -> Agent commands
@SubscribeMessage('session:start')
async handleSessionStart(
@MessageBody() data: { sessionId: string; machineId: string; command?: string },
) {
const agentSocketId = this.agents.get(data.machineId);
if (agentSocketId) {
this.server.to(agentSocketId).emit('command:session_start', {
sessionId: data.sessionId,
command: data.command || 'cmd.exe',
});
}
}
@SubscribeMessage('session:input')
handleSessionInput(
@MessageBody() data: { sessionId: string; machineId: string; input: string },
) {
const agentSocketId = this.agents.get(data.machineId);
if (agentSocketId) {
this.server.to(agentSocketId).emit('command:session_input', {
sessionId: data.sessionId,
input: data.input,
});
}
}
@SubscribeMessage('session:stop')
handleSessionStop(
@MessageBody() data: { sessionId: string; machineId: string },
) {
const agentSocketId = this.agents.get(data.machineId);
if (agentSocketId) {
this.server.to(agentSocketId).emit('command:session_stop', {
sessionId: data.sessionId,
});
}
}
@SubscribeMessage('session:subscribe')
handleSessionSubscribe(
@ConnectedSocket() client: Socket,
@MessageBody() data: { sessionId: string },
) {
if (!this.sessionViewers.has(data.sessionId)) {
this.sessionViewers.set(data.sessionId, new Set());
}
this.sessionViewers.get(data.sessionId)!.add(client.id);
client.join(`session:${data.sessionId}`);
}
}

View File

@ -0,0 +1,29 @@
import { Controller, Get, Post, Delete, Body, Param, UseGuards } from '@nestjs/common';
import { MachinesService } from './machines.service';
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
@Controller('machines')
@UseGuards(JwtAuthGuard)
export class MachinesController {
constructor(private machinesService: MachinesService) {}
@Post()
create(@Body() body: { name: string; hostname?: string }) {
return this.machinesService.create(body);
}
@Get()
findAll() {
return this.machinesService.findAll();
}
@Get(':id')
findOne(@Param('id') id: string) {
return this.machinesService.findById(id);
}
@Delete(':id')
remove(@Param('id') id: string) {
return this.machinesService.remove(id);
}
}

View File

@ -0,0 +1,13 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { Machine } from '../entities';
import { MachinesService } from './machines.service';
import { MachinesController } from './machines.controller';
@Module({
imports: [TypeOrmModule.forFeature([Machine])],
controllers: [MachinesController],
providers: [MachinesService],
exports: [MachinesService],
})
export class MachinesModule {}

View File

@ -0,0 +1,42 @@
import { Injectable, NotFoundException } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { randomBytes } from 'crypto';
import { Machine } from '../entities';
@Injectable()
export class MachinesService {
constructor(@InjectRepository(Machine) private repo: Repository<Machine>) {}
async create(data: { name: string; hostname?: string }) {
const token = randomBytes(32).toString('hex');
return this.repo.save(this.repo.create({ ...data, hostname: data.hostname || data.name, token }));
}
findAll() {
return this.repo.find({ select: ['id', 'name', 'hostname', 'os', 'status', 'lastHeartbeat', 'createdAt'] });
}
async findById(id: string) {
const machine = await this.repo.findOne({ where: { id } });
if (!machine) throw new NotFoundException('Machine not found');
return machine;
}
findByToken(token: string) {
return this.repo.findOne({ where: { token } });
}
async updateStatus(id: string, status: string) {
await this.repo.update(id, { status, lastHeartbeat: new Date() });
}
async heartbeat(id: string) {
await this.repo.update(id, { status: 'online', lastHeartbeat: new Date() });
}
async remove(id: string) {
const machine = await this.findById(id);
await this.repo.remove(machine);
}
}

View File

@ -0,0 +1,34 @@
import { Controller, Get, Post, Patch, Delete, Body, Param, UseGuards, Request } from '@nestjs/common';
import { ProjectsService } from './projects.service';
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
@Controller('projects')
@UseGuards(JwtAuthGuard)
export class ProjectsController {
constructor(private projectsService: ProjectsService) {}
@Post()
create(@Request() req: any, @Body() body: { name: string; description?: string }) {
return this.projectsService.create({ ...body, ownerId: req.user.sub });
}
@Get()
findAll() {
return this.projectsService.findAll();
}
@Get(':id')
findOne(@Param('id') id: string) {
return this.projectsService.findById(id);
}
@Patch(':id')
update(@Param('id') id: string, @Body() body: { name?: string; description?: string }) {
return this.projectsService.update(id, body);
}
@Delete(':id')
remove(@Param('id') id: string) {
return this.projectsService.remove(id);
}
}

View File

@ -0,0 +1,13 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { Project } from '../entities';
import { ProjectsService } from './projects.service';
import { ProjectsController } from './projects.controller';
@Module({
imports: [TypeOrmModule.forFeature([Project])],
controllers: [ProjectsController],
providers: [ProjectsService],
exports: [ProjectsService],
})
export class ProjectsModule {}

View File

@ -0,0 +1,34 @@
import { Injectable, NotFoundException } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { Project } from '../entities';
@Injectable()
export class ProjectsService {
constructor(@InjectRepository(Project) private repo: Repository<Project>) {}
create(data: { name: string; description?: string; ownerId: string }) {
return this.repo.save(this.repo.create(data));
}
findAll() {
return this.repo.find({ relations: ['owner'], order: { createdAt: 'DESC' } });
}
async findById(id: string) {
const project = await this.repo.findOne({ where: { id }, relations: ['owner', 'workspaces'] });
if (!project) throw new NotFoundException('Project not found');
return project;
}
async update(id: string, data: Partial<{ name: string; description: string }>) {
await this.findById(id);
await this.repo.update(id, data);
return this.findById(id);
}
async remove(id: string) {
const project = await this.findById(id);
await this.repo.remove(project);
}
}

View File

@ -0,0 +1,29 @@
import { Controller, Get, Post, Patch, Body, Param, Query, UseGuards } from '@nestjs/common';
import { SessionsService } from './sessions.service';
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
@Controller('sessions')
@UseGuards(JwtAuthGuard)
export class SessionsController {
constructor(private sessionsService: SessionsService) {}
@Post()
create(@Body() body: { workspaceId: string; command?: string }) {
return this.sessionsService.create(body);
}
@Get()
findByWorkspace(@Query('workspaceId') workspaceId: string) {
return this.sessionsService.findByWorkspace(workspaceId);
}
@Get(':id')
findOne(@Param('id') id: string) {
return this.sessionsService.findById(id);
}
@Patch(':id/status')
updateStatus(@Param('id') id: string, @Body() body: { status: string }) {
return this.sessionsService.updateStatus(id, body.status);
}
}

View File

@ -0,0 +1,13 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { Session } from '../entities';
import { SessionsService } from './sessions.service';
import { SessionsController } from './sessions.controller';
@Module({
imports: [TypeOrmModule.forFeature([Session])],
controllers: [SessionsController],
providers: [SessionsService],
exports: [SessionsService],
})
export class SessionsModule {}

View File

@ -0,0 +1,35 @@
import { Injectable, NotFoundException } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { Session } from '../entities';
@Injectable()
export class SessionsService {
constructor(@InjectRepository(Session) private repo: Repository<Session>) {}
create(data: { workspaceId: string; command?: string }) {
return this.repo.save(this.repo.create({ ...data, status: 'running' }));
}
findByWorkspace(workspaceId: string) {
return this.repo.find({ where: { workspaceId }, order: { startedAt: 'DESC' } });
}
async findById(id: string) {
const session = await this.repo.findOne({ where: { id }, relations: ['workspace', 'workspace.machine'] });
if (!session) throw new NotFoundException('Session not found');
return session;
}
async updateStatus(id: string, status: string) {
const update: any = { status };
if (status === 'stopped' || status === 'error') update.stoppedAt = new Date();
await this.repo.update(id, update);
return this.findById(id);
}
async remove(id: string) {
const session = await this.findById(id);
await this.repo.remove(session);
}
}

View File

@ -0,0 +1,14 @@
import { Controller, Get, UseGuards } from '@nestjs/common';
import { UsersService } from './users.service';
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
@Controller('users')
@UseGuards(JwtAuthGuard)
export class UsersController {
constructor(private usersService: UsersService) {}
@Get()
findAll() {
return this.usersService.findAll();
}
}

View File

@ -0,0 +1,13 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { User } from '../entities';
import { UsersService } from './users.service';
import { UsersController } from './users.controller';
@Module({
imports: [TypeOrmModule.forFeature([User])],
controllers: [UsersController],
providers: [UsersService],
exports: [UsersService],
})
export class UsersModule {}

View File

@ -0,0 +1,27 @@
import { Injectable, ConflictException } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { User } from '../entities';
@Injectable()
export class UsersService {
constructor(@InjectRepository(User) private repo: Repository<User>) {}
async create(data: { email: string; password: string; name: string }): Promise<User> {
const exists = await this.repo.findOne({ where: { email: data.email } });
if (exists) throw new ConflictException('Email already registered');
return this.repo.save(this.repo.create(data));
}
findByEmail(email: string) {
return this.repo.findOne({ where: { email } });
}
findById(id: string) {
return this.repo.findOne({ where: { id } });
}
findAll() {
return this.repo.find({ select: ['id', 'email', 'name', 'role', 'createdAt'] });
}
}

View File

@ -0,0 +1,34 @@
import { Controller, Get, Post, Patch, Delete, Body, Param, Query, UseGuards } from '@nestjs/common';
import { WorkspacesService } from './workspaces.service';
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
@Controller('workspaces')
@UseGuards(JwtAuthGuard)
export class WorkspacesController {
constructor(private workspacesService: WorkspacesService) {}
@Post()
create(@Body() body: { projectId: string; machineId: string; path?: string; gitUrl?: string; branch?: string }) {
return this.workspacesService.create(body);
}
@Get()
findByProject(@Query('projectId') projectId: string) {
return this.workspacesService.findByProject(projectId);
}
@Get(':id')
findOne(@Param('id') id: string) {
return this.workspacesService.findById(id);
}
@Patch(':id')
update(@Param('id') id: string, @Body() body: { path?: string; gitUrl?: string; branch?: string }) {
return this.workspacesService.update(id, body);
}
@Delete(':id')
remove(@Param('id') id: string) {
return this.workspacesService.remove(id);
}
}

View File

@ -0,0 +1,13 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { Workspace } from '../entities';
import { WorkspacesService } from './workspaces.service';
import { WorkspacesController } from './workspaces.controller';
@Module({
imports: [TypeOrmModule.forFeature([Workspace])],
controllers: [WorkspacesController],
providers: [WorkspacesService],
exports: [WorkspacesService],
})
export class WorkspacesModule {}

View File

@ -0,0 +1,34 @@
import { Injectable, NotFoundException } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { Workspace } from '../entities';
@Injectable()
export class WorkspacesService {
constructor(@InjectRepository(Workspace) private repo: Repository<Workspace>) {}
create(data: { projectId: string; machineId: string; path?: string; gitUrl?: string; branch?: string }) {
return this.repo.save(this.repo.create(data));
}
findByProject(projectId: string) {
return this.repo.find({ where: { projectId }, relations: ['machine'] });
}
async findById(id: string) {
const ws = await this.repo.findOne({ where: { id }, relations: ['project', 'machine', 'sessions'] });
if (!ws) throw new NotFoundException('Workspace not found');
return ws;
}
async update(id: string, data: Partial<{ path: string; gitUrl: string; branch: string }>) {
await this.findById(id);
await this.repo.update(id, data);
return this.findById(id);
}
async remove(id: string) {
const ws = await this.findById(id);
await this.repo.remove(ws);
}
}

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

@ -10,7 +10,10 @@
"dependencies": {
"next": "^15.0.0",
"react": "^19.0.0",
"react-dom": "^19.0.0"
"react-dom": "^19.0.0",
"socket.io-client": "^4.8.0",
"@xterm/xterm": "^5.5.0",
"@xterm/addon-fit": "^0.10.0"
},
"devDependencies": {
"@types/node": "^22.0.0",

View File

@ -0,0 +1,27 @@
'use client';
import { useAuth } from '@/lib/auth-context';
import { useRouter } from 'next/navigation';
import { useEffect } from 'react';
import { Sidebar } from '@/components/sidebar';
export default function DashboardLayout({ children }: { children: React.ReactNode }) {
const { user, loading } = useAuth();
const router = useRouter();
useEffect(() => {
if (!loading && !user) router.push('/login');
}, [user, loading, router]);
if (loading) return <div style={{ padding: 40, textAlign: 'center' }}>Loading...</div>;
if (!user) return null;
return (
<div style={{ display: 'flex' }}>
<Sidebar />
<main style={{ marginLeft: 240, flex: 1, padding: 24, minHeight: '100vh' }}>
{children}
</main>
</div>
);
}

View File

@ -0,0 +1,84 @@
'use client';
import { useEffect, useState } from 'react';
import { api } from '@/lib/api';
interface Machine {
id: string;
name: string;
hostname: string;
os: string;
status: string;
lastHeartbeat: string;
token?: string;
}
export default function MachinesPage() {
const [machines, setMachines] = useState<Machine[]>([]);
const [showAdd, setShowAdd] = useState(false);
const [name, setName] = useState('');
const [newToken, setNewToken] = useState('');
const load = () => api.get('/machines').then(setMachines).catch(() => {});
useEffect(() => { load(); }, []);
const addMachine = async () => {
if (!name.trim()) return;
const machine = await api.post('/machines', { name: name.trim() });
setNewToken(machine.token);
setName('');
load();
};
return (
<div>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 24 }}>
<h2 style={{ fontSize: 24 }}>Machines</h2>
<button onClick={() => setShowAdd(!showAdd)} style={{
padding: '8px 16px', background: 'var(--primary)', color: '#fff',
border: 'none', borderRadius: 8, cursor: 'pointer', fontSize: 14,
}}>+ Add Machine</button>
</div>
{showAdd && (
<div style={{ background: 'var(--bg-card)', border: '1px solid var(--border)', borderRadius: 12, padding: 20, marginBottom: 16 }}>
<div style={{ display: 'flex', gap: 8 }}>
<input placeholder="Machine name" value={name} onChange={e => setName(e.target.value)}
style={{ flex: 1, padding: '8px 12px', background: 'var(--bg)', border: '1px solid var(--border)', borderRadius: 8, color: 'var(--text)', fontSize: 14 }} />
<button onClick={addMachine} style={{ padding: '8px 16px', background: 'var(--primary)', color: '#fff', border: 'none', borderRadius: 8, cursor: 'pointer' }}>Create</button>
</div>
{newToken && (
<div style={{ marginTop: 12, padding: 12, background: 'var(--bg)', borderRadius: 8, fontSize: 13 }}>
<div style={{ color: 'var(--warning)', marginBottom: 4 }}>Save this token (shown only once):</div>
<code style={{ wordBreak: 'break-all' }}>{newToken}</code>
</div>
)}
</div>
)}
<div style={{ display: 'grid', gap: 12 }}>
{machines.map(m => (
<div key={m.id} style={{
background: 'var(--bg-card)', border: '1px solid var(--border)',
borderRadius: 12, padding: 20, display: 'flex', alignItems: 'center', justifyContent: 'space-between',
}}>
<div>
<div style={{ fontWeight: 600, marginBottom: 4 }}>{m.name}</div>
<div style={{ fontSize: 13, color: 'var(--text-muted)' }}>{m.hostname} {m.os ? `(${m.os})` : ''}</div>
</div>
<div style={{
padding: '4px 12px', borderRadius: 20, fontSize: 12, fontWeight: 600,
background: m.status === 'online' ? 'rgba(34,197,94,0.15)' : 'rgba(239,68,68,0.15)',
color: m.status === 'online' ? 'var(--success)' : 'var(--danger)',
}}>
{m.status === 'online' ? '\u25CF Online' : '\u25CB Offline'}
</div>
</div>
))}
{machines.length === 0 && (
<div style={{ textAlign: 'center', color: 'var(--text-muted)', padding: 40 }}>No machines yet</div>
)}
</div>
</div>
);
}

View File

@ -0,0 +1,48 @@
'use client';
import { useAuth } from '@/lib/auth-context';
import { useEffect, useState } from 'react';
import { api } from '@/lib/api';
export default function Dashboard() {
const { user } = useAuth();
const [stats, setStats] = useState({ projects: 0, machines: 0, onlineMachines: 0 });
useEffect(() => {
Promise.all([
api.get('/projects').catch(() => []),
api.get('/machines').catch(() => []),
]).then(([projects, machines]) => {
setStats({
projects: projects.length,
machines: machines.length,
onlineMachines: machines.filter((m: any) => m.status === 'online').length,
});
});
}, []);
const cards = [
{ label: 'Projects', value: stats.projects, color: 'var(--primary)' },
{ label: 'Machines', value: stats.machines, color: 'var(--warning)' },
{ label: 'Online', value: stats.onlineMachines, color: 'var(--success)' },
];
return (
<div>
<h2 style={{ fontSize: 24, marginBottom: 24 }}>Welcome, {user?.name}</h2>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(3, 1fr)', gap: 16 }}>
{cards.map(c => (
<div key={c.label} style={{
background: 'var(--bg-card)',
border: '1px solid var(--border)',
borderRadius: 12,
padding: 24,
}}>
<div style={{ color: 'var(--text-muted)', fontSize: 14, marginBottom: 8 }}>{c.label}</div>
<div style={{ fontSize: 32, fontWeight: 700, color: c.color }}>{c.value}</div>
</div>
))}
</div>
</div>
);
}

View File

@ -0,0 +1,128 @@
'use client';
import { useEffect, useState } from 'react';
import { useParams, useRouter } from 'next/navigation';
import { api } from '@/lib/api';
interface Workspace {
id: string;
path: string;
gitUrl: string;
branch: string;
machine: { id: string; name: string; status: string };
}
interface Project {
id: string;
name: string;
description: string;
workspaces: Workspace[];
}
export default function ProjectDetailPage() {
const params = useParams();
const router = useRouter();
const [project, setProject] = useState<Project | null>(null);
const [machines, setMachines] = useState<any[]>([]);
const [showAdd, setShowAdd] = useState(false);
const [machineId, setMachineId] = useState('');
const [path, setPath] = useState('');
const [gitUrl, setGitUrl] = useState('');
const [launchingWs, setLaunchingWs] = useState<string | null>(null);
const load = () => {
api.get(`/projects/${params.id}`).then(setProject).catch(() => {});
api.get('/machines').then(setMachines).catch(() => {});
};
useEffect(() => { load(); }, [params.id]);
const addWorkspace = async () => {
if (!machineId) return;
await api.post('/workspaces', { projectId: params.id as string, machineId, path, gitUrl });
setShowAdd(false); setMachineId(''); setPath(''); setGitUrl('');
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>;
return (
<div>
<h2 style={{ fontSize: 24, marginBottom: 4 }}>{project.name}</h2>
<p style={{ color: 'var(--text-muted)', marginBottom: 24 }}>{project.description}</p>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 16 }}>
<h3 style={{ fontSize: 18 }}>Workspaces</h3>
<button onClick={() => setShowAdd(!showAdd)} style={{
padding: '6px 12px', background: 'var(--primary)', color: '#fff',
border: 'none', borderRadius: 8, cursor: 'pointer', fontSize: 13,
}}>+ Add Workspace</button>
</div>
{showAdd && (
<div style={{ background: 'var(--bg-card)', border: '1px solid var(--border)', borderRadius: 12, padding: 20, marginBottom: 16 }}>
<select value={machineId} onChange={e => setMachineId(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 machine...</option>
{machines.map((m: any) => <option key={m.id} value={m.id}>{m.name} ({m.status})</option>)}
</select>
<input placeholder="Workspace path" value={path} onChange={e => setPath(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 }} />
<input placeholder="Git URL" value={gitUrl} onChange={e => setGitUrl(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={addWorkspace} style={{ padding: '8px 16px', background: 'var(--primary)', color: '#fff', border: 'none', borderRadius: 8, cursor: 'pointer' }}>Create</button>
</div>
)}
<div style={{ display: 'grid', gap: 12 }}>
{project.workspaces?.map(ws => (
<div key={ws.id} style={{
background: 'var(--bg-card)', border: '1px solid var(--border)', borderRadius: 12, padding: 20,
}}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<div>
<div style={{ fontWeight: 600, marginBottom: 4 }}>{ws.machine?.name}</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>}
</div>
<div style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
{ws.machine?.status === 'online' && (
<button
onClick={() => openTerminal(ws)}
disabled={launchingWs === ws.id}
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>
))}
{(!project.workspaces || project.workspaces.length === 0) && (
<div style={{ textAlign: 'center', color: 'var(--text-muted)', padding: 40 }}>No workspaces yet</div>
)}
</div>
</div>
);
}

View File

@ -0,0 +1,67 @@
'use client';
import { useEffect, useState } from 'react';
import { api } from '@/lib/api';
import Link from 'next/link';
interface Project {
id: string;
name: string;
description: string;
owner: { name: string };
createdAt: string;
}
export default function ProjectsPage() {
const [projects, setProjects] = useState<Project[]>([]);
const [showAdd, setShowAdd] = useState(false);
const [name, setName] = useState('');
const [desc, setDesc] = useState('');
const load = () => api.get('/projects').then(setProjects).catch(() => {});
useEffect(() => { load(); }, []);
const addProject = async () => {
if (!name.trim()) return;
await api.post('/projects', { name: name.trim(), description: desc });
setName(''); setDesc(''); setShowAdd(false);
load();
};
return (
<div>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 24 }}>
<h2 style={{ fontSize: 24 }}>Projects</h2>
<button onClick={() => setShowAdd(!showAdd)} style={{
padding: '8px 16px', background: 'var(--primary)', color: '#fff',
border: 'none', borderRadius: 8, cursor: 'pointer', fontSize: 14,
}}>+ New Project</button>
</div>
{showAdd && (
<div style={{ background: 'var(--bg-card)', border: '1px solid var(--border)', borderRadius: 12, padding: 20, marginBottom: 16 }}>
<input placeholder="Project name" value={name} onChange={e => setName(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 }} />
<textarea placeholder="Description" value={desc} onChange={e => setDesc(e.target.value)} rows={3}
style={{ width: '100%', padding: '8px 12px', marginBottom: 8, background: 'var(--bg)', border: '1px solid var(--border)', borderRadius: 8, color: 'var(--text)', fontSize: 14, resize: 'vertical' }} />
<button onClick={addProject} style={{ padding: '8px 16px', background: 'var(--primary)', color: '#fff', border: 'none', borderRadius: 8, cursor: 'pointer' }}>Create</button>
</div>
)}
<div style={{ display: 'grid', gap: 12 }}>
{projects.map(p => (
<Link key={p.id} href={`/projects/${p.id}`} style={{
background: 'var(--bg-card)', border: '1px solid var(--border)',
borderRadius: 12, padding: 20, display: 'block', textDecoration: 'none', color: 'inherit',
}}>
<div style={{ fontWeight: 600, marginBottom: 4 }}>{p.name}</div>
<div style={{ fontSize: 13, color: 'var(--text-muted)' }}>{p.description || 'No description'}</div>
</Link>
))}
{projects.length === 0 && (
<div style={{ textAlign: 'center', color: 'var(--text-muted)', padding: 40 }}>No projects yet</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

@ -0,0 +1,100 @@
'use client';
import { useEffect, useState } from 'react';
import { useRouter } from 'next/navigation';
import { api } from '@/lib/api';
export default function SessionsPage() {
const router = useRouter();
const [sessions, setSessions] = useState<any[]>([]);
const [workspaces, setWorkspaces] = useState<any[]>([]);
const [showNew, setShowNew] = useState(false);
const [newWsId, setNewWsId] = useState('');
const [newCmd, setNewCmd] = useState('');
const load = () => {
api.get('/projects').then(async (projects: any[]) => {
const allWs: any[] = [];
for (const p of projects) {
const ws = await api.get(`/workspaces?projectId=${p.id}`).catch(() => []);
allWs.push(...ws);
}
setWorkspaces(allWs);
const allSessions: any[] = [];
for (const ws of allWs) {
const s = await api.get(`/sessions?workspaceId=${ws.id}`).catch(() => []);
allSessions.push(...s.map((sess: any) => ({ ...sess, workspacePath: ws.path, machineName: ws.machine?.name })));
}
setSessions(allSessions);
}).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 (
<div>
<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 }}>
{sessions.map(s => (
<div
key={s.id}
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 style={{ fontWeight: 600, marginBottom: 4 }}>{s.command || 'Session'}</div>
<div style={{ fontSize: 13, color: 'var(--text-muted)' }}>{s.machineName} {s.workspacePath}</div>
</div>
<div style={{
padding: '4px 12px', borderRadius: 20, fontSize: 12, fontWeight: 600,
background: s.status === 'running' ? 'rgba(34,197,94,0.15)' : 'rgba(136,136,136,0.15)',
color: s.status === 'running' ? 'var(--success)' : 'var(--text-muted)',
}}>
{s.status}
</div>
</div>
))}
{sessions.length === 0 && (
<div style={{ textAlign: 'center', color: 'var(--text-muted)', padding: 40 }}>No sessions yet</div>
)}
</div>
</div>
);
}

View File

@ -0,0 +1,25 @@
* { margin: 0; padding: 0; box-sizing: border-box; }
:root {
--bg: #0a0a0a;
--bg-secondary: #141414;
--bg-card: #1a1a1a;
--border: #2a2a2a;
--text: #e5e5e5;
--text-muted: #888;
--primary: #3b82f6;
--primary-hover: #2563eb;
--success: #22c55e;
--danger: #ef4444;
--warning: #f59e0b;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
background: var(--bg);
color: var(--text);
min-height: 100vh;
}
a { color: var(--primary); text-decoration: none; }
a:hover { text-decoration: underline; }

View File

@ -1,3 +1,6 @@
import './globals.css';
import { AuthProvider } from '@/lib/auth-context';
export const metadata = {
title: 'Reckue Dev',
description: 'Project management & Claude Code sessions',
@ -6,7 +9,9 @@ export const metadata = {
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="en">
<body>{children}</body>
<body>
<AuthProvider>{children}</AuthProvider>
</body>
</html>
);
}

View File

@ -0,0 +1,57 @@
'use client';
import { useState } from 'react';
import { useAuth } from '@/lib/auth-context';
import { useRouter } from 'next/navigation';
import Link from 'next/link';
export default function LoginPage() {
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [error, setError] = useState('');
const { login } = useAuth();
const router = useRouter();
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setError('');
try {
await login(email, password);
router.push('/');
} catch (err: any) {
setError(err.message);
}
};
return (
<div style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', minHeight: '100vh' }}>
<form onSubmit={handleSubmit} style={{
background: 'var(--bg-card)', border: '1px solid var(--border)',
borderRadius: 12, padding: 32, width: 380,
}}>
<h1 style={{ fontSize: 24, marginBottom: 24, textAlign: 'center' }}>Reckue Dev</h1>
{error && <div style={{ color: 'var(--danger)', marginBottom: 16, fontSize: 14 }}>{error}</div>}
<input type="email" placeholder="Email" value={email} onChange={e => setEmail(e.target.value)}
required style={inputStyle} />
<input type="password" placeholder="Password" value={password} onChange={e => setPassword(e.target.value)}
required style={inputStyle} />
<button type="submit" style={btnStyle}>Login</button>
<p style={{ textAlign: 'center', marginTop: 16, fontSize: 14, color: 'var(--text-muted)' }}>
No account? <Link href="/register">Register</Link>
</p>
</form>
</div>
);
}
const inputStyle: React.CSSProperties = {
width: '100%', padding: '10px 14px', marginBottom: 12,
background: 'var(--bg)', border: '1px solid var(--border)',
borderRadius: 8, color: 'var(--text)', fontSize: 14, outline: 'none',
};
const btnStyle: React.CSSProperties = {
width: '100%', padding: '10px 14px', background: 'var(--primary)',
color: '#fff', border: 'none', borderRadius: 8, fontSize: 14,
cursor: 'pointer', fontWeight: 600,
};

View File

@ -1,8 +0,0 @@
export default function Home() {
return (
<main style={{ padding: '2rem', fontFamily: 'system-ui' }}>
<h1>Reckue Dev</h1>
<p>Platform is starting up...</p>
</main>
);
}

View File

@ -0,0 +1,60 @@
'use client';
import { useState } from 'react';
import { useAuth } from '@/lib/auth-context';
import { useRouter } from 'next/navigation';
import Link from 'next/link';
export default function RegisterPage() {
const [name, setName] = useState('');
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [error, setError] = useState('');
const { register } = useAuth();
const router = useRouter();
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setError('');
try {
await register(email, password, name);
router.push('/');
} catch (err: any) {
setError(err.message);
}
};
return (
<div style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', minHeight: '100vh' }}>
<form onSubmit={handleSubmit} style={{
background: 'var(--bg-card)', border: '1px solid var(--border)',
borderRadius: 12, padding: 32, width: 380,
}}>
<h1 style={{ fontSize: 24, marginBottom: 24, textAlign: 'center' }}>Register</h1>
{error && <div style={{ color: 'var(--danger)', marginBottom: 16, fontSize: 14 }}>{error}</div>}
<input type="text" placeholder="Name" value={name} onChange={e => setName(e.target.value)}
required style={inputStyle} />
<input type="email" placeholder="Email" value={email} onChange={e => setEmail(e.target.value)}
required style={inputStyle} />
<input type="password" placeholder="Password" value={password} onChange={e => setPassword(e.target.value)}
required style={inputStyle} />
<button type="submit" style={btnStyle}>Register</button>
<p style={{ textAlign: 'center', marginTop: 16, fontSize: 14, color: 'var(--text-muted)' }}>
Have an account? <Link href="/login">Login</Link>
</p>
</form>
</div>
);
}
const inputStyle: React.CSSProperties = {
width: '100%', padding: '10px 14px', marginBottom: 12,
background: 'var(--bg)', border: '1px solid var(--border)',
borderRadius: 8, color: 'var(--text)', fontSize: 14, outline: 'none',
};
const btnStyle: React.CSSProperties = {
width: '100%', padding: '10px 14px', background: 'var(--primary)',
color: '#fff', border: 'none', borderRadius: 8, fontSize: 14,
cursor: 'pointer', fontWeight: 600,
};

View File

@ -0,0 +1,74 @@
'use client';
import Link from 'next/link';
import { usePathname } from 'next/navigation';
import { useAuth } from '@/lib/auth-context';
const navItems = [
{ href: '/', label: 'Dashboard', icon: '\u25C9' },
{ href: '/projects', label: 'Projects', icon: '\u25A6' },
{ href: '/machines', label: 'Machines', icon: '\u2B21' },
{ href: '/sessions', label: 'Sessions', icon: '\u25B6' },
];
export function Sidebar() {
const pathname = usePathname();
const { user, logout } = useAuth();
return (
<aside style={{
width: 240,
height: '100vh',
background: 'var(--bg-secondary)',
borderRight: '1px solid var(--border)',
display: 'flex',
flexDirection: 'column',
position: 'fixed',
left: 0,
top: 0,
}}>
<div style={{ padding: '20px 16px', borderBottom: '1px solid var(--border)' }}>
<h1 style={{ fontSize: 18, fontWeight: 700 }}>Reckue Dev</h1>
</div>
<nav style={{ flex: 1, padding: '8px' }}>
{navItems.map(item => {
const active = item.href === '/' ? pathname === '/' : pathname.startsWith(item.href);
return (
<Link key={item.href} href={item.href} style={{
display: 'flex',
alignItems: 'center',
gap: 10,
padding: '10px 12px',
borderRadius: 8,
marginBottom: 2,
background: active ? 'var(--primary)' : 'transparent',
color: active ? '#fff' : 'var(--text-muted)',
textDecoration: 'none',
fontSize: 14,
}}>
<span style={{ fontSize: 16 }}>{item.icon}</span>
{item.label}
</Link>
);
})}
</nav>
{user && (
<div style={{ padding: 16, borderTop: '1px solid var(--border)', fontSize: 13 }}>
<div style={{ color: 'var(--text-muted)', marginBottom: 8 }}>{user.email}</div>
<button onClick={logout} style={{
background: 'none',
border: '1px solid var(--border)',
color: 'var(--text-muted)',
padding: '6px 12px',
borderRadius: 6,
cursor: 'pointer',
width: '100%',
fontSize: 13,
}}>
Logout
</button>
</div>
)}
</aside>
);
}

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',
}}
/>
);
}

66
apps/web/src/lib/api.ts Normal file
View File

@ -0,0 +1,66 @@
const API_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3001/api';
class ApiClient {
private token: string | null = null;
constructor() {
if (typeof window !== 'undefined') {
this.token = localStorage.getItem('token');
}
}
setToken(token: string) {
this.token = token;
if (typeof window !== 'undefined') {
localStorage.setItem('token', token);
}
}
clearToken() {
this.token = null;
if (typeof window !== 'undefined') {
localStorage.removeItem('token');
}
}
getToken() {
return this.token;
}
async request<T = any>(path: string, options: RequestInit = {}): Promise<T> {
const headers: Record<string, string> = {
'Content-Type': 'application/json',
...((options.headers as Record<string, string>) || {}),
};
if (this.token) headers['Authorization'] = `Bearer ${this.token}`;
const res = await fetch(`${API_URL}${path}`, { ...options, headers });
if (res.status === 401) {
this.clearToken();
if (typeof window !== 'undefined') window.location.href = '/login';
throw new Error('Unauthorized');
}
if (!res.ok) {
const err = await res.json().catch(() => ({}));
throw new Error(err.message || `HTTP ${res.status}`);
}
if (res.status === 204) return null as T;
return res.json();
}
get<T = any>(path: string) { return this.request<T>(path); }
post<T = any>(path: string, body: any) {
return this.request<T>(path, { method: 'POST', body: JSON.stringify(body) });
}
patch<T = any>(path: string, body: any) {
return this.request<T>(path, { method: 'PATCH', body: JSON.stringify(body) });
}
delete(path: string) {
return this.request(path, { method: 'DELETE' });
}
}
export const api = new ApiClient();

View File

@ -0,0 +1,64 @@
'use client';
import { createContext, useContext, useState, useEffect, ReactNode } from 'react';
import { api } from './api';
interface User {
id: string;
email: string;
name: string;
role: string;
}
interface AuthContextType {
user: User | null;
loading: boolean;
login: (email: string, password: string) => Promise<void>;
register: (email: string, password: string, name: string) => Promise<void>;
logout: () => void;
}
const AuthContext = createContext<AuthContextType | null>(null);
export function AuthProvider({ children }: { children: ReactNode }) {
const [user, setUser] = useState<User | null>(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
const token = api.getToken();
if (token) {
const payload = JSON.parse(atob(token.split('.')[1]));
setUser({ id: payload.sub, email: payload.email, name: payload.email, role: payload.role });
}
setLoading(false);
}, []);
const login = async (email: string, password: string) => {
const data = await api.post('/auth/login', { email, password });
api.setToken(data.accessToken);
setUser(data.user);
};
const register = async (email: string, password: string, name: string) => {
const data = await api.post('/auth/register', { email, password, name });
api.setToken(data.accessToken);
setUser(data.user);
};
const logout = () => {
api.clearToken();
setUser(null);
};
return (
<AuthContext.Provider value={{ user, loading, login, register, logout }}>
{children}
</AuthContext.Provider>
);
}
export function useAuth() {
const ctx = useContext(AuthContext);
if (!ctx) throw new Error('useAuth must be used within AuthProvider');
return ctx;
}

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

View File

@ -1,2 +1,7 @@
DATABASE_HOST=dfbcf8e96179bce116eb84b5.twc1.net
DATABASE_PORT=5432
DATABASE_USER=gen_user
DATABASE_PASSWORD=
DATABASE_NAME=default_db
DATABASE_SSL=true
JWT_SECRET=

View File

@ -10,11 +10,12 @@ services:
environment:
- NODE_ENV=production
- PORT=3001
- DATABASE_HOST=72.56.119.162
- DATABASE_HOST=${DATABASE_HOST:-dfbcf8e96179bce116eb84b5.twc1.net}
- DATABASE_PORT=5432
- DATABASE_NAME=reckue_dev
- DATABASE_USER=reckue
- DATABASE_NAME=${DATABASE_NAME:-default_db}
- DATABASE_USER=${DATABASE_USER:-gen_user}
- DATABASE_PASSWORD=${DATABASE_PASSWORD}
- DATABASE_SSL=true
- JWT_SECRET=${JWT_SECRET}
restart: unless-stopped

7192
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff