mirror of
https://github.com/Dvorinka/Containr.git
synced 2026-10-28 07:41:27 +00:00
feat(environments): env CRUD, workspace switcher, CLI + MCP parity (#68)
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>
This commit is contained in:
1 parent
dd7b735b38
commit
60145f7b21
13 files changed
+727
-6
No files matched your search
+5
-1
@@ -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.
|
||||
|
||||
@@ -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"})
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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 <name>",
|
||||
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 <id>",
|
||||
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)
|
||||
}
|
||||
@@ -95,6 +95,7 @@ func init() {
|
||||
rootCmd.AddCommand(commands.InvitesCmd)
|
||||
rootCmd.AddCommand(commands.UpCmd)
|
||||
rootCmd.AddCommand(commands.MigrateCmd)
|
||||
rootCmd.AddCommand(commands.EnvironmentsCmd)
|
||||
}
|
||||
|
||||
func initConfig() {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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})
|
||||
|
||||
@@ -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;
|
||||
@@ -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<string | null>(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 */}
|
||||
<div className="panel p-6">
|
||||
<h3 className="text-sm font-medium text-[var(--text-secondary)] mb-3">Services</h3>
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<h3 className="text-sm font-medium text-[var(--text-secondary)]">Services</h3>
|
||||
{!isDemoMode && environments.length > 0 && (
|
||||
<div className="flex items-center gap-1.5 flex-wrap">
|
||||
<button
|
||||
onClick={() => setEnvFilter(null)}
|
||||
className={`px-2.5 py-1 rounded-full text-xs font-medium transition-colors ${
|
||||
envFilter === null
|
||||
? 'bg-[var(--accent-primary)] text-white'
|
||||
: 'bg-[var(--surface-muted)] text-[var(--text-secondary)] hover:bg-[var(--surface-card)]'
|
||||
}`}
|
||||
>
|
||||
All
|
||||
</button>
|
||||
{environments.map((env) => (
|
||||
<button
|
||||
key={env.id}
|
||||
onClick={() => setEnvFilter(envFilter === env.name ? null : env.name)}
|
||||
className={`px-2.5 py-1 rounded-full text-xs font-medium transition-colors ${
|
||||
envFilter === env.name
|
||||
? 'bg-[var(--accent-primary)] text-white'
|
||||
: 'bg-[var(--surface-muted)] text-[var(--text-secondary)] hover:bg-[var(--surface-card)]'
|
||||
}`}
|
||||
>
|
||||
{env.name} ({env.service_count})
|
||||
</button>
|
||||
))}
|
||||
<EnvCreateButton onCreate={(name) => createEnvMutation.mutate(name)} pending={createEnvMutation.isPending} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="rounded-[var(--radius-md)] border border-[var(--border-subtle)] overflow-hidden divide-y divide-[var(--border-subtle)]">
|
||||
{services.length === 0 ? (
|
||||
{visibleServices.length === 0 ? (
|
||||
<div className="p-8 text-center">
|
||||
<div className="w-12 h-12 mx-auto mb-3 rounded-full bg-[var(--surface-muted)] flex items-center justify-center">
|
||||
<Layers size={20} className="text-[var(--text-tertiary)]" />
|
||||
</div>
|
||||
<p className="text-sm text-[var(--text-secondary)]">No services deployed yet</p>
|
||||
<p className="text-xs text-[var(--text-muted)] mt-1">Add services from the Canvas view</p>
|
||||
<p className="text-sm text-[var(--text-secondary)]">
|
||||
{envFilter ? `No services in ${envFilter}` : 'No services deployed yet'}
|
||||
</p>
|
||||
{!envFilter && <p className="text-xs text-[var(--text-muted)] mt-1">Add services from the Canvas view</p>}
|
||||
</div>
|
||||
) : (
|
||||
services.map((service) => (
|
||||
visibleServices.map((service) => (
|
||||
<button
|
||||
key={service.id}
|
||||
onClick={() => navigate(serviceHref(service.id))}
|
||||
@@ -1124,3 +1174,39 @@ export function ProjectWorkspacePage() {
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 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 (
|
||||
<button
|
||||
onClick={() => setOpen(true)}
|
||||
className="px-2.5 py-1 rounded-full text-xs font-medium border border-dashed border-[var(--border-subtle)] text-[var(--text-muted)] hover:text-[var(--text-secondary)] transition-colors"
|
||||
>
|
||||
+ env
|
||||
</button>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<span className="inline-flex items-center gap-1">
|
||||
<input
|
||||
autoFocus
|
||||
value={name}
|
||||
onChange={(e) => 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 && <Loader2 size={12} className="animate-spin text-[var(--text-muted)]" />}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -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<string, never>;
|
||||
health_check?: Record<string, never>;
|
||||
};
|
||||
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;
|
||||
|
||||
@@ -1321,6 +1321,34 @@ export async function listServicesByProject(projectId: string): Promise<ServiceE
|
||||
return normalizeServiceArray(rows);
|
||||
}
|
||||
|
||||
export interface ProjectEnvironment {
|
||||
id: string;
|
||||
name: string;
|
||||
project_id: string;
|
||||
service_count: number;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export async function listProjectEnvironments(projectId: string): Promise<ProjectEnvironment[]> {
|
||||
const payload = await requestJson<{ environments?: ProjectEnvironment[] }>(
|
||||
`/projects/${projectId}/environments`,
|
||||
);
|
||||
return payload.environments ?? [];
|
||||
}
|
||||
|
||||
export async function createProjectEnvironment(projectId: string, name: string): Promise<ProjectEnvironment> {
|
||||
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<void> {
|
||||
await requestJson(`/environments/${encodeURIComponent(environmentId)}`, { method: 'DELETE' });
|
||||
}
|
||||
|
||||
export async function getServiceById(serviceId: string): Promise<ServiceEntity> {
|
||||
const payload = await requestJson<RawService | { service?: RawService }>(`/services/${serviceId}`);
|
||||
const row = (payload as { service?: RawService }).service ?? (payload as RawService);
|
||||
|
||||
@@ -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:
|
||||
|
||||
Reference in new issue
Block a user