Compare commits
2 Commits
703c254074
...
f774e0f4f1
| Author | SHA1 | Date | |
|---|---|---|---|
| f774e0f4f1 | |||
| ec4fb1155b |
1
.gitignore
vendored
1
.gitignore
vendored
@ -4,3 +4,4 @@ dist/
|
||||
target/
|
||||
.env
|
||||
*.log
|
||||
ca.crt
|
||||
|
||||
@ -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
27
apps/agent/src/config.rs
Normal 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())
|
||||
}
|
||||
187
apps/agent/src/connection.rs
Normal file
187
apps/agent/src/connection.rs
Normal 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,{}", ®ister.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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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
105
apps/agent/src/messages.rs
Normal 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()
|
||||
}
|
||||
113
apps/agent/src/pty_manager.rs
Normal file
113
apps/agent/src/pty_manager.rs
Normal 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())
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@ -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 {}
|
||||
|
||||
24
apps/api/src/auth/auth.controller.ts
Normal file
24
apps/api/src/auth/auth.controller.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
27
apps/api/src/auth/auth.module.ts
Normal file
27
apps/api/src/auth/auth.module.ts
Normal 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 {}
|
||||
41
apps/api/src/auth/auth.service.ts
Normal file
41
apps/api/src/auth/auth.service.ts
Normal 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 },
|
||||
};
|
||||
}
|
||||
}
|
||||
5
apps/api/src/auth/jwt-auth.guard.ts
Normal file
5
apps/api/src/auth/jwt-auth.guard.ts
Normal file
@ -0,0 +1,5 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { AuthGuard } from '@nestjs/passport';
|
||||
|
||||
@Injectable()
|
||||
export class JwtAuthGuard extends AuthGuard('jwt') {}
|
||||
19
apps/api/src/auth/jwt.strategy.ts
Normal file
19
apps/api/src/auth/jwt.strategy.ts
Normal 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 };
|
||||
}
|
||||
}
|
||||
28
apps/api/src/entities/audit-log.entity.ts
Normal file
28
apps/api/src/entities/audit-log.entity.ts
Normal 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;
|
||||
}
|
||||
6
apps/api/src/entities/index.ts
Normal file
6
apps/api/src/entities/index.ts
Normal 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';
|
||||
35
apps/api/src/entities/machine.entity.ts
Normal file
35
apps/api/src/entities/machine.entity.ts
Normal 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;
|
||||
}
|
||||
31
apps/api/src/entities/project.entity.ts
Normal file
31
apps/api/src/entities/project.entity.ts
Normal 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;
|
||||
}
|
||||
30
apps/api/src/entities/session.entity.ts
Normal file
30
apps/api/src/entities/session.entity.ts
Normal 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;
|
||||
}
|
||||
29
apps/api/src/entities/user.entity.ts
Normal file
29
apps/api/src/entities/user.entity.ts
Normal 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;
|
||||
}
|
||||
42
apps/api/src/entities/workspace.entity.ts
Normal file
42
apps/api/src/entities/workspace.entity.ts
Normal 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;
|
||||
}
|
||||
140
apps/api/src/gateway/agent.gateway.ts
Normal file
140
apps/api/src/gateway/agent.gateway.ts
Normal 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}`);
|
||||
}
|
||||
}
|
||||
29
apps/api/src/machines/machines.controller.ts
Normal file
29
apps/api/src/machines/machines.controller.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
13
apps/api/src/machines/machines.module.ts
Normal file
13
apps/api/src/machines/machines.module.ts
Normal 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 {}
|
||||
42
apps/api/src/machines/machines.service.ts
Normal file
42
apps/api/src/machines/machines.service.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
34
apps/api/src/projects/projects.controller.ts
Normal file
34
apps/api/src/projects/projects.controller.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
13
apps/api/src/projects/projects.module.ts
Normal file
13
apps/api/src/projects/projects.module.ts
Normal 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 {}
|
||||
34
apps/api/src/projects/projects.service.ts
Normal file
34
apps/api/src/projects/projects.service.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
29
apps/api/src/sessions/sessions.controller.ts
Normal file
29
apps/api/src/sessions/sessions.controller.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
13
apps/api/src/sessions/sessions.module.ts
Normal file
13
apps/api/src/sessions/sessions.module.ts
Normal 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 {}
|
||||
35
apps/api/src/sessions/sessions.service.ts
Normal file
35
apps/api/src/sessions/sessions.service.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
14
apps/api/src/users/users.controller.ts
Normal file
14
apps/api/src/users/users.controller.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
13
apps/api/src/users/users.module.ts
Normal file
13
apps/api/src/users/users.module.ts
Normal 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 {}
|
||||
27
apps/api/src/users/users.service.ts
Normal file
27
apps/api/src/users/users.service.ts
Normal 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'] });
|
||||
}
|
||||
}
|
||||
34
apps/api/src/workspaces/workspaces.controller.ts
Normal file
34
apps/api/src/workspaces/workspaces.controller.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
13
apps/api/src/workspaces/workspaces.module.ts
Normal file
13
apps/api/src/workspaces/workspaces.module.ts
Normal 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 {}
|
||||
34
apps/api/src/workspaces/workspaces.service.ts
Normal file
34
apps/api/src/workspaces/workspaces.service.ts
Normal 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
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.
|
||||
@ -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",
|
||||
|
||||
27
apps/web/src/app/(dashboard)/layout.tsx
Normal file
27
apps/web/src/app/(dashboard)/layout.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
84
apps/web/src/app/(dashboard)/machines/page.tsx
Normal file
84
apps/web/src/app/(dashboard)/machines/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
48
apps/web/src/app/(dashboard)/page.tsx
Normal file
48
apps/web/src/app/(dashboard)/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
128
apps/web/src/app/(dashboard)/projects/[id]/page.tsx
Normal file
128
apps/web/src/app/(dashboard)/projects/[id]/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
67
apps/web/src/app/(dashboard)/projects/page.tsx
Normal file
67
apps/web/src/app/(dashboard)/projects/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
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>
|
||||
);
|
||||
}
|
||||
100
apps/web/src/app/(dashboard)/sessions/page.tsx
Normal file
100
apps/web/src/app/(dashboard)/sessions/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
25
apps/web/src/app/globals.css
Normal file
25
apps/web/src/app/globals.css
Normal 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; }
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
57
apps/web/src/app/login/page.tsx
Normal file
57
apps/web/src/app/login/page.tsx
Normal 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,
|
||||
};
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
60
apps/web/src/app/register/page.tsx
Normal file
60
apps/web/src/app/register/page.tsx
Normal 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,
|
||||
};
|
||||
74
apps/web/src/components/sidebar.tsx
Normal file
74
apps/web/src/components/sidebar.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
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',
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
66
apps/web/src/lib/api.ts
Normal file
66
apps/web/src/lib/api.ts
Normal 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();
|
||||
64
apps/web/src/lib/auth-context.tsx
Normal file
64
apps/web/src/lib/auth-context.tsx
Normal 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;
|
||||
}
|
||||
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": {
|
||||
"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"
|
||||
]
|
||||
}
|
||||
|
||||
@ -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=
|
||||
|
||||
@ -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
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