From 255fce5807c6acb1cd2e4d35e311b49d22254842 Mon Sep 17 00:00:00 2001 From: default Date: Wed, 4 Feb 2026 17:10:09 +0000 Subject: [PATCH] API and TUI Updates --- .gitignore | 4 +- backend/cmd/server/main.go | 11 +- backend/internal/api/api.go | 23 + backend/internal/api/auth.go | 2 +- backend/internal/api/project.go | 32 +- backend/internal/api/storage.go | 21 +- cli/Cargo.toml | 6 + cli/src/api.rs | 320 ++++++ cli/src/config.rs | 75 ++ cli/src/main.rs | 932 ++++++++++++++---- cli/src/models.rs | 116 +++ cli/src/ui/activity.rs | 117 +++ cli/src/ui/app.rs | 211 ++++ cli/src/ui/create_project.rs | 106 ++ cli/src/ui/deployment_logs.rs | 91 ++ cli/src/ui/deployments.rs | 83 ++ cli/src/ui/docs.rs | 86 ++ cli/src/ui/mod.rs | 40 + cli/src/ui/network.rs | 85 ++ cli/src/ui/project_detail.rs | 160 +++ cli/src/ui/project_settings.rs | 166 ++++ cli/src/ui/projects.rs | 135 +++ cli/src/ui/settings.rs | 94 ++ cli/src/ui/setup.rs | 138 +++ cli/src/ui/storage.rs | 319 ++++++ frontend/package.json | 2 +- frontend/src/lib/api.ts | 13 + frontend/src/routes/+layout.ts | 1 + .../src/routes/projects/[id]/+page.svelte | 22 +- frontend/svelte.config.js | 8 +- 30 files changed, 3234 insertions(+), 185 deletions(-) create mode 100644 cli/src/api.rs create mode 100644 cli/src/config.rs create mode 100644 cli/src/models.rs create mode 100644 cli/src/ui/activity.rs create mode 100644 cli/src/ui/app.rs create mode 100644 cli/src/ui/create_project.rs create mode 100644 cli/src/ui/deployment_logs.rs create mode 100644 cli/src/ui/deployments.rs create mode 100644 cli/src/ui/docs.rs create mode 100644 cli/src/ui/mod.rs create mode 100644 cli/src/ui/network.rs create mode 100644 cli/src/ui/project_detail.rs create mode 100644 cli/src/ui/project_settings.rs create mode 100644 cli/src/ui/projects.rs create mode 100644 cli/src/ui/settings.rs create mode 100644 cli/src/ui/setup.rs create mode 100644 cli/src/ui/storage.rs diff --git a/.gitignore b/.gitignore index e6ab479..d4f9549 100644 --- a/.gitignore +++ b/.gitignore @@ -29,4 +29,6 @@ bun.lock .vscode .svelte-kit -target/ \ No newline at end of file +target/ +dist/ +data/ \ No newline at end of file diff --git a/backend/cmd/server/main.go b/backend/cmd/server/main.go index beff3d0..57d9b6e 100644 --- a/backend/cmd/server/main.go +++ b/backend/cmd/server/main.go @@ -1,15 +1,17 @@ package main + import ( - "log" - "time" - "github.com/gin-contrib/cors" - "github.com/gin-gonic/gin" "clickploy/internal/api" "clickploy/internal/builder" "clickploy/internal/db" "clickploy/internal/deployer" "clickploy/internal/ports" + "github.com/gin-contrib/cors" + "github.com/gin-gonic/gin" + "log" + "time" ) + func main() { db.Init(".") pm := ports.NewManager(2000, 60000) @@ -36,6 +38,7 @@ func main() { handler.RegisterSystemRoutes(r) handler.RegisterStorageRoutes(r) handler.RegisterAdminRoutes(r) + handler.RegisterFrontendRoutes(r) log.Println("Starting Clickploy Backend on :8080") if err := r.Run(":8080"); err != nil { log.Fatalf("Server failed: %v", err) diff --git a/backend/internal/api/api.go b/backend/internal/api/api.go index 742569c..668a0c0 100644 --- a/backend/internal/api/api.go +++ b/backend/internal/api/api.go @@ -77,6 +77,29 @@ func (h *Handler) handleDeploy(c *gin.Context) { func (h *Handler) RegisterSystemRoutes(r *gin.Engine) { r.GET("/api/system/status", h.handleSystemStatus) } + +func (h *Handler) RegisterFrontendRoutes(r *gin.Engine) { + // Serve static files from the build directory + r.Static("/_app", "./dist/_app") + r.StaticFile("/robots.txt", "./dist/robots.txt") + + // Serve index.html for root + r.GET("/", func(c *gin.Context) { + c.File("./dist/index.html") + }) + + // Handle SPA client-side routing: if no other route matches, serve index.html + // But ensure we don't serve HTML for API 404s + r.NoRoute(func(c *gin.Context) { + path := c.Request.URL.Path + if len(path) >= 4 && path[:4] == "/api" { + c.JSON(http.StatusNotFound, gin.H{"error": "API route not found"}) + return + } + c.File("./dist/index.html") + }) +} + func (h *Handler) handleSystemStatus(c *gin.Context) { localIP := GetLocalIP() publicIP := GetPublicIP() diff --git a/backend/internal/api/auth.go b/backend/internal/api/auth.go index 39c988f..d568c51 100644 --- a/backend/internal/api/auth.go +++ b/backend/internal/api/auth.go @@ -83,7 +83,7 @@ func (h *Handler) login(c *gin.Context) { func (h *Handler) RegisterUserRoutes(r *gin.Engine) { userGroup := r.Group("/api/user", AuthMiddleware()) { - userGroup.GET("/", h.getMe) + userGroup.GET("", h.getMe) userGroup.PUT("/profile", h.updateProfile) userGroup.PUT("/password", h.updatePassword) userGroup.POST("/key", h.regenerateAPIKey) diff --git a/backend/internal/api/project.go b/backend/internal/api/project.go index d391027..a54b2b5 100644 --- a/backend/internal/api/project.go +++ b/backend/internal/api/project.go @@ -26,6 +26,7 @@ func (h *Handler) RegisterProjectRoutes(r *gin.Engine) { protected.PUT("/projects/:id", h.updateProject) protected.PUT("/projects/:id/env", h.updateProjectEnv) protected.POST("/projects/:id/redeploy", h.redeployProject) + protected.POST("/projects/:id/stop", h.stopProject) protected.GET("/activity", h.getActivity) } } @@ -310,7 +311,9 @@ func (h *Handler) resolveEnvVars(ctx context.Context, userID string, envVars map func (h *Handler) listProjects(c *gin.Context) { userID, _ := c.Get("userID") var projects []models.Project - if result := db.DB.Preload("Deployments").Where("owner_id = ?", userID).Find(&projects); result.Error != nil { + if result := db.DB.Preload("Deployments", func(db *gorm.DB) *gorm.DB { + return db.Order("deployments.created_at desc") + }).Where("owner_id = ?", userID).Find(&projects); result.Error != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch projects"}) return } @@ -328,3 +331,30 @@ func (h *Handler) getProject(c *gin.Context) { } c.JSON(http.StatusOK, project) } + +func (h *Handler) stopProject(c *gin.Context) { + userID, _ := c.Get("userID") + projectID := c.Param("id") + + var project models.Project + if result := db.DB.Where("id = ? AND owner_id = ?", projectID, userID).First(&project); result.Error != nil { + c.JSON(http.StatusNotFound, gin.H{"error": "Project not found"}) + return + } + + // Stop the container using the project name + err := h.deployer.StopContainer(c.Request.Context(), project.Name) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("Failed to stop container: %v", err)}) + return + } + + // Update the latest deployment status to stopped + var deployment models.Deployment + if result := db.DB.Where("project_id = ?", project.ID).Order("created_at desc").First(&deployment); result.Error == nil { + deployment.Status = "stopped" + db.DB.Save(&deployment) + } + + c.JSON(http.StatusOK, gin.H{"status": "stopped", "message": "Container stopped successfully"}) +} diff --git a/backend/internal/api/storage.go b/backend/internal/api/storage.go index b625673..7f401c6 100644 --- a/backend/internal/api/storage.go +++ b/backend/internal/api/storage.go @@ -229,13 +229,18 @@ func (h *Handler) handleGetDatabaseCredentials(c *gin.Context) { return } var username, password string + fmt.Printf("Debug: Container env vars count: %d\n", len(envVars)) for _, env := range envVars { - if len(env) > 25 && env[:25] == "MONGO_INITDB_ROOT_USERNAME=" { - username = env[25:] - } else if len(env) > 25 && env[:25] == "MONGO_INITDB_ROOT_PASSWORD=" { - password = env[25:] + fmt.Printf("Debug: env var: %s\n", env) + if len(env) > 26 && env[:26] == "MONGO_INITDB_ROOT_USERNAME=" { + username = env[26:] + fmt.Printf("Debug: Found username: %s\n", username) + } else if len(env) > 26 && env[:26] == "MONGO_INITDB_ROOT_PASSWORD=" { + password = env[26:] + fmt.Printf("Debug: Found password: %s\n", password) } } + fmt.Printf("Debug: Final username=%s, password=%s\n", username, password) uri := fmt.Sprintf("mongodb://%s:%s@localhost:%d/?authSource=admin", username, password, database.Port) publicUri := fmt.Sprintf("mongodb://%s:%s@:%d/?authSource=admin", username, password, database.Port) c.JSON(http.StatusOK, gin.H{ @@ -273,10 +278,10 @@ func (h *Handler) handleUpdateDatabaseCredentials(c *gin.Context) { var currentUser, currentPass string var otherEnv []string for _, env := range envVars { - if len(env) > 25 && env[:25] == "MONGO_INITDB_ROOT_USERNAME=" { - currentUser = env[25:] - } else if len(env) > 25 && env[:25] == "MONGO_INITDB_ROOT_PASSWORD=" { - currentPass = env[25:] + if len(env) > 26 && env[:26] == "MONGO_INITDB_ROOT_USERNAME=" { + currentUser = env[26:] + } else if len(env) > 26 && env[:26] == "MONGO_INITDB_ROOT_PASSWORD=" { + currentPass = env[26:] } else { otherEnv = append(otherEnv, env) } diff --git a/cli/Cargo.toml b/cli/Cargo.toml index ba54a6b..8b93fe6 100644 --- a/cli/Cargo.toml +++ b/cli/Cargo.toml @@ -12,3 +12,9 @@ reqwest = { version = "0.13.1", features = ["json"] } serde = { version = "1.0.228", features = ["derive"] } serde_json = "1.0.149" tokio = { version = "1.49.0", features = ["full"] } +tokio-tungstenite = "0.24" +toml = "0.8.19" +dirs = "5.0.1" +tui-input = "0.10.1" +webbrowser = "1.0.6" +futures-util = "0.3" diff --git a/cli/src/api.rs b/cli/src/api.rs new file mode 100644 index 0000000..2e41a11 --- /dev/null +++ b/cli/src/api.rs @@ -0,0 +1,320 @@ +use anyhow::{Context, Result}; +use reqwest::Client; +use crate::models::*; + +pub struct ApiClient { + client: Client, + base_url: String, + api_key: String, +} + +impl ApiClient { + pub fn new(base_url: String, api_key: String) -> Self { + Self { + client: Client::new(), + base_url: base_url.trim_end_matches('/').to_string(), + api_key, + } + } + + pub async fn validate_connection(&self) -> Result { + let url = format!("{}/api/user", self.base_url); + + let response = self.client + .get(&url) + .header("Authorization", &self.api_key) + .send() + .await + .context("Failed to connect to server")?; + + if !response.status().is_success() { + anyhow::bail!("Authentication failed: {}", response.status()); + } + + let user = response.json::().await + .context("Failed to parse user response")?; + + Ok(user) + } + + pub async fn list_projects(&self) -> Result> { + let url = format!("{}/api/projects", self.base_url); + + let response = self.client + .get(&url) + .header("Authorization", &self.api_key) + .send() + .await + .context("Failed to fetch projects")?; + + if !response.status().is_success() { + anyhow::bail!("Failed to fetch projects: {}", response.status()); + } + + let projects = response.json::>().await + .context("Failed to parse projects response")?; + + Ok(projects) + } + + pub async fn get_project(&self, id: &str) -> Result { + let url = format!("{}/api/projects/{}", self.base_url, id); + + let response = self.client + .get(&url) + .header("Authorization", &self.api_key) + .send() + .await + .context("Failed to fetch project")?; + + if !response.status().is_success() { + anyhow::bail!("Failed to fetch project: {}", response.status()); + } + + let project = response.json::().await + .context("Failed to parse project response")?; + + Ok(project) + } + + pub async fn create_project(&self, request: CreateProjectRequest) -> Result { + let url = format!("{}/api/projects", self.base_url); + + let response = self.client + .post(&url) + .header("Authorization", &self.api_key) + .json(&request) + .send() + .await + .context("Failed to create project")?; + + if !response.status().is_success() { + let error_text = response.text().await.unwrap_or_default(); + anyhow::bail!("Failed to create project: {}", error_text); + } + + let project = response.json::().await + .context("Failed to parse project response")?; + + Ok(project) + } + + pub async fn redeploy_project(&self, id: &str, commit: Option) -> Result { + let url = format!("{}/api/projects/{}/redeploy", self.base_url, id); + + let request = RedeployRequest { commit }; + + let response = self.client + .post(&url) + .header("Authorization", &self.api_key) + .json(&request) + .send() + .await + .context("Failed to redeploy project")?; + + if !response.status().is_success() { + anyhow::bail!("Failed to redeploy project: {}", response.status()); + } + + let result = response.json::().await + .context("Failed to parse redeploy response")?; + + Ok(result) + } + + pub async fn stop_project(&self, id: &str) -> Result { + let url = format!("{}/api/projects/{}/stop", self.base_url, id); + + let response = self.client + .post(&url) + .header("Authorization", &self.api_key) + .send() + .await + .context("Failed to stop project")?; + + if !response.status().is_success() { + anyhow::bail!("Failed to stop project: {}", response.status()); + } + + let result = response.json::().await + .context("Failed to parse stop response")?; + + Ok(result) + } + + pub async fn get_activity(&self) -> Result> { + let url = format!("{}/api/activity", self.base_url); + + let response = self.client + .get(&url) + .header("Authorization", &self.api_key) + .send() + .await + .context("Failed to fetch activity")?; + + if !response.status().is_success() { + anyhow::bail!("Failed to fetch activity: {}", response.status()); + } + + let deployments = response.json::>().await + .context("Failed to parse activity response")?; + + Ok(deployments) + } + + // Storage API methods + pub async fn get_storage_stats(&self) -> Result { + let url = format!("{}/api/storage/stats", self.base_url); + + let response = self.client + .get(&url) + .header("Authorization", &self.api_key) + .send() + .await + .context("Failed to fetch storage stats")?; + + if !response.status().is_success() { + anyhow::bail!("Failed to fetch storage stats: {}", response.status()); + } + + let stats = response.json::().await + .context("Failed to parse storage stats response")?; + + Ok(stats) + } + + pub async fn list_databases(&self) -> Result> { + let url = format!("{}/api/storage/databases", self.base_url); + + let response = self.client + .get(&url) + .header("Authorization", &self.api_key) + .send() + .await + .context("Failed to fetch databases")?; + + if !response.status().is_success() { + anyhow::bail!("Failed to fetch databases: {}", response.status()); + } + + let databases = response.json::>().await + .context("Failed to parse databases response")?; + + Ok(databases) + } + + pub async fn create_database(&self, name: String, db_type: String) -> Result { + let url = format!("{}/api/storage/databases", self.base_url); + + let request = CreateDatabaseRequest { name, db_type }; + + let response = self.client + .post(&url) + .header("Authorization", &self.api_key) + .json(&request) + .send() + .await + .context("Failed to create database")?; + + if !response.status().is_success() { + let error_text = response.text().await.unwrap_or_default(); + anyhow::bail!("Failed to create database: {}", error_text); + } + + let result = response.json::().await + .context("Failed to parse create database response")?; + + Ok(result) + } + + pub async fn delete_database(&self, id: u32) -> Result<()> { + let url = format!("{}/api/storage/databases/{}", self.base_url, id); + + let response = self.client + .delete(&url) + .header("Authorization", &self.api_key) + .send() + .await + .context("Failed to delete database")?; + + if !response.status().is_success() { + anyhow::bail!("Failed to delete database: {}", response.status()); + } + + Ok(()) + } + + pub async fn get_database_credentials(&self, id: u32) -> Result { + let url = format!("{}/api/storage/databases/{}/credentials", self.base_url, id); + + let response = self.client + .get(&url) + .header("Authorization", &self.api_key) + .send() + .await + .context("Failed to fetch database credentials")?; + + if !response.status().is_success() { + anyhow::bail!("Failed to fetch database credentials: {}", response.status()); + } + + let credentials = response.json::().await + .context("Failed to parse database credentials response")?; + + Ok(credentials) + } + + pub async fn update_database_credentials(&self, id: u32, username: String, password: String) -> Result<()> { + let url = format!("{}/api/storage/databases/{}/credentials", self.base_url, id); + + let request = UpdateDatabaseCredentialsRequest { username, password }; + + let response = self.client + .put(&url) + .header("Authorization", &self.api_key) + .json(&request) + .send() + .await + .context("Failed to update database credentials")?; + + if !response.status().is_success() { + anyhow::bail!("Failed to update database credentials: {}", response.status()); + } + + Ok(()) + } + + pub async fn stop_database(&self, id: u32) -> Result<()> { + let url = format!("{}/api/storage/databases/{}/stop", self.base_url, id); + + let response = self.client + .post(&url) + .header("Authorization", &self.api_key) + .send() + .await + .context("Failed to stop database")?; + + if !response.status().is_success() { + anyhow::bail!("Failed to stop database: {}", response.status()); + } + + Ok(()) + } + + pub async fn restart_database(&self, id: u32) -> Result<()> { + let url = format!("{}/api/storage/databases/{}/restart", self.base_url, id); + + let response = self.client + .post(&url) + .header("Authorization", &self.api_key) + .send() + .await + .context("Failed to restart database")?; + + if !response.status().is_success() { + anyhow::bail!("Failed to restart database: {}", response.status()); + } + + Ok(()) + } +} diff --git a/cli/src/config.rs b/cli/src/config.rs new file mode 100644 index 0000000..8ab8e1a --- /dev/null +++ b/cli/src/config.rs @@ -0,0 +1,75 @@ +use anyhow::{Context, Result}; +use serde::{Deserialize, Serialize}; +use std::fs; +use std::path::PathBuf; + +#[derive(Debug, Serialize, Deserialize, Clone)] +pub struct Config { + pub server_url: String, + pub api_key: String, +} + +impl Config { + pub fn new(server_url: String, api_key: String) -> Self { + Self { + server_url, + api_key, + } + } +} + +pub fn get_config_path() -> Result { + let config_dir = dirs::config_dir() + .context("Failed to get config directory")? + .join("clickploy"); + + fs::create_dir_all(&config_dir) + .context("Failed to create config directory")?; + + Ok(config_dir.join("config.toml")) +} + +pub fn load_config() -> Result { + let config_path = get_config_path()?; + + if !config_path.exists() { + anyhow::bail!("Config file not found. Please run setup first."); + } + + let contents = fs::read_to_string(&config_path) + .context("Failed to read config file")?; + + let config: Config = toml::from_str(&contents) + .context("Failed to parse config file")?; + + Ok(config) +} + +pub fn save_config(config: &Config) -> Result<()> { + let config_path = get_config_path()?; + + let contents = toml::to_string_pretty(config) + .context("Failed to serialize config")?; + + fs::write(&config_path, contents) + .context("Failed to write config file")?; + + Ok(()) +} + +pub fn config_exists() -> bool { + get_config_path() + .map(|path| path.exists()) + .unwrap_or(false) +} + +pub fn delete_config() -> Result<()> { + let config_path = get_config_path()?; + + if config_path.exists() { + fs::remove_file(&config_path) + .context("Failed to delete config file")?; + } + + Ok(()) +} diff --git a/cli/src/main.rs b/cli/src/main.rs index a38bd2e..16ea7da 100644 --- a/cli/src/main.rs +++ b/cli/src/main.rs @@ -1,119 +1,184 @@ +mod api; +mod config; +mod models; +mod ui; + use anyhow::Result; +use api::ApiClient; +use config::{config_exists, delete_config, load_config, save_config, Config}; use crossterm::{ - event::{self, DisableMouseCapture, EnableMouseCapture, Event, KeyCode}, + event::{self, DisableMouseCapture, EnableMouseCapture, Event, KeyCode, KeyModifiers}, execute, terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen}, }; -use ratatui::{ - backend::CrosstermBackend, - layout::{Constraint, Direction, Layout}, - style::{Color, Modifier, Style}, - widgets::{Block, Borders, List, ListItem, ListState, Paragraph}, - Frame, Terminal, -}; -use serde::Deserialize; +use ratatui::{backend::CrosstermBackend, Terminal}; use std::{io, time::Duration}; - -#[derive(Debug, Deserialize, Clone)] -struct Project { - #[serde(rename = "ID")] - id: u32, - name: String, - repo_url: String, - port: u32, - deployments: Option>, -} - -#[derive(Debug, Deserialize, Clone)] -struct Deployment { - #[serde(rename = "ID")] - id: u32, - status: String, -} - -#[derive(Debug, Deserialize, Clone)] -struct ProjectCorrected { - #[serde(rename = "ID")] - id: u32, - name: String, - repo_url: String, - port: u32, - deployments: Option>, -} - -struct App { - projects: Vec, - state: ListState, - message: String, -} - -impl App { - fn new() -> App { - let mut state = ListState::default(); - state.select(Some(0)); - App { - projects: vec![], - state, - message: "Fetching...".to_string(), - } - } - - async fn fetch_data(&mut self) { - match reqwest::get("http://localhost:8080/api/projects").await { - Ok(resp) => { - if resp.status().is_success() { - match resp.json::>().await { - Ok(projects) => { - self.projects = projects; - self.message = format!("Loaded {} projects", self.projects.len()); - } - Err(e) => self.message = format!("Parse error: {}", e), - } - } else { - self.message = format!("Error: {}", resp.status()); - } - } - Err(e) => self.message = format!("Req error: {}", e), - } - } - - fn next(&mut self) { - if self.projects.is_empty() { - return; - } - let i = match self.state.selected() { - Some(i) => { - if i >= self.projects.len() - 1 { - 0 - } else { - i + 1 - } - } - None => 0, - }; - self.state.select(Some(i)); - } - - fn previous(&mut self) { - if self.projects.is_empty() { - return; - } - let i = match self.state.selected() { - Some(i) => { - if i == 0 { - self.projects.len() - 1 - } else { - i - 1 - } - } - None => 0, - }; - self.state.select(Some(i)); - } -} +use tokio::sync::mpsc; +use ui::{App, Screen, SetupState}; #[tokio::main] async fn main() -> Result<()> { + // Check if config exists + if !config_exists() { + // Run setup + run_setup().await?; + } + + // Load config + let config = load_config()?; + + // Validate connection + let client = ApiClient::new(config.server_url.clone(), config.api_key.clone()); + + match client.validate_connection().await { + Ok(user) => { + // Run main app + run_app(client, user).await?; + } + Err(e) => { + eprintln!("Failed to connect to server: {}", e); + eprintln!("Please reconfigure by running the setup again."); + delete_config()?; + std::process::exit(1); + } + } + + Ok(()) +} + +async fn run_setup() -> Result<()> { + enable_raw_mode()?; + let mut stdout = io::stdout(); + execute!(stdout, EnterAlternateScreen, EnableMouseCapture)?; + let backend = CrosstermBackend::new(stdout); + let mut terminal = Terminal::new(backend)?; + + let mut setup_state = SetupState::new(); + let mut should_quit = false; + + loop { + terminal.draw(|f| { + ui::setup::render(f, f.area(), &setup_state); + })?; + + if event::poll(Duration::from_millis(100))? { + if let Event::Key(key) = event::read()? { + match key.code { + KeyCode::Esc => { + should_quit = true; + break; + } + KeyCode::Char('c') if key.modifiers.contains(KeyModifiers::CONTROL) => { + should_quit = true; + break; + } + KeyCode::Tab => { + setup_state.next_field(); + } + KeyCode::BackTab => { + setup_state.previous_field(); + } + KeyCode::Enter => { + let server_url = setup_state.server_url.value().trim().to_string(); + let api_key = setup_state.api_key.value().trim().to_string(); + + if setup_state.focused_field == 0 { + if !server_url.is_empty() { + let url = format!("{}/settings/session", server_url.trim_end_matches('/')); + let _ = webbrowser::open(&url); + setup_state.next_field(); + } else { + setup_state.error = Some("Server URL cannot be empty".to_string()); + } + continue; + } + + // Validate and save + if server_url.is_empty() { + setup_state.error = Some("Server URL cannot be empty".to_string()); + continue; + } + + if api_key.is_empty() { + setup_state.error = Some("API key cannot be empty".to_string()); + continue; + } + + // Test connection + let test_client = ApiClient::new(server_url.clone(), api_key.clone()); + + match test_client.validate_connection().await { + Ok(_) => { + // Save config + let config = Config::new(server_url, api_key); + if let Err(e) = save_config(&config) { + setup_state.error = Some(format!("Failed to save config: {}", e)); + continue; + } + break; + } + Err(e) => { + setup_state.error = Some(format!("Connection failed: {}", e)); + } + } + } + KeyCode::Char(c) => { + if setup_state.focused_field == 0 { + setup_state.server_url.handle(tui_input::InputRequest::InsertChar(c)); + } else { + setup_state.api_key.handle(tui_input::InputRequest::InsertChar(c)); + } + } + KeyCode::Backspace => { + if setup_state.focused_field == 0 { + setup_state.server_url.handle(tui_input::InputRequest::DeletePrevChar); + } else { + setup_state.api_key.handle(tui_input::InputRequest::DeletePrevChar); + } + } + _ => {} + } + } else if let Event::Mouse(mouse) = event::read()? { + if mouse.kind == event::MouseEventKind::Down(crossterm::event::MouseButton::Left) { + // Simple hit testing based on known layout in setup.rs + // URL input is at chunks[1] + // API key input is at chunks[2] + // We need to know where these chunks are. + // Since we can't easily share layout state, we'll approximate or toggle. + // Actually, a simpler way for now is just to support field switching via click + // if we know the Y coordinates. + // Layout: + // Title: 7 lines + // URL: 3 lines (y: 7-9) + // API: 3 lines (y: 10-12) + + let y = mouse.row; + if y >= 7 && y < 10 { + setup_state.focused_field = 0; + } else if y >= 10 && y < 13 { + setup_state.focused_field = 1; + } + } + } + } + } + + disable_raw_mode()?; + execute!( + terminal.backend_mut(), + LeaveAlternateScreen, + DisableMouseCapture + )?; + terminal.show_cursor()?; + + if should_quit { + std::process::exit(0); + } + + Ok(()) +} + +async fn run_app(client: ApiClient, user: models::User) -> Result<()> { enable_raw_mode()?; let mut stdout = io::stdout(); execute!(stdout, EnterAlternateScreen, EnableMouseCapture)?; @@ -121,10 +186,14 @@ async fn main() -> Result<()> { let mut terminal = Terminal::new(backend)?; let mut app = App::new(); - - app.fetch_data().await; + app.screen = Screen::Projects; + app.user = Some(user.clone()); + app.message = format!("Welcome, {}!", user.name); - let res = run_app(&mut terminal, app).await; + // Initial data fetch + app.fetch_projects(&client).await?; + + let res = run_app_loop(&mut terminal, &mut app, &client).await; disable_raw_mode()?; execute!( @@ -135,75 +204,606 @@ async fn main() -> Result<()> { terminal.show_cursor()?; if let Err(err) = res { - println!("{:?}", err) + println!("{:?}", err); } Ok(()) } -async fn run_app(terminal: &mut Terminal>, mut app: App) -> Result<()> { +async fn stream_logs(server_url: String, deployment_id: String, tx: mpsc::UnboundedSender) -> Result<()> { + use tokio_tungstenite::connect_async; + use futures_util::StreamExt; + + let ws_url = server_url + .replace("http://", "ws://") + .replace("https://", "wss://"); + let url = format!("{}/api/deployments/{}/logs/stream", ws_url, deployment_id); + + let (ws_stream, _) = connect_async(&url).await?; + let (_, mut read) = ws_stream.split(); + + while let Some(msg) = read.next().await { + match msg { + Ok(msg) => { + if let Ok(text) = msg.to_text() { + if tx.send(text.to_string()).is_err() { + break; + } + } + } + Err(_) => break, + } + } + + Ok(()) +} + +async fn run_app_loop( + terminal: &mut Terminal>, + app: &mut App, + client: &ApiClient, +) -> Result<()> { + let (log_tx, mut log_rx) = mpsc::unbounded_channel::(); + let mut ws_task: Option> = None; + let mut current_deployment_id: Option = None; + loop { - terminal.draw(|f| ui(f, &mut app))?; + // Check if we need to start/stop WebSocket connection + if let Screen::DeploymentLogs(ref id) = app.screen { + if current_deployment_id.as_ref() != Some(id) { + // Stop old task + if let Some(task) = ws_task.take() { + task.abort(); + } + + // Start new WebSocket connection + let deployment_id = id.clone(); + let server_url = load_config().ok().map(|c| c.server_url).unwrap_or_else(|| "http://localhost:8080".to_string()); + let tx = log_tx.clone(); + + ws_task = Some(tokio::spawn(async move { + if let Err(e) = stream_logs(server_url, deployment_id, tx).await { + eprintln!("WebSocket error: {}", e); + } + })); + + current_deployment_id = Some(id.clone()); + app.live_logs.clear(); + } + } else if ws_task.is_some() { + // Stop WebSocket if we're not on logs screen + if let Some(task) = ws_task.take() { + task.abort(); + } + current_deployment_id = None; + app.live_logs.clear(); + } + + // Check for new log messages + while let Ok(log_chunk) = log_rx.try_recv() { + app.live_logs.push_str(&log_chunk); + // Auto-scroll to bottom if at bottom + if app.log_scroll >= u16::MAX - 10 { + app.log_scroll = u16::MAX; + } + } + + terminal.draw(|f| { + // Split layout for status bar + let chunks = ratatui::layout::Layout::default() + .direction(ratatui::layout::Direction::Vertical) + .constraints([ + ratatui::layout::Constraint::Min(0), + ratatui::layout::Constraint::Length(1), + ]) + .split(f.area()); + + let content_area = chunks[0]; + let status_area = chunks[1]; + + match &app.screen { + Screen::Setup => { + // Should not reach here + } + Screen::Projects => { + ui::projects::render(f, content_area, app); + } + Screen::CreateProject => { + ui::create_project::render(f, content_area, &app.create_project_state); + } + Screen::DeploymentLogs(id) => { + ui::deployment_logs::render(f, content_area, app, id); + } + Screen::ProjectDetail(_id) => { + ui::project_detail::render(f, content_area, app); + } + Screen::ProjectSettings(_id) => { + ui::project_settings::render(f, content_area, app); + } + Screen::Deployments => { + ui::deployments::render(f, content_area, app); + } + Screen::Activity => { + ui::activity::render(f, content_area, app); + } + Screen::Network => { + ui::network::render(f, content_area, app); + } + Screen::Storage => { + ui::storage::render(f, content_area, app); + } + Screen::CreateDatabase => { + ui::storage::render_create_database(f, content_area, app); + } + Screen::Docs => { + ui::docs::render(f, content_area, app); + } + Screen::Settings => { + if let Ok(config) = load_config() { + ui::settings::render(f, content_area, &config); + } + } + } + + // Render Status Bar + let status_text = format!( + " Clickploy CLI | User: {} | Screen: {:?} | q: Quit", + app.user.as_ref().map(|u| u.name.as_str()).unwrap_or("Unknown"), + app.screen + ); + let status_bar = ratatui::widgets::Paragraph::new(status_text) + .style(ratatui::style::Style::default().fg(ratatui::style::Color::Black).bg(ratatui::style::Color::Cyan)); + f.render_widget(status_bar, status_area); + + })?; if event::poll(Duration::from_millis(100))? { if let Event::Key(key) = event::read()? { + // Global keys match key.code { - KeyCode::Char('q') => return Ok(()), - KeyCode::Char('r') => app.fetch_data().await, - KeyCode::Down => app.next(), - KeyCode::Up => app.previous(), + KeyCode::Char('q') => { + app.should_quit = true; + return Ok(()); + } + KeyCode::Char('c') if key.modifiers.contains(KeyModifiers::CONTROL) => { + app.should_quit = true; + return Ok(()); + } _ => {} } + + // Screen-specific keys + match app.screen.clone() { + Screen::Projects => { + match key.code { + KeyCode::Down | KeyCode::Char('j') => app.next(), + KeyCode::Up | KeyCode::Char('k') => app.previous(), + KeyCode::Enter => { + app.select_project(); + if let Screen::ProjectDetail(id) = &app.screen.clone() { + app.fetch_project_detail(client, id).await?; + } + } + KeyCode::Char('n') => { + app.screen = Screen::CreateProject; + app.create_project_state = ui::CreateProjectState::new(); + } + KeyCode::Char('r') => { + app.fetch_projects(client).await?; + } + KeyCode::Char('a') => { + app.screen = Screen::Activity; + app.selected_index = 0; + app.fetch_activity(client).await?; + } + KeyCode::Char('d') => { + app.screen = Screen::Deployments; + app.selected_index = 0; + app.fetch_activity(client).await?; + } + KeyCode::Char('w') => { + app.screen = Screen::Network; + } + KeyCode::Char('t') => { + app.screen = Screen::Storage; + app.selected_index = 0; + app.fetch_storage_data(client).await?; + } + KeyCode::Char('h') => { + app.screen = Screen::Docs; + } + KeyCode::Char('s') => { + app.screen = Screen::Settings; + } + _ => {} + } + } + Screen::CreateProject => { + match key.code { + KeyCode::Esc => { + app.screen = Screen::Projects; + } + KeyCode::Tab => { + app.create_project_state.next_field(); + } + KeyCode::BackTab => { + app.create_project_state.previous_field(); + } + KeyCode::Enter => { + // Create project + let name = app.create_project_state.name.value().trim().to_string(); + let repo = app.create_project_state.repo_url.value().trim().to_string(); + + if name.is_empty() || repo.is_empty() { + app.create_project_state.error = Some("Name and Repo URL are required".to_string()); + continue; + } + + let build_cmd = app.create_project_state.build_command.value().trim(); + let start_cmd = app.create_project_state.start_command.value().trim(); + let install_cmd = app.create_project_state.install_command.value().trim(); + + let request = models::CreateProjectRequest { + name, + repo, + port: Some(3000), // Default port + git_token: None, + env_vars: None, + build_command: if build_cmd.is_empty() { None } else { Some(build_cmd.to_string()) }, + start_command: if start_cmd.is_empty() { None } else { Some(start_cmd.to_string()) }, + install_command: if install_cmd.is_empty() { None } else { Some(install_cmd.to_string()) }, + runtime: None, + }; + + app.message = "Creating project...".to_string(); + match client.create_project(request).await { + Ok(_) => { + app.message = "Project created successfully".to_string(); + app.screen = Screen::Projects; + app.fetch_projects(client).await?; + } + Err(e) => { + app.create_project_state.error = Some(format!("Failed to create project: {}", e)); + } + } + } + KeyCode::Char(c) => { + match app.create_project_state.focused_field { + 0 => app.create_project_state.name.handle(tui_input::InputRequest::InsertChar(c)), + 1 => app.create_project_state.repo_url.handle(tui_input::InputRequest::InsertChar(c)), + 2 => app.create_project_state.install_command.handle(tui_input::InputRequest::InsertChar(c)), + 3 => app.create_project_state.build_command.handle(tui_input::InputRequest::InsertChar(c)), + 4 => app.create_project_state.start_command.handle(tui_input::InputRequest::InsertChar(c)), + _ => None, + }; + } + KeyCode::Backspace => { + match app.create_project_state.focused_field { + 0 => app.create_project_state.name.handle(tui_input::InputRequest::DeletePrevChar), + 1 => app.create_project_state.repo_url.handle(tui_input::InputRequest::DeletePrevChar), + 2 => app.create_project_state.install_command.handle(tui_input::InputRequest::DeletePrevChar), + 3 => app.create_project_state.build_command.handle(tui_input::InputRequest::DeletePrevChar), + 4 => app.create_project_state.start_command.handle(tui_input::InputRequest::DeletePrevChar), + _ => None, + }; + } + _ => {} + } + } + Screen::ProjectDetail(id) => { + match key.code { + KeyCode::Backspace => { + app.go_back(); + } + KeyCode::Char('r') => { + // Redeploy + app.message = "Redeploying...".to_string(); + match client.redeploy_project(&id, None).await { + Ok(_) => { + app.message = "Redeploy started successfully".to_string(); + app.error = None; + // Refresh project details + tokio::time::sleep(Duration::from_millis(500)).await; + app.fetch_project_detail(client, &id).await?; + } + Err(e) => { + app.error = Some(format!("Redeploy failed: {}", e)); + } + } + } + KeyCode::Char('s') => { + // Stop project + app.message = "Stopping project...".to_string(); + match client.stop_project(&id).await { + Ok(_) => { + app.message = "Project stopped successfully".to_string(); + app.error = None; + // Refresh project details + tokio::time::sleep(Duration::from_millis(500)).await; + app.fetch_project_detail(client, &id).await?; + } + Err(e) => { + app.error = Some(format!("Stop failed: {}", e)); + } + } + } + KeyCode::Char('l') => { + // View Logs for latest deployment + if let Some(project) = &app.selected_project { + if let Some(deployments) = &project.deployments { + if let Some(latest) = deployments.first() { + app.screen = Screen::DeploymentLogs(latest.id.clone()); + } else { + app.error = Some("No deployments found".to_string()); + } + } + } + } + KeyCode::Char('c') => { + // View project settings + app.screen = Screen::ProjectSettings(id.clone()); + } + _ => {} + } + } + Screen::ProjectSettings(_id) => { + match key.code { + KeyCode::Backspace => { + app.go_back(); + } + _ => {} + } + } + Screen::Deployments => { + match key.code { + KeyCode::Backspace => { + app.go_back(); + } + KeyCode::Char('r') => { + app.fetch_activity(client).await?; + } + _ => {} + } + } + Screen::Network => { + match key.code { + KeyCode::Backspace => { + app.go_back(); + } + KeyCode::Char('r') => { + app.fetch_projects(client).await?; + } + _ => {} + } + } + Screen::Storage => { + match key.code { + KeyCode::Backspace | KeyCode::Esc => { + if app.show_db_credentials { + app.show_db_credentials = false; + app.db_credentials = None; + } else { + app.go_back(); + } + } + KeyCode::Up | KeyCode::Char('k') => { + if !app.show_db_credentials { + app.previous(); + } + } + KeyCode::Down | KeyCode::Char('j') => { + if !app.show_db_credentials { + app.next(); + } + } + KeyCode::Enter => { + if !app.show_db_credentials && !app.databases.is_empty() { + app.select_database(); + if let Some(db) = &app.selected_database { + if db.db_type == "mongodb" { + // Fetch credentials + match client.get_database_credentials(db.id).await { + Ok(creds) => { + app.db_credentials = Some(creds); + } + Err(e) => { + app.error = Some(format!("Failed to fetch credentials: {}", e)); + } + } + } + } + } + } + KeyCode::Char('n') => { + if !app.show_db_credentials { + app.create_database_state.reset(); + app.screen = Screen::CreateDatabase; + } + } + KeyCode::Char('d') => { + if !app.show_db_credentials && !app.databases.is_empty() && app.selected_index < app.databases.len() { + let db_id = app.databases[app.selected_index].id; + match client.delete_database(db_id).await { + Ok(_) => { + app.message = "Database deleted".to_string(); + let _ = app.fetch_storage_data(client).await; + } + Err(e) => { + app.error = Some(format!("Failed to delete database: {}", e)); + } + } + } + } + KeyCode::Char('s') => { + if !app.show_db_credentials && !app.databases.is_empty() && app.selected_index < app.databases.len() { + let db_id = app.databases[app.selected_index].id; + match client.stop_database(db_id).await { + Ok(_) => { + app.message = "Database stopped".to_string(); + let _ = app.fetch_storage_data(client).await; + } + Err(e) => { + app.error = Some(format!("Failed to stop database: {}", e)); + } + } + } + } + KeyCode::Char('r') => { + if !app.show_db_credentials && !app.databases.is_empty() && app.selected_index < app.databases.len() { + let db_id = app.databases[app.selected_index].id; + match client.restart_database(db_id).await { + Ok(_) => { + app.message = "Database restarted".to_string(); + let _ = app.fetch_storage_data(client).await; + } + Err(e) => { + app.error = Some(format!("Failed to restart database: {}", e)); + } + } + } + } + _ => {} + } + } + Screen::CreateDatabase => { + match key.code { + KeyCode::Esc => { + app.create_database_state.reset(); + app.screen = Screen::Storage; + } + KeyCode::Tab => { + app.create_database_state.focused_field = + (app.create_database_state.focused_field + 1) % 2; + } + KeyCode::Char(c) => { + if app.create_database_state.focused_field == 0 { + app.create_database_state.name.push(c); + } + } + KeyCode::Backspace => { + if app.create_database_state.focused_field == 0 { + app.create_database_state.name.pop(); + } else { + app.create_database_state.reset(); + app.screen = Screen::Storage; + } + } + KeyCode::Up => { + if app.create_database_state.focused_field == 1 { + app.create_database_state.db_type = if app.create_database_state.db_type == "mongodb" { + "sqlite".to_string() + } else { + "mongodb".to_string() + }; + } + } + KeyCode::Down => { + if app.create_database_state.focused_field == 1 { + app.create_database_state.db_type = if app.create_database_state.db_type == "sqlite" { + "mongodb".to_string() + } else { + "sqlite".to_string() + }; + } + } + KeyCode::Enter => { + if !app.create_database_state.name.is_empty() { + let name = app.create_database_state.name.clone(); + let db_type = app.create_database_state.db_type.clone(); + + match client.create_database(name, db_type).await { + Ok(_) => { + app.message = "Database created successfully".to_string(); + app.create_database_state.reset(); + app.screen = Screen::Storage; + let _ = app.fetch_storage_data(client).await; + } + Err(e) => { + app.error = Some(format!("Failed to create database: {}", e)); + } + } + } + } + _ => {} + } + } + Screen::Docs => { + match key.code { + KeyCode::Backspace => { + app.go_back(); + } + _ => {} + } + } + Screen::DeploymentLogs(_) => { + match key.code { + KeyCode::Backspace => { + app.live_logs.clear(); + app.log_scroll = 0; + app.go_back(); + } + KeyCode::Down | KeyCode::Char('j') => { + app.log_scroll = app.log_scroll.saturating_add(1); + } + KeyCode::Up | KeyCode::Char('k') => { + app.log_scroll = app.log_scroll.saturating_sub(1); + } + KeyCode::Home => { + app.log_scroll = 0; + } + KeyCode::End => { + app.log_scroll = u16::MAX; + } + KeyCode::PageDown => { + app.log_scroll = app.log_scroll.saturating_add(10); + } + KeyCode::PageUp => { + app.log_scroll = app.log_scroll.saturating_sub(10); + } + KeyCode::Char('q') => { + app.go_back(); + } + _ => {} + } + } + Screen::Activity => { + match key.code { + KeyCode::Down => app.next(), + KeyCode::Up => app.previous(), + KeyCode::Backspace => { + app.go_back(); + } + KeyCode::Char('r') => { + app.fetch_activity(client).await?; + } + _ => {} + } + } + Screen::Settings => { + match key.code { + KeyCode::Backspace => { + app.go_back(); + } + KeyCode::Char('c') => { + // Reconfigure + delete_config()?; + app.should_quit = true; + return Ok(()); + } + KeyCode::Char('d') => { + // Delete config and quit + delete_config()?; + app.should_quit = true; + return Ok(()); + } + _ => {} + } + } + _ => {} + } + } else if let Event::Mouse(_mouse) = event::read()? { + // Handle mouse events if needed, for now just ignore to prevent errors } } } } - -fn ui(f: &mut Frame, app: &mut App) { - let chunks = Layout::default() - .direction(Direction::Vertical) - .constraints([Constraint::Length(3), Constraint::Min(0)]) - .split(f.area()); - - let header_text = format!("Clickploy CLI - {} (Press 'r' to refresh, 'q' to quit)", app.message); - let header = Paragraph::new(header_text) - .block(Block::default().borders(Borders::ALL).title("Info")); - f.render_widget(header, chunks[0]); - - let items: Vec = app - .projects - .iter() - .map(|p| { - let status = if let Some(deps) = &p.deployments { - if let Some(first) = deps.first() { - first.status.clone() - } else { - "unknown".to_string() - } - } else { - "unknown".to_string() - }; - - let color = match status.as_str() { - "live" | "success" => Color::Green, - "building" => Color::Yellow, - "failed" => Color::Red, - _ => Color::White, - }; - - let display_name = format!("{} - {}", p.name, status); - ListItem::new(display_name).style(Style::default().fg(color)) - }) - .collect(); - - let list = List::new(items) - .block(Block::default().borders(Borders::ALL).title("Projects")) - .highlight_style( - Style::default() - .bg(Color::DarkGray) - .add_modifier(Modifier::BOLD), - ) - .highlight_symbol(">> "); - - f.render_stateful_widget(list, chunks[1], &mut app.state); -} diff --git a/cli/src/models.rs b/cli/src/models.rs new file mode 100644 index 0000000..3158da1 --- /dev/null +++ b/cli/src/models.rs @@ -0,0 +1,116 @@ +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Deserialize, Serialize, Clone)] +pub struct User { + pub id: String, + pub email: String, + pub name: String, + pub avatar: String, + pub is_admin: bool, + pub api_key: String, +} + +#[derive(Debug, Deserialize, Serialize, Clone)] +pub struct Project { + pub id: String, + pub name: String, + pub repo_url: String, + pub port: i32, + pub owner_id: String, + pub webhook_secret: String, + pub build_command: String, + pub start_command: String, + pub install_command: String, + pub runtime: String, + pub created_at: String, + pub updated_at: String, + pub deployments: Option>, + pub env_vars: Option>, +} + +#[derive(Debug, Deserialize, Serialize, Clone)] +pub struct Deployment { + pub id: String, + pub project_id: String, + pub status: String, + pub commit: String, + pub logs: String, + pub url: String, + pub created_at: String, + pub updated_at: String, +} + +#[derive(Debug, Deserialize, Serialize, Clone)] +pub struct EnvVar { + #[serde(rename = "ID")] + pub id: u32, + pub project_id: String, + pub key: String, + pub value: String, +} + +#[derive(Debug, Serialize)] +pub struct CreateProjectRequest { + pub name: String, + pub repo: String, + pub port: Option, + pub git_token: Option, + pub env_vars: Option>, + pub build_command: Option, + pub start_command: Option, + pub install_command: Option, + pub runtime: Option, +} + +#[derive(Debug, Serialize)] +pub struct RedeployRequest { + pub commit: Option, +} + +#[derive(Debug, Deserialize, Serialize, Clone)] +pub struct Database { + #[serde(rename = "ID")] + pub id: u32, + #[serde(rename = "CreatedAt")] + pub created_at: String, + #[serde(rename = "UpdatedAt")] + pub updated_at: String, + #[serde(rename = "DeletedAt")] + pub deleted_at: Option, + pub name: String, + #[serde(rename = "type")] + pub db_type: String, + pub status: String, + pub owner_id: String, + pub size_mb: f64, + pub container_id: String, + pub port: i32, +} + +#[derive(Debug, Deserialize)] +pub struct StorageStats { + pub used: u64, + pub total: u64, + pub percent: f64, +} + +#[derive(Debug, Deserialize)] +pub struct DatabaseCredentials { + pub username: String, + pub password: String, + pub uri: String, + pub public_uri: String, +} + +#[derive(Debug, Serialize)] +pub struct CreateDatabaseRequest { + pub name: String, + #[serde(rename = "type")] + pub db_type: String, +} + +#[derive(Debug, Serialize)] +pub struct UpdateDatabaseCredentialsRequest { + pub username: String, + pub password: String, +} diff --git a/cli/src/ui/activity.rs b/cli/src/ui/activity.rs new file mode 100644 index 0000000..9d29657 --- /dev/null +++ b/cli/src/ui/activity.rs @@ -0,0 +1,117 @@ +use ratatui::{ + layout::{Constraint, Direction, Layout, Rect}, + style::{Color, Modifier, Style}, + text::{Line, Span}, + widgets::{Block, Borders, List, ListItem, Paragraph}, + Frame, +}; +use crate::ui::app::App; + +pub fn render(f: &mut Frame, area: Rect, app: &App) { + let chunks = Layout::default() + .direction(Direction::Vertical) + .constraints([Constraint::Length(3), Constraint::Min(0), Constraint::Length(3)]) + .split(area); + + // Header + let header = Paragraph::new("Recent Deployment Activity") + .block(Block::default().borders(Borders::ALL).title("Activity")) + .style(Style::default().fg(Color::Cyan)); + f.render_widget(header, chunks[0]); + + // Activity list + let items: Vec = app + .activity + .iter() + .enumerate() + .map(|(i, d)| { + let status_color = match d.status.as_str() { + "live" => Color::Green, + "building" => Color::Yellow, + "failed" => Color::Red, + _ => Color::White, + }; + + let symbol = match d.status.as_str() { + "live" => "●", + "building" => "◐", + "failed" => "✗", + _ => "○", + }; + + let commit_short = if d.commit.len() > 7 { + &d.commit[..7] + } else { + &d.commit + }; + + let timestamp = d.created_at.split('T').next().unwrap_or(&d.created_at); + + let display_text = format!( + "{} {} - {} - {} - {}", + symbol, + d.project_id, + d.status, + commit_short, + timestamp + ); + + let style = if i == app.selected_index { + Style::default() + .fg(status_color) + .add_modifier(Modifier::BOLD) + .bg(Color::DarkGray) + } else { + Style::default().fg(status_color) + }; + + ListItem::new(display_text).style(style) + }) + .collect(); + + let list = List::new(items) + .block( + Block::default() + .borders(Borders::ALL) + .title(format!("Deployments ({})", app.activity.len())) + ) + .highlight_symbol(">> "); + + f.render_widget(list, chunks[1]); + + // Footer + let footer_text = vec![ + Line::from(vec![ + Span::styled("↑↓", Style::default().fg(Color::Yellow)), + Span::raw(" Navigate | "), + Span::styled("Enter", Style::default().fg(Color::Yellow)), + Span::raw(" View Logs | "), + Span::styled("Backspace", Style::default().fg(Color::Yellow)), + Span::raw(" Back | "), + Span::styled("r", Style::default().fg(Color::Yellow)), + Span::raw(" Refresh | "), + Span::styled("q", Style::default().fg(Color::Yellow)), + Span::raw(" Quit"), + ]), + ]; + + let footer = Paragraph::new(footer_text) + .block(Block::default().borders(Borders::ALL)) + .style(Style::default().fg(Color::DarkGray)); + f.render_widget(footer, chunks[2]); + + // Show error if any + if let Some(error) = &app.error { + let error_area = Rect { + x: area.width / 4, + y: area.height / 2 - 2, + width: area.width / 2, + height: 5, + }; + + let error_widget = Paragraph::new(error.as_str()) + .style(Style::default().fg(Color::Red)) + .block(Block::default().borders(Borders::ALL).title("Error")); + f.render_widget(error_widget, error_area); + } +} diff --git a/cli/src/ui/app.rs b/cli/src/ui/app.rs new file mode 100644 index 0000000..67b8afe --- /dev/null +++ b/cli/src/ui/app.rs @@ -0,0 +1,211 @@ +use crate::api::ApiClient; +use crate::models::{Deployment, Project, User, Database, StorageStats, DatabaseCredentials}; +use crate::ui::{CreateProjectState, CreateDatabaseState}; +use anyhow::Result; + +#[derive(Debug, Clone, PartialEq)] +pub enum Screen { + Setup, + Projects, + CreateProject, + CreateDatabase, + ProjectDetail(String), // project ID + ProjectSettings(String), // project ID + DeploymentLogs(String), // deployment ID + Deployments, + Activity, + Network, + Storage, + Docs, + Settings, +} + +pub struct App { + pub screen: Screen, + pub projects: Vec, + pub selected_project: Option, + pub activity: Vec, + pub user: Option, + pub message: String, + pub error: Option, + pub selected_index: usize, + pub should_quit: bool, + pub create_project_state: CreateProjectState, + pub live_logs: String, + pub log_scroll: u16, + pub databases: Vec, + pub selected_database: Option, + pub storage_stats: Option, + pub create_database_state: CreateDatabaseState, + pub show_db_credentials: bool, + pub db_credentials: Option, +} + +impl App { + pub fn new() -> Self { + Self { + screen: Screen::Setup, + projects: Vec::new(), + selected_project: None, + activity: Vec::new(), + user: None, + message: String::new(), + error: None, + selected_index: 0, + should_quit: false, + create_project_state: CreateProjectState::new(), + live_logs: String::new(), + log_scroll: 0, + databases: Vec::new(), + selected_database: None, + storage_stats: None, + create_database_state: CreateDatabaseState::new(), + show_db_credentials: false, + db_credentials: None, + } + } + + pub async fn fetch_projects(&mut self, client: &ApiClient) -> Result<()> { + self.message = "Fetching projects...".to_string(); + self.error = None; + + match client.list_projects().await { + Ok(projects) => { + self.projects = projects; + self.message = format!("Loaded {} projects", self.projects.len()); + } + Err(e) => { + self.error = Some(format!("Failed to fetch projects: {}", e)); + } + } + + Ok(()) + } + + pub async fn fetch_project_detail(&mut self, client: &ApiClient, id: &str) -> Result<()> { + self.error = None; + + match client.get_project(id).await { + Ok(project) => { + self.selected_project = Some(project); + self.message.clear(); + } + Err(e) => { + self.error = Some(format!("Failed to fetch project: {}", e)); + } + } + + Ok(()) + } + + pub async fn fetch_activity(&mut self, client: &ApiClient) -> Result<()> { + self.message = "Fetching activity...".to_string(); + self.error = None; + + match client.get_activity().await { + Ok(activity) => { + self.activity = activity; + self.message = format!("Loaded {} deployments", self.activity.len()); + } + Err(e) => { + self.error = Some(format!("Failed to fetch activity: {}", e)); + } + } + + Ok(()) + } + + pub async fn fetch_storage_data(&mut self, client: &ApiClient) -> Result<()> { + self.message = "Fetching storage data...".to_string(); + self.error = None; + + match client.list_databases().await { + Ok(databases) => { + self.databases = databases; + } + Err(e) => { + self.error = Some(format!("Failed to fetch databases: {}", e)); + } + } + + match client.get_storage_stats().await { + Ok(stats) => { + self.storage_stats = Some(stats); + self.message = format!("Loaded {} databases", self.databases.len()); + } + Err(e) => { + self.error = Some(format!("Failed to fetch storage stats: {}", e)); + } + } + + Ok(()) + } + + pub fn next(&mut self) { + let len = match self.screen { + Screen::Projects => self.projects.len(), + Screen::Activity => self.activity.len(), + Screen::Storage => self.databases.len(), + _ => 0, + }; + + if len > 0 { + self.selected_index = (self.selected_index + 1) % len; + } + } + + pub fn previous(&mut self) { + let len = match self.screen { + Screen::Projects => self.projects.len(), + Screen::Activity => self.activity.len(), + Screen::Storage => self.databases.len(), + _ => 0, + }; + + if len > 0 { + if self.selected_index == 0 { + self.selected_index = len - 1; + } else { + self.selected_index -= 1; + } + } + } + + pub fn select_project(&mut self) { + if self.selected_index < self.projects.len() { + let project_id = self.projects[self.selected_index].id.clone(); + self.screen = Screen::ProjectDetail(project_id); + self.selected_index = 0; + self.message.clear(); + } + } + + pub fn select_database(&mut self) { + if self.selected_index < self.databases.len() { + self.selected_database = Some(self.databases[self.selected_index].clone()); + self.show_db_credentials = true; + } + } + + pub fn go_back(&mut self) { + match &self.screen { + Screen::ProjectDetail(_) | Screen::ProjectSettings(_) | Screen::Activity | Screen::Settings | Screen::CreateProject | Screen::CreateDatabase | Screen::Deployments | Screen::Network | Screen::Storage | Screen::Docs => { + self.screen = Screen::Projects; + self.selected_index = 0; + self.message.clear(); + self.show_db_credentials = false; + self.db_credentials = None; + } + Screen::DeploymentLogs(_) => { + // Go back to ProjectDetail if we have selected_project + if let Some(p) = &self.selected_project { + self.screen = Screen::ProjectDetail(p.id.clone()); + } else { + self.screen = Screen::Projects; + } + self.message.clear(); + } + _ => {} + } + } +} diff --git a/cli/src/ui/create_project.rs b/cli/src/ui/create_project.rs new file mode 100644 index 0000000..5b9bbc2 --- /dev/null +++ b/cli/src/ui/create_project.rs @@ -0,0 +1,106 @@ +use ratatui::{ + layout::{Alignment, Constraint, Direction, Layout, Rect}, + style::{Color, Modifier, Style}, + text::{Line, Span}, + widgets::{Block, Borders, Paragraph, Wrap}, + Frame, +}; +use tui_input::Input; + +pub struct CreateProjectState { + pub name: Input, + pub repo_url: Input, + pub build_command: Input, + pub start_command: Input, + pub install_command: Input, + pub focused_field: usize, + pub error: Option, +} + +impl CreateProjectState { + pub fn new() -> Self { + Self { + name: Input::default(), + repo_url: Input::default(), + build_command: Input::default(), + start_command: Input::default(), + install_command: Input::default(), + focused_field: 0, + error: None, + } + } + + pub fn next_field(&mut self) { + self.focused_field = (self.focused_field + 1) % 5; + } + + pub fn previous_field(&mut self) { + if self.focused_field == 0 { + self.focused_field = 4; + } else { + self.focused_field -= 1; + } + } +} + +pub fn render(f: &mut Frame, area: Rect, state: &CreateProjectState) { + let chunks = Layout::default() + .direction(Direction::Vertical) + .constraints([ + Constraint::Length(3), // Title + Constraint::Length(3), // Name + Constraint::Length(3), // Repo + Constraint::Length(3), // Install + Constraint::Length(3), // Build + Constraint::Length(3), // Start + Constraint::Min(0), // Error/Help + ]) + .split(area); + + let title = Paragraph::new("Create New Project") + .style( + Style::default() + .fg(Color::Green) + .add_modifier(Modifier::BOLD), + ) + .alignment(Alignment::Center) + .block(Block::default().borders(Borders::ALL)); + f.render_widget(title, chunks[0]); + + let fields = [ + ("Project Name", &state.name), + ("Git Repository URL", &state.repo_url), + ("Install Command (optional)", &state.install_command), + ("Build Command (optional)", &state.build_command), + ("Start Command (optional)", &state.start_command), + ]; + + for (i, (label, input)) in fields.iter().enumerate() { + let style = if state.focused_field == i { + Style::default().fg(Color::Yellow) + } else { + Style::default() + }; + + let block = Block::default() + .borders(Borders::ALL) + .title(*label) + .border_style(style); + + let widget = Paragraph::new(input.value()).block(block); + f.render_widget(widget, chunks[i + 1]); + } + + if let Some(error) = &state.error { + let error_widget = Paragraph::new(error.as_str()) + .style(Style::default().fg(Color::Red)) + .block(Block::default().borders(Borders::ALL).title("Error")) + .wrap(Wrap { trim: true }); + f.render_widget(error_widget, chunks[6]); + } else { + let help = Paragraph::new("Tab: Next Field | Enter: Create | Esc: Cancel") + .style(Style::default().fg(Color::DarkGray)) + .alignment(Alignment::Center); + f.render_widget(help, chunks[6]); + } +} diff --git a/cli/src/ui/deployment_logs.rs b/cli/src/ui/deployment_logs.rs new file mode 100644 index 0000000..feda73e --- /dev/null +++ b/cli/src/ui/deployment_logs.rs @@ -0,0 +1,91 @@ +use crate::ui::app::App; +use ratatui::{ + layout::{Constraint, Direction, Layout, Rect}, + style::{Color, Modifier, Style}, + text::{Line, Span}, + widgets::{Block, Borders, Paragraph, Wrap}, + Frame, +}; + +pub fn render(f: &mut Frame, area: Rect, app: &App, deployment_id: &str) { + let chunks = Layout::default() + .direction(Direction::Vertical) + .constraints([ + Constraint::Length(3), // Header + Constraint::Min(0), // Logs + Constraint::Length(3), // Footer + ]) + .split(area); + + // Header with deployment info + let deployment = if let Some(project) = &app.selected_project { + if let Some(deployments) = &project.deployments { + deployments.iter().find(|d| d.id == deployment_id) + } else { + None + } + } else { + app.activity.iter().find(|d| d.id == deployment_id) + }; + + let header_text = if let Some(dep) = deployment { + let commit_short = if dep.commit.len() > 7 { + &dep.commit[..7] + } else { + &dep.commit + }; + + format!("Deployment: {} | Status: {} | Commit: {}", + deployment_id, dep.status, commit_short) + } else { + format!("Deployment: {}", deployment_id) + }; + + let header = Paragraph::new(header_text) + .block(Block::default().borders(Borders::ALL).title("Live Logs")) + .style(Style::default().fg(Color::Cyan).add_modifier(Modifier::BOLD)); + f.render_widget(header, chunks[0]); + + // Logs content - use live_logs if available, otherwise fall back to stored logs + let logs_content = if !app.live_logs.is_empty() { + app.live_logs.clone() + } else if let Some(dep) = deployment { + if dep.logs.is_empty() { + "Connecting to log stream...".to_string() + } else { + dep.logs.clone() + } + } else { + "Loading logs...".to_string() + }; + + let logs_widget = Paragraph::new(logs_content) + .block( + Block::default() + .borders(Borders::ALL) + .title(if !app.live_logs.is_empty() { "● Live Stream" } else { "Logs" }), + ) + .wrap(Wrap { trim: false }) + .scroll((app.log_scroll, 0)); + + f.render_widget(logs_widget, chunks[1]); + + // Footer + let footer_text = vec![ + Line::from(vec![ + Span::styled("Backspace", Style::default().fg(Color::Yellow)), + Span::raw(" Back | "), + Span::styled("↑↓", Style::default().fg(Color::Yellow)), + Span::raw(" Scroll | "), + Span::styled("Home/End", Style::default().fg(Color::Yellow)), + Span::raw(" Top/Bottom | "), + Span::styled("q", Style::default().fg(Color::Yellow)), + Span::raw(" Quit"), + ]), + ]; + + let footer = Paragraph::new(footer_text) + .style(Style::default().fg(Color::DarkGray)) + .block(Block::default().borders(Borders::ALL)); + f.render_widget(footer, chunks[2]); +} diff --git a/cli/src/ui/deployments.rs b/cli/src/ui/deployments.rs new file mode 100644 index 0000000..7524a9e --- /dev/null +++ b/cli/src/ui/deployments.rs @@ -0,0 +1,83 @@ +use ratatui::{ + layout::{Constraint, Direction, Layout, Rect}, + style::{Color, Modifier, Style}, + text::{Line, Span}, + widgets::{Block, Borders, List, ListItem, Paragraph}, + Frame, +}; +use crate::ui::app::App; + +pub fn render(f: &mut Frame, area: Rect, app: &App) { + let chunks = Layout::default() + .direction(Direction::Vertical) + .constraints([Constraint::Length(3), Constraint::Min(0), Constraint::Length(3)]) + .split(area); + + // Header + let header = Paragraph::new("All Deployments") + .block(Block::default().borders(Borders::ALL).title("Info")) + .style(Style::default().fg(Color::Cyan).add_modifier(Modifier::BOLD)); + f.render_widget(header, chunks[0]); + + // Deployments list from activity + let items: Vec = app + .activity + .iter() + .map(|d| { + let status_color = match d.status.as_str() { + "live" => Color::Green, + "building" => Color::Yellow, + "failed" => Color::Red, + _ => Color::White, + }; + + let symbol = match d.status.as_str() { + "live" => "●", + "building" => "◐", + "failed" => "✗", + _ => "○", + }; + + let commit_short = if d.commit.len() > 7 { + &d.commit[..7] + } else { + &d.commit + }; + + let date = d.created_at.split('T').next().unwrap_or(&d.created_at); + + let display_text = format!( + "{} {} - {} - {}", + symbol, d.status, commit_short, date + ); + + ListItem::new(display_text).style(Style::default().fg(status_color)) + }) + .collect(); + + let list = List::new(items) + .block( + Block::default() + .borders(Borders::ALL) + .title(format!("Recent Deployments ({})", app.activity.len())) + ); + + f.render_widget(list, chunks[1]); + + // Footer + let footer_text = vec![ + Line::from(vec![ + Span::styled("Backspace", Style::default().fg(Color::Yellow)), + Span::raw(" Back | "), + Span::styled("r", Style::default().fg(Color::Yellow)), + Span::raw(" Refresh | "), + Span::styled("q", Style::default().fg(Color::Yellow)), + Span::raw(" Quit"), + ]), + ]; + + let footer = Paragraph::new(footer_text) + .block(Block::default().borders(Borders::ALL)) + .style(Style::default().fg(Color::DarkGray)); + f.render_widget(footer, chunks[2]); +} diff --git a/cli/src/ui/docs.rs b/cli/src/ui/docs.rs new file mode 100644 index 0000000..04088ce --- /dev/null +++ b/cli/src/ui/docs.rs @@ -0,0 +1,86 @@ +use ratatui::{ + layout::{Constraint, Direction, Layout, Rect}, + style::{Color, Modifier, Style}, + text::{Line, Span}, + widgets::{Block, Borders, Paragraph, Wrap}, + Frame, +}; +use crate::ui::app::App; + +pub fn render(f: &mut Frame, area: Rect, _app: &App) { + let chunks = Layout::default() + .direction(Direction::Vertical) + .constraints([Constraint::Length(3), Constraint::Min(0), Constraint::Length(3)]) + .split(area); + + // Header + let header = Paragraph::new("Documentation & Help") + .block(Block::default().borders(Borders::ALL).title("Info")) + .style(Style::default().fg(Color::Cyan).add_modifier(Modifier::BOLD)); + f.render_widget(header, chunks[0]); + + // Content + let content_text = vec![ + Line::from(""), + Line::from(vec![ + Span::styled("Clickploy CLI Quick Reference", Style::default().add_modifier(Modifier::BOLD).fg(Color::Yellow)), + ]), + Line::from(""), + Line::from(vec![ + Span::styled("Navigation:", Style::default().add_modifier(Modifier::BOLD)), + ]), + Line::from(" ↑↓ / j k - Navigate lists"), + Line::from(" Enter - Select / View details"), + Line::from(" Backspace - Go back"), + Line::from(" Tab - Next field (forms)"), + Line::from(" q / Ctrl+C - Quit"), + Line::from(""), + Line::from(vec![ + Span::styled("Main Screen Shortcuts:", Style::default().add_modifier(Modifier::BOLD)), + ]), + Line::from(" n - Create new project"), + Line::from(" a - View activity"), + Line::from(" d - View deployments"), + Line::from(" w - Network overview"), + Line::from(" t - Storage management"), + Line::from(" s - Settings"), + Line::from(" h - Help (this screen)"), + Line::from(" r - Refresh current view"), + Line::from(""), + Line::from(vec![ + Span::styled("Project Actions:", Style::default().add_modifier(Modifier::BOLD)), + ]), + Line::from(" r - Redeploy project"), + Line::from(" l - View logs"), + Line::from(" c - View settings"), + Line::from(""), + Line::from(vec![ + Span::styled("For full documentation, visit:", Style::default().add_modifier(Modifier::BOLD)), + ]), + Line::from(vec![ + Span::raw(" "), + Span::styled("http://localhost:8080/docs", Style::default().fg(Color::Cyan).add_modifier(Modifier::UNDERLINED)), + ]), + ]; + + let content = Paragraph::new(content_text) + .block(Block::default().borders(Borders::ALL).title("Help")) + .style(Style::default()) + .wrap(Wrap { trim: false }); + f.render_widget(content, chunks[1]); + + // Footer + let footer_text = vec![ + Line::from(vec![ + Span::styled("Backspace", Style::default().fg(Color::Yellow)), + Span::raw(" Back | "), + Span::styled("q", Style::default().fg(Color::Yellow)), + Span::raw(" Quit"), + ]), + ]; + + let footer = Paragraph::new(footer_text) + .block(Block::default().borders(Borders::ALL)) + .style(Style::default().fg(Color::DarkGray)); + f.render_widget(footer, chunks[2]); +} diff --git a/cli/src/ui/mod.rs b/cli/src/ui/mod.rs new file mode 100644 index 0000000..1b53da2 --- /dev/null +++ b/cli/src/ui/mod.rs @@ -0,0 +1,40 @@ +pub mod app; +pub mod setup; +pub mod projects; +pub mod project_detail; +pub mod project_settings; +pub mod activity; +pub mod settings; +pub mod create_project; +pub mod deployment_logs; +pub mod deployments; +pub mod network; +pub mod storage; +pub mod docs; + +pub use app::{App, Screen}; +pub use setup::SetupState; +pub use create_project::CreateProjectState; + +#[derive(Debug, Clone)] +pub struct CreateDatabaseState { + pub name: String, + pub db_type: String, + pub focused_field: usize, +} + +impl CreateDatabaseState { + pub fn new() -> Self { + Self { + name: String::new(), + db_type: "sqlite".to_string(), + focused_field: 0, + } + } + + pub fn reset(&mut self) { + self.name.clear(); + self.db_type = "sqlite".to_string(); + self.focused_field = 0; + } +} diff --git a/cli/src/ui/network.rs b/cli/src/ui/network.rs new file mode 100644 index 0000000..4ca0c72 --- /dev/null +++ b/cli/src/ui/network.rs @@ -0,0 +1,85 @@ +use ratatui::{ + layout::{Constraint, Direction, Layout, Rect}, + style::{Color, Modifier, Style}, + text::{Line, Span}, + widgets::{Block, Borders, List, ListItem, Paragraph}, + Frame, +}; +use crate::ui::app::App; + +pub fn render(f: &mut Frame, area: Rect, app: &App) { + let chunks = Layout::default() + .direction(Direction::Vertical) + .constraints([Constraint::Length(3), Constraint::Min(0), Constraint::Length(3)]) + .split(area); + + // Header + let header = Paragraph::new("Network Overview") + .block(Block::default().borders(Borders::ALL).title("Info")) + .style(Style::default().fg(Color::Cyan).add_modifier(Modifier::BOLD)); + f.render_widget(header, chunks[0]); + + // Projects with their network info + let items: Vec = app + .projects + .iter() + .map(|p| { + let status = if let Some(deps) = &p.deployments { + if let Some(first) = deps.first() { + first.status.clone() + } else { + "unknown".to_string() + } + } else { + "unknown".to_string() + }; + + let status_color = match status.as_str() { + "live" => Color::Green, + "building" => Color::Yellow, + "failed" => Color::Red, + _ => Color::DarkGray, + }; + + let symbol = match status.as_str() { + "live" => "●", + "building" => "◐", + "failed" => "✗", + _ => "○", + }; + + let display_text = format!( + "{} {} - Port {} - http://localhost:{}", + symbol, p.name, p.port, p.port + ); + + ListItem::new(display_text).style(Style::default().fg(status_color)) + }) + .collect(); + + let list = List::new(items) + .block( + Block::default() + .borders(Borders::ALL) + .title(format!("Active Services ({})", app.projects.len())) + ); + + f.render_widget(list, chunks[1]); + + // Footer + let footer_text = vec![ + Line::from(vec![ + Span::styled("Backspace", Style::default().fg(Color::Yellow)), + Span::raw(" Back | "), + Span::styled("r", Style::default().fg(Color::Yellow)), + Span::raw(" Refresh | "), + Span::styled("q", Style::default().fg(Color::Yellow)), + Span::raw(" Quit"), + ]), + ]; + + let footer = Paragraph::new(footer_text) + .block(Block::default().borders(Borders::ALL)) + .style(Style::default().fg(Color::DarkGray)); + f.render_widget(footer, chunks[2]); +} diff --git a/cli/src/ui/project_detail.rs b/cli/src/ui/project_detail.rs new file mode 100644 index 0000000..844c577 --- /dev/null +++ b/cli/src/ui/project_detail.rs @@ -0,0 +1,160 @@ +use ratatui::{ + layout::{Constraint, Direction, Layout, Rect}, + style::{Color, Modifier, Style}, + text::{Line, Span}, + widgets::{Block, Borders, List, ListItem, Paragraph, Wrap}, + Frame, +}; +use crate::ui::app::App; + +pub fn render(f: &mut Frame, area: Rect, app: &App) { + if let Some(project) = &app.selected_project { + let chunks = Layout::default() + .direction(Direction::Vertical) + .constraints([ + Constraint::Length(8), + Constraint::Min(0), + Constraint::Length(3), + ]) + .split(area); + + // Project info + let info_text = vec![ + Line::from(vec![ + Span::styled("Name: ", Style::default().add_modifier(Modifier::BOLD)), + Span::raw(&project.name), + ]), + Line::from(vec![ + Span::styled("Repository: ", Style::default().add_modifier(Modifier::BOLD)), + Span::raw(&project.repo_url), + ]), + Line::from(vec![ + Span::styled("Port: ", Style::default().add_modifier(Modifier::BOLD)), + Span::raw(project.port.to_string()), + ]), + Line::from(vec![ + Span::styled("Runtime: ", Style::default().add_modifier(Modifier::BOLD)), + Span::raw(if project.runtime.is_empty() { "auto" } else { &project.runtime }), + ]), + Line::from(vec![ + Span::styled("URL: ", Style::default().add_modifier(Modifier::BOLD)), + Span::styled( + format!("http://localhost:{}", project.port), + Style::default().fg(Color::Cyan), + ), + ]), + ]; + + let info = Paragraph::new(info_text) + .block( + Block::default() + .borders(Borders::ALL) + .title("Project Details") + ) + .wrap(Wrap { trim: true }); + f.render_widget(info, chunks[0]); + + // Deployments + let deployments = if let Some(deps) = &project.deployments { + deps.iter() + .map(|d| { + let status_color = match d.status.as_str() { + "live" => Color::Green, + "building" => Color::Yellow, + "failed" => Color::Red, + _ => Color::White, + }; + + let symbol = match d.status.as_str() { + "live" => "●", + "building" => "◐", + "failed" => "✗", + _ => "○", + }; + + let commit_short = if d.commit.len() > 7 { + &d.commit[..7] + } else { + &d.commit + }; + + ListItem::new(format!( + "{} {} - {} - {}", + symbol, + d.status, + commit_short, + d.created_at.split('T').next().unwrap_or(&d.created_at) + )) + .style(Style::default().fg(status_color)) + }) + .collect::>() + } else { + vec![ListItem::new("No deployments yet")] + }; + + let deployment_list = List::new(deployments) + .block( + Block::default() + .borders(Borders::ALL) + .title("Deployment History") + ); + f.render_widget(deployment_list, chunks[1]); + + // Footer + let footer_text = vec![ + Line::from(vec![ + Span::styled("Backspace", Style::default().fg(Color::Yellow)), + Span::raw(" Back | "), + Span::styled("r", Style::default().fg(Color::Yellow)), + Span::raw(" Redeploy | "), + Span::styled("s", Style::default().fg(Color::Yellow)), + Span::raw(" Stop | "), + Span::styled("l", Style::default().fg(Color::Yellow)), + Span::raw(" View Logs | "), + Span::styled("c", Style::default().fg(Color::Yellow)), + Span::raw(" Settings | "), + Span::styled("q", Style::default().fg(Color::Yellow)), + Span::raw(" Quit"), + ]), + ]; + + let footer = Paragraph::new(footer_text) + .block(Block::default().borders(Borders::ALL)) + .style(Style::default().fg(Color::DarkGray)); + f.render_widget(footer, chunks[2]); + + // Show message or error + if !app.message.is_empty() && app.error.is_none() { + let msg_area = Rect { + x: area.width / 4, + y: area.height / 2 - 2, + width: area.width / 2, + height: 5, + }; + + let msg_widget = Paragraph::new(app.message.as_str()) + .style(Style::default().fg(Color::Green)) + .block(Block::default().borders(Borders::ALL).title("Info")); + f.render_widget(msg_widget, msg_area); + } + + if let Some(error) = &app.error { + let error_area = Rect { + x: area.width / 4, + y: area.height / 2 - 2, + width: area.width / 2, + height: 5, + }; + + let error_widget = Paragraph::new(error.as_str()) + .style(Style::default().fg(Color::Red)) + .block(Block::default().borders(Borders::ALL).title("Error")); + f.render_widget(error_widget, error_area); + } + } else { + let loading = Paragraph::new("Loading project details...") + .block(Block::default().borders(Borders::ALL).title("Project")) + .style(Style::default().fg(Color::Yellow)); + f.render_widget(loading, area); + } +} diff --git a/cli/src/ui/project_settings.rs b/cli/src/ui/project_settings.rs new file mode 100644 index 0000000..51d1c94 --- /dev/null +++ b/cli/src/ui/project_settings.rs @@ -0,0 +1,166 @@ +use ratatui::{ + layout::{Constraint, Direction, Layout, Rect}, + style::{Color, Modifier, Style}, + text::{Line, Span}, + widgets::{Block, Borders, List, ListItem, Paragraph, Wrap}, + Frame, +}; +use crate::ui::app::App; + +pub fn render(f: &mut Frame, area: Rect, app: &App) { + if let Some(project) = &app.selected_project { + let chunks = Layout::default() + .direction(Direction::Vertical) + .constraints([ + Constraint::Length(12), // Git Config + Constraint::Length(10), // Build Settings + Constraint::Length(6), // Networking + Constraint::Min(8), // Env vars + Constraint::Length(5), // Webhook + Constraint::Length(3), // Footer + ]) + .split(area); + + // Git Configuration + let git_info = vec![ + Line::from(vec![ + Span::styled("Git Configuration", Style::default().add_modifier(Modifier::BOLD).fg(Color::Cyan)), + ]), + Line::from(""), + Line::from(vec![ + Span::styled("Project Name: ", Style::default().add_modifier(Modifier::BOLD)), + Span::raw(&project.name), + ]), + Line::from(vec![ + Span::styled("Repository URL: ", Style::default().add_modifier(Modifier::BOLD)), + Span::raw(&project.repo_url), + ]), + Line::from(vec![ + Span::styled("Git Token: ", Style::default().add_modifier(Modifier::BOLD)), + Span::raw("••••••••"), + ]), + ]; + + let git_widget = Paragraph::new(git_info) + .block(Block::default().borders(Borders::ALL).title("Git Configuration")) + .wrap(Wrap { trim: true }); + f.render_widget(git_widget, chunks[0]); + + // Build & Output Settings + let build_info = vec![ + Line::from(vec![ + Span::styled("Build & Output Settings", Style::default().add_modifier(Modifier::BOLD).fg(Color::Cyan)), + ]), + Line::from(""), + Line::from(vec![ + Span::styled("Runtime: ", Style::default().add_modifier(Modifier::BOLD)), + Span::raw(if project.runtime.is_empty() { "auto (nodejs)" } else { &project.runtime }), + ]), + Line::from(vec![ + Span::styled("Install Cmd: ", Style::default().add_modifier(Modifier::BOLD)), + Span::raw(if project.install_command.is_empty() { "default" } else { &project.install_command }), + ]), + Line::from(vec![ + Span::styled("Build Cmd: ", Style::default().add_modifier(Modifier::BOLD)), + Span::raw(if project.build_command.is_empty() { "default" } else { &project.build_command }), + ]), + Line::from(vec![ + Span::styled("Start Cmd: ", Style::default().add_modifier(Modifier::BOLD)), + Span::raw(if project.start_command.is_empty() { "default" } else { &project.start_command }), + ]), + ]; + + let build_widget = Paragraph::new(build_info) + .block(Block::default().borders(Borders::ALL).title("Build & Output")) + .wrap(Wrap { trim: true }); + f.render_widget(build_widget, chunks[1]); + + // Networking + let network_info = vec![ + Line::from(vec![ + Span::styled("Internal Port: ", Style::default().add_modifier(Modifier::BOLD)), + Span::raw(project.port.to_string()), + ]), + Line::from(vec![ + Span::styled("Local URL: ", Style::default().add_modifier(Modifier::BOLD)), + Span::styled( + format!("http://localhost:{}", project.port), + Style::default().fg(Color::Cyan), + ), + ]), + ]; + + let network_widget = Paragraph::new(network_info) + .block(Block::default().borders(Borders::ALL).title("Networking")) + .wrap(Wrap { trim: true }); + f.render_widget(network_widget, chunks[2]); + + // Environment Variables + let env_items = if let Some(env_vars) = &project.env_vars { + if env_vars.is_empty() { + vec![ListItem::new("No environment variables configured")] + } else { + env_vars + .iter() + .map(|e| { + ListItem::new(format!("{} = ••••••••", e.key)) + .style(Style::default().fg(Color::Green)) + }) + .collect() + } + } else { + vec![ListItem::new("No environment variables configured")] + }; + + let env_list = List::new(env_items) + .block( + Block::default() + .borders(Borders::ALL) + .title(format!("Environment Variables ({})", + project.env_vars.as_ref().map(|e| e.len()).unwrap_or(0) + )) + ); + f.render_widget(env_list, chunks[3]); + + // Webhook Integration + let webhook_info = vec![ + Line::from(vec![ + Span::styled("Webhook URL: ", Style::default().add_modifier(Modifier::BOLD)), + ]), + Line::from(vec![ + Span::styled( + format!("http://localhost:8080/projects/{}/webhook/{}", + project.id, project.webhook_secret), + Style::default().fg(Color::Yellow), + ), + ]), + ]; + + let webhook_widget = Paragraph::new(webhook_info) + .block(Block::default().borders(Borders::ALL).title("Webhook Integration")) + .wrap(Wrap { trim: true }); + f.render_widget(webhook_widget, chunks[4]); + + // Footer + let footer_text = vec![ + Line::from(vec![ + Span::styled("Backspace", Style::default().fg(Color::Yellow)), + Span::raw(" Back | "), + Span::styled("e", Style::default().fg(Color::Yellow)), + Span::raw(" Edit (Web UI) | "), + Span::styled("q", Style::default().fg(Color::Yellow)), + Span::raw(" Quit"), + ]), + ]; + + let footer = Paragraph::new(footer_text) + .block(Block::default().borders(Borders::ALL)) + .style(Style::default().fg(Color::DarkGray)); + f.render_widget(footer, chunks[5]); + } else { + let loading = Paragraph::new("Loading project settings...") + .block(Block::default().borders(Borders::ALL).title("Settings")) + .style(Style::default().fg(Color::Yellow)); + f.render_widget(loading, area); + } +} diff --git a/cli/src/ui/projects.rs b/cli/src/ui/projects.rs new file mode 100644 index 0000000..9d23b8c --- /dev/null +++ b/cli/src/ui/projects.rs @@ -0,0 +1,135 @@ +use ratatui::{ + layout::{Constraint, Direction, Layout, Rect}, + style::{Color, Modifier, Style}, + text::{Line, Span}, + widgets::{Block, Borders, List, ListItem, Paragraph}, + Frame, +}; +use crate::ui::app::App; + +pub fn render(f: &mut Frame, area: Rect, app: &App) { + let chunks = Layout::default() + .direction(Direction::Vertical) + .constraints([Constraint::Length(3), Constraint::Min(0), Constraint::Length(4)]) + .split(area); + + // Header + let header_text = if let Some(user) = &app.user { + format!("Clickploy CLI - {} ({})", user.name, user.email) + } else { + "Clickploy CLI".to_string() + }; + + let header = Paragraph::new(header_text) + .block(Block::default().borders(Borders::ALL).title("Info")) + .style(Style::default().fg(Color::Cyan)); + f.render_widget(header, chunks[0]); + + // Projects list + let items: Vec = app + .projects + .iter() + .enumerate() + .map(|(i, p)| { + let status = if let Some(deps) = &p.deployments { + if let Some(first) = deps.first() { + first.status.clone() + } else { + "unknown".to_string() + } + } else { + "unknown".to_string() + }; + + let color = match status.as_str() { + "live" => Color::Green, + "building" => Color::Yellow, + "failed" => Color::Red, + _ => Color::White, + }; + + let symbol = match status.as_str() { + "live" => "●", + "building" => "◐", + "failed" => "✗", + _ => "○", + }; + + let display_name = format!( + "{} {} - {} (port {})", + symbol, p.name, status, p.port + ); + + let style = if i == app.selected_index { + Style::default() + .fg(color) + .add_modifier(Modifier::BOLD) + .bg(Color::DarkGray) + } else { + Style::default().fg(color) + }; + + ListItem::new(display_name).style(style) + }) + .collect(); + + let list = List::new(items) + .block( + Block::default() + .borders(Borders::ALL) + .title(format!("Projects ({})", app.projects.len())) + ) + .highlight_symbol(">> "); + + f.render_widget(list, chunks[1]); + + // Footer with controls + let footer_text = vec![ + Line::from(vec![ + Span::styled("↑↓/jk", Style::default().fg(Color::Yellow)), + Span::raw(" Navigate | "), + Span::styled("Enter", Style::default().fg(Color::Yellow)), + Span::raw(" Details | "), + Span::styled("n", Style::default().fg(Color::Yellow)), + Span::raw(" New | "), + Span::styled("d", Style::default().fg(Color::Yellow)), + Span::raw(" Deployments | "), + Span::styled("w", Style::default().fg(Color::Yellow)), + Span::raw(" Network"), + ]), + Line::from(vec![ + Span::styled("a", Style::default().fg(Color::Yellow)), + Span::raw(" Activity | "), + Span::styled("t", Style::default().fg(Color::Yellow)), + Span::raw(" Storage | "), + Span::styled("h", Style::default().fg(Color::Yellow)), + Span::raw(" Help | "), + Span::styled("s", Style::default().fg(Color::Yellow)), + Span::raw(" Settings | "), + Span::styled("r", Style::default().fg(Color::Yellow)), + Span::raw(" Refresh | "), + Span::styled("q", Style::default().fg(Color::Yellow)), + Span::raw(" Quit"), + ]), + ]; + + let footer = Paragraph::new(footer_text) + .block(Block::default().borders(Borders::ALL)) + .style(Style::default().fg(Color::DarkGray)); + f.render_widget(footer, chunks[2]); + + // Show message or error + if let Some(error) = &app.error { + let error_area = Rect { + x: area.width / 4, + y: area.height / 2 - 2, + width: area.width / 2, + height: 5, + }; + + let error_widget = Paragraph::new(error.as_str()) + .style(Style::default().fg(Color::Red)) + .block(Block::default().borders(Borders::ALL).title("Error")); + f.render_widget(error_widget, error_area); + } +} diff --git a/cli/src/ui/settings.rs b/cli/src/ui/settings.rs new file mode 100644 index 0000000..3b844bd --- /dev/null +++ b/cli/src/ui/settings.rs @@ -0,0 +1,94 @@ +use ratatui::{ + layout::{Constraint, Direction, Layout, Rect}, + style::{Color, Modifier, Style}, + text::{Line, Span}, + widgets::{Block, Borders, Paragraph, Wrap}, + Frame, +}; +use crate::config::Config; + +pub fn render(f: &mut Frame, area: Rect, config: &Config) { + let chunks = Layout::default() + .direction(Direction::Vertical) + .constraints([ + Constraint::Length(3), + Constraint::Length(10), + Constraint::Min(0), + Constraint::Length(3), + ]) + .split(area); + + // Header + let header = Paragraph::new("CLI Configuration") + .block(Block::default().borders(Borders::ALL).title("Settings")) + .style(Style::default().fg(Color::Cyan)); + f.render_widget(header, chunks[0]); + + // Config display + let masked_key = if config.api_key.len() > 10 { + format!("{}...{}", &config.api_key[..4], &config.api_key[config.api_key.len()-4..]) + } else { + "••••••••".to_string() + }; + + let config_text = vec![ + Line::from(""), + Line::from(vec![ + Span::styled("Server URL: ", Style::default().add_modifier(Modifier::BOLD)), + Span::styled(&config.server_url, Style::default().fg(Color::Cyan)), + ]), + Line::from(""), + Line::from(vec![ + Span::styled("API Key: ", Style::default().add_modifier(Modifier::BOLD)), + Span::styled(masked_key, Style::default().fg(Color::Yellow)), + ]), + Line::from(""), + Line::from(vec![ + Span::styled("Config File: ", Style::default().add_modifier(Modifier::BOLD)), + Span::raw("~/.config/clickploy/config.toml"), + ]), + ]; + + let config_display = Paragraph::new(config_text) + .block(Block::default().borders(Borders::ALL).title("Current Configuration")) + .wrap(Wrap { trim: true }); + f.render_widget(config_display, chunks[1]); + + // Actions + let actions_text = vec![ + Line::from(""), + Line::from(Span::styled( + "Available Actions:", + Style::default().add_modifier(Modifier::BOLD), + )), + Line::from(""), + Line::from(vec![ + Span::styled("c", Style::default().fg(Color::Yellow)), + Span::raw(" - Reconfigure (change server URL and API key)"), + ]), + Line::from(vec![ + Span::styled("d", Style::default().fg(Color::Red)), + Span::raw(" - Delete configuration (logout)"), + ]), + ]; + + let actions = Paragraph::new(actions_text) + .block(Block::default().borders(Borders::ALL).title("Actions")) + .wrap(Wrap { trim: true }); + f.render_widget(actions, chunks[2]); + + // Footer + let footer_text = vec![ + Line::from(vec![ + Span::styled("Backspace", Style::default().fg(Color::Yellow)), + Span::raw(" Back | "), + Span::styled("q", Style::default().fg(Color::Yellow)), + Span::raw(" Quit"), + ]), + ]; + + let footer = Paragraph::new(footer_text) + .block(Block::default().borders(Borders::ALL)) + .style(Style::default().fg(Color::DarkGray)); + f.render_widget(footer, chunks[3]); +} diff --git a/cli/src/ui/setup.rs b/cli/src/ui/setup.rs new file mode 100644 index 0000000..512dbca --- /dev/null +++ b/cli/src/ui/setup.rs @@ -0,0 +1,138 @@ +use ratatui::{ + layout::{Alignment, Constraint, Direction, Layout, Rect}, + style::{Color, Modifier, Style}, + text::{Line, Span}, + widgets::{Block, Borders, Paragraph, Wrap}, + Frame, +}; +use tui_input::Input; + +pub struct SetupState { + pub server_url: Input, + pub api_key: Input, + pub focused_field: usize, + pub error: Option, +} + +impl SetupState { + pub fn new() -> Self { + let server_url = Input::from("http://localhost:8080"); + + Self { + server_url, + api_key: Input::default(), + focused_field: 0, + error: None, + } + } + + pub fn next_field(&mut self) { + self.focused_field = (self.focused_field + 1) % 2; + } + + pub fn previous_field(&mut self) { + if self.focused_field == 0 { + self.focused_field = 1; + } else { + self.focused_field -= 1; + } + } +} + +pub fn render(f: &mut Frame, area: Rect, state: &SetupState) { + let chunks = Layout::default() + .direction(Direction::Vertical) + .constraints([ + Constraint::Length(7), + Constraint::Length(3), + Constraint::Length(3), + Constraint::Length(10), + Constraint::Min(0), + ]) + .split(area); + + // Title + let title = Paragraph::new(vec![ + Line::from(Span::styled( + "Clickploy CLI Setup", + Style::default() + .fg(Color::Cyan) + .add_modifier(Modifier::BOLD), + )), + Line::from(""), + Line::from("Welcome! Let's configure your CLI to connect to Clickploy."), + ]) + .block(Block::default().borders(Borders::ALL).title("Setup")) + .alignment(Alignment::Center); + f.render_widget(title, chunks[0]); + + // Server URL input + let server_url_style = if state.focused_field == 0 { + Style::default().fg(Color::Yellow) + } else { + Style::default() + }; + + let server_url_block = Block::default() + .borders(Borders::ALL) + .title("Server URL") + .border_style(server_url_style); + + let server_url_input = Paragraph::new(state.server_url.value()).block(server_url_block); + f.render_widget(server_url_input, chunks[1]); + + // API Key input + let api_key_style = if state.focused_field == 1 { + Style::default().fg(Color::Yellow) + } else { + Style::default() + }; + + let api_key_block = Block::default() + .borders(Borders::ALL) + .title("API Key") + .border_style(api_key_style); + + let masked_key = if state.api_key.value().is_empty() { + "" + } else { + "••••••••••••••••••••••••••••••••" + }; + + let api_key_input = Paragraph::new(masked_key).block(api_key_block); + f.render_widget(api_key_input, chunks[2]); + + // Instructions + let instructions = Paragraph::new(vec![ + Line::from(Span::styled( + "How to get your API key:", + Style::default().add_modifier(Modifier::BOLD), + )), + Line::from(""), + Line::from("1. Open your browser and navigate to:"), + Line::from(Span::styled( + " /settings/session", + Style::default().fg(Color::Cyan), + )), + Line::from(""), + Line::from("2. Copy your API key from the page"), + Line::from("3. Paste it into the API Key field above"), + Line::from(""), + Line::from(Span::styled( + "Press Enter on URL to open browser | Tab to switch | Esc to quit", + Style::default().fg(Color::DarkGray), + )), + ]) + .block(Block::default().borders(Borders::ALL).title("Instructions")) + .wrap(Wrap { trim: true }); + f.render_widget(instructions, chunks[3]); + + // Error message + if let Some(error) = &state.error { + let error_widget = Paragraph::new(error.as_str()) + .style(Style::default().fg(Color::Red)) + .block(Block::default().borders(Borders::ALL).title("Error")) + .wrap(Wrap { trim: true }); + f.render_widget(error_widget, chunks[4]); + } +} diff --git a/cli/src/ui/storage.rs b/cli/src/ui/storage.rs new file mode 100644 index 0000000..154c317 --- /dev/null +++ b/cli/src/ui/storage.rs @@ -0,0 +1,319 @@ +use ratatui::{ + layout::{Constraint, Direction, Layout, Rect}, + style::{Color, Modifier, Style}, + text::{Line, Span}, + widgets::{Block, Borders, Paragraph, List, ListItem, Gauge}, + Frame, +}; +use crate::ui::app::App; + +pub fn render(f: &mut Frame, area: Rect, app: &App) { + if app.show_db_credentials { + render_db_credentials(f, area, app); + return; + } + + let chunks = Layout::default() + .direction(Direction::Vertical) + .constraints([ + Constraint::Length(3), + Constraint::Length(5), + Constraint::Min(0), + Constraint::Length(3), + ]) + .split(area); + + // Header + let header = Paragraph::new("Storage Management") + .block(Block::default().borders(Borders::ALL)) + .style(Style::default().fg(Color::Cyan).add_modifier(Modifier::BOLD)); + f.render_widget(header, chunks[0]); + + // Storage stats + let storage_text = if let Some(stats) = &app.storage_stats { + let used_gb = stats.used as f64 / 1024.0 / 1024.0 / 1024.0; + let total_gb = stats.total as f64 / 1024.0 / 1024.0 / 1024.0; + vec![ + Line::from(vec![ + Span::styled("Storage: ", Style::default().add_modifier(Modifier::BOLD)), + Span::raw(format!("{:.2} GB / {:.2} GB", used_gb, total_gb)), + ]), + ] + } else { + vec![Line::from("Loading storage stats...")] + }; + + let storage_block = Paragraph::new(storage_text) + .block(Block::default().borders(Borders::ALL).title("Disk Usage")); + f.render_widget(storage_block, chunks[1]); + + // Render gauge inside the storage block + if let Some(stats) = &app.storage_stats { + let gauge_area = Rect { + x: chunks[1].x + 2, + y: chunks[1].y + 2, + width: chunks[1].width - 4, + height: 2, + }; + + let gauge = Gauge::default() + .block(Block::default()) + .gauge_style(Style::default().fg(Color::Cyan)) + .percent(stats.percent as u16); + f.render_widget(gauge, gauge_area); + } + + // Database list + if app.databases.is_empty() { + let empty_text = vec![ + Line::from(""), + Line::from(vec![ + Span::styled("No databases yet", Style::default().fg(Color::Yellow)), + ]), + Line::from(""), + Line::from("Press 'n' to create a new database"), + ]; + let empty = Paragraph::new(empty_text) + .block(Block::default().borders(Borders::ALL).title("Databases")) + .style(Style::default()); + f.render_widget(empty, chunks[2]); + } else { + let items: Vec = app.databases.iter().enumerate().map(|(i, db)| { + let status_color = match db.status.as_str() { + "running" => Color::Green, + "stopped" => Color::Yellow, + _ => Color::Red, + }; + + let content = vec![ + Line::from(vec![ + Span::styled(&db.name, Style::default().add_modifier(Modifier::BOLD)), + Span::raw(" - "), + Span::styled(&db.db_type, Style::default().fg(Color::Cyan)), + Span::raw(" - "), + Span::styled(&db.status, Style::default().fg(status_color)), + Span::raw(format!(" - Port: {}", db.port)), + ]), + ]; + + let style = if i == app.selected_index { + Style::default().bg(Color::DarkGray) + } else { + Style::default() + }; + + ListItem::new(content).style(style) + }).collect(); + + let list = List::new(items) + .block(Block::default().borders(Borders::ALL).title("Databases")); + f.render_widget(list, chunks[2]); + } + + // Footer + let footer_text = vec![ + Line::from(vec![ + Span::styled("↑↓", Style::default().fg(Color::Yellow)), + Span::raw(" Navigate | "), + Span::styled("Enter", Style::default().fg(Color::Yellow)), + Span::raw(" View Credentials | "), + Span::styled("n", Style::default().fg(Color::Yellow)), + Span::raw(" New Database | "), + Span::styled("d", Style::default().fg(Color::Yellow)), + Span::raw(" Delete | "), + Span::styled("s", Style::default().fg(Color::Yellow)), + Span::raw(" Stop | "), + Span::styled("r", Style::default().fg(Color::Yellow)), + Span::raw(" Restart | "), + Span::styled("Backspace", Style::default().fg(Color::Yellow)), + Span::raw(" Back | "), + Span::styled("q", Style::default().fg(Color::Yellow)), + Span::raw(" Quit"), + ]), + ]; + + let footer = Paragraph::new(footer_text) + .block(Block::default().borders(Borders::ALL)); + f.render_widget(footer, chunks[3]); +} + +fn render_db_credentials(f: &mut Frame, area: Rect, app: &App) { + let chunks = Layout::default() + .direction(Direction::Vertical) + .constraints([ + Constraint::Length(3), + Constraint::Min(0), + Constraint::Length(3), + ]) + .split(area); + + // Header + let header = Paragraph::new("Database Credentials") + .block(Block::default().borders(Borders::ALL)) + .style(Style::default().fg(Color::Cyan).add_modifier(Modifier::BOLD)); + f.render_widget(header, chunks[0]); + + // Credentials + if let Some(db) = &app.selected_database { + let content = if let Some(creds) = &app.db_credentials { + vec![ + Line::from(""), + Line::from(vec![ + Span::styled("Database: ", Style::default().add_modifier(Modifier::BOLD)), + Span::raw(&db.name), + ]), + Line::from(vec![ + Span::styled("Type: ", Style::default().add_modifier(Modifier::BOLD)), + Span::raw(&db.db_type), + ]), + Line::from(vec![ + Span::styled("Status: ", Style::default().add_modifier(Modifier::BOLD)), + Span::styled(&db.status, Style::default().fg(if db.status == "running" { Color::Green } else { Color::Yellow })), + ]), + Line::from(vec![ + Span::styled("Port: ", Style::default().add_modifier(Modifier::BOLD)), + Span::raw(format!("{}", db.port)), + ]), + Line::from(""), + Line::from(vec![ + Span::styled("Username: ", Style::default().add_modifier(Modifier::BOLD).fg(Color::Yellow)), + Span::raw(&creds.username), + ]), + Line::from(vec![ + Span::styled("Password: ", Style::default().add_modifier(Modifier::BOLD).fg(Color::Yellow)), + Span::raw(&creds.password), + ]), + Line::from(""), + Line::from(vec![ + Span::styled("Local URI:", Style::default().add_modifier(Modifier::BOLD).fg(Color::Cyan)), + ]), + Line::from(creds.uri.clone()), + Line::from(""), + Line::from(vec![ + Span::styled("Public URI:", Style::default().add_modifier(Modifier::BOLD).fg(Color::Cyan)), + ]), + Line::from(creds.public_uri.clone()), + ] + } else if db.db_type == "sqlite" { + vec![ + Line::from(""), + Line::from(vec![ + Span::styled("Database: ", Style::default().add_modifier(Modifier::BOLD)), + Span::raw(&db.name), + ]), + Line::from(vec![ + Span::styled("Type: ", Style::default().add_modifier(Modifier::BOLD)), + Span::raw(&db.db_type), + ]), + Line::from(""), + Line::from("SQLite databases are file-based and don't have credentials."), + Line::from(format!("Database file: data/user_dbs/{}.db", &db.name)), + ] + } else { + vec![ + Line::from(""), + Line::from("Loading credentials..."), + ] + }; + + let paragraph = Paragraph::new(content) + .block(Block::default().borders(Borders::ALL).title("Details")); + f.render_widget(paragraph, chunks[1]); + } + + // Footer + let footer_text = vec![ + Line::from(vec![ + Span::styled("Backspace/Esc", Style::default().fg(Color::Yellow)), + Span::raw(" Back | "), + Span::styled("q", Style::default().fg(Color::Yellow)), + Span::raw(" Quit"), + ]), + ]; + + let footer = Paragraph::new(footer_text) + .block(Block::default().borders(Borders::ALL)); + f.render_widget(footer, chunks[2]); +} + +pub fn render_create_database(f: &mut Frame, area: Rect, app: &App) { + let chunks = Layout::default() + .direction(Direction::Vertical) + .constraints([ + Constraint::Length(3), + Constraint::Min(0), + Constraint::Length(3), + ]) + .split(area); + + // Header + let header = Paragraph::new("Create New Database") + .block(Block::default().borders(Borders::ALL)) + .style(Style::default().fg(Color::Cyan).add_modifier(Modifier::BOLD)); + f.render_widget(header, chunks[0]); + + // Form + let form_chunks = Layout::default() + .direction(Direction::Vertical) + .constraints([ + Constraint::Length(3), + Constraint::Length(5), + Constraint::Min(0), + ]) + .margin(1) + .split(chunks[1]); + + // Name input + let name_style = if app.create_database_state.focused_field == 0 { + Style::default().fg(Color::Yellow) + } else { + Style::default() + }; + let name_input = Paragraph::new(app.create_database_state.name.as_str()) + .block(Block::default().borders(Borders::ALL).title("Name").border_style(name_style)); + f.render_widget(name_input, form_chunks[0]); + + // Type selection + let type_style = if app.create_database_state.focused_field == 1 { + Style::default().fg(Color::Yellow) + } else { + Style::default() + }; + + let db_types = vec!["sqlite", "mongodb"]; + let type_list: Vec = db_types.iter().map(|&t| { + if t == app.create_database_state.db_type { + Line::from(vec![ + Span::styled("▸ ", Style::default().fg(Color::Green)), + Span::styled(t, Style::default().fg(Color::Green).add_modifier(Modifier::BOLD)), + ]) + } else { + Line::from(vec![ + Span::raw(" "), + Span::raw(t), + ]) + } + }).collect(); + + let type_select = Paragraph::new(type_list) + .block(Block::default().borders(Borders::ALL).title("Type").border_style(type_style)); + f.render_widget(type_select, form_chunks[1]); + + // Footer + let footer_text = vec![ + Line::from(vec![ + Span::styled("Tab", Style::default().fg(Color::Yellow)), + Span::raw(" Switch Field | "), + Span::styled("↑↓", Style::default().fg(Color::Yellow)), + Span::raw(" Select Type | "), + Span::styled("Enter", Style::default().fg(Color::Yellow)), + Span::raw(" Create | "), + Span::styled("Esc", Style::default().fg(Color::Yellow)), + Span::raw(" Cancel"), + ]), + ]; + + let footer = Paragraph::new(footer_text) + .block(Block::default().borders(Borders::ALL)); + f.render_widget(footer, chunks[2]); +} diff --git a/frontend/package.json b/frontend/package.json index deff9af..2933ece 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -14,7 +14,7 @@ "devDependencies": { "@internationalized/date": "^3.11.0", "@lucide/svelte": "^0.561.0", - "@sveltejs/adapter-auto": "^7.0.0", + "@sveltejs/adapter-static": "^3.0.10", "@sveltejs/kit": "^2.50.2", "@sveltejs/vite-plugin-svelte": "^6.2.4", "@tailwindcss/forms": "^0.5.11", diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index 92677ce..a8be633 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -181,6 +181,19 @@ export async function redeployProject(id: string, commit?: string): Promise { + try { + await fetchWithAuth(`/api/projects/${id}/stop`, { + method: "POST", + }); + toast.success("Project stopped successfully"); + return true; + } catch (e: any) { + toast.error(e.message); + return false; + } +} + export async function listProjects(): Promise { try { return await fetchWithAuth("/api/projects"); diff --git a/frontend/src/routes/+layout.ts b/frontend/src/routes/+layout.ts index a3d1578..ae88a27 100644 --- a/frontend/src/routes/+layout.ts +++ b/frontend/src/routes/+layout.ts @@ -1 +1,2 @@ +export const prerender = false; export const ssr = false; diff --git a/frontend/src/routes/projects/[id]/+page.svelte b/frontend/src/routes/projects/[id]/+page.svelte index 94f46a3..bf53b14 100644 --- a/frontend/src/routes/projects/[id]/+page.svelte +++ b/frontend/src/routes/projects/[id]/+page.svelte @@ -1,7 +1,7 @@