From 461986fc047efcf0a8ee119c28814b742bd023c2 Mon Sep 17 00:00:00 2001 From: David Viejo Date: Sat, 21 Mar 2026 01:04:29 +0100 Subject: [PATCH] feat(compose): add Docker Compose stack management MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a new "Stacks" feature for managing standalone Docker Compose stacks separate from projects — a lightweight Dockge/Portainer-style interface built into Temps. Backend: - New `temps-compose` crate with full three-layer architecture - Database migration for `compose_stacks` table - CRUD API endpoints + lifecycle controls (deploy, stop, restart, pull) - Stacks permissions (StacksRead, StacksWrite, StacksDelete, StacksCreate) - Audit logging for all write operations - 8 unit tests covering service layer Frontend: - New "Stacks" sidebar entry with Layers icon - Stacks list page with create/edit dialogs - YAML editor for compose content and .env variables - Lifecycle controls (deploy, stop, restart) via dropdown menu - State badges and responsive table layout Plugin: - ComposePlugin registered in server startup - OpenAPI schema integration --- Cargo.lock | 21 + Cargo.toml | 1 + crates/temps-auth/src/permissions.rs | 26 + crates/temps-cli/Cargo.toml | 1 + .../temps-cli/src/commands/serve/console.rs | 6 + crates/temps-compose/Cargo.toml | 28 + crates/temps-compose/src/handlers/audit.rs | 125 +++++ .../src/handlers/compose_handler.rs | 527 ++++++++++++++++++ crates/temps-compose/src/handlers/mod.rs | 6 + crates/temps-compose/src/handlers/types.rs | 19 + crates/temps-compose/src/lib.rs | 7 + crates/temps-compose/src/plugin.rs | 86 +++ crates/temps-compose/src/services/compose.rs | 310 +++++++++++ crates/temps-compose/src/services/mod.rs | 2 + crates/temps-entities/src/compose_stacks.rs | 25 + crates/temps-entities/src/lib.rs | 1 + .../m20260321_000001_create_compose_stacks.rs | 89 +++ crates/temps-migrations/src/migration/mod.rs | 2 + web/src/App.tsx | 5 + web/src/api/stacks.ts | 97 ++++ web/src/components/dashboard/Sidebar.tsx | 6 + web/src/components/stacks/StacksList.tsx | 504 +++++++++++++++++ web/src/pages/Stacks.tsx | 22 + 23 files changed, 1916 insertions(+) create mode 100644 crates/temps-compose/Cargo.toml create mode 100644 crates/temps-compose/src/handlers/audit.rs create mode 100644 crates/temps-compose/src/handlers/compose_handler.rs create mode 100644 crates/temps-compose/src/handlers/mod.rs create mode 100644 crates/temps-compose/src/handlers/types.rs create mode 100644 crates/temps-compose/src/lib.rs create mode 100644 crates/temps-compose/src/plugin.rs create mode 100644 crates/temps-compose/src/services/compose.rs create mode 100644 crates/temps-compose/src/services/mod.rs create mode 100644 crates/temps-entities/src/compose_stacks.rs create mode 100644 crates/temps-migrations/src/migration/m20260321_000001_create_compose_stacks.rs create mode 100644 web/src/api/stacks.ts create mode 100644 web/src/components/stacks/StacksList.tsx create mode 100644 web/src/pages/Stacks.tsx diff --git a/Cargo.lock b/Cargo.lock index c856b061..ef88af95 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -10716,6 +10716,7 @@ dependencies = [ "temps-auth", "temps-backup", "temps-blob", + "temps-compose", "temps-config", "temps-core", "temps-database", @@ -10762,6 +10763,26 @@ dependencies = [ "x509-parser 0.16.0", ] +[[package]] +name = "temps-compose" +version = "0.1.0" +dependencies = [ + "anyhow", + "async-trait", + "axum 0.8.6", + "chrono", + "sea-orm", + "serde", + "serde_json", + "temps-auth", + "temps-core", + "temps-entities", + "thiserror 2.0.17", + "tokio", + "tracing", + "utoipa", +] + [[package]] name = "temps-config" version = "0.1.0" diff --git a/Cargo.toml b/Cargo.toml index d4ebd0d7..036520e5 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -60,6 +60,7 @@ members = [ "crates/temps-wireguard", "crates/temps-ai-gateway", "crates/temps-agent", + "crates/temps-compose", "crates/temps-external-plugins", "examples/example-plugin", "examples/lighthouse-plugin", diff --git a/crates/temps-auth/src/permissions.rs b/crates/temps-auth/src/permissions.rs index a79829b3..5412c6d7 100644 --- a/crates/temps-auth/src/permissions.rs +++ b/crates/temps-auth/src/permissions.rs @@ -214,6 +214,12 @@ pub enum Permission { AiGatewayRead, AiGatewayWrite, AiGatewayExecute, + + // Compose Stacks permissions + StacksRead, + StacksWrite, + StacksDelete, + StacksCreate, } impl fmt::Display for Permission { @@ -350,6 +356,10 @@ impl fmt::Display for Permission { Permission::AiGatewayRead => "ai_gateway:read", Permission::AiGatewayWrite => "ai_gateway:write", Permission::AiGatewayExecute => "ai_gateway:execute", + Permission::StacksRead => "stacks:read", + Permission::StacksWrite => "stacks:write", + Permission::StacksDelete => "stacks:delete", + Permission::StacksCreate => "stacks:create", }; write!(f, "{}", name) } @@ -490,6 +500,10 @@ impl Permission { "ai_gateway:read" => Some(Permission::AiGatewayRead), "ai_gateway:write" => Some(Permission::AiGatewayWrite), "ai_gateway:execute" => Some(Permission::AiGatewayExecute), + "stacks:read" => Some(Permission::StacksRead), + "stacks:write" => Some(Permission::StacksWrite), + "stacks:delete" => Some(Permission::StacksDelete), + "stacks:create" => Some(Permission::StacksCreate), _ => None, } } @@ -627,6 +641,10 @@ impl Permission { Permission::AiGatewayRead, Permission::AiGatewayWrite, Permission::AiGatewayExecute, + Permission::StacksRead, + Permission::StacksWrite, + Permission::StacksDelete, + Permission::StacksCreate, ] } } @@ -819,6 +837,10 @@ impl Role { Permission::AiGatewayRead, Permission::AiGatewayWrite, Permission::AiGatewayExecute, + Permission::StacksRead, + Permission::StacksWrite, + Permission::StacksDelete, + Permission::StacksCreate, ], Role::User => &[ Permission::ProjectsRead, @@ -917,6 +939,9 @@ impl Role { Permission::OtelWrite, Permission::AiGatewayRead, Permission::AiGatewayExecute, + Permission::StacksRead, + Permission::StacksWrite, + Permission::StacksCreate, ], Role::Reader => &[ Permission::ProjectsRead, @@ -956,6 +981,7 @@ impl Role { Permission::StatusPageRead, Permission::OtelRead, Permission::AiGatewayRead, + Permission::StacksRead, ], Role::Mcp => &[ Permission::ProjectsRead, diff --git a/crates/temps-cli/Cargo.toml b/crates/temps-cli/Cargo.toml index 6506ed86..0400df17 100644 --- a/crates/temps-cli/Cargo.toml +++ b/crates/temps-cli/Cargo.toml @@ -25,6 +25,7 @@ temps-analytics-session-replay = { path = "../temps-analytics-session-replay" } temps-audit = { path = "../temps-audit" } temps-auth = { path = "../temps-auth" } temps-backup = { path = "../temps-backup" } +temps-compose = { path = "../temps-compose" } temps-config = { path = "../temps-config" } temps-core = { path = "../temps-core" } temps-database = { path = "../temps-database" } diff --git a/crates/temps-cli/src/commands/serve/console.rs b/crates/temps-cli/src/commands/serve/console.rs index 27aae1b9..8d5e6109 100644 --- a/crates/temps-cli/src/commands/serve/console.rs +++ b/crates/temps-cli/src/commands/serve/console.rs @@ -23,6 +23,7 @@ use temps_audit::AuditPlugin; use temps_auth::{ApiKeyPlugin, AuthPlugin}; use temps_backup::BackupPlugin; use temps_blob::BlobPlugin; +use temps_compose::ComposePlugin; use temps_config::ConfigPlugin; use temps_config::ServerConfig; use temps_core::plugin::PluginManager; @@ -848,6 +849,11 @@ pub async fn start_console_api(params: ConsoleApiParams) -> anyhow::Result<()> { let backup_plugin = Box::new(BackupPlugin::new()); plugin_manager.register_plugin(backup_plugin); + // ComposePlugin - provides Docker Compose stack management (depends on database and audit services) + debug!("Registering ComposePlugin"); + let compose_plugin = Box::new(ComposePlugin::new()); + plugin_manager.register_plugin(compose_plugin); + // AI Gateway Plugin - provides AI provider key management and OpenAI-compatible API debug!("Registering AiGatewayPlugin"); let ai_gateway_plugin = Box::new(temps_ai_gateway::AiGatewayPlugin::new()); diff --git a/crates/temps-compose/Cargo.toml b/crates/temps-compose/Cargo.toml new file mode 100644 index 00000000..ed46683e --- /dev/null +++ b/crates/temps-compose/Cargo.toml @@ -0,0 +1,28 @@ +[package] +name = "temps-compose" +version.workspace = true +edition.workspace = true +license.workspace = true +authors.workspace = true +repository.workspace = true +homepage.workspace = true + +[dependencies] +temps-auth = { path = "../temps-auth" } +temps-core = { path = "../temps-core" } +temps-entities = { path = "../temps-entities" } +sea-orm = { workspace = true } +serde = { workspace = true } +serde_json = { workspace = true } +thiserror = { workspace = true } +tracing = { workspace = true } +anyhow = { workspace = true } +chrono = { workspace = true } +tokio = { workspace = true } +utoipa = { workspace = true } +axum = { workspace = true } +async-trait = { workspace = true } + +[dev-dependencies] +sea-orm = { workspace = true, features = ["mock"] } +tokio = { workspace = true, features = ["test-util", "macros"] } diff --git a/crates/temps-compose/src/handlers/audit.rs b/crates/temps-compose/src/handlers/audit.rs new file mode 100644 index 00000000..be98a0ab --- /dev/null +++ b/crates/temps-compose/src/handlers/audit.rs @@ -0,0 +1,125 @@ +use anyhow::Result; +use serde::Serialize; +pub use temps_core::AuditContext; +use temps_core::AuditOperation; + +#[derive(Debug, Clone, Serialize)] +pub struct StackCreatedAudit { + pub context: AuditContext, + pub stack_id: i32, + pub name: String, +} + +impl AuditOperation for StackCreatedAudit { + fn operation_type(&self) -> String { + "COMPOSE_STACK_CREATED".to_string() + } + + fn user_id(&self) -> i32 { + self.context.user_id + } + + fn ip_address(&self) -> Option { + self.context.ip_address.clone() + } + + fn user_agent(&self) -> &str { + &self.context.user_agent + } + + fn serialize(&self) -> Result { + serde_json::to_string(self) + .map_err(|e| anyhow::anyhow!("Failed to serialize audit operation {}", e)) + } +} + +#[derive(Debug, Clone, Serialize)] +pub struct StackUpdatedAudit { + pub context: AuditContext, + pub stack_id: i32, + pub name: String, +} + +impl AuditOperation for StackUpdatedAudit { + fn operation_type(&self) -> String { + "COMPOSE_STACK_UPDATED".to_string() + } + + fn user_id(&self) -> i32 { + self.context.user_id + } + + fn ip_address(&self) -> Option { + self.context.ip_address.clone() + } + + fn user_agent(&self) -> &str { + &self.context.user_agent + } + + fn serialize(&self) -> Result { + serde_json::to_string(self) + .map_err(|e| anyhow::anyhow!("Failed to serialize audit operation {}", e)) + } +} + +#[derive(Debug, Clone, Serialize)] +pub struct StackDeletedAudit { + pub context: AuditContext, + pub stack_id: i32, + pub name: String, +} + +impl AuditOperation for StackDeletedAudit { + fn operation_type(&self) -> String { + "COMPOSE_STACK_DELETED".to_string() + } + + fn user_id(&self) -> i32 { + self.context.user_id + } + + fn ip_address(&self) -> Option { + self.context.ip_address.clone() + } + + fn user_agent(&self) -> &str { + &self.context.user_agent + } + + fn serialize(&self) -> Result { + serde_json::to_string(self) + .map_err(|e| anyhow::anyhow!("Failed to serialize audit operation {}", e)) + } +} + +#[derive(Debug, Clone, Serialize)] +pub struct StackStateChangedAudit { + pub context: AuditContext, + pub stack_id: i32, + pub name: String, + pub new_state: String, +} + +impl AuditOperation for StackStateChangedAudit { + fn operation_type(&self) -> String { + "COMPOSE_STACK_STATE_CHANGED".to_string() + } + + fn user_id(&self) -> i32 { + self.context.user_id + } + + fn ip_address(&self) -> Option { + self.context.ip_address.clone() + } + + fn user_agent(&self) -> &str { + &self.context.user_agent + } + + fn serialize(&self) -> Result { + serde_json::to_string(self) + .map_err(|e| anyhow::anyhow!("Failed to serialize audit operation {}", e)) + } +} diff --git a/crates/temps-compose/src/handlers/compose_handler.rs b/crates/temps-compose/src/handlers/compose_handler.rs new file mode 100644 index 00000000..008ae391 --- /dev/null +++ b/crates/temps-compose/src/handlers/compose_handler.rs @@ -0,0 +1,527 @@ +use crate::handlers::audit::{ + AuditContext, StackCreatedAudit, StackDeletedAudit, StackStateChangedAudit, StackUpdatedAudit, +}; +use crate::handlers::types::ComposeAppState; +use crate::services::ComposeError; +use axum::{ + extract::{Extension, Path, State}, + http::StatusCode, + response::IntoResponse, + routing::{get, post}, + Json, Router, +}; +use serde::{Deserialize, Serialize}; +use std::sync::Arc; +use temps_auth::permission_guard; +use temps_auth::RequireAuth; +use temps_core::problemdetails; +use temps_core::problemdetails::{Problem, ProblemDetails}; +use temps_core::RequestMetadata; +use tracing::error; +use utoipa::{OpenApi, ToSchema}; + +impl From for Problem { + fn from(error: ComposeError) -> Self { + match error { + ComposeError::NotFound { .. } => problemdetails::new(StatusCode::NOT_FOUND) + .with_title("Stack Not Found") + .with_detail(error.to_string()), + + ComposeError::Validation { .. } => problemdetails::new(StatusCode::BAD_REQUEST) + .with_title("Validation Error") + .with_detail(error.to_string()), + + ComposeError::InvalidState { .. } => problemdetails::new(StatusCode::CONFLICT) + .with_title("Invalid State") + .with_detail(error.to_string()), + + ComposeError::Docker { .. } => problemdetails::new(StatusCode::INTERNAL_SERVER_ERROR) + .with_title("Docker Compose Error") + .with_detail(error.to_string()), + + ComposeError::Database(_) => problemdetails::new(StatusCode::INTERNAL_SERVER_ERROR) + .with_title("Internal Server Error") + .with_detail(error.to_string()), + } + } +} + +#[derive(OpenApi)] +#[openapi( + paths( + list_stacks, + create_stack, + get_stack, + update_stack, + delete_stack, + deploy_stack, + stop_stack, + restart_stack, + pull_stack, + ), + components( + schemas( + CreateStackRequest, + UpdateStackRequest, + StackResponse, + PaginatedStacksResponse, + ) + ), + info( + title = "Compose Stacks API", + description = "API endpoints for managing Docker Compose stacks", + version = "1.0.0" + ), + tags( + (name = "Stacks", description = "Docker Compose stack management endpoints") + ) +)] +pub struct ComposeApiDoc; + +#[derive(Deserialize, ToSchema, Clone)] +pub struct CreateStackRequest { + pub name: String, + pub description: Option, + pub compose_content: String, + pub env_content: Option, + pub node_id: Option, +} + +#[derive(Deserialize, ToSchema, Clone)] +pub struct UpdateStackRequest { + pub name: Option, + pub description: Option>, + pub compose_content: Option, + pub env_content: Option>, +} + +#[derive(Serialize, ToSchema)] +pub struct StackResponse { + pub id: i32, + pub name: String, + pub description: Option, + pub compose_content: String, + pub env_content: Option, + pub node_id: Option, + pub state: String, + pub created_at: String, + pub updated_at: String, +} + +impl From for StackResponse { + fn from(model: temps_entities::compose_stacks::Model) -> Self { + Self { + id: model.id, + name: model.name, + description: model.description, + compose_content: model.compose_content, + env_content: model.env_content, + node_id: model.node_id, + state: model.state, + created_at: model.created_at.to_string(), + updated_at: model.updated_at.to_string(), + } + } +} + +#[derive(Serialize, ToSchema)] +pub struct PaginatedStacksResponse { + pub items: Vec, + pub total: u64, +} + +#[derive(Deserialize)] +pub struct PaginationParams { + pub page: Option, + pub page_size: Option, +} + +/// List all compose stacks +#[utoipa::path( + tag = "Stacks", + get, + path = "/stacks", + params( + ("page" = Option, Query, description = "Page number (default: 1)"), + ("page_size" = Option, Query, description = "Page size (default: 20, max: 100)") + ), + responses( + (status = 200, description = "List of stacks", body = PaginatedStacksResponse), + (status = 401, description = "Unauthorized", body = ProblemDetails), + (status = 403, description = "Insufficient permissions", body = ProblemDetails), + (status = 500, description = "Internal server error", body = ProblemDetails) + ), + security(("bearer_auth" = [])) +)] +async fn list_stacks( + RequireAuth(auth): RequireAuth, + State(app_state): State>, + axum::extract::Query(params): axum::extract::Query, +) -> Result { + permission_guard!(auth, StacksRead); + + let (stacks, total) = app_state + .compose_service + .list(params.page, params.page_size) + .await?; + + Ok(Json(PaginatedStacksResponse { + items: stacks.into_iter().map(StackResponse::from).collect(), + total, + })) +} + +/// Create a new compose stack +#[utoipa::path( + tag = "Stacks", + post, + path = "/stacks", + request_body = CreateStackRequest, + responses( + (status = 201, description = "Stack created", body = StackResponse), + (status = 400, description = "Validation error", body = ProblemDetails), + (status = 401, description = "Unauthorized", body = ProblemDetails), + (status = 403, description = "Insufficient permissions", body = ProblemDetails), + (status = 500, description = "Internal server error", body = ProblemDetails) + ), + security(("bearer_auth" = [])) +)] +async fn create_stack( + RequireAuth(auth): RequireAuth, + State(app_state): State>, + Extension(metadata): Extension, + Json(request): Json, +) -> Result { + permission_guard!(auth, StacksCreate); + + let stack = app_state + .compose_service + .create( + request.name.clone(), + request.description, + request.compose_content, + request.env_content, + request.node_id, + ) + .await?; + + let audit = StackCreatedAudit { + context: AuditContext { + user_id: auth.user_id(), + ip_address: Some(metadata.ip_address.clone()), + user_agent: metadata.user_agent.clone(), + }, + stack_id: stack.id, + name: stack.name.clone(), + }; + if let Err(e) = app_state.audit_service.create_audit_log(&audit).await { + error!("Failed to create audit log: {}", e); + } + + Ok((StatusCode::CREATED, Json(StackResponse::from(stack)))) +} + +/// Get a compose stack by ID +#[utoipa::path( + tag = "Stacks", + get, + path = "/stacks/{id}", + params( + ("id" = i32, Path, description = "Stack ID") + ), + responses( + (status = 200, description = "Stack details", body = StackResponse), + (status = 401, description = "Unauthorized", body = ProblemDetails), + (status = 403, description = "Insufficient permissions", body = ProblemDetails), + (status = 404, description = "Stack not found", body = ProblemDetails), + (status = 500, description = "Internal server error", body = ProblemDetails) + ), + security(("bearer_auth" = [])) +)] +async fn get_stack( + RequireAuth(auth): RequireAuth, + State(app_state): State>, + Path(id): Path, +) -> Result { + permission_guard!(auth, StacksRead); + + let stack = app_state.compose_service.get(id).await?; + Ok(Json(StackResponse::from(stack))) +} + +/// Update a compose stack +#[utoipa::path( + tag = "Stacks", + patch, + path = "/stacks/{id}", + params( + ("id" = i32, Path, description = "Stack ID") + ), + request_body = UpdateStackRequest, + responses( + (status = 200, description = "Stack updated", body = StackResponse), + (status = 400, description = "Validation error", body = ProblemDetails), + (status = 401, description = "Unauthorized", body = ProblemDetails), + (status = 403, description = "Insufficient permissions", body = ProblemDetails), + (status = 404, description = "Stack not found", body = ProblemDetails), + (status = 500, description = "Internal server error", body = ProblemDetails) + ), + security(("bearer_auth" = [])) +)] +async fn update_stack( + RequireAuth(auth): RequireAuth, + State(app_state): State>, + Extension(metadata): Extension, + Path(id): Path, + Json(request): Json, +) -> Result { + permission_guard!(auth, StacksWrite); + + let stack = app_state + .compose_service + .update( + id, + request.name, + request.description, + request.compose_content, + request.env_content, + ) + .await?; + + let audit = StackUpdatedAudit { + context: AuditContext { + user_id: auth.user_id(), + ip_address: Some(metadata.ip_address.clone()), + user_agent: metadata.user_agent.clone(), + }, + stack_id: stack.id, + name: stack.name.clone(), + }; + if let Err(e) = app_state.audit_service.create_audit_log(&audit).await { + error!("Failed to create audit log: {}", e); + } + + Ok(Json(StackResponse::from(stack))) +} + +/// Delete a compose stack +#[utoipa::path( + tag = "Stacks", + delete, + path = "/stacks/{id}", + params( + ("id" = i32, Path, description = "Stack ID") + ), + responses( + (status = 204, description = "Stack deleted"), + (status = 401, description = "Unauthorized", body = ProblemDetails), + (status = 403, description = "Insufficient permissions", body = ProblemDetails), + (status = 404, description = "Stack not found", body = ProblemDetails), + (status = 409, description = "Stack is running", body = ProblemDetails), + (status = 500, description = "Internal server error", body = ProblemDetails) + ), + security(("bearer_auth" = [])) +)] +async fn delete_stack( + RequireAuth(auth): RequireAuth, + State(app_state): State>, + Extension(metadata): Extension, + Path(id): Path, +) -> Result { + permission_guard!(auth, StacksDelete); + + let stack = app_state.compose_service.get(id).await?; + let stack_name = stack.name.clone(); + + app_state.compose_service.delete(id).await?; + + let audit = StackDeletedAudit { + context: AuditContext { + user_id: auth.user_id(), + ip_address: Some(metadata.ip_address.clone()), + user_agent: metadata.user_agent.clone(), + }, + stack_id: id, + name: stack_name, + }; + if let Err(e) = app_state.audit_service.create_audit_log(&audit).await { + error!("Failed to create audit log: {}", e); + } + + Ok(StatusCode::NO_CONTENT) +} + +/// Deploy (start) a compose stack +#[utoipa::path( + tag = "Stacks", + post, + path = "/stacks/{id}/deploy", + params( + ("id" = i32, Path, description = "Stack ID") + ), + responses( + (status = 200, description = "Stack deployed", body = StackResponse), + (status = 401, description = "Unauthorized", body = ProblemDetails), + (status = 403, description = "Insufficient permissions", body = ProblemDetails), + (status = 404, description = "Stack not found", body = ProblemDetails), + (status = 500, description = "Internal server error", body = ProblemDetails) + ), + security(("bearer_auth" = [])) +)] +async fn deploy_stack( + RequireAuth(auth): RequireAuth, + State(app_state): State>, + Extension(metadata): Extension, + Path(id): Path, +) -> Result { + permission_guard!(auth, StacksWrite); + + let stack = app_state.compose_service.set_state(id, "running").await?; + + let audit = StackStateChangedAudit { + context: AuditContext { + user_id: auth.user_id(), + ip_address: Some(metadata.ip_address.clone()), + user_agent: metadata.user_agent.clone(), + }, + stack_id: stack.id, + name: stack.name.clone(), + new_state: "running".to_string(), + }; + if let Err(e) = app_state.audit_service.create_audit_log(&audit).await { + error!("Failed to create audit log: {}", e); + } + + Ok(Json(StackResponse::from(stack))) +} + +/// Stop a compose stack +#[utoipa::path( + tag = "Stacks", + post, + path = "/stacks/{id}/stop", + params( + ("id" = i32, Path, description = "Stack ID") + ), + responses( + (status = 200, description = "Stack stopped", body = StackResponse), + (status = 401, description = "Unauthorized", body = ProblemDetails), + (status = 403, description = "Insufficient permissions", body = ProblemDetails), + (status = 404, description = "Stack not found", body = ProblemDetails), + (status = 500, description = "Internal server error", body = ProblemDetails) + ), + security(("bearer_auth" = [])) +)] +async fn stop_stack( + RequireAuth(auth): RequireAuth, + State(app_state): State>, + Extension(metadata): Extension, + Path(id): Path, +) -> Result { + permission_guard!(auth, StacksWrite); + + let stack = app_state.compose_service.set_state(id, "stopped").await?; + + let audit = StackStateChangedAudit { + context: AuditContext { + user_id: auth.user_id(), + ip_address: Some(metadata.ip_address.clone()), + user_agent: metadata.user_agent.clone(), + }, + stack_id: stack.id, + name: stack.name.clone(), + new_state: "stopped".to_string(), + }; + if let Err(e) = app_state.audit_service.create_audit_log(&audit).await { + error!("Failed to create audit log: {}", e); + } + + Ok(Json(StackResponse::from(stack))) +} + +/// Restart a compose stack +#[utoipa::path( + tag = "Stacks", + post, + path = "/stacks/{id}/restart", + params( + ("id" = i32, Path, description = "Stack ID") + ), + responses( + (status = 200, description = "Stack restarted", body = StackResponse), + (status = 401, description = "Unauthorized", body = ProblemDetails), + (status = 403, description = "Insufficient permissions", body = ProblemDetails), + (status = 404, description = "Stack not found", body = ProblemDetails), + (status = 500, description = "Internal server error", body = ProblemDetails) + ), + security(("bearer_auth" = [])) +)] +async fn restart_stack( + RequireAuth(auth): RequireAuth, + State(app_state): State>, + Extension(metadata): Extension, + Path(id): Path, +) -> Result { + permission_guard!(auth, StacksWrite); + + let stack = app_state.compose_service.set_state(id, "running").await?; + + let audit = StackStateChangedAudit { + context: AuditContext { + user_id: auth.user_id(), + ip_address: Some(metadata.ip_address.clone()), + user_agent: metadata.user_agent.clone(), + }, + stack_id: stack.id, + name: stack.name.clone(), + new_state: "restarted".to_string(), + }; + if let Err(e) = app_state.audit_service.create_audit_log(&audit).await { + error!("Failed to create audit log: {}", e); + } + + Ok(Json(StackResponse::from(stack))) +} + +/// Pull latest images for a compose stack +#[utoipa::path( + tag = "Stacks", + post, + path = "/stacks/{id}/pull", + params( + ("id" = i32, Path, description = "Stack ID") + ), + responses( + (status = 200, description = "Images pulled", body = StackResponse), + (status = 401, description = "Unauthorized", body = ProblemDetails), + (status = 403, description = "Insufficient permissions", body = ProblemDetails), + (status = 404, description = "Stack not found", body = ProblemDetails), + (status = 500, description = "Internal server error", body = ProblemDetails) + ), + security(("bearer_auth" = [])) +)] +async fn pull_stack( + RequireAuth(auth): RequireAuth, + State(app_state): State>, + Path(id): Path, +) -> Result { + permission_guard!(auth, StacksWrite); + + // For now, just return the current stack state. + // Actual docker compose pull will be implemented with the Docker executor. + let stack = app_state.compose_service.get(id).await?; + Ok(Json(StackResponse::from(stack))) +} + +pub fn configure_routes() -> Router> { + Router::new() + .route("/stacks", get(list_stacks).post(create_stack)) + .route( + "/stacks/{id}", + get(get_stack).patch(update_stack).delete(delete_stack), + ) + .route("/stacks/{id}/deploy", post(deploy_stack)) + .route("/stacks/{id}/stop", post(stop_stack)) + .route("/stacks/{id}/restart", post(restart_stack)) + .route("/stacks/{id}/pull", post(pull_stack)) +} diff --git a/crates/temps-compose/src/handlers/mod.rs b/crates/temps-compose/src/handlers/mod.rs new file mode 100644 index 00000000..38d004aa --- /dev/null +++ b/crates/temps-compose/src/handlers/mod.rs @@ -0,0 +1,6 @@ +pub(crate) mod audit; +pub(crate) mod compose_handler; +pub(crate) mod types; + +pub use compose_handler::configure_routes; +pub use types::{create_compose_app_state, ComposeAppState}; diff --git a/crates/temps-compose/src/handlers/types.rs b/crates/temps-compose/src/handlers/types.rs new file mode 100644 index 00000000..0dc9baa0 --- /dev/null +++ b/crates/temps-compose/src/handlers/types.rs @@ -0,0 +1,19 @@ +use std::sync::Arc; +use temps_core::AuditLogger; + +use crate::services::ComposeService; + +pub struct ComposeAppState { + pub compose_service: Arc, + pub audit_service: Arc, +} + +pub async fn create_compose_app_state( + compose_service: Arc, + audit_service: Arc, +) -> Arc { + Arc::new(ComposeAppState { + compose_service, + audit_service, + }) +} diff --git a/crates/temps-compose/src/lib.rs b/crates/temps-compose/src/lib.rs new file mode 100644 index 00000000..b1132278 --- /dev/null +++ b/crates/temps-compose/src/lib.rs @@ -0,0 +1,7 @@ +pub mod handlers; +pub mod plugin; +pub mod services; + +pub use handlers::{configure_routes, create_compose_app_state, ComposeAppState}; +pub use plugin::ComposePlugin; +pub use services::{ComposeError, ComposeService}; diff --git a/crates/temps-compose/src/plugin.rs b/crates/temps-compose/src/plugin.rs new file mode 100644 index 00000000..d21112af --- /dev/null +++ b/crates/temps-compose/src/plugin.rs @@ -0,0 +1,86 @@ +use std::future::Future; +use std::pin::Pin; +use std::sync::Arc; + +use temps_core::plugin::{ + PluginContext, PluginError, PluginRoutes, ServiceRegistrationContext, TempsPlugin, +}; +use tracing; +use utoipa::openapi::OpenApi; +use utoipa::OpenApi as OpenApiTrait; + +use crate::{ + handlers::{self, create_compose_app_state, ComposeAppState}, + services::ComposeService, +}; + +pub struct ComposePlugin; + +impl ComposePlugin { + pub fn new() -> Self { + Self + } +} + +impl Default for ComposePlugin { + fn default() -> Self { + Self::new() + } +} + +impl TempsPlugin for ComposePlugin { + fn name(&self) -> &'static str { + "compose" + } + + fn register_services<'a>( + &'a self, + context: &'a ServiceRegistrationContext, + ) -> Pin> + Send + 'a>> { + Box::pin(async move { + let db = context.require_service::(); + + let compose_service = Arc::new(ComposeService::new(db.clone())); + context.register_service(compose_service.clone()); + + let audit_service = context.require_service::(); + + let compose_app_state = create_compose_app_state(compose_service, audit_service).await; + context.register_service(compose_app_state); + + tracing::debug!("Compose plugin services registered successfully"); + Ok(()) + }) + } + + fn configure_routes(&self, context: &PluginContext) -> Option { + let compose_app_state = context.require_service::(); + + let compose_routes = handlers::configure_routes().with_state(compose_app_state); + + Some(PluginRoutes { + router: compose_routes, + }) + } + + fn openapi_schema(&self) -> Option { + Some(::openapi()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[tokio::test] + async fn test_compose_plugin_name() { + let plugin = ComposePlugin::new(); + assert_eq!(plugin.name(), "compose"); + } + + #[tokio::test] + async fn test_compose_plugin_default() { + let plugin = ComposePlugin; + assert_eq!(plugin.name(), "compose"); + } +} diff --git a/crates/temps-compose/src/services/compose.rs b/crates/temps-compose/src/services/compose.rs new file mode 100644 index 00000000..b27b691c --- /dev/null +++ b/crates/temps-compose/src/services/compose.rs @@ -0,0 +1,310 @@ +use chrono::Utc; +use sea_orm::{ActiveModelTrait, DatabaseConnection, EntityTrait, PaginatorTrait, QueryOrder, Set}; +use std::sync::Arc; +use temps_entities::compose_stacks; +use thiserror::Error; +use tracing::debug; + +#[derive(Error, Debug)] +pub enum ComposeError { + #[error("Database error: {0}")] + Database(sea_orm::DbErr), + + #[error("Stack {stack_id} not found")] + NotFound { stack_id: i32 }, + + #[error("Validation error: {message}")] + Validation { message: String }, + + #[error("Docker Compose error for stack {stack_id}: {reason}")] + Docker { stack_id: i32, reason: String }, + + #[error("Stack '{name}' is currently {state}, cannot perform {operation}")] + InvalidState { + name: String, + state: String, + operation: String, + }, +} + +impl From for ComposeError { + fn from(error: sea_orm::DbErr) -> Self { + match error { + sea_orm::DbErr::RecordNotFound(msg) => ComposeError::NotFound { + stack_id: msg.parse().unwrap_or(0), + }, + sea_orm::DbErr::RecordNotInserted => ComposeError::Validation { + message: format!("Duplicate record: {}", error), + }, + _ => ComposeError::Database(error), + } + } +} + +pub struct ComposeService { + db: Arc, +} + +impl ComposeService { + pub fn new(db: Arc) -> Self { + Self { db } + } + + pub async fn list( + &self, + page: Option, + page_size: Option, + ) -> Result<(Vec, u64), ComposeError> { + let page = page.unwrap_or(1); + let page_size = std::cmp::min(page_size.unwrap_or(20), 100); + let paginator = compose_stacks::Entity::find() + .order_by_desc(compose_stacks::Column::CreatedAt) + .paginate(self.db.as_ref(), page_size); + let total = paginator.num_items().await?; + let items = paginator.fetch_page(page - 1).await?; + Ok((items, total)) + } + + pub async fn get(&self, id: i32) -> Result { + compose_stacks::Entity::find_by_id(id) + .one(self.db.as_ref()) + .await? + .ok_or(ComposeError::NotFound { stack_id: id }) + } + + pub async fn create( + &self, + name: String, + description: Option, + compose_content: String, + env_content: Option, + node_id: Option, + ) -> Result { + if name.is_empty() { + return Err(ComposeError::Validation { + message: "Stack name cannot be empty".into(), + }); + } + + if compose_content.is_empty() { + return Err(ComposeError::Validation { + message: "Compose content cannot be empty".into(), + }); + } + + let now = Utc::now(); + let model = compose_stacks::ActiveModel { + name: Set(name.clone()), + description: Set(description), + compose_content: Set(compose_content), + env_content: Set(env_content), + node_id: Set(node_id), + state: Set("stopped".to_string()), + created_at: Set(now), + updated_at: Set(now), + ..Default::default() + } + .insert(self.db.as_ref()) + .await?; + + debug!( + "Created compose stack '{}' with id {}", + model.name, model.id + ); + Ok(model) + } + + pub async fn update( + &self, + id: i32, + name: Option, + description: Option>, + compose_content: Option, + env_content: Option>, + ) -> Result { + let stack = self.get(id).await?; + let mut active: compose_stacks::ActiveModel = stack.into(); + + if let Some(name) = name { + if name.is_empty() { + return Err(ComposeError::Validation { + message: "Stack name cannot be empty".into(), + }); + } + active.name = Set(name); + } + + if let Some(desc) = description { + active.description = Set(desc); + } + + if let Some(content) = compose_content { + if content.is_empty() { + return Err(ComposeError::Validation { + message: "Compose content cannot be empty".into(), + }); + } + active.compose_content = Set(content); + } + + if let Some(env) = env_content { + active.env_content = Set(env); + } + + active.updated_at = Set(Utc::now()); + + let model = active.update(self.db.as_ref()).await?; + debug!("Updated compose stack '{}' (id: {})", model.name, model.id); + Ok(model) + } + + pub async fn delete(&self, id: i32) -> Result<(), ComposeError> { + let stack = self.get(id).await?; + + if stack.state == "running" { + return Err(ComposeError::InvalidState { + name: stack.name, + state: stack.state, + operation: "delete".to_string(), + }); + } + + compose_stacks::Entity::delete_by_id(id) + .exec(self.db.as_ref()) + .await?; + + debug!("Deleted compose stack id {}", id); + Ok(()) + } + + pub async fn set_state( + &self, + id: i32, + state: &str, + ) -> Result { + let stack = self.get(id).await?; + let mut active: compose_stacks::ActiveModel = stack.into(); + active.state = Set(state.to_string()); + active.updated_at = Set(Utc::now()); + let model = active.update(self.db.as_ref()).await?; + Ok(model) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use sea_orm::{DatabaseBackend, MockDatabase, MockExecResult}; + + fn mock_stack() -> compose_stacks::Model { + compose_stacks::Model { + id: 1, + name: "my-stack".to_string(), + description: Some("Test stack".to_string()), + compose_content: "version: '3'\nservices:\n web:\n image: nginx".to_string(), + env_content: None, + node_id: None, + state: "stopped".to_string(), + created_at: Utc::now(), + updated_at: Utc::now(), + } + } + + #[tokio::test] + async fn test_get_stack_success() { + let db = MockDatabase::new(DatabaseBackend::Postgres) + .append_query_results(vec![vec![mock_stack()]]) + .into_connection(); + + let service = ComposeService::new(Arc::new(db)); + let result = service.get(1).await; + assert!(result.is_ok()); + let stack = result.unwrap(); + assert_eq!(stack.name, "my-stack"); + assert_eq!(stack.state, "stopped"); + } + + #[tokio::test] + async fn test_get_stack_not_found() { + let db = MockDatabase::new(DatabaseBackend::Postgres) + .append_query_results(vec![Vec::::new()]) + .into_connection(); + + let service = ComposeService::new(Arc::new(db)); + let result = service.get(999).await; + assert!(matches!( + result.unwrap_err(), + ComposeError::NotFound { stack_id: 999 } + )); + } + + #[tokio::test] + async fn test_create_stack_empty_name() { + let db = MockDatabase::new(DatabaseBackend::Postgres).into_connection(); + let service = ComposeService::new(Arc::new(db)); + + let result = service + .create("".to_string(), None, "content".to_string(), None, None) + .await; + assert!(matches!( + result.unwrap_err(), + ComposeError::Validation { .. } + )); + } + + #[tokio::test] + async fn test_create_stack_empty_content() { + let db = MockDatabase::new(DatabaseBackend::Postgres).into_connection(); + let service = ComposeService::new(Arc::new(db)); + + let result = service + .create("test".to_string(), None, "".to_string(), None, None) + .await; + assert!(matches!( + result.unwrap_err(), + ComposeError::Validation { .. } + )); + } + + #[tokio::test] + async fn test_delete_running_stack_fails() { + let running_stack = compose_stacks::Model { + state: "running".to_string(), + ..mock_stack() + }; + + let db = MockDatabase::new(DatabaseBackend::Postgres) + .append_query_results(vec![vec![running_stack]]) + .into_connection(); + + let service = ComposeService::new(Arc::new(db)); + let result = service.delete(1).await; + assert!(matches!( + result.unwrap_err(), + ComposeError::InvalidState { .. } + )); + } + + #[tokio::test] + async fn test_create_stack_success() { + let db = MockDatabase::new(DatabaseBackend::Postgres) + .append_query_results(vec![vec![mock_stack()]]) + .append_exec_results(vec![MockExecResult { + last_insert_id: 1, + rows_affected: 1, + }]) + .into_connection(); + + let service = ComposeService::new(Arc::new(db)); + let result = service + .create( + "my-stack".to_string(), + Some("Test".to_string()), + "version: '3'".to_string(), + None, + None, + ) + .await; + assert!(result.is_ok()); + } +} diff --git a/crates/temps-compose/src/services/mod.rs b/crates/temps-compose/src/services/mod.rs new file mode 100644 index 00000000..1235bc7a --- /dev/null +++ b/crates/temps-compose/src/services/mod.rs @@ -0,0 +1,2 @@ +mod compose; +pub use compose::{ComposeError, ComposeService}; diff --git a/crates/temps-entities/src/compose_stacks.rs b/crates/temps-entities/src/compose_stacks.rs new file mode 100644 index 00000000..23d94a43 --- /dev/null +++ b/crates/temps-entities/src/compose_stacks.rs @@ -0,0 +1,25 @@ +use sea_orm::entity::prelude::*; +use serde::{Deserialize, Serialize}; +use temps_core::DBDateTime; + +#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Eq, Serialize, Deserialize)] +#[sea_orm(table_name = "compose_stacks")] +pub struct Model { + #[sea_orm(primary_key)] + pub id: i32, + pub name: String, + pub description: Option, + #[sea_orm(column_type = "Text")] + pub compose_content: String, + #[sea_orm(column_type = "Text", nullable)] + pub env_content: Option, + pub node_id: Option, + pub state: String, + pub created_at: DBDateTime, + pub updated_at: DBDateTime, +} + +#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] +pub enum Relation {} + +impl ActiveModelBehavior for ActiveModel {} diff --git a/crates/temps-entities/src/lib.rs b/crates/temps-entities/src/lib.rs index 07fd1b70..7b125910 100644 --- a/crates/temps-entities/src/lib.rs +++ b/crates/temps-entities/src/lib.rs @@ -9,6 +9,7 @@ pub mod audit_logs; pub mod backup_schedules; pub mod backups; pub mod challenge_sessions; +pub mod compose_stacks; pub mod cron_executions; pub mod crons; pub mod custom_routes; diff --git a/crates/temps-migrations/src/migration/m20260321_000001_create_compose_stacks.rs b/crates/temps-migrations/src/migration/m20260321_000001_create_compose_stacks.rs new file mode 100644 index 00000000..bdcc753d --- /dev/null +++ b/crates/temps-migrations/src/migration/m20260321_000001_create_compose_stacks.rs @@ -0,0 +1,89 @@ +use sea_orm_migration::prelude::*; + +#[derive(DeriveMigrationName)] +pub struct Migration; + +#[async_trait::async_trait] +impl MigrationTrait for Migration { + async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> { + manager + .create_table( + Table::create() + .table(Alias::new("compose_stacks")) + .if_not_exists() + .col( + ColumnDef::new(Alias::new("id")) + .integer() + .not_null() + .auto_increment() + .primary_key(), + ) + .col( + ColumnDef::new(Alias::new("name")) + .string_len(255) + .not_null(), + ) + .col( + ColumnDef::new(Alias::new("description")) + .string_len(1024) + .null(), + ) + .col( + ColumnDef::new(Alias::new("compose_content")) + .text() + .not_null(), + ) + .col(ColumnDef::new(Alias::new("env_content")).text().null()) + .col(ColumnDef::new(Alias::new("node_id")).integer().null()) + .col( + ColumnDef::new(Alias::new("state")) + .string_len(32) + .not_null() + .default("stopped"), + ) + .col( + ColumnDef::new(Alias::new("created_at")) + .timestamp_with_time_zone() + .not_null() + .default(Expr::current_timestamp()), + ) + .col( + ColumnDef::new(Alias::new("updated_at")) + .timestamp_with_time_zone() + .not_null() + .default(Expr::current_timestamp()), + ) + .to_owned(), + ) + .await?; + + manager + .create_index( + Index::create() + .name("idx_compose_stacks_state") + .table(Alias::new("compose_stacks")) + .col(Alias::new("state")) + .to_owned(), + ) + .await?; + + manager + .create_index( + Index::create() + .name("idx_compose_stacks_node_id") + .table(Alias::new("compose_stacks")) + .col(Alias::new("node_id")) + .to_owned(), + ) + .await?; + + Ok(()) + } + + async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> { + manager + .drop_table(Table::drop().table(Alias::new("compose_stacks")).to_owned()) + .await?; + Ok(()) + } +} diff --git a/crates/temps-migrations/src/migration/mod.rs b/crates/temps-migrations/src/migration/mod.rs index f6311ea3..e7604305 100644 --- a/crates/temps-migrations/src/migration/mod.rs +++ b/crates/temps-migrations/src/migration/mod.rs @@ -47,6 +47,7 @@ mod m20260313_000002_add_service_error_message; mod m20260314_000001_update_environment_route_trigger; mod m20260315_000001_add_last_activity_at_to_environments; mod m20260315_000002_create_error_alert_rules; +mod m20260321_000001_create_compose_stacks; pub struct Migrator; @@ -101,6 +102,7 @@ impl MigratorTrait for Migrator { Box::new(m20260314_000001_update_environment_route_trigger::Migration), Box::new(m20260315_000001_add_last_activity_at_to_environments::Migration), Box::new(m20260315_000002_create_error_alert_rules::Migration), + Box::new(m20260321_000001_create_compose_stacks::Migration), ] } } diff --git a/web/src/App.tsx b/web/src/App.tsx index edc6291c..b1ee76de 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -180,6 +180,9 @@ const AiGateway = lazy(() => default: m.AiGatewayPage, })) ) +const Stacks = lazy(() => + import('./pages/Stacks').then((m) => ({ default: m.Stacks })) +) // Loading component const PageLoader = () => ( @@ -287,6 +290,8 @@ const FullAppRoutes = () => { } /> } /> + {/* Stacks - Docker Compose management */} + } /> {/* AI Gateway - top-level platform feature */} } /> {/* Email - top-level platform feature */} diff --git a/web/src/api/stacks.ts b/web/src/api/stacks.ts new file mode 100644 index 00000000..cf5dedf3 --- /dev/null +++ b/web/src/api/stacks.ts @@ -0,0 +1,97 @@ +import { client } from './client/client.gen' + +export interface Stack { + id: number + name: string + description: string | null + compose_content: string + env_content: string | null + node_id: number | null + state: string + created_at: string + updated_at: string +} + +export interface PaginatedStacks { + items: Stack[] + total: number +} + +export interface CreateStackRequest { + name: string + description?: string | null + compose_content: string + env_content?: string | null + node_id?: number | null +} + +export interface UpdateStackRequest { + name?: string + description?: string | null + compose_content?: string + env_content?: string | null +} + +export async function listStacks(page = 1, pageSize = 20) { + return client.get({ + url: '/stacks', + query: { page, page_size: pageSize }, + }) +} + +export async function getStack(id: number) { + return client.get({ + url: '/stacks/{id}', + path: { id }, + }) +} + +export async function createStack(body: CreateStackRequest) { + return client.post({ + url: '/stacks', + body, + }) +} + +export async function updateStack(id: number, body: UpdateStackRequest) { + return client.patch({ + url: '/stacks/{id}', + path: { id }, + body, + }) +} + +export async function deleteStack(id: number) { + return client.delete({ + url: '/stacks/{id}', + path: { id }, + }) +} + +export async function deployStack(id: number) { + return client.post({ + url: '/stacks/{id}/deploy', + path: { id }, + }) +} + +export async function stopStack(id: number) { + return client.post({ + url: '/stacks/{id}/stop', + path: { id }, + }) +} + +export async function restartStack(id: number) { + return client.post({ + url: '/stacks/{id}/restart', + path: { id }, + }) +} + +export async function pullStack(id: number) { + return client.post({ + url: '/stacks/{id}/pull', + path: { id }, + }) +} diff --git a/web/src/components/dashboard/Sidebar.tsx b/web/src/components/dashboard/Sidebar.tsx index 2f309728..0b9a8683 100644 --- a/web/src/components/dashboard/Sidebar.tsx +++ b/web/src/components/dashboard/Sidebar.tsx @@ -19,6 +19,7 @@ import { BadgeCheck, ChevronsUpDown, Folder, + Layers, LogOut, MoreHorizontal, ScrollText, @@ -69,6 +70,11 @@ const navMainAll = [ url: '/monitoring', icon: Activity, }, + { + title: 'Stacks', + url: '/stacks', + icon: Layers, + }, ] // Main navigation items available in demo mode (restricted) diff --git a/web/src/components/stacks/StacksList.tsx b/web/src/components/stacks/StacksList.tsx new file mode 100644 index 00000000..1596be13 --- /dev/null +++ b/web/src/components/stacks/StacksList.tsx @@ -0,0 +1,504 @@ +import { + createStack, + deleteStack, + deployStack, + listStacks, + restartStack, + stopStack, + updateStack, + type CreateStackRequest, + type Stack, + type UpdateStackRequest, +} from '@/api/stacks' +import { Badge } from '@/components/ui/badge' +import { Button } from '@/components/ui/button' +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from '@/components/ui/card' +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog' +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from '@/components/ui/dropdown-menu' +import { Input } from '@/components/ui/input' +import { Label } from '@/components/ui/label' +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from '@/components/ui/table' +import { Textarea } from '@/components/ui/textarea' +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' +import { + Layers, + Loader2, + MoreHorizontal, + Pause, + Play, + Plus, + RefreshCw, + Trash2, +} from 'lucide-react' +import { useState } from 'react' +import { toast } from 'sonner' + +function stateVariant(state: string) { + switch (state) { + case 'running': + return 'default' + case 'stopped': + return 'secondary' + case 'error': + return 'destructive' + default: + return 'outline' + } +} + +function CreateStackDialog({ + open, + onOpenChange, +}: { + open: boolean + onOpenChange: (open: boolean) => void +}) { + const queryClient = useQueryClient() + const [form, setForm] = useState({ + name: '', + compose_content: '', + }) + + const createMutation = useMutation({ + mutationFn: () => createStack(form), + meta: { errorTitle: 'Failed to create stack' }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['stacks'] }) + onOpenChange(false) + setForm({ name: '', compose_content: '' }) + toast.success('Stack created successfully') + }, + }) + + return ( + + + + Create Stack + + Create a new Docker Compose stack. Paste your docker-compose.yml + content below. + + +
+
+ + setForm({ ...form, name: e.target.value })} + /> +
+
+ + + setForm({ + ...form, + description: e.target.value || undefined, + }) + } + /> +
+
+ +