From 60145f7b212fb2e94cdc0cfaf6ec7dd81b46b417 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tom=C3=A1=C5=A1=20Dvo=C5=99=C3=A1k?= <150935816+Dvorinka@users.noreply.github.com> Date: Sat, 10 Oct 2026 01:17:32 +0200 Subject: [PATCH] feat(environments): env CRUD, workspace switcher, CLI + MCP parity (#68) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Environment lanes existed as seeded rows (production/preview/development) with a per-service environment label, but there was no way to list, create, or delete them. Adds the management surface and the switcher: - GET /projects/:id/environments with live service counts (sqlc) - POST /projects/:id/environments — lowercase dns-label names, 1-50 chars, unique per project (409 on dupe) - DELETE /environments/:id — refuses while it holds services (409) - Workspace service list gains env filter chips with counts plus an inline "+ env" creator - CLI: containr environments list|create|delete (--yes on delete); MCP: containr_environments_list/create/delete (delete needs confirm) - OpenAPI: new Environments tag + ProjectEnvironment schema Live-verified: list counts, name validation, dupe 409, empty delete, non-empty 409, CLI list. Generated with [Devin](https://devin.ai) Co-authored-by: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- ROADMAP2.md | 6 +- app/backend/internal/api/environments.go | 140 +++++++++++++++++ app/backend/internal/api/routes.go | 3 + .../internal/cli/commands/environments.go | 97 ++++++++++++ app/backend/internal/cli/root.go | 1 + .../internal/database/sqlcdb/projects.sql.go | 96 ++++++++++++ .../internal/database/sqlcdb/querier.go | 4 + app/backend/internal/mcpserver/server.go | 5 + app/backend/sqlc/queries/projects.sql | 18 +++ .../workspace/pages/ProjectWorkspacePage.tsx | 96 +++++++++++- app/frontend/src/generated/api-types.ts | 143 ++++++++++++++++++ app/frontend/src/lib/api-client.ts | 28 ++++ docs/api/openapi.yaml | 96 ++++++++++++ 13 files changed, 727 insertions(+), 6 deletions(-) create mode 100644 app/backend/internal/api/environments.go create mode 100644 app/backend/internal/cli/commands/environments.go diff --git a/ROADMAP2.md b/ROADMAP2.md index a4aa3cb..d737b7f 100644 --- a/ROADMAP2.md +++ b/ROADMAP2.md @@ -523,7 +523,11 @@ and wired refs, one click or one CLI call. - [x] **Preview environments runtime** — previews are real service clones: branch override, queued deployment, node-domain hostnames, TTL sweeper, promote = redeploy of the source at the preview branch. -- [ ] **Environments** — env switcher, per-env vars/domains, promote. +- [x] **Environments** — CRUD (`GET/POST /projects/:id/environments`, + `DELETE /environments/:id`), switcher chips with service counts in the + workspace, CLI `environments` + MCP parity. Per-env vars/domains ride the + service's own scope (one env per service); promote ships via preview + promote. - [ ] **sqlc port** — ~140 raw `database/sql` sites → `sqlc/queries/`. - [ ] **Tests** — canvas/wizard frontend coverage, deployment/template/ webhook table tests, Playwright e2e golden path. diff --git a/app/backend/internal/api/environments.go b/app/backend/internal/api/environments.go new file mode 100644 index 0000000..24d3c11 --- /dev/null +++ b/app/backend/internal/api/environments.go @@ -0,0 +1,140 @@ +package api + +// Project environments — the named lanes services deploy into +// (production/development/preview are seeded; custom names allowed). + +import ( + "context" + "net/http" + "regexp" + "strings" + "time" + + "containr/internal/database" + "containr/internal/database/sqlcdb" + + "github.com/gin-gonic/gin" + "github.com/google/uuid" +) + +var envNamePattern = regexp.MustCompile(`^[a-z0-9]([a-z0-9-]{0,48}[a-z0-9])?$`) + +type environmentDTO struct { + ID uuid.UUID `json:"id"` + Name string `json:"name"` + ProjectID uuid.UUID `json:"project_id"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + ServiceCount int64 `json:"service_count"` +} + +func toEnvironmentDTO(id uuid.UUID, name string, projectID uuid.UUID, created, updated time.Time, count int64) environmentDTO { + return environmentDTO{ID: id, Name: name, ProjectID: projectID, CreatedAt: created, UpdatedAt: updated, ServiceCount: count} +} + +// handleListEnvironments returns the project's environments with live +// service counts — the data behind the env switcher. +func handleListEnvironments(c *gin.Context) { + db := c.MustGet("db").(*database.DB) + projectID, err := uuid.Parse(c.Param("id")) + if err != nil { + respondError(c, http.StatusBadRequest, "VALIDATION", "Invalid project ID") + return + } + if exists, allowed := projectReadAccess(c, db, projectID); !exists || !allowed { + respondError(c, http.StatusNotFound, "NOT_FOUND", "Project not found") + return + } + rows, err := sqlcdb.New(db.DB).ListProjectEnvironments(context.Background(), projectID) + if err != nil { + respondError(c, http.StatusInternalServerError, "INTERNAL_ERROR", "Failed to list environments") + return + } + envs := make([]environmentDTO, 0, len(rows)) + for _, r := range rows { + envs = append(envs, toEnvironmentDTO(r.ID, r.Name, r.ProjectID, r.CreatedAt.Time, r.UpdatedAt.Time, r.ServiceCount)) + } + c.JSON(http.StatusOK, gin.H{"environments": envs}) +} + +// handleCreateEnvironment adds a named environment lane to the project. +func handleCreateEnvironment(c *gin.Context) { + db := c.MustGet("db").(*database.DB) + projectID, err := uuid.Parse(c.Param("id")) + if err != nil { + respondError(c, http.StatusBadRequest, "VALIDATION", "Invalid project ID") + return + } + userID, exists := c.Get("user_id") + if !exists || !ownsProject(db, projectID, userID.(string), contextIsAdmin(c)) { + respondError(c, http.StatusForbidden, "FORBIDDEN", "Access denied") + return + } + var req struct { + Name string `json:"name" binding:"required"` + } + if err := c.ShouldBindJSON(&req); err != nil { + respondError(c, http.StatusBadRequest, "VALIDATION", "name is required") + return + } + name := strings.ToLower(strings.TrimSpace(req.Name)) + if !envNamePattern.MatchString(name) { + respondError(c, http.StatusBadRequest, "VALIDATION", "Name must be lowercase alphanumeric with dashes, 1-50 chars") + return + } + q := sqlcdb.New(db.DB) + if _, err := q.GetProjectEnvironmentByName(context.Background(), sqlcdb.GetProjectEnvironmentByNameParams{ + ProjectID: projectID, Name: name, + }); err == nil { + respondError(c, http.StatusConflict, "CONFLICT", "Environment already exists") + return + } + if err := q.InsertProjectEnvironment(context.Background(), sqlcdb.InsertProjectEnvironmentParams{ + Name: name, ProjectID: projectID, + }); err != nil { + respondError(c, http.StatusInternalServerError, "INTERNAL_ERROR", "Failed to create environment") + return + } + env, _ := q.GetProjectEnvironmentByName(context.Background(), sqlcdb.GetProjectEnvironmentByNameParams{ + ProjectID: projectID, Name: name, + }) + LogAuditWithRequest(c, "project", projectID.String(), "environment.create", map[string]interface{}{"name": name}) + c.JSON(http.StatusCreated, gin.H{"environment": toEnvironmentDTO(env.ID, env.Name, env.ProjectID, env.CreatedAt.Time, env.UpdatedAt.Time, 0)}) +} + +// handleDeleteEnvironment removes an environment that holds no services. +func handleDeleteEnvironment(c *gin.Context) { + db := c.MustGet("db").(*database.DB) + envID, err := uuid.Parse(c.Param("id")) + if err != nil { + respondError(c, http.StatusBadRequest, "VALIDATION", "Invalid environment ID") + return + } + userID, exists := c.Get("user_id") + if !exists { + respondError(c, http.StatusUnauthorized, "UNAUTHORIZED", "User not authenticated") + return + } + var projectID uuid.UUID + if err := db.QueryRow(`SELECT project_id FROM environments WHERE id = $1`, envID).Scan(&projectID); err != nil { + respondError(c, http.StatusNotFound, "NOT_FOUND", "Environment not found") + return + } + if !ownsProject(db, projectID, userID.(string), contextIsAdmin(c)) { + respondError(c, http.StatusForbidden, "FORBIDDEN", "Access denied") + return + } + q := sqlcdb.New(db.DB) + if n, err := q.CountEnvironmentServices(context.Background(), envID); err == nil && n > 0 { + respondError(c, http.StatusConflict, "CONFLICT", "Environment still contains services — move them first") + return + } + if err := q.DeleteProjectEnvironment(context.Background(), sqlcdb.DeleteProjectEnvironmentParams{ + ID: envID, ProjectID: projectID, + }); err != nil { + respondError(c, http.StatusInternalServerError, "INTERNAL_ERROR", "Failed to delete environment") + return + } + LogAuditWithRequest(c, "project", projectID.String(), "environment.delete", map[string]interface{}{"environment_id": envID.String()}) + c.JSON(http.StatusOK, gin.H{"message": "Environment deleted"}) +} diff --git a/app/backend/internal/api/routes.go b/app/backend/internal/api/routes.go index b875b79..c2218c8 100644 --- a/app/backend/internal/api/routes.go +++ b/app/backend/internal/api/routes.go @@ -307,6 +307,7 @@ func SetupRoutes(router *gin.Engine, db *database.DB, redis *database.Redis, cfg read.GET("/projects", handleGetProjects) read.GET("/projects/:id", handleGetProject) read.GET("/projects/:id/services", handleGetServices) + read.GET("/projects/:id/environments", handleListEnvironments) read.GET("/projects/:id/preview-environments", handleGetPreviewEnvironments) read.GET("/projects/:id/security/history", securityHandler.GetProjectSecurityHistory) read.GET("/projects/:id/vulnerabilities", securityHandler.GetVulnerabilities) @@ -476,6 +477,8 @@ func SetupRoutes(router *gin.Engine, db *database.DB, redis *database.Redis, cfg authed.POST("/databases/:id/backups/import", databaseHandler.ImportBackup) authed.GET("/databases/:id/backups/:bid/download", databaseHandler.DownloadBackup) + authed.POST("/projects/:id/environments", handleCreateEnvironment) + authed.DELETE("/environments/:id", handleDeleteEnvironment) authed.POST("/projects/:id/preview-environments", handleCreatePreviewEnvironment) authed.PUT("/preview-environments/:id", handleUpdatePreviewEnvironment) authed.DELETE("/preview-environments/:id", handleDeletePreviewEnvironment) diff --git a/app/backend/internal/cli/commands/environments.go b/app/backend/internal/cli/commands/environments.go new file mode 100644 index 0000000..7ded692 --- /dev/null +++ b/app/backend/internal/cli/commands/environments.go @@ -0,0 +1,97 @@ +package commands + +import ( + "fmt" + + "github.com/spf13/cobra" +) + +// EnvironmentsCmd manages a project's environment lanes +// (production/development/preview are seeded; custom names allowed). +var EnvironmentsCmd = &cobra.Command{ + Use: "environments", + Aliases: []string{"env", "envs"}, + Short: "Manage project environments", +} + +var envListCmd = &cobra.Command{ + Use: "list", + Short: "List environments in a project", + RunE: func(cmd *cobra.Command, args []string) error { + c, err := client() + if err != nil { + return err + } + project, _ := cmd.Flags().GetString("project") + if project == "" { + return fmt.Errorf("--project is required") + } + data, err := c.Do("GET", "/projects/"+project+"/environments", nil) + if err != nil { + return err + } + items, err := unwrapList(data, "environments") + if err != nil { + return err + } + rows := make([][]string, 0, len(items)) + for _, e := range items { + rows = append(rows, []string{ + str(e, "id"), str(e, "name"), str(e, "service_count"), relTime(e, "created_at"), + }) + } + printRows(data, []string{"ID", "NAME", "SERVICES", "CREATED"}, rows) + return nil + }, +} + +var envCreateCmd = &cobra.Command{ + Use: "create ", + Short: "Create an environment in a project", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + c, err := client() + if err != nil { + return err + } + project, _ := cmd.Flags().GetString("project") + if project == "" { + return fmt.Errorf("--project is required") + } + data, err := c.Do("POST", "/projects/"+project+"/environments", map[string]interface{}{ + "name": args[0], + }) + if err != nil { + return err + } + PrintRaw(data) + return nil + }, +} + +var envDeleteCmd = &cobra.Command{ + Use: "delete ", + Short: "Delete an empty environment", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + if !Confirm("Delete environment " + args[0] + "?") { + return &APIError{Message: "aborted (pass --yes to skip confirmation)", ExitCode: ExitError} + } + c, err := client() + if err != nil { + return err + } + _, err = c.Do("DELETE", "/environments/"+args[0], nil) + if err != nil { + return err + } + fmt.Println("Environment deleted") + return nil + }, +} + +func init() { + envListCmd.Flags().String("project", "", "project ID (required)") + envCreateCmd.Flags().String("project", "", "project ID (required)") + EnvironmentsCmd.AddCommand(envListCmd, envCreateCmd, envDeleteCmd) +} diff --git a/app/backend/internal/cli/root.go b/app/backend/internal/cli/root.go index 4c3b693..a0ebca9 100644 --- a/app/backend/internal/cli/root.go +++ b/app/backend/internal/cli/root.go @@ -95,6 +95,7 @@ func init() { rootCmd.AddCommand(commands.InvitesCmd) rootCmd.AddCommand(commands.UpCmd) rootCmd.AddCommand(commands.MigrateCmd) + rootCmd.AddCommand(commands.EnvironmentsCmd) } func initConfig() { diff --git a/app/backend/internal/database/sqlcdb/projects.sql.go b/app/backend/internal/database/sqlcdb/projects.sql.go index 7625f49..8bfb4fd 100644 --- a/app/backend/internal/database/sqlcdb/projects.sql.go +++ b/app/backend/internal/database/sqlcdb/projects.sql.go @@ -12,6 +12,17 @@ import ( "github.com/google/uuid" ) +const countEnvironmentServices = `-- name: CountEnvironmentServices :one +SELECT COUNT(*) FROM services WHERE environment_id = $1 +` + +func (q *Queries) CountEnvironmentServices(ctx context.Context, environmentID uuid.UUID) (int64, error) { + row := q.db.QueryRowContext(ctx, countEnvironmentServices, environmentID) + var count int64 + err := row.Scan(&count) + return count, err +} + const countProjectsByUser = `-- name: CountProjectsByUser :one SELECT COUNT(*)::bigint AS total FROM projects p @@ -86,6 +97,20 @@ func (q *Queries) DeleteProjectByID(ctx context.Context, projectID uuid.UUID) (i return result.RowsAffected() } +const deleteProjectEnvironment = `-- name: DeleteProjectEnvironment :exec +DELETE FROM environments WHERE id = $1 AND project_id = $2 +` + +type DeleteProjectEnvironmentParams struct { + ID uuid.UUID `json:"id"` + ProjectID uuid.UUID `json:"project_id"` +} + +func (q *Queries) DeleteProjectEnvironment(ctx context.Context, arg DeleteProjectEnvironmentParams) error { + _, err := q.db.ExecContext(ctx, deleteProjectEnvironment, arg.ID, arg.ProjectID) + return err +} + const getProjectByIDForUser = `-- name: GetProjectByIDForUser :one SELECT p.id, p.name, p.description, p.owner_id, p.is_approved, p.created_at, p.updated_at FROM projects p @@ -123,6 +148,30 @@ func (q *Queries) GetProjectByIDForUser(ctx context.Context, arg GetProjectByIDF return i, err } +const getProjectEnvironmentByName = `-- name: GetProjectEnvironmentByName :one +SELECT id, name, project_id, created_at, updated_at +FROM environments +WHERE project_id = $1 AND name = $2 +` + +type GetProjectEnvironmentByNameParams struct { + ProjectID uuid.UUID `json:"project_id"` + Name string `json:"name"` +} + +func (q *Queries) GetProjectEnvironmentByName(ctx context.Context, arg GetProjectEnvironmentByNameParams) (Environment, error) { + row := q.db.QueryRowContext(ctx, getProjectEnvironmentByName, arg.ProjectID, arg.Name) + var i Environment + err := row.Scan( + &i.ID, + &i.Name, + &i.ProjectID, + &i.CreatedAt, + &i.UpdatedAt, + ) + return i, err +} + const getProjectOwnerByID = `-- name: GetProjectOwnerByID :one SELECT owner_id FROM projects @@ -212,6 +261,53 @@ func (q *Queries) ListPendingProjects(ctx context.Context) ([]Project, error) { return items, nil } +const listProjectEnvironments = `-- name: ListProjectEnvironments :many +SELECT e.id, e.name, e.project_id, e.created_at, e.updated_at, + (SELECT COUNT(*) FROM services s WHERE s.environment_id = e.id) AS service_count +FROM environments e +WHERE e.project_id = $1 +ORDER BY e.created_at +` + +type ListProjectEnvironmentsRow struct { + ID uuid.UUID `json:"id"` + Name string `json:"name"` + ProjectID uuid.UUID `json:"project_id"` + CreatedAt sql.NullTime `json:"created_at"` + UpdatedAt sql.NullTime `json:"updated_at"` + ServiceCount int64 `json:"service_count"` +} + +func (q *Queries) ListProjectEnvironments(ctx context.Context, projectID uuid.UUID) ([]ListProjectEnvironmentsRow, error) { + rows, err := q.db.QueryContext(ctx, listProjectEnvironments, projectID) + if err != nil { + return nil, err + } + defer rows.Close() + items := []ListProjectEnvironmentsRow{} + for rows.Next() { + var i ListProjectEnvironmentsRow + if err := rows.Scan( + &i.ID, + &i.Name, + &i.ProjectID, + &i.CreatedAt, + &i.UpdatedAt, + &i.ServiceCount, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Close(); err != nil { + return nil, err + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + const listProjectsWithStatsByUser = `-- name: ListProjectsWithStatsByUser :many SELECT diff --git a/app/backend/internal/database/sqlcdb/querier.go b/app/backend/internal/database/sqlcdb/querier.go index 656dc27..82844a7 100644 --- a/app/backend/internal/database/sqlcdb/querier.go +++ b/app/backend/internal/database/sqlcdb/querier.go @@ -17,6 +17,7 @@ type Querier interface { CompleteCommand(ctx context.Context, arg CompleteCommandParams) (AgentCommand, error) CountDatabaseServicesByUserAndName(ctx context.Context, arg CountDatabaseServicesByUserAndNameParams) (int64, error) CountDatabasesUsingBackupTarget(ctx context.Context, targetID sql.NullString) (int64, error) + CountEnvironmentServices(ctx context.Context, environmentID uuid.UUID) (int64, error) CountProjectsByUser(ctx context.Context, arg CountProjectsByUserParams) (int64, error) CountServicesByProjectAndName(ctx context.Context, arg CountServicesByProjectAndNameParams) (int64, error) CountUnreadNotificationsByUser(ctx context.Context, userID uuid.UUID) (int64, error) @@ -45,6 +46,7 @@ type Querier interface { DeleteDatabaseServiceByIDAndUser(ctx context.Context, arg DeleteDatabaseServiceByIDAndUserParams) error DeleteOutboundWebhookByIDAndUser(ctx context.Context, arg DeleteOutboundWebhookByIDAndUserParams) error DeleteProjectByID(ctx context.Context, projectID uuid.UUID) (int64, error) + DeleteProjectEnvironment(ctx context.Context, arg DeleteProjectEnvironmentParams) error DeleteServiceContainersOnAgent(ctx context.Context, arg DeleteServiceContainersOnAgentParams) error DeleteUserInvite(ctx context.Context, id uuid.UUID) error DeleteUserTemplate(ctx context.Context, arg DeleteUserTemplateParams) (int64, error) @@ -65,6 +67,7 @@ type Querier interface { GetOutboundWebhookByID(ctx context.Context, id uuid.UUID) (OutboundWebhook, error) GetOutboundWebhookByIDAndUser(ctx context.Context, arg GetOutboundWebhookByIDAndUserParams) (OutboundWebhook, error) GetProjectByIDForUser(ctx context.Context, arg GetProjectByIDForUserParams) (Project, error) + GetProjectEnvironmentByName(ctx context.Context, arg GetProjectEnvironmentByNameParams) (Environment, error) GetProjectOwnerByID(ctx context.Context, projectID uuid.UUID) (uuid.UUID, error) GetProjectOwnerID(ctx context.Context, id uuid.UUID) (uuid.UUID, error) GetProjectRoleForUser(ctx context.Context, arg GetProjectRoleForUserParams) (string, error) @@ -91,6 +94,7 @@ type Querier interface { ListOutboundWebhooksByUser(ctx context.Context, userID uuid.UUID) ([]OutboundWebhook, error) ListPendingCommands(ctx context.Context, nodeAgentID string) ([]AgentCommand, error) ListPendingProjects(ctx context.Context) ([]Project, error) + ListProjectEnvironments(ctx context.Context, projectID uuid.UUID) ([]ListProjectEnvironmentsRow, error) // Visibility model: a project is readable when it is approved for public // display, when the caller owns or is a member of it, or when the caller is a // platform admin. Anonymous callers pass user_id = uuid.Nil, is_admin = false. diff --git a/app/backend/internal/mcpserver/server.go b/app/backend/internal/mcpserver/server.go index df91459..ba0f46d 100644 --- a/app/backend/internal/mcpserver/server.go +++ b/app/backend/internal/mcpserver/server.go @@ -50,6 +50,11 @@ func init() { register(spec{"containr_projects_create", "Create a project. Args: name (required), description.", "POST", "/projects", nil, []string{"name", "description"}, false}) register(spec{"containr_projects_delete", "Delete a project and all its services. Requires confirm:true.", "DELETE", "/projects/{id}", []string{"id"}, nil, true}) + // Environments + register(spec{"containr_environments_list", "List environments in a project with service counts", "GET", "/projects/{id}/environments", []string{"id"}, nil, false}) + register(spec{"containr_environments_create", "Create an environment. Args: name (required — lowercase alphanumeric with dashes, 1-50 chars).", "POST", "/projects/{id}/environments", []string{"id"}, []string{"name"}, false}) + register(spec{"containr_environments_delete", "Delete an empty environment. Requires confirm:true.", "DELETE", "/environments/{id}", []string{"id"}, nil, true}) + // Services register(spec{"containr_services_list", "List services in a project", "GET", "/projects/{project_id}/services", []string{"project_id"}, nil, false}) register(spec{"containr_services_get", "Get a service by id", "GET", "/services/{id}", []string{"id"}, nil, false}) diff --git a/app/backend/sqlc/queries/projects.sql b/app/backend/sqlc/queries/projects.sql index 88a949c..46da874 100644 --- a/app/backend/sqlc/queries/projects.sql +++ b/app/backend/sqlc/queries/projects.sql @@ -125,3 +125,21 @@ SELECT p.id, p.name, p.description, p.owner_id, p.is_approved, p.created_at, p.u FROM projects p WHERE p.is_approved = false ORDER BY p.created_at DESC; + +-- name: ListProjectEnvironments :many +SELECT e.id, e.name, e.project_id, e.created_at, e.updated_at, + (SELECT COUNT(*) FROM services s WHERE s.environment_id = e.id) AS service_count +FROM environments e +WHERE e.project_id = $1 +ORDER BY e.created_at; + +-- name: GetProjectEnvironmentByName :one +SELECT id, name, project_id, created_at, updated_at +FROM environments +WHERE project_id = $1 AND name = $2; + +-- name: DeleteProjectEnvironment :exec +DELETE FROM environments WHERE id = $1 AND project_id = $2; + +-- name: CountEnvironmentServices :one +SELECT COUNT(*) FROM services WHERE environment_id = $1; diff --git a/app/frontend/src/features/workspace/pages/ProjectWorkspacePage.tsx b/app/frontend/src/features/workspace/pages/ProjectWorkspacePage.tsx index 3119f15..5d6da3c 100644 --- a/app/frontend/src/features/workspace/pages/ProjectWorkspacePage.tsx +++ b/app/frontend/src/features/workspace/pages/ProjectWorkspacePage.tsx @@ -8,12 +8,14 @@ import { createManagedDatabase, createService, getProjectById, + createProjectEnvironment, importComposeIntoProject, listConnectedGitRepositories, listGitBranches, listGitProviders, listGitRepositories, listProjectActivity, + listProjectEnvironments, listServiceLogs, listServiceVariables, listServicesByProject, @@ -589,6 +591,22 @@ export function ProjectWorkspacePage() { () => (isDemoMode ? getDemoServicesByProject(projectId) : servicesQuery.data ?? []), [isDemoMode, projectId, servicesQuery.data], ); + const environmentsQuery = useQuery({ + queryKey: ['project-environments', projectId], + queryFn: () => listProjectEnvironments(projectId!), + enabled: !!projectId && !isDemoMode, + }); + const [envFilter, setEnvFilter] = useState(null); + const environments = environmentsQuery.data ?? []; + const visibleServices = useMemo( + () => (envFilter ? services.filter((s) => (s.environment ?? 'production') === envFilter) : services), + [services, envFilter], + ); + const createEnvMutation = useMutation({ + mutationFn: (name: string) => createProjectEnvironment(projectId!, name), + onSuccess: () => queryClient.invalidateQueries({ queryKey: ['project-environments', projectId] }), + onError: (err) => toast.showToast(err instanceof ApiError ? err.message : 'Failed to create environment', 'error'), + }); const serviceIdFingerprint = services.map((service) => service.id).sort().join('|'); const variablesQuery = useQuery({ @@ -850,18 +868,50 @@ export function ProjectWorkspacePage() { {/* Service List */}
-

Services

+
+

Services

+ {!isDemoMode && environments.length > 0 && ( +
+ + {environments.map((env) => ( + + ))} + createEnvMutation.mutate(name)} pending={createEnvMutation.isPending} /> +
+ )} +
- {services.length === 0 ? ( + {visibleServices.length === 0 ? (
-

No services deployed yet

-

Add services from the Canvas view

+

+ {envFilter ? `No services in ${envFilter}` : 'No services deployed yet'} +

+ {!envFilter &&

Add services from the Canvas view

}
) : ( - services.map((service) => ( + visibleServices.map((service) => (
); } + +// EnvCreateButton expands into an inline name input for a new environment. +function EnvCreateButton({ onCreate, pending }: { onCreate: (name: string) => void; pending: boolean }) { + const [open, setOpen] = useState(false); + const [name, setName] = useState(''); + if (!open) { + return ( + + ); + } + return ( + + setName(e.target.value)} + onKeyDown={(e) => { + if (e.key === 'Enter' && name.trim()) { + onCreate(name.trim()); + setName(''); + setOpen(false); + } + if (e.key === 'Escape') setOpen(false); + }} + placeholder="staging" + className="w-24 px-2 py-1 rounded-full text-xs bg-[var(--surface-muted)] border border-[var(--border-subtle)] text-[var(--text-primary)] outline-none" + /> + {pending && } + + ); +} diff --git a/app/frontend/src/generated/api-types.ts b/app/frontend/src/generated/api-types.ts index 3279427..6a5d403 100644 --- a/app/frontend/src/generated/api-types.ts +++ b/app/frontend/src/generated/api-types.ts @@ -9391,6 +9391,137 @@ export interface paths { patch?: never; trace?: never; }; + "/projects/{id}/environments": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * List project environments + * @description Returns each environment lane with a live service count — the data behind the environment switcher. + */ + get: { + parameters: { + query?: never; + header?: never; + path: { + /** @description Project ID */ + id: components["parameters"]["ProjectIdParam"]; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Project environments */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + environments?: components["schemas"]["ProjectEnvironment"][]; + }; + }; + }; + }; + }; + put?: never; + /** + * Create an environment + * @description Adds a named environment lane. Names are lowercase alphanumeric with dashes, 1-50 chars, unique per project. + */ + post: { + parameters: { + query?: never; + header?: never; + path: { + /** @description Project ID */ + id: components["parameters"]["ProjectIdParam"]; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": { + name: string; + }; + }; + }; + responses: { + /** @description Created environment */ + 201: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + environment?: components["schemas"]["ProjectEnvironment"]; + }; + }; + }; + /** @description Environment already exists */ + 409: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/environments/{id}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post?: never; + /** + * Delete an empty environment + * @description Removes an environment lane; fails with 409 while it still holds services. + */ + delete: { + parameters: { + query?: never; + header?: never; + path: { + id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Environment deleted */ + 200: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Environment still contains services */ + 409: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/projects/{id}/preview-environments": { parameters: { query?: never; @@ -12488,6 +12619,18 @@ export interface components { restart_policy?: Record; health_check?: Record; }; + ProjectEnvironment: { + /** Format: uuid */ + id?: string; + name?: string; + /** Format: uuid */ + project_id?: string; + service_count?: number; + /** Format: date-time */ + created_at?: string; + /** Format: date-time */ + updated_at?: string; + }; PreviewEnvironment: { /** Format: uuid */ id?: string; diff --git a/app/frontend/src/lib/api-client.ts b/app/frontend/src/lib/api-client.ts index 2f381fa..9eb5cfe 100644 --- a/app/frontend/src/lib/api-client.ts +++ b/app/frontend/src/lib/api-client.ts @@ -1321,6 +1321,34 @@ export async function listServicesByProject(projectId: string): Promise { + const payload = await requestJson<{ environments?: ProjectEnvironment[] }>( + `/projects/${projectId}/environments`, + ); + return payload.environments ?? []; +} + +export async function createProjectEnvironment(projectId: string, name: string): Promise { + const payload = await requestJson<{ environment: ProjectEnvironment }>( + `/projects/${projectId}/environments`, + { method: 'POST', body: JSON.stringify({ name }) }, + ); + return payload.environment; +} + +export async function deleteProjectEnvironment(environmentId: string): Promise { + await requestJson(`/environments/${encodeURIComponent(environmentId)}`, { method: 'DELETE' }); +} + export async function getServiceById(serviceId: string): Promise { const payload = await requestJson(`/services/${serviceId}`); const row = (payload as { service?: RawService }).service ?? (payload as RawService); diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index b395369..d1642c9 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -5939,6 +5939,82 @@ paths: schema: $ref: '#/components/schemas/Error' + # Environments + /projects/{id}/environments: + get: + tags: + - Environments + summary: List project environments + description: Returns each environment lane with a live service count — the data behind the environment switcher. + security: + - bearerAuth: [] + parameters: + - $ref: '#/components/parameters/ProjectIdParam' + responses: + '200': + description: Project environments + content: + application/json: + schema: + type: object + properties: + environments: + type: array + items: + $ref: '#/components/schemas/ProjectEnvironment' + post: + tags: + - Environments + summary: Create an environment + description: Adds a named environment lane. Names are lowercase alphanumeric with dashes, 1-50 chars, unique per project. + security: + - bearerAuth: [] + parameters: + - $ref: '#/components/parameters/ProjectIdParam' + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [name] + properties: + name: + type: string + responses: + '201': + description: Created environment + content: + application/json: + schema: + type: object + properties: + environment: + $ref: '#/components/schemas/ProjectEnvironment' + '409': + description: Environment already exists + + /environments/{id}: + delete: + tags: + - Environments + summary: Delete an empty environment + description: Removes an environment lane; fails with 409 while it still holds services. + security: + - bearerAuth: [] + parameters: + - name: id + in: path + required: true + schema: + type: string + format: uuid + responses: + '200': + description: Environment deleted + '409': + description: Environment still contains services + # Preview environments /projects/{id}/preview-environments: get: @@ -9552,6 +9628,26 @@ components: health_check: type: object + ProjectEnvironment: + type: object + properties: + id: + type: string + format: uuid + name: + type: string + project_id: + type: string + format: uuid + service_count: + type: integer + created_at: + type: string + format: date-time + updated_at: + type: string + format: date-time + PreviewEnvironment: type: object properties: