API and TUI Updates
This commit is contained in:
2
.gitignore
vendored
2
.gitignore
vendored
@@ -30,3 +30,5 @@ bun.lock
|
||||
.svelte-kit
|
||||
|
||||
target/
|
||||
dist/
|
||||
data/
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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"})
|
||||
}
|
||||
|
||||
@@ -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@<HOST>:%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)
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
320
cli/src/api.rs
Normal file
320
cli/src/api.rs
Normal file
@@ -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<User> {
|
||||
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::<User>().await
|
||||
.context("Failed to parse user response")?;
|
||||
|
||||
Ok(user)
|
||||
}
|
||||
|
||||
pub async fn list_projects(&self) -> Result<Vec<Project>> {
|
||||
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::<Vec<Project>>().await
|
||||
.context("Failed to parse projects response")?;
|
||||
|
||||
Ok(projects)
|
||||
}
|
||||
|
||||
pub async fn get_project(&self, id: &str) -> Result<Project> {
|
||||
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::<Project>().await
|
||||
.context("Failed to parse project response")?;
|
||||
|
||||
Ok(project)
|
||||
}
|
||||
|
||||
pub async fn create_project(&self, request: CreateProjectRequest) -> Result<Project> {
|
||||
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::<Project>().await
|
||||
.context("Failed to parse project response")?;
|
||||
|
||||
Ok(project)
|
||||
}
|
||||
|
||||
pub async fn redeploy_project(&self, id: &str, commit: Option<String>) -> Result<serde_json::Value> {
|
||||
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::<serde_json::Value>().await
|
||||
.context("Failed to parse redeploy response")?;
|
||||
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
pub async fn stop_project(&self, id: &str) -> Result<serde_json::Value> {
|
||||
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::<serde_json::Value>().await
|
||||
.context("Failed to parse stop response")?;
|
||||
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
pub async fn get_activity(&self) -> Result<Vec<Deployment>> {
|
||||
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::<Vec<Deployment>>().await
|
||||
.context("Failed to parse activity response")?;
|
||||
|
||||
Ok(deployments)
|
||||
}
|
||||
|
||||
// Storage API methods
|
||||
pub async fn get_storage_stats(&self) -> Result<StorageStats> {
|
||||
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::<StorageStats>().await
|
||||
.context("Failed to parse storage stats response")?;
|
||||
|
||||
Ok(stats)
|
||||
}
|
||||
|
||||
pub async fn list_databases(&self) -> Result<Vec<Database>> {
|
||||
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::<Vec<Database>>().await
|
||||
.context("Failed to parse databases response")?;
|
||||
|
||||
Ok(databases)
|
||||
}
|
||||
|
||||
pub async fn create_database(&self, name: String, db_type: String) -> Result<serde_json::Value> {
|
||||
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::<serde_json::Value>().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<DatabaseCredentials> {
|
||||
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::<DatabaseCredentials>().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(())
|
||||
}
|
||||
}
|
||||
75
cli/src/config.rs
Normal file
75
cli/src/config.rs
Normal file
@@ -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<PathBuf> {
|
||||
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<Config> {
|
||||
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(())
|
||||
}
|
||||
930
cli/src/main.rs
930
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<Vec<Deployment>>,
|
||||
}
|
||||
|
||||
#[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<Vec<Deployment>>,
|
||||
}
|
||||
|
||||
struct App {
|
||||
projects: Vec<ProjectCorrected>,
|
||||
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::<Vec<ProjectCorrected>>().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.screen = Screen::Projects;
|
||||
app.user = Some(user.clone());
|
||||
app.message = format!("Welcome, {}!", user.name);
|
||||
|
||||
app.fetch_data().await;
|
||||
// Initial data fetch
|
||||
app.fetch_projects(&client).await?;
|
||||
|
||||
let res = run_app(&mut terminal, app).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<CrosstermBackend<io::Stdout>>, mut app: App) -> Result<()> {
|
||||
async fn stream_logs(server_url: String, deployment_id: String, tx: mpsc::UnboundedSender<String>) -> 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<CrosstermBackend<io::Stdout>>,
|
||||
app: &mut App,
|
||||
client: &ApiClient,
|
||||
) -> Result<()> {
|
||||
let (log_tx, mut log_rx) = mpsc::unbounded_channel::<String>();
|
||||
let mut ws_task: Option<tokio::task::JoinHandle<()>> = None;
|
||||
let mut current_deployment_id: Option<String> = 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<ListItem> = 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);
|
||||
}
|
||||
|
||||
116
cli/src/models.rs
Normal file
116
cli/src/models.rs
Normal file
@@ -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<Vec<Deployment>>,
|
||||
pub env_vars: Option<Vec<EnvVar>>,
|
||||
}
|
||||
|
||||
#[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<i32>,
|
||||
pub git_token: Option<String>,
|
||||
pub env_vars: Option<std::collections::HashMap<String, String>>,
|
||||
pub build_command: Option<String>,
|
||||
pub start_command: Option<String>,
|
||||
pub install_command: Option<String>,
|
||||
pub runtime: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct RedeployRequest {
|
||||
pub commit: Option<String>,
|
||||
}
|
||||
|
||||
#[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<String>,
|
||||
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,
|
||||
}
|
||||
117
cli/src/ui/activity.rs
Normal file
117
cli/src/ui/activity.rs
Normal file
@@ -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<ListItem> = 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);
|
||||
}
|
||||
}
|
||||
211
cli/src/ui/app.rs
Normal file
211
cli/src/ui/app.rs
Normal file
@@ -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<Project>,
|
||||
pub selected_project: Option<Project>,
|
||||
pub activity: Vec<Deployment>,
|
||||
pub user: Option<User>,
|
||||
pub message: String,
|
||||
pub error: Option<String>,
|
||||
pub selected_index: usize,
|
||||
pub should_quit: bool,
|
||||
pub create_project_state: CreateProjectState,
|
||||
pub live_logs: String,
|
||||
pub log_scroll: u16,
|
||||
pub databases: Vec<Database>,
|
||||
pub selected_database: Option<Database>,
|
||||
pub storage_stats: Option<StorageStats>,
|
||||
pub create_database_state: CreateDatabaseState,
|
||||
pub show_db_credentials: bool,
|
||||
pub db_credentials: Option<DatabaseCredentials>,
|
||||
}
|
||||
|
||||
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();
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
106
cli/src/ui/create_project.rs
Normal file
106
cli/src/ui/create_project.rs
Normal file
@@ -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<String>,
|
||||
}
|
||||
|
||||
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]);
|
||||
}
|
||||
}
|
||||
91
cli/src/ui/deployment_logs.rs
Normal file
91
cli/src/ui/deployment_logs.rs
Normal file
@@ -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]);
|
||||
}
|
||||
83
cli/src/ui/deployments.rs
Normal file
83
cli/src/ui/deployments.rs
Normal file
@@ -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<ListItem> = 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]);
|
||||
}
|
||||
86
cli/src/ui/docs.rs
Normal file
86
cli/src/ui/docs.rs
Normal file
@@ -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]);
|
||||
}
|
||||
40
cli/src/ui/mod.rs
Normal file
40
cli/src/ui/mod.rs
Normal file
@@ -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;
|
||||
}
|
||||
}
|
||||
85
cli/src/ui/network.rs
Normal file
85
cli/src/ui/network.rs
Normal file
@@ -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<ListItem> = 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]);
|
||||
}
|
||||
160
cli/src/ui/project_detail.rs
Normal file
160
cli/src/ui/project_detail.rs
Normal file
@@ -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::<Vec<_>>()
|
||||
} 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);
|
||||
}
|
||||
}
|
||||
166
cli/src/ui/project_settings.rs
Normal file
166
cli/src/ui/project_settings.rs
Normal file
@@ -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);
|
||||
}
|
||||
}
|
||||
135
cli/src/ui/projects.rs
Normal file
135
cli/src/ui/projects.rs
Normal file
@@ -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<ListItem> = 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);
|
||||
}
|
||||
}
|
||||
94
cli/src/ui/settings.rs
Normal file
94
cli/src/ui/settings.rs
Normal file
@@ -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]);
|
||||
}
|
||||
138
cli/src/ui/setup.rs
Normal file
138
cli/src/ui/setup.rs
Normal file
@@ -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<String>,
|
||||
}
|
||||
|
||||
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(
|
||||
" <server-url>/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]);
|
||||
}
|
||||
}
|
||||
319
cli/src/ui/storage.rs
Normal file
319
cli/src/ui/storage.rs
Normal file
@@ -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<ListItem> = 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<Line> = 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]);
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -181,6 +181,19 @@ export async function redeployProject(id: string, commit?: string): Promise<bool
|
||||
}
|
||||
}
|
||||
|
||||
export async function stopProject(id: string): Promise<boolean> {
|
||||
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<Project[] | null> {
|
||||
try {
|
||||
return await fetchWithAuth("/api/projects");
|
||||
|
||||
@@ -1 +1,2 @@
|
||||
export const prerender = false;
|
||||
export const ssr = false;
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<script lang="ts">
|
||||
import { onMount, tick } from "svelte";
|
||||
import { page } from "$app/stores";
|
||||
import { getProject, type Project, redeployProject } from "$lib/api";
|
||||
import { getProject, type Project, redeployProject, stopProject } from "$lib/api";
|
||||
import { Button } from "$lib/components/ui/button";
|
||||
import { Card } from "$lib/components/ui/card";
|
||||
import {
|
||||
@@ -15,6 +15,7 @@
|
||||
Check,
|
||||
Copy,
|
||||
GitCommit,
|
||||
Square,
|
||||
} from "@lucide/svelte";
|
||||
import { toast } from "svelte-sonner";
|
||||
|
||||
@@ -70,6 +71,15 @@
|
||||
}
|
||||
}
|
||||
|
||||
async function handleStop() {
|
||||
if (!project) return;
|
||||
toast.info("Stopping project...");
|
||||
const success = await stopProject(project.id.toString());
|
||||
if (success) {
|
||||
setTimeout(loadProject, 1000);
|
||||
}
|
||||
}
|
||||
|
||||
function selectDeployment(deployment: any) {
|
||||
if (activeDeploymentId === deployment.id) return;
|
||||
|
||||
@@ -180,6 +190,16 @@
|
||||
<Play class="h-3.5 w-3.5 mr-2" /> Redeploy
|
||||
{/if}
|
||||
</Button>
|
||||
{#if status === "live" || status === "building"}
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
class="h-8"
|
||||
onclick={handleStop}
|
||||
>
|
||||
<Square class="h-3.5 w-3.5 mr-2" /> Stop
|
||||
</Button>
|
||||
{/if}
|
||||
<Button
|
||||
href={latestDeployment?.url}
|
||||
target="_blank"
|
||||
|
||||
@@ -1,9 +1,13 @@
|
||||
import adapter from '@sveltejs/adapter-auto';
|
||||
import adapter from '@sveltejs/adapter-static';
|
||||
|
||||
/** @type {import('@sveltejs/kit').Config} */
|
||||
const config = {
|
||||
kit: {
|
||||
adapter: adapter(),
|
||||
adapter: adapter({
|
||||
pages: '../backend/dist',
|
||||
assets: '../backend/dist',
|
||||
fallback: 'index.html'
|
||||
}),
|
||||
alias: {
|
||||
// "@/*": "./path/to/lib/*",
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user