mirror of
https://github.com/Dvorinka/Containr.git
synced 2026-10-28 07:41:27 +00:00
feat: real service runtime — private networks, replicas, lifecycle, rollback
Services now reconcile into actual Docker containers instead of
bookkeeping rows:
- Per-project bridge network with service-name DNS aliases and
${{service.KEY}} env expansion (HOST/PORT builtins)
- Replicas, published ports, Traefik domain routing, health checks,
restart policies persisted on the service spec; spec-hash drift
recreates containers on redeploy
- Lifecycle API: start/stop/restart/redeploy, live runtime state,
real image rollback (replaces the fake sleep)
- Manual scale and PUT replicas reconcile live containers; service
and project delete remove their containers and the project network
- Canvas: real URLs, replica badges, env filter, right-click
deploy/restart/stop/delete menu, empty state
- Service detail: real lifecycle buttons, live replica/URL strip,
Networking editor, ${{ }} variable helper
- Fix prebuilt-image pulls racing the tag (drain the pull stream)
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
c16b32f56a
commit
ff356230fe
20 files changed
+2270
-243
No files matched your search
@@ -40,8 +40,10 @@ click away.
|
||||
|
||||
## Features
|
||||
|
||||
- **Project canvas** — visual service topology, groups, drag/drop, auto-inferred connections
|
||||
- **Deployments & builds** — Docker deploys with history, logs, rollback, live build status
|
||||
- **Project canvas** — visual service topology, groups, drag/drop, auto-inferred connections, right-click lifecycle actions
|
||||
- **Private networking** — every project gets an isolated Docker network; services reach each other by name (`web:3000`) and share variables via `${{service.KEY}}` references
|
||||
- **Deployments & builds** — Docker deploys with history, logs, image rollback, live build status
|
||||
- **Runtime** — replicas, published ports, domains, health checks, restart policies; start/stop/restart/redeploy from canvas or service page
|
||||
- **Git integration** — GitHub, GitLab, Bitbucket, Gitea; webhooks, GitHub App
|
||||
- **Managed databases** — one-click provisioning with backup/restore
|
||||
- **Metrics** — per-service Docker stats plus host CPU/memory/disk telemetry
|
||||
|
||||
@@ -153,8 +153,11 @@ func handleCreateDeployment(c *gin.Context) {
|
||||
var service Service
|
||||
var projectOwner string
|
||||
err = db.(*database.DB).QueryRow(
|
||||
`SELECT s.id, s.project_id, s.name, s.type, s.status, s.image, s.command,
|
||||
s.environment, s.git_repo, s.git_branch, s.build_path, s.cpu, s.memory,
|
||||
`SELECT s.id, s.project_id, s.name, s.type, s.status, s.image, s.command,
|
||||
s.environment, s.git_repo, s.git_branch, s.build_path, s.cpu, s.memory,
|
||||
COALESCE(s.replicas, 1), COALESCE(s.port, 0),
|
||||
COALESCE(s.domain, ''), COALESCE(s.healthcheck_path, ''),
|
||||
COALESCE(s.restart_policy, 'unless-stopped'),
|
||||
s.created_at, s.updated_at, p.owner_id
|
||||
FROM services s
|
||||
JOIN projects p ON s.project_id = p.id
|
||||
@@ -164,7 +167,8 @@ func handleCreateDeployment(c *gin.Context) {
|
||||
&service.ID, &service.ProjectID, &service.Name, &service.Type, &service.Status,
|
||||
&service.Image, &service.Command, &service.Environment, &service.GitRepo,
|
||||
&service.GitBranch, &service.BuildPath, &service.CPU, &service.Memory,
|
||||
&service.CreatedAt, &service.UpdatedAt, &projectOwner,
|
||||
&service.Replicas, &service.Port, &service.Domain, &service.HealthCheckPath,
|
||||
&service.RestartPolicy, &service.CreatedAt, &service.UpdatedAt, &projectOwner,
|
||||
)
|
||||
|
||||
if err != nil {
|
||||
@@ -256,6 +260,22 @@ func runDeploymentAndSync(
|
||||
service Service,
|
||||
req CreateDeploymentRequest,
|
||||
userID string,
|
||||
) {
|
||||
runDeploymentAndSyncWithImage(parentCtx, db, engine, dbDeployment, service, req, userID, "")
|
||||
}
|
||||
|
||||
// runDeploymentAndSyncWithImage deploys imageOverride as a prebuilt image
|
||||
// (rollback / redeploy of an existing tag) when set; otherwise builds from
|
||||
// source or pulls the service image.
|
||||
func runDeploymentAndSyncWithImage(
|
||||
parentCtx context.Context,
|
||||
db *database.DB,
|
||||
engine *deployment.DeploymentEngine,
|
||||
dbDeployment *DeploymentModel,
|
||||
service Service,
|
||||
req CreateDeploymentRequest,
|
||||
userID string,
|
||||
imageOverride string,
|
||||
) {
|
||||
ctx, cancel := context.WithTimeout(parentCtx, 30*time.Minute)
|
||||
defer cancel()
|
||||
@@ -265,21 +285,44 @@ func runDeploymentAndSync(
|
||||
sourcePath = "."
|
||||
}
|
||||
|
||||
// Resolve the runtime env: stored service variables (with ${{svc.KEY}}
|
||||
// references expanded) plus any per-deployment overrides.
|
||||
env, envErr := resolveServiceEnv(db, service)
|
||||
if envErr != nil {
|
||||
env = map[string]string{}
|
||||
}
|
||||
for k, v := range req.EnvVars {
|
||||
env[k] = v
|
||||
}
|
||||
|
||||
var command []string
|
||||
if cmd := strings.TrimSpace(service.Command); cmd != "" {
|
||||
command = strings.Fields(cmd)
|
||||
}
|
||||
|
||||
replicas := service.Replicas
|
||||
if replicas < 1 {
|
||||
replicas = 1
|
||||
}
|
||||
|
||||
deployReq := &deployment.DeploymentRequest{
|
||||
ProjectID: service.ProjectID.String(),
|
||||
ServiceID: service.ID.String(),
|
||||
Environment: service.Environment,
|
||||
Config: deployment.ServiceConfig{
|
||||
Name: service.Name,
|
||||
Image: service.Image,
|
||||
Environment: req.EnvVars,
|
||||
Replicas: 1,
|
||||
},
|
||||
BuildConfig: &deployment.BuildConfig{
|
||||
BuildType: "nixpacks",
|
||||
SourcePath: sourcePath,
|
||||
Branch: req.Branch,
|
||||
Commit: req.CommitHash,
|
||||
Name: service.Name,
|
||||
Image: service.Image,
|
||||
Command: command,
|
||||
Environment: env,
|
||||
Replicas: replicas,
|
||||
PublicPort: int32(service.Port),
|
||||
Domain: service.Domain,
|
||||
HealthPath: service.HealthCheckPath,
|
||||
RestartPolicy: service.RestartPolicy,
|
||||
Resources: deployment.ResourceLimits{
|
||||
MemoryBytes: parseMemoryLimit(service.Memory),
|
||||
CPUQuota: parseCPULimit(service.CPU),
|
||||
},
|
||||
},
|
||||
Trigger: deployment.TriggerConfig{
|
||||
Type: req.Trigger,
|
||||
@@ -289,6 +332,26 @@ func runDeploymentAndSync(
|
||||
},
|
||||
}
|
||||
|
||||
if imageOverride != "" {
|
||||
deployReq.BuildConfig = &deployment.BuildConfig{
|
||||
BuildType: "prebuilt",
|
||||
PrebuiltImage: imageOverride,
|
||||
}
|
||||
} else if service.GitRepo == "" {
|
||||
// Image-sourced service: pull the image instead of building.
|
||||
deployReq.BuildConfig = &deployment.BuildConfig{
|
||||
BuildType: "prebuilt",
|
||||
PrebuiltImage: service.Image,
|
||||
}
|
||||
} else {
|
||||
deployReq.BuildConfig = &deployment.BuildConfig{
|
||||
BuildType: "nixpacks",
|
||||
SourcePath: sourcePath,
|
||||
Branch: req.Branch,
|
||||
Commit: req.CommitHash,
|
||||
}
|
||||
}
|
||||
|
||||
engineDeployment, err := engine.Deploy(ctx, deployReq)
|
||||
if err != nil {
|
||||
failedAt := time.Now()
|
||||
@@ -550,17 +613,58 @@ func handleRollbackDeployment(c *gin.Context) {
|
||||
time.Now(), serviceID,
|
||||
)
|
||||
|
||||
go func() {
|
||||
time.Sleep(2 * time.Second)
|
||||
db.(*database.DB).Exec(
|
||||
`UPDATE deployments SET status = 'deployed', completed_at = $1, updated_at = $1 WHERE id = $2`,
|
||||
time.Now(), rollbackID,
|
||||
// Real rollback: redeploy the target deployment's image.
|
||||
engineValue, _ := c.Get("deployment_engine")
|
||||
engine, _ := engineValue.(*deployment.DeploymentEngine)
|
||||
targetImage := targetDeployment.ImageName
|
||||
if targetDeployment.ImageTag != "" {
|
||||
targetImage += ":" + targetDeployment.ImageTag
|
||||
}
|
||||
|
||||
if engine == nil || targetImage == "" {
|
||||
completedAt := time.Now()
|
||||
reason := "Rollback image unavailable"
|
||||
if engine == nil {
|
||||
reason = "Deployment engine unavailable. Docker may not be configured on this server."
|
||||
}
|
||||
_, _ = db.(*database.DB).Exec(
|
||||
`UPDATE deployments SET status = 'failed', error = $1, completed_at = $2, updated_at = $2 WHERE id = $3`,
|
||||
reason, completedAt, rollbackID,
|
||||
)
|
||||
db.(*database.DB).Exec(
|
||||
`UPDATE services SET status = 'running', updated_at = $1 WHERE id = $2`,
|
||||
time.Now(), serviceID,
|
||||
_, _ = db.(*database.DB).Exec(
|
||||
`UPDATE services SET status = 'failed', updated_at = $1 WHERE id = $2`,
|
||||
completedAt, serviceID,
|
||||
)
|
||||
}()
|
||||
} else {
|
||||
var service Service
|
||||
_ = db.(*database.DB).QueryRow(
|
||||
`SELECT s.id, s.project_id, s.name, s.type, s.status, s.image, s.command,
|
||||
s.environment, s.git_repo, s.git_branch, s.build_path, s.cpu, s.memory,
|
||||
COALESCE(s.replicas, 1), COALESCE(s.port, 0),
|
||||
COALESCE(s.domain, ''), COALESCE(s.healthcheck_path, ''),
|
||||
COALESCE(s.restart_policy, 'unless-stopped'),
|
||||
s.created_at, s.updated_at
|
||||
FROM services s WHERE s.id = $1`, serviceID,
|
||||
).Scan(
|
||||
&service.ID, &service.ProjectID, &service.Name, &service.Type, &service.Status,
|
||||
&service.Image, &service.Command, &service.Environment, &service.GitRepo,
|
||||
&service.GitBranch, &service.BuildPath, &service.CPU, &service.Memory,
|
||||
&service.Replicas, &service.Port, &service.Domain, &service.HealthCheckPath,
|
||||
&service.RestartPolicy, &service.CreatedAt, &service.UpdatedAt,
|
||||
)
|
||||
|
||||
rollbackReq := CreateDeploymentRequest{
|
||||
CommitHash: func() string {
|
||||
if targetDeployment.CommitHash != nil {
|
||||
return *targetDeployment.CommitHash
|
||||
}
|
||||
return ""
|
||||
}(),
|
||||
Trigger: "rollback",
|
||||
}
|
||||
// Reuse the stored image directly — no rebuild.
|
||||
go runDeploymentAndSyncWithImage(context.Background(), db.(*database.DB), engine, &rollback, service, rollbackReq, userID.(string), targetImage)
|
||||
}
|
||||
|
||||
c.JSON(http.StatusCreated, gin.H{
|
||||
"deployment": DeploymentResponse{
|
||||
|
||||
@@ -3,6 +3,8 @@ package api
|
||||
import (
|
||||
"containr/internal/database"
|
||||
"containr/internal/database/sqlcdb"
|
||||
"containr/internal/deployment"
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"net/http"
|
||||
@@ -310,6 +312,16 @@ func handleDeleteProject(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
// Remove live containers and the project network before dropping rows.
|
||||
// Best-effort: Docker may be unavailable; DB cleanup proceeds regardless.
|
||||
if engineValue, exists := c.Get("deployment_engine"); exists && engineValue != nil {
|
||||
engine := engineValue.(*deployment.DeploymentEngine)
|
||||
ctx, cancel := context.WithTimeout(c.Request.Context(), 30*time.Second)
|
||||
_ = engine.RemoveProjectContainers(ctx, projectID.String())
|
||||
_ = engine.RemoveProjectNetwork(ctx, projectID.String())
|
||||
cancel()
|
||||
}
|
||||
|
||||
deletedRows, err := queries.DeleteProjectByID(c.Request.Context(), projectID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to delete project"})
|
||||
|
||||
@@ -209,6 +209,13 @@ func SetupRoutes(router *gin.Engine, db *database.DB, redis *database.Redis, cfg
|
||||
protected.DELETE("/services/:id", handleDeleteService)
|
||||
protected.GET("/services/:id/metrics", handleGetServiceMetrics)
|
||||
|
||||
// Runtime lifecycle (Railway-style)
|
||||
protected.GET("/services/:id/runtime", handleGetServiceRuntime)
|
||||
protected.POST("/services/:id/start", handleServiceStart)
|
||||
protected.POST("/services/:id/stop", handleServiceStop)
|
||||
protected.POST("/services/:id/restart", handleServiceRestart)
|
||||
protected.POST("/services/:id/redeploy", handleServiceRedeploy)
|
||||
|
||||
// Deployment routes
|
||||
protected.GET("/services/:id/deployments", handleGetDeployments)
|
||||
protected.POST("/services/:id/deployments", handleCreateDeployment)
|
||||
|
||||
@@ -0,0 +1,438 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"containr/internal/database"
|
||||
"containr/internal/deployment"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// serviceRuntimeSpec builds the container spec for a service: stored env vars
|
||||
// merged with ${{Service.KEY}} references resolved across the project.
|
||||
func serviceRuntimeSpec(db *database.DB, service Service) (deployment.RuntimeSpec, error) {
|
||||
env, err := resolveServiceEnv(db, service)
|
||||
if err != nil {
|
||||
return deployment.RuntimeSpec{}, err
|
||||
}
|
||||
|
||||
spec := deployment.RuntimeSpec{
|
||||
ProjectID: service.ProjectID.String(),
|
||||
ServiceID: service.ID.String(),
|
||||
Name: service.Name,
|
||||
Image: service.Image,
|
||||
Env: env,
|
||||
Replicas: service.Replicas,
|
||||
Port: int32(service.Port),
|
||||
Domain: service.Domain,
|
||||
HealthPath: service.HealthCheckPath,
|
||||
RestartPolicy: service.RestartPolicy,
|
||||
}
|
||||
if cmd := strings.TrimSpace(service.Command); cmd != "" {
|
||||
spec.Command = strings.Fields(cmd)
|
||||
}
|
||||
if spec.Replicas < 1 {
|
||||
spec.Replicas = 1
|
||||
}
|
||||
if spec.RestartPolicy == "" {
|
||||
spec.RestartPolicy = "unless-stopped"
|
||||
}
|
||||
spec.NanoCPUs = parseCPULimit(service.CPU)
|
||||
spec.MemoryBytes = parseMemoryLimit(service.Memory)
|
||||
if spec.Port > 0 {
|
||||
env["PORT"] = strconv.Itoa(service.Port)
|
||||
}
|
||||
return spec, nil
|
||||
}
|
||||
|
||||
var refVarPattern = regexp.MustCompile(`\$\{\{\s*([A-Za-z0-9_-]+)\.([A-Za-z_][A-Za-z0-9_]*)\s*\}\}`)
|
||||
|
||||
// resolveServiceEnv loads the service's variables (real values, including
|
||||
// secrets) and expands ${{service.KEY}} references. KEY may be a variable on
|
||||
// the referenced service, or the builtins HOST (service name alias) and PORT.
|
||||
func resolveServiceEnv(db *database.DB, service Service) (map[string]string, error) {
|
||||
rows, err := db.Query(
|
||||
`SELECT key, value FROM environment_variables WHERE service_id = $1`,
|
||||
service.ID,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
env := map[string]string{}
|
||||
for rows.Next() {
|
||||
var k, v string
|
||||
if err := rows.Scan(&k, &v); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
env[k] = v
|
||||
}
|
||||
|
||||
var refServices []struct{ ID, Name string }
|
||||
svcRows, err := db.Query(
|
||||
`SELECT id, name FROM services WHERE project_id = $1`,
|
||||
service.ProjectID,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer svcRows.Close()
|
||||
for svcRows.Next() {
|
||||
var s struct{ ID, Name string }
|
||||
if err := svcRows.Scan(&s.ID, &s.Name); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
refServices = append(refServices, s)
|
||||
}
|
||||
|
||||
byName := map[string]string{}
|
||||
for _, s := range refServices {
|
||||
byName[strings.ToLower(s.Name)] = s.ID
|
||||
}
|
||||
|
||||
resolve := func(serviceName, key string) (string, bool) {
|
||||
id, ok := byName[strings.ToLower(serviceName)]
|
||||
if !ok {
|
||||
return "", false
|
||||
}
|
||||
switch strings.ToUpper(key) {
|
||||
case "HOST":
|
||||
return serviceName, true
|
||||
case "PORT":
|
||||
var port int
|
||||
if err := db.QueryRow(`SELECT COALESCE(port, 0) FROM services WHERE id = $1`, id).Scan(&port); err == nil && port > 0 {
|
||||
return strconv.Itoa(port), true
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
var value string
|
||||
err := db.QueryRow(
|
||||
`SELECT value FROM environment_variables WHERE service_id = $1 AND key = $2`,
|
||||
id, key,
|
||||
).Scan(&value)
|
||||
if err != nil {
|
||||
return "", false
|
||||
}
|
||||
return value, true
|
||||
}
|
||||
|
||||
for k, v := range env {
|
||||
env[k] = refVarPattern.ReplaceAllStringFunc(v, func(match string) string {
|
||||
parts := refVarPattern.FindStringSubmatch(match)
|
||||
if len(parts) != 3 {
|
||||
return match
|
||||
}
|
||||
if resolved, ok := resolve(parts[1], parts[2]); ok {
|
||||
return resolved
|
||||
}
|
||||
return match
|
||||
})
|
||||
}
|
||||
return env, nil
|
||||
}
|
||||
|
||||
func parseCPULimit(cpu string) int64 {
|
||||
v, err := strconv.ParseFloat(strings.TrimSpace(cpu), 64)
|
||||
if err != nil || v <= 0 {
|
||||
return 0
|
||||
}
|
||||
return int64(v * 1e9)
|
||||
}
|
||||
|
||||
var memUnits = map[string]int64{"k": 1e3, "m": 1e6, "g": 1e9, "ki": 1 << 10, "mi": 1 << 20, "gi": 1 << 30}
|
||||
|
||||
func parseMemoryLimit(mem string) int64 {
|
||||
s := strings.ToLower(strings.TrimSpace(mem))
|
||||
for suffix, mult := range memUnits {
|
||||
if strings.HasSuffix(s, suffix) {
|
||||
if v, err := strconv.ParseFloat(strings.TrimSuffix(s, suffix), 64); err == nil {
|
||||
return int64(v * float64(mult))
|
||||
}
|
||||
}
|
||||
}
|
||||
if v, err := strconv.ParseInt(s, 10, 64); err == nil {
|
||||
return v
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func getRuntimeEngine(c *gin.Context) (*deployment.DeploymentEngine, *database.DB, bool) {
|
||||
dbValue, exists := c.Get("db")
|
||||
if !exists {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Database connection not available"})
|
||||
return nil, nil, false
|
||||
}
|
||||
engineValue, exists := c.Get("deployment_engine")
|
||||
if !exists || engineValue == nil {
|
||||
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "Deployment engine unavailable. Docker may not be configured on this server."})
|
||||
return nil, nil, false
|
||||
}
|
||||
return engineValue.(*deployment.DeploymentEngine), dbValue.(*database.DB), true
|
||||
}
|
||||
|
||||
func loadOwnedService(c *gin.Context, db *database.DB) (Service, bool) {
|
||||
serviceID, err := uuid.Parse(c.Param("id"))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid service ID"})
|
||||
return Service{}, false
|
||||
}
|
||||
userID, exists := c.Get("user_id")
|
||||
if !exists {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "User not authenticated"})
|
||||
return Service{}, false
|
||||
}
|
||||
|
||||
var service Service
|
||||
var owner string
|
||||
err = db.QueryRow(
|
||||
`SELECT s.id, s.project_id, s.name,
|
||||
COALESCE(s.type, s.service_type, ''), COALESCE(s.status, ''),
|
||||
COALESCE(s.image, s.image_name, ''), COALESCE(s.command, s.start_command, ''),
|
||||
COALESCE(s.environment, ''), COALESCE(s.git_repo, s.source_url, ''),
|
||||
COALESCE(s.git_branch, ''), COALESCE(s.build_path, ''),
|
||||
COALESCE(s.cpu, ''), COALESCE(s.memory, ''),
|
||||
COALESCE(s.replicas, 1), COALESCE(s.port, 0),
|
||||
COALESCE(s.domain, ''), COALESCE(s.healthcheck_path, ''),
|
||||
COALESCE(s.restart_policy, 'unless-stopped'),
|
||||
s.created_at, s.updated_at, p.owner_id
|
||||
FROM services s JOIN projects p ON s.project_id = p.id
|
||||
WHERE s.id = $1`,
|
||||
serviceID,
|
||||
).Scan(
|
||||
&service.ID, &service.ProjectID, &service.Name, &service.Type, &service.Status,
|
||||
&service.Image, &service.Command, &service.Environment, &service.GitRepo,
|
||||
&service.GitBranch, &service.BuildPath, &service.CPU, &service.Memory,
|
||||
&service.Replicas, &service.Port, &service.Domain, &service.HealthCheckPath,
|
||||
&service.RestartPolicy, &service.CreatedAt, &service.UpdatedAt, &owner,
|
||||
)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "Service not found"})
|
||||
return Service{}, false
|
||||
}
|
||||
if owner != userID.(string) && !isAdminUser(db, userID.(string)) {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "Access denied"})
|
||||
return Service{}, false
|
||||
}
|
||||
return service, true
|
||||
}
|
||||
|
||||
// handleServiceRuntime returns live container state for a service.
|
||||
func handleGetServiceRuntime(c *gin.Context) {
|
||||
engine, db, ok := getRuntimeEngine(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
service, ok := loadOwnedService(c, db)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
state, err := engine.RuntimeState(c.Request.Context(), service.ID.String(), service.Replicas)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"runtime": state})
|
||||
}
|
||||
|
||||
func reconcileNow(c *gin.Context, engine *deployment.DeploymentEngine, db *database.DB, service Service) bool {
|
||||
spec, err := serviceRuntimeSpec(db, service)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return false
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
|
||||
defer cancel()
|
||||
state, err := engine.ReconcileService(ctx, spec)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()})
|
||||
return false
|
||||
}
|
||||
status := "stopped"
|
||||
if state != nil {
|
||||
status = state.Status
|
||||
}
|
||||
_, _ = db.Exec(`UPDATE services SET status = $1, updated_at = $2 WHERE id = $3`, status, time.Now(), service.ID)
|
||||
c.JSON(http.StatusOK, gin.H{"runtime": state})
|
||||
return true
|
||||
}
|
||||
|
||||
// handleServiceStart starts all stopped replicas.
|
||||
func handleServiceStart(c *gin.Context) {
|
||||
engine, db, ok := getRuntimeEngine(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
service, ok := loadOwnedService(c, db)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if err := engine.StartService(c.Request.Context(), service.ID.String()); err != nil {
|
||||
c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
_, _ = db.Exec(`UPDATE services SET status = 'running', updated_at = $1 WHERE id = $2`, time.Now(), service.ID)
|
||||
c.JSON(http.StatusOK, gin.H{"status": "running"})
|
||||
}
|
||||
|
||||
// handleServiceStop stops all replicas without removing them.
|
||||
func handleServiceStop(c *gin.Context) {
|
||||
engine, db, ok := getRuntimeEngine(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
service, ok := loadOwnedService(c, db)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if err := engine.StopService(c.Request.Context(), service.ID.String()); err != nil {
|
||||
c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
_, _ = db.Exec(`UPDATE services SET status = 'stopped', updated_at = $1 WHERE id = $2`, time.Now(), service.ID)
|
||||
c.JSON(http.StatusOK, gin.H{"status": "stopped"})
|
||||
}
|
||||
|
||||
// handleServiceRestart restarts all replicas.
|
||||
func handleServiceRestart(c *gin.Context) {
|
||||
engine, db, ok := getRuntimeEngine(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
service, ok := loadOwnedService(c, db)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
ctx := c.Request.Context()
|
||||
if err := engine.StopService(ctx, service.ID.String()); err != nil {
|
||||
c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
if err := engine.StartService(ctx, service.ID.String()); err != nil {
|
||||
c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
_, _ = db.Exec(`UPDATE services SET status = 'running', updated_at = $1 WHERE id = $2`, time.Now(), service.ID)
|
||||
c.JSON(http.StatusOK, gin.H{"status": "running"})
|
||||
}
|
||||
|
||||
// handleServiceRedeploy re-applies the current spec: pulls the image for
|
||||
// image-sourced services and reconciles containers (env, ports, replicas).
|
||||
func handleServiceRedeploy(c *gin.Context) {
|
||||
engine, db, ok := getRuntimeEngine(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
service, ok := loadOwnedService(c, db)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
image := service.Image
|
||||
if image == "" {
|
||||
// Git-sourced service: reuse the last successful deployment image.
|
||||
err := db.QueryRow(
|
||||
`SELECT image_name || ':' || image_tag FROM deployments
|
||||
WHERE service_id = $1 AND status = 'deployed' AND image_name <> ''
|
||||
ORDER BY created_at DESC LIMIT 1`,
|
||||
service.ID,
|
||||
).Scan(&image)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "No deployable image yet — run a deployment first"})
|
||||
return
|
||||
}
|
||||
}
|
||||
service.Image = image
|
||||
reconcileNow(c, engine, db, service)
|
||||
}
|
||||
|
||||
// liveServiceStatus reconciles the stored status with real container state.
|
||||
// No-op when Docker is unavailable — the stored status is returned as-is.
|
||||
func liveServiceStatus(c *gin.Context, db *database.DB, service *Service) {
|
||||
engineValue, exists := c.Get("deployment_engine")
|
||||
if !exists || engineValue == nil {
|
||||
return
|
||||
}
|
||||
engine := engineValue.(*deployment.DeploymentEngine)
|
||||
ctx, cancel := context.WithTimeout(c.Request.Context(), 3*time.Second)
|
||||
defer cancel()
|
||||
state, err := engine.RuntimeState(ctx, service.ID.String(), service.Replicas)
|
||||
if err != nil || state == nil || len(state.Containers) == 0 {
|
||||
return
|
||||
}
|
||||
if state.Status != service.Status {
|
||||
service.Status = state.Status
|
||||
_, _ = db.Exec(`UPDATE services SET status = $1, updated_at = $2 WHERE id = $3`, state.Status, time.Now(), service.ID)
|
||||
}
|
||||
if len(state.URLs) > 0 && service.Domain == "" {
|
||||
service.PublicURL = state.URLs[0]
|
||||
}
|
||||
}
|
||||
|
||||
// runtimeScaleService adjusts replica count both in bookkeeping and reality.
|
||||
func runtimeScaleService(c *gin.Context, serviceID string, replicas int) error {
|
||||
dbValue, _ := c.Get("db")
|
||||
engineValue, _ := c.Get("deployment_engine")
|
||||
db, _ := dbValue.(*database.DB)
|
||||
engine, _ := engineValue.(*deployment.DeploymentEngine)
|
||||
if db == nil || engine == nil {
|
||||
return fmt.Errorf("runtime unavailable")
|
||||
}
|
||||
|
||||
var service Service
|
||||
var owner string
|
||||
err := db.QueryRow(
|
||||
`SELECT s.id, s.project_id, s.name,
|
||||
COALESCE(s.type, s.service_type, ''), COALESCE(s.status, ''),
|
||||
COALESCE(s.image, s.image_name, ''), COALESCE(s.command, s.start_command, ''),
|
||||
COALESCE(s.environment, ''), COALESCE(s.git_repo, s.source_url, ''),
|
||||
COALESCE(s.git_branch, ''), COALESCE(s.build_path, ''),
|
||||
COALESCE(s.cpu, ''), COALESCE(s.memory, ''),
|
||||
COALESCE(s.replicas, 1), COALESCE(s.port, 0),
|
||||
COALESCE(s.domain, ''), COALESCE(s.healthcheck_path, ''),
|
||||
COALESCE(s.restart_policy, 'unless-stopped'),
|
||||
s.created_at, s.updated_at, p.owner_id
|
||||
FROM services s JOIN projects p ON s.project_id = p.id
|
||||
WHERE s.id = $1`, serviceID,
|
||||
).Scan(
|
||||
&service.ID, &service.ProjectID, &service.Name, &service.Type, &service.Status,
|
||||
&service.Image, &service.Command, &service.Environment, &service.GitRepo,
|
||||
&service.GitBranch, &service.BuildPath, &service.CPU, &service.Memory,
|
||||
&service.Replicas, &service.Port, &service.Domain, &service.HealthCheckPath,
|
||||
&service.RestartPolicy, &service.CreatedAt, &service.UpdatedAt, &owner,
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if service.Image == "" {
|
||||
if err := db.QueryRow(
|
||||
`SELECT image_name || ':' || image_tag FROM deployments
|
||||
WHERE service_id = $1 AND status = 'deployed' AND image_name <> ''
|
||||
ORDER BY created_at DESC LIMIT 1`, service.ID,
|
||||
).Scan(&service.Image); err != nil {
|
||||
return fmt.Errorf("no deployed image to scale")
|
||||
}
|
||||
}
|
||||
|
||||
spec, err := serviceRuntimeSpec(db, service)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
spec.Replicas = replicas
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
|
||||
defer cancel()
|
||||
if _, err := engine.ReconcileService(ctx, spec); err != nil {
|
||||
return err
|
||||
}
|
||||
_, _ = db.Exec(`UPDATE services SET replicas = $1, updated_at = $2 WHERE id = $3`, replicas, time.Now(), service.ID)
|
||||
return nil
|
||||
}
|
||||
@@ -204,7 +204,16 @@ func (h *ScalingHandler) ManualScale(c *gin.Context) {
|
||||
|
||||
event, err := h.autoScaler.ManualScale(c.Request.Context(), serviceID, request.Replicas, strings.TrimSpace(request.Reason))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
// Service unknown to the autoscaler: the runtime is authoritative —
|
||||
// reconcile containers directly and persist the replica count.
|
||||
if runtimeErr := runtimeScaleService(c, serviceID, request.Replicas); runtimeErr != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error(), "runtime_error": runtimeErr.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"message": "Service scaled successfully",
|
||||
"state": gin.H{"service_id": serviceID, "desired_replicas": request.Replicas},
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
@@ -214,11 +223,19 @@ func (h *ScalingHandler) ManualScale(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
// Apply the replica change to live containers (best effort — the
|
||||
// bookkeeping update above already succeeded).
|
||||
runtimeErr := runtimeScaleService(c, serviceID, request.Replicas)
|
||||
|
||||
resp := gin.H{
|
||||
"message": "Service scaled successfully",
|
||||
"event": event,
|
||||
"state": state,
|
||||
})
|
||||
}
|
||||
if runtimeErr != nil {
|
||||
resp["runtime_error"] = runtimeErr.Error()
|
||||
}
|
||||
c.JSON(http.StatusOK, resp)
|
||||
}
|
||||
|
||||
// GetScalingStatus returns the overall status of the auto-scaler
|
||||
|
||||
@@ -2,6 +2,8 @@ package api
|
||||
|
||||
import (
|
||||
"containr/internal/database"
|
||||
"containr/internal/deployment"
|
||||
"context"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
@@ -24,37 +26,54 @@ type Service struct {
|
||||
BuildPath string `json:"build_path" db:"build_path"`
|
||||
CPU string `json:"cpu" db:"cpu"`
|
||||
Memory string `json:"memory" db:"memory"`
|
||||
CreatedAt time.Time `json:"created_at" db:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at" db:"updated_at"`
|
||||
// Runtime spec
|
||||
Replicas int `json:"replicas" db:"replicas"`
|
||||
Port int `json:"port" db:"port"` // container port to expose
|
||||
Domain string `json:"domain" db:"domain"` // public hostname via Traefik
|
||||
HealthCheckPath string `json:"healthcheck_path" db:"healthcheck_path"` // probed on Port
|
||||
RestartPolicy string `json:"restart_policy" db:"restart_policy"`
|
||||
PublicURL string `json:"public_url,omitempty" db:"-"` // computed at read time
|
||||
CreatedAt time.Time `json:"created_at" db:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at" db:"updated_at"`
|
||||
}
|
||||
|
||||
// CreateServiceRequest represents a request to create a service
|
||||
type CreateServiceRequest struct {
|
||||
ProjectID uuid.UUID `json:"project_id"`
|
||||
Name string `json:"name" binding:"required,min=1,max=255"`
|
||||
Type string `json:"type" binding:"required,oneof=web worker database cron"`
|
||||
Image string `json:"image"`
|
||||
Command string `json:"command"`
|
||||
Environment string `json:"environment" binding:"required,oneof=production preview development"`
|
||||
GitRepo string `json:"git_repo"`
|
||||
GitBranch string `json:"git_branch"`
|
||||
BuildPath string `json:"build_path"`
|
||||
CPU string `json:"cpu"`
|
||||
Memory string `json:"memory"`
|
||||
ProjectID uuid.UUID `json:"project_id"`
|
||||
Name string `json:"name" binding:"required,min=1,max=255"`
|
||||
Type string `json:"type" binding:"required,oneof=web worker database cron"`
|
||||
Image string `json:"image"`
|
||||
Command string `json:"command"`
|
||||
Environment string `json:"environment" binding:"required,oneof=production preview development"`
|
||||
GitRepo string `json:"git_repo"`
|
||||
GitBranch string `json:"git_branch"`
|
||||
BuildPath string `json:"build_path"`
|
||||
CPU string `json:"cpu"`
|
||||
Memory string `json:"memory"`
|
||||
Replicas int `json:"replicas"`
|
||||
Port int `json:"port"`
|
||||
Domain string `json:"domain"`
|
||||
HealthCheckPath string `json:"healthcheck_path"`
|
||||
RestartPolicy string `json:"restart_policy"`
|
||||
}
|
||||
|
||||
// UpdateServiceRequest represents a request to update a service
|
||||
type UpdateServiceRequest struct {
|
||||
Name string `json:"name" binding:"omitempty,min=1,max=255"`
|
||||
Type string `json:"type" binding:"omitempty,oneof=web worker database cron"`
|
||||
Image string `json:"image"`
|
||||
Command string `json:"command"`
|
||||
Environment string `json:"environment" binding:"omitempty,oneof=production preview development"`
|
||||
GitRepo string `json:"git_repo"`
|
||||
GitBranch string `json:"git_branch"`
|
||||
BuildPath string `json:"build_path"`
|
||||
CPU string `json:"cpu"`
|
||||
Memory string `json:"memory"`
|
||||
Name string `json:"name" binding:"omitempty,min=1,max=255"`
|
||||
Type string `json:"type" binding:"omitempty,oneof=web worker database cron"`
|
||||
Image string `json:"image"`
|
||||
Command string `json:"command"`
|
||||
Environment string `json:"environment" binding:"omitempty,oneof=production preview development"`
|
||||
GitRepo string `json:"git_repo"`
|
||||
GitBranch string `json:"git_branch"`
|
||||
BuildPath string `json:"build_path"`
|
||||
CPU string `json:"cpu"`
|
||||
Memory string `json:"memory"`
|
||||
Replicas *int `json:"replicas"`
|
||||
Port *int `json:"port"`
|
||||
Domain *string `json:"domain"`
|
||||
HealthCheckPath *string `json:"healthcheck_path"`
|
||||
RestartPolicy string `json:"restart_policy"`
|
||||
}
|
||||
|
||||
// handleGetServices retrieves all services for a project
|
||||
@@ -110,6 +129,9 @@ func handleGetServices(c *gin.Context) {
|
||||
COALESCE(build_path, ''),
|
||||
COALESCE(cpu, ''),
|
||||
COALESCE(memory, ''),
|
||||
COALESCE(replicas, 1), COALESCE(port, 0),
|
||||
COALESCE(domain, ''), COALESCE(healthcheck_path, ''),
|
||||
COALESCE(restart_policy, 'unless-stopped'),
|
||||
created_at, updated_at
|
||||
FROM services
|
||||
WHERE project_id = $1
|
||||
@@ -129,7 +151,8 @@ func handleGetServices(c *gin.Context) {
|
||||
&service.ID, &service.ProjectID, &service.Name, &service.Type, &service.Status,
|
||||
&service.Image, &service.Command, &service.Environment, &service.GitRepo,
|
||||
&service.GitBranch, &service.BuildPath, &service.CPU, &service.Memory,
|
||||
&service.CreatedAt, &service.UpdatedAt,
|
||||
&service.Replicas, &service.Port, &service.Domain, &service.HealthCheckPath,
|
||||
&service.RestartPolicy, &service.CreatedAt, &service.UpdatedAt,
|
||||
)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to scan service"})
|
||||
@@ -138,6 +161,11 @@ func handleGetServices(c *gin.Context) {
|
||||
services = append(services, service)
|
||||
}
|
||||
|
||||
// Reconcile stored status with live container state (no-op without Docker).
|
||||
for i := range services {
|
||||
liveServiceStatus(c, db.(*database.DB), &services[i])
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"services": services})
|
||||
}
|
||||
|
||||
@@ -213,21 +241,26 @@ func handleCreateService(c *gin.Context) {
|
||||
|
||||
// Create new service
|
||||
service := Service{
|
||||
ID: uuid.New(),
|
||||
ProjectID: req.ProjectID,
|
||||
Name: req.Name,
|
||||
Type: req.Type,
|
||||
Status: "stopped", // Initial status
|
||||
Image: req.Image,
|
||||
Command: req.Command,
|
||||
Environment: req.Environment,
|
||||
GitRepo: req.GitRepo,
|
||||
GitBranch: req.GitBranch,
|
||||
BuildPath: req.BuildPath,
|
||||
CPU: req.CPU,
|
||||
Memory: req.Memory,
|
||||
CreatedAt: time.Now(),
|
||||
UpdatedAt: time.Now(),
|
||||
ID: uuid.New(),
|
||||
ProjectID: req.ProjectID,
|
||||
Name: req.Name,
|
||||
Type: req.Type,
|
||||
Status: "stopped", // Initial status
|
||||
Image: req.Image,
|
||||
Command: req.Command,
|
||||
Environment: req.Environment,
|
||||
GitRepo: req.GitRepo,
|
||||
GitBranch: req.GitBranch,
|
||||
BuildPath: req.BuildPath,
|
||||
CPU: req.CPU,
|
||||
Memory: req.Memory,
|
||||
Replicas: req.Replicas,
|
||||
Port: req.Port,
|
||||
Domain: req.Domain,
|
||||
HealthCheckPath: req.HealthCheckPath,
|
||||
RestartPolicy: req.RestartPolicy,
|
||||
CreatedAt: time.Now(),
|
||||
UpdatedAt: time.Now(),
|
||||
}
|
||||
|
||||
// Set default values if not provided
|
||||
@@ -237,6 +270,12 @@ func handleCreateService(c *gin.Context) {
|
||||
if service.Memory == "" {
|
||||
service.Memory = "512Mi"
|
||||
}
|
||||
if service.Replicas < 1 {
|
||||
service.Replicas = 1
|
||||
}
|
||||
if service.RestartPolicy == "" {
|
||||
service.RestartPolicy = "unless-stopped"
|
||||
}
|
||||
|
||||
environmentID, err := getProjectEnvironmentID(db.(*database.DB), service.ProjectID, service.Environment)
|
||||
if err != nil {
|
||||
@@ -248,15 +287,17 @@ func handleCreateService(c *gin.Context) {
|
||||
|
||||
// Insert service into database
|
||||
_, err = db.(*database.DB).Exec(
|
||||
`INSERT INTO services
|
||||
`INSERT INTO services
|
||||
(id, project_id, name, environment_id, service_type, source_type, source_url, image_name,
|
||||
build_command, start_command, type, status, image, command, environment,
|
||||
git_repo, git_branch, build_path, cpu, memory, created_at, updated_at)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21, $22)`,
|
||||
git_repo, git_branch, build_path, cpu, memory, replicas, port, domain,
|
||||
healthcheck_path, restart_policy, created_at, updated_at)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21, $22, $23, $24, $25, $26, $27)`,
|
||||
service.ID, service.ProjectID, service.Name, environmentID, service.Type,
|
||||
sourceType, firstNonEmpty(service.GitRepo, service.Image), service.Image,
|
||||
"", service.Command, service.Type, service.Status, service.Image, service.Command,
|
||||
service.Environment, service.GitRepo, service.GitBranch, service.BuildPath, service.CPU, service.Memory,
|
||||
service.Replicas, service.Port, service.Domain, service.HealthCheckPath, service.RestartPolicy,
|
||||
service.CreatedAt, service.UpdatedAt,
|
||||
)
|
||||
|
||||
@@ -324,6 +365,9 @@ func handleGetService(c *gin.Context) {
|
||||
COALESCE(s.build_path, ''),
|
||||
COALESCE(s.cpu, ''),
|
||||
COALESCE(s.memory, ''),
|
||||
COALESCE(s.replicas, 1), COALESCE(s.port, 0),
|
||||
COALESCE(s.domain, ''), COALESCE(s.healthcheck_path, ''),
|
||||
COALESCE(s.restart_policy, 'unless-stopped'),
|
||||
s.created_at, s.updated_at
|
||||
FROM services s
|
||||
JOIN projects p ON s.project_id = p.id
|
||||
@@ -333,7 +377,8 @@ func handleGetService(c *gin.Context) {
|
||||
&service.ID, &service.ProjectID, &service.Name, &service.Type, &service.Status,
|
||||
&service.Image, &service.Command, &service.Environment, &service.GitRepo,
|
||||
&service.GitBranch, &service.BuildPath, &service.CPU, &service.Memory,
|
||||
&service.CreatedAt, &service.UpdatedAt,
|
||||
&service.Replicas, &service.Port, &service.Domain, &service.HealthCheckPath,
|
||||
&service.RestartPolicy, &service.CreatedAt, &service.UpdatedAt,
|
||||
)
|
||||
|
||||
if err != nil {
|
||||
@@ -341,6 +386,7 @@ func handleGetService(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
liveServiceStatus(c, db.(*database.DB), &service)
|
||||
c.JSON(http.StatusOK, gin.H{"service": service})
|
||||
}
|
||||
|
||||
@@ -386,6 +432,9 @@ func handleUpdateService(c *gin.Context) {
|
||||
COALESCE(s.build_path, ''),
|
||||
COALESCE(s.cpu, ''),
|
||||
COALESCE(s.memory, ''),
|
||||
COALESCE(s.replicas, 1), COALESCE(s.port, 0),
|
||||
COALESCE(s.domain, ''), COALESCE(s.healthcheck_path, ''),
|
||||
COALESCE(s.restart_policy, 'unless-stopped'),
|
||||
s.created_at, s.updated_at
|
||||
FROM services s
|
||||
JOIN projects p ON s.project_id = p.id
|
||||
@@ -396,6 +445,8 @@ func handleUpdateService(c *gin.Context) {
|
||||
&existingService.Status, &existingService.Image, &existingService.Command,
|
||||
&existingService.Environment, &existingService.GitRepo, &existingService.GitBranch,
|
||||
&existingService.BuildPath, &existingService.CPU, &existingService.Memory,
|
||||
&existingService.Replicas, &existingService.Port, &existingService.Domain,
|
||||
&existingService.HealthCheckPath, &existingService.RestartPolicy,
|
||||
&existingService.CreatedAt, &existingService.UpdatedAt,
|
||||
)
|
||||
|
||||
@@ -435,18 +486,37 @@ func handleUpdateService(c *gin.Context) {
|
||||
if req.Memory != "" {
|
||||
existingService.Memory = req.Memory
|
||||
}
|
||||
if req.Replicas != nil {
|
||||
existingService.Replicas = *req.Replicas
|
||||
}
|
||||
if req.Port != nil {
|
||||
existingService.Port = *req.Port
|
||||
}
|
||||
if req.Domain != nil {
|
||||
existingService.Domain = *req.Domain
|
||||
}
|
||||
if req.HealthCheckPath != nil {
|
||||
existingService.HealthCheckPath = *req.HealthCheckPath
|
||||
}
|
||||
if req.RestartPolicy != "" {
|
||||
existingService.RestartPolicy = req.RestartPolicy
|
||||
}
|
||||
|
||||
existingService.UpdatedAt = time.Now()
|
||||
|
||||
// Update service in database
|
||||
_, err = db.(*database.DB).Exec(
|
||||
`UPDATE services
|
||||
`UPDATE services
|
||||
SET name = $1, type = $2, image = $3, command = $4, environment = $5,
|
||||
git_repo = $6, git_branch = $7, build_path = $8, cpu = $9, memory = $10, updated_at = $11
|
||||
WHERE id = $12`,
|
||||
git_repo = $6, git_branch = $7, build_path = $8, cpu = $9, memory = $10,
|
||||
replicas = $11, port = $12, domain = $13, healthcheck_path = $14,
|
||||
restart_policy = $15, updated_at = $16
|
||||
WHERE id = $17`,
|
||||
existingService.Name, existingService.Type, existingService.Image, existingService.Command,
|
||||
existingService.Environment, existingService.GitRepo, existingService.GitBranch,
|
||||
existingService.BuildPath, existingService.CPU, existingService.Memory,
|
||||
existingService.Replicas, existingService.Port, existingService.Domain,
|
||||
existingService.HealthCheckPath, existingService.RestartPolicy,
|
||||
existingService.UpdatedAt, existingService.ID,
|
||||
)
|
||||
|
||||
@@ -455,6 +525,24 @@ func handleUpdateService(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
// A replica change applies immediately when the service has live
|
||||
// containers; other spec fields take effect on the next deploy.
|
||||
if req.Replicas != nil && existingService.Status == "running" {
|
||||
if engineValue, exists := c.Get("deployment_engine"); exists && engineValue != nil {
|
||||
engine := engineValue.(*deployment.DeploymentEngine)
|
||||
serviceCopy := existingService
|
||||
go func() {
|
||||
spec, err := serviceRuntimeSpec(db.(*database.DB), serviceCopy)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
|
||||
defer cancel()
|
||||
_, _ = engine.ReconcileService(ctx, spec)
|
||||
}()
|
||||
}
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"service": existingService})
|
||||
}
|
||||
|
||||
@@ -496,11 +584,18 @@ func handleDeleteService(c *gin.Context) {
|
||||
}
|
||||
|
||||
// Check if user owns the project
|
||||
if projectOwnerID != userID.(string) {
|
||||
if projectOwnerID != userID.(string) && !isAdminUser(db.(*database.DB), userID.(string)) {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "Access denied"})
|
||||
return
|
||||
}
|
||||
|
||||
// Remove runtime containers before dropping the row.
|
||||
if engineValue, exists := c.Get("deployment_engine"); exists && engineValue != nil {
|
||||
if engine, ok := engineValue.(*deployment.DeploymentEngine); ok {
|
||||
_ = engine.RemoveServiceContainers(c.Request.Context(), serviceID.String())
|
||||
}
|
||||
}
|
||||
|
||||
// Delete service (cascade will handle related records)
|
||||
_, err = db.(*database.DB).Exec(
|
||||
"DELETE FROM services WHERE id = $1",
|
||||
|
||||
@@ -3,7 +3,6 @@ package api
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
@@ -241,10 +240,7 @@ func applyCloudflaredTunnel(client *docker.Client, token string) {
|
||||
return
|
||||
}
|
||||
|
||||
if reader, err := client.PullImage(ctx, cloudflaredImage, registry.AuthConfig{}); err == nil {
|
||||
_, _ = io.Copy(io.Discard, reader)
|
||||
_ = reader.Close()
|
||||
} else {
|
||||
if err := client.PullImageWait(ctx, cloudflaredImage, registry.AuthConfig{}); err != nil {
|
||||
log.Printf("Failed to pull %s: %v", cloudflaredImage, err)
|
||||
}
|
||||
|
||||
|
||||
@@ -93,10 +93,10 @@ func (bm *BuildManager) buildPrebuilt(ctx context.Context, req *types.BuildReque
|
||||
return nil, fmt.Errorf("prebuilt image not specified")
|
||||
}
|
||||
|
||||
// Pull the prebuilt image
|
||||
// Pull the prebuilt image — must drain the pull stream or the daemon
|
||||
// aborts it and the tag below races a missing image.
|
||||
auth := registry.AuthConfig{}
|
||||
_, err := bm.dockerClient.PullImage(ctx, req.PrebuiltImage, auth)
|
||||
if err != nil {
|
||||
if err := bm.dockerClient.PullImageWait(ctx, req.PrebuiltImage, auth); err != nil {
|
||||
return nil, fmt.Errorf("failed to pull prebuilt image: %w", err)
|
||||
}
|
||||
|
||||
@@ -104,7 +104,7 @@ func (bm *BuildManager) buildPrebuilt(ctx context.Context, req *types.BuildReque
|
||||
if req.ImageName != "" && req.ImageTag != "" {
|
||||
targetImage := fmt.Sprintf("%s:%s", req.ImageName, req.ImageTag)
|
||||
if targetImage != req.PrebuiltImage {
|
||||
err = bm.dockerClient.TagImage(ctx, req.PrebuiltImage, targetImage)
|
||||
err := bm.dockerClient.TagImage(ctx, req.PrebuiltImage, targetImage)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to tag image: %w", err)
|
||||
}
|
||||
@@ -114,7 +114,7 @@ func (bm *BuildManager) buildPrebuilt(ctx context.Context, req *types.BuildReque
|
||||
|
||||
// Push to registry if specified
|
||||
if req.RegistryURL != "" {
|
||||
err = bm.dockerClient.PushImage(ctx, req.PrebuiltImage, req.RegistryURL)
|
||||
err := bm.dockerClient.PushImage(ctx, req.PrebuiltImage, req.RegistryURL)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to push image: %w", err)
|
||||
}
|
||||
|
||||
@@ -9,10 +9,6 @@ import (
|
||||
"containr/internal/build"
|
||||
"containr/internal/docker"
|
||||
"containr/internal/types"
|
||||
|
||||
"github.com/docker/docker/api/types/mount"
|
||||
"github.com/docker/docker/api/types/network"
|
||||
"github.com/docker/go-connections/nat"
|
||||
)
|
||||
|
||||
type DeploymentEngine struct {
|
||||
@@ -56,6 +52,13 @@ type ServiceConfig struct {
|
||||
Resources ResourceLimits `json:"resources,omitempty"`
|
||||
HealthCheck *HealthCheck `json:"health_check,omitempty"`
|
||||
Replicas int `json:"replicas"`
|
||||
// PublicPort is the container port exposed to users (published host port,
|
||||
// or Traefik routing when Domain is set). 0 = internal-only service.
|
||||
PublicPort int32 `json:"public_port,omitempty"`
|
||||
// Domain is a public hostname routed to PublicPort via Traefik.
|
||||
Domain string `json:"domain,omitempty"`
|
||||
// HealthPath is an HTTP path probed on PublicPort for container health.
|
||||
HealthPath string `json:"health_path,omitempty"`
|
||||
}
|
||||
|
||||
type PortMapping struct {
|
||||
@@ -297,91 +300,53 @@ func (de *DeploymentEngine) buildImage(ctx context.Context, deployment *Deployme
|
||||
return response.ImageName, nil
|
||||
}
|
||||
|
||||
// deployService deploys the service using the built image
|
||||
// deployService deploys the service using the built image. Containers are
|
||||
// reconciled onto the project network with a DNS alias per service name —
|
||||
// other services reach it at http://<name>:<port>.
|
||||
func (de *DeploymentEngine) deployService(ctx context.Context, deployment *Deployment) error {
|
||||
// Convert service config to Docker container config
|
||||
containerConfig := &docker.ContainerConfig{
|
||||
Name: fmt.Sprintf("containr-%s-%s", deployment.ServiceID, deployment.ID),
|
||||
Image: deployment.ImageName,
|
||||
Cmd: deployment.Config.Command,
|
||||
Labels: deployment.Config.Labels,
|
||||
Networks: make(map[string]*network.EndpointSettings),
|
||||
cfg := deployment.Config
|
||||
|
||||
spec := RuntimeSpec{
|
||||
ProjectID: deployment.ProjectID,
|
||||
ServiceID: deployment.ServiceID,
|
||||
Name: cfg.Name,
|
||||
Image: deployment.ImageName,
|
||||
Command: cfg.Command,
|
||||
Env: cfg.Environment,
|
||||
Replicas: cfg.Replicas,
|
||||
Port: cfg.PublicPort,
|
||||
Domain: cfg.Domain,
|
||||
HealthPath: cfg.HealthPath,
|
||||
RestartPolicy: cfg.RestartPolicy,
|
||||
MemoryBytes: cfg.Resources.MemoryBytes,
|
||||
NanoCPUs: cfg.Resources.CPUQuota,
|
||||
}
|
||||
|
||||
// Set environment variables
|
||||
for k, v := range deployment.Config.Environment {
|
||||
containerConfig.Env = append(containerConfig.Env, fmt.Sprintf("%s=%s", k, v))
|
||||
}
|
||||
|
||||
// Set restart policy
|
||||
containerConfig.RestartPolicy = deployment.Config.RestartPolicy
|
||||
|
||||
// Configure port mappings
|
||||
portBindings := make(nat.PortMap)
|
||||
for _, pm := range deployment.Config.PortMappings {
|
||||
port := nat.Port(fmt.Sprintf("%d/%s", pm.ContainerPort, pm.Protocol))
|
||||
if pm.HostPort > 0 {
|
||||
portBindings[port] = []nat.PortBinding{
|
||||
{
|
||||
HostIP: pm.HostIP,
|
||||
HostPort: fmt.Sprintf("%d", pm.HostPort),
|
||||
},
|
||||
}
|
||||
for _, pm := range cfg.PortMappings {
|
||||
if spec.Port == 0 {
|
||||
spec.Port = pm.ContainerPort
|
||||
}
|
||||
}
|
||||
containerConfig.PortBindings = portBindings
|
||||
|
||||
// Configure resource limits
|
||||
if deployment.Config.Resources.MemoryBytes > 0 {
|
||||
containerConfig.Memory = deployment.Config.Resources.MemoryBytes
|
||||
}
|
||||
if deployment.Config.Resources.CPUQuota > 0 {
|
||||
containerConfig.NanoCPUs = deployment.Config.Resources.CPUQuota
|
||||
if spec.RestartPolicy == "" {
|
||||
spec.RestartPolicy = "unless-stopped"
|
||||
}
|
||||
|
||||
// Configure volume mounts
|
||||
for _, vm := range deployment.Config.VolumeMounts {
|
||||
mount := mount.Mount{
|
||||
Type: mount.Type(vm.Type),
|
||||
Source: vm.Source,
|
||||
Target: vm.Destination,
|
||||
ReadOnly: vm.ReadOnly,
|
||||
}
|
||||
containerConfig.Mounts = append(containerConfig.Mounts, mount)
|
||||
// Volume mounts aren't reconciled replicas-aware yet; still applied on the
|
||||
// container config of every replica via spec extension later if needed.
|
||||
state, err := de.ReconcileService(ctx, spec)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Create containers based on replica count
|
||||
deployment.Containers = make([]ContainerInfo, deployment.Config.Replicas)
|
||||
for i := 0; i < deployment.Config.Replicas; i++ {
|
||||
containerName := fmt.Sprintf("%s-%d", containerConfig.Name, i)
|
||||
|
||||
// Create container
|
||||
containerID, err := de.dockerClient.CreateContainer(ctx, *containerConfig)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create container %d: %w", i, err)
|
||||
}
|
||||
|
||||
// Start container
|
||||
err = de.dockerClient.StartContainer(ctx, containerID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to start container %d: %w", i, err)
|
||||
}
|
||||
|
||||
// Get container info
|
||||
_, err = de.dockerClient.GetContainer(ctx, containerID)
|
||||
if err != nil {
|
||||
log.Printf("Failed to get container info for %s: %v", containerID, err)
|
||||
}
|
||||
|
||||
deployment.Containers = make([]ContainerInfo, len(state.Containers))
|
||||
for i, c := range state.Containers {
|
||||
deployment.Containers[i] = ContainerInfo{
|
||||
ID: containerID,
|
||||
Name: containerName,
|
||||
Status: "running",
|
||||
ID: c.ID,
|
||||
Name: c.Name,
|
||||
Status: c.State,
|
||||
CreatedAt: time.Now(),
|
||||
StartedAt: time.Now(),
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,374 @@
|
||||
package deployment
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"containr/internal/docker"
|
||||
|
||||
"github.com/docker/docker/api/types/container"
|
||||
"github.com/docker/docker/api/types/filters"
|
||||
"github.com/docker/docker/api/types/network"
|
||||
"github.com/docker/go-connections/nat"
|
||||
)
|
||||
|
||||
// Container labels used to associate runtime containers with services.
|
||||
const (
|
||||
LabelManaged = "containr.managed"
|
||||
LabelProject = "containr.project"
|
||||
LabelService = "containr.service"
|
||||
LabelReplica = "containr.replica"
|
||||
LabelSpecHash = "containr.spec-hash"
|
||||
LabelDeployment = "containr.deployment"
|
||||
)
|
||||
|
||||
// RuntimeSpec is everything needed to run a service's containers.
|
||||
type RuntimeSpec struct {
|
||||
ProjectID string
|
||||
ServiceID string
|
||||
Name string // DNS alias on the project network
|
||||
Image string
|
||||
Command []string
|
||||
Env map[string]string // already resolved
|
||||
Replicas int
|
||||
Port int32 // container port to expose publicly; 0 = none
|
||||
Domain string // public hostname routed via Traefik; empty = none
|
||||
HealthPath string // http path probed on Port for container healthcheck
|
||||
RestartPolicy string
|
||||
MemoryBytes int64
|
||||
NanoCPUs int64
|
||||
}
|
||||
|
||||
// RuntimeContainer describes one live replica.
|
||||
type RuntimeContainer struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
State string `json:"state"` // running, exited, ...
|
||||
Replica int `json:"replica"`
|
||||
}
|
||||
|
||||
// RuntimeState is the live view of a service's containers.
|
||||
type RuntimeState struct {
|
||||
Desired int `json:"desired"`
|
||||
Containers []RuntimeContainer `json:"containers"`
|
||||
URLs []string `json:"urls"`
|
||||
Status string `json:"status"` // running, degraded, stopped
|
||||
}
|
||||
|
||||
func projectNetworkName(projectID string) string {
|
||||
short := strings.ReplaceAll(projectID, "-", "")
|
||||
if len(short) > 12 {
|
||||
short = short[:12]
|
||||
}
|
||||
return "containr-proj-" + short
|
||||
}
|
||||
|
||||
func serviceNamePrefix(serviceID string) string {
|
||||
return fmt.Sprintf("containr-%s-", serviceID)
|
||||
}
|
||||
|
||||
// EnsureProjectNetwork creates the per-project bridge network if missing.
|
||||
// Containers on it resolve each other by service name (private networking).
|
||||
func (de *DeploymentEngine) EnsureProjectNetwork(ctx context.Context, projectID string) (string, error) {
|
||||
name := projectNetworkName(projectID)
|
||||
|
||||
networks, err := de.dockerClient.ListNetworks(ctx)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
for _, n := range networks {
|
||||
if n.Name == name {
|
||||
return name, nil
|
||||
}
|
||||
}
|
||||
|
||||
_, err = de.dockerClient.CreateNetwork(ctx, docker.NetworkConfig{
|
||||
Name: name,
|
||||
Driver: "bridge",
|
||||
Labels: map[string]string{
|
||||
LabelManaged: "true",
|
||||
LabelProject: projectID,
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("create project network: %w", err)
|
||||
}
|
||||
return name, nil
|
||||
}
|
||||
|
||||
// traefikNetworkName finds the shared edge network (the infra stack's
|
||||
// containr-network where Traefik listens) if it exists.
|
||||
func (de *DeploymentEngine) traefikNetworkName(ctx context.Context) string {
|
||||
networks, err := de.dockerClient.ListNetworks(ctx)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
for _, n := range networks {
|
||||
if n.Name == "containr-network" {
|
||||
return n.Name
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// ListServiceContainers returns containers managed for a service.
|
||||
func (de *DeploymentEngine) ListServiceContainers(ctx context.Context, serviceID string) ([]container.Summary, error) {
|
||||
return de.dockerClient.ListContainersFiltered(ctx, filters.NewArgs(
|
||||
filters.Arg("label", LabelService+"="+serviceID),
|
||||
), true)
|
||||
}
|
||||
|
||||
// ReconcileService brings live containers in line with the spec. When the
|
||||
// spec hash changed (image/env/ports/etc.) all replicas are recreated;
|
||||
// otherwise only the replica delta is applied.
|
||||
func (de *DeploymentEngine) ReconcileService(ctx context.Context, spec RuntimeSpec) (*RuntimeState, error) {
|
||||
if spec.Replicas < 1 {
|
||||
spec.Replicas = 1
|
||||
}
|
||||
|
||||
networkName, err := de.EnsureProjectNetwork(ctx, spec.ProjectID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
edgeNetwork := ""
|
||||
if spec.Domain != "" {
|
||||
edgeNetwork = de.traefikNetworkName(ctx)
|
||||
}
|
||||
|
||||
hash := specHash(spec)
|
||||
existing, err := de.ListServiceContainers(ctx, spec.ServiceID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// Deterministic order: scale-down removes the highest replica indices.
|
||||
sort.Slice(existing, func(i, j int) bool {
|
||||
return replicaIndex(existing[i]) < replicaIndex(existing[j])
|
||||
})
|
||||
|
||||
drifted := false
|
||||
for _, c := range existing {
|
||||
if c.Labels[LabelSpecHash] != hash {
|
||||
drifted = true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if drifted {
|
||||
for _, c := range existing {
|
||||
_ = de.dockerClient.RemoveContainer(ctx, c.ID, true)
|
||||
}
|
||||
existing = nil
|
||||
}
|
||||
|
||||
for i := len(existing); i > spec.Replicas; i-- {
|
||||
victim := existing[i-1]
|
||||
_ = de.dockerClient.StopContainer(ctx, victim.ID, nil)
|
||||
_ = de.dockerClient.RemoveContainer(ctx, victim.ID, true)
|
||||
existing = existing[:i-1]
|
||||
}
|
||||
|
||||
for i := len(existing); i < spec.Replicas; i++ {
|
||||
if _, err := de.createReplica(ctx, spec, networkName, edgeNetwork, i, hash); err != nil {
|
||||
return nil, fmt.Errorf("create replica %d: %w", i, err)
|
||||
}
|
||||
}
|
||||
|
||||
for _, c := range existing {
|
||||
if c.State != "running" {
|
||||
_ = de.dockerClient.StartContainer(ctx, c.ID)
|
||||
}
|
||||
}
|
||||
|
||||
return de.RuntimeState(ctx, spec.ServiceID, spec.Replicas)
|
||||
}
|
||||
|
||||
func (de *DeploymentEngine) createReplica(ctx context.Context, spec RuntimeSpec, projectNet, edgeNet string, index int, hash string) (string, error) {
|
||||
env := make([]string, 0, len(spec.Env))
|
||||
for k, v := range spec.Env {
|
||||
env = append(env, k+"="+v)
|
||||
}
|
||||
sort.Strings(env)
|
||||
|
||||
labels := map[string]string{
|
||||
LabelManaged: "true",
|
||||
LabelProject: spec.ProjectID,
|
||||
LabelService: spec.ServiceID,
|
||||
LabelReplica: fmt.Sprintf("%d", index),
|
||||
LabelSpecHash: hash,
|
||||
}
|
||||
|
||||
endpoints := map[string]*network.EndpointSettings{
|
||||
projectNet: {Aliases: []string{spec.Name}},
|
||||
}
|
||||
if edgeNet != "" {
|
||||
endpoints[edgeNet] = &network.EndpointSettings{}
|
||||
labels["traefik.enable"] = "true"
|
||||
labels["traefik.docker.network"] = edgeNet
|
||||
labels["traefik.http.routers.svc-"+spec.ServiceID[:8]+".rule"] = "Host(`" + spec.Domain + "`)"
|
||||
labels["traefik.http.routers.svc-"+spec.ServiceID[:8]+".entrypoints"] = "web"
|
||||
labels["traefik.http.services.svc-"+spec.ServiceID[:8]+".loadbalancer.server.port"] = fmt.Sprintf("%d", spec.Port)
|
||||
}
|
||||
|
||||
cfg := docker.ContainerConfig{
|
||||
Name: fmt.Sprintf("%s%d", serviceNamePrefix(spec.ServiceID), index),
|
||||
Image: spec.Image,
|
||||
Cmd: spec.Command,
|
||||
Env: env,
|
||||
Labels: labels,
|
||||
RestartPolicy: spec.RestartPolicy,
|
||||
Memory: spec.MemoryBytes,
|
||||
NanoCPUs: spec.NanoCPUs,
|
||||
Networks: endpoints,
|
||||
}
|
||||
|
||||
if spec.Port > 0 {
|
||||
port := nat.Port(fmt.Sprintf("%d/tcp", spec.Port))
|
||||
cfg.ExposedPorts = nat.PortSet{port: struct{}{}}
|
||||
if spec.Domain == "" {
|
||||
// No edge router: publish an ephemeral host port for direct access.
|
||||
cfg.PortBindings = nat.PortMap{port: []nat.PortBinding{{HostIP: "0.0.0.0", HostPort: ""}}}
|
||||
}
|
||||
if spec.HealthPath != "" {
|
||||
cfg.Healthcheck = &container.HealthConfig{
|
||||
Test: []string{"CMD-SHELL", fmt.Sprintf("wget -q -O /dev/null http://127.0.0.1:%d%s || exit 1", spec.Port, spec.HealthPath)},
|
||||
Interval: 30 * time.Second,
|
||||
Timeout: 5 * time.Second,
|
||||
Retries: 3,
|
||||
StartPeriod: 10 * time.Second,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
id, err := de.dockerClient.CreateContainer(ctx, cfg)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if err := de.dockerClient.StartContainer(ctx, id); err != nil {
|
||||
return "", fmt.Errorf("start: %w", err)
|
||||
}
|
||||
return id, nil
|
||||
}
|
||||
|
||||
// RuntimeState reads live container state for a service.
|
||||
func (de *DeploymentEngine) RuntimeState(ctx context.Context, serviceID string, desired int) (*RuntimeState, error) {
|
||||
containers, err := de.ListServiceContainers(ctx, serviceID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
state := &RuntimeState{Desired: desired, Status: "stopped"}
|
||||
running := 0
|
||||
for _, c := range containers {
|
||||
rc := RuntimeContainer{ID: c.ID, Name: strings.TrimPrefix(c.Names[0], "/"), State: c.State, Replica: replicaIndex(c)}
|
||||
state.Containers = append(state.Containers, rc)
|
||||
if c.State == "running" {
|
||||
running++
|
||||
}
|
||||
for _, p := range c.Ports {
|
||||
if p.PublicPort != 0 {
|
||||
state.URLs = append(state.URLs, fmt.Sprintf("http://localhost:%d", p.PublicPort))
|
||||
}
|
||||
}
|
||||
}
|
||||
sort.Slice(state.Containers, func(i, j int) bool { return state.Containers[i].Replica < state.Containers[j].Replica })
|
||||
sort.Strings(state.URLs)
|
||||
|
||||
switch {
|
||||
case running == 0:
|
||||
state.Status = "stopped"
|
||||
case running < desired:
|
||||
state.Status = "degraded"
|
||||
default:
|
||||
state.Status = "running"
|
||||
}
|
||||
return state, nil
|
||||
}
|
||||
|
||||
// StopService stops (but keeps) all replicas of a service.
|
||||
func (de *DeploymentEngine) StopService(ctx context.Context, serviceID string) error {
|
||||
containers, err := de.ListServiceContainers(ctx, serviceID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, c := range containers {
|
||||
if c.State == "running" {
|
||||
if err := de.dockerClient.StopContainer(ctx, c.ID, nil); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// StartService starts all stopped replicas.
|
||||
func (de *DeploymentEngine) StartService(ctx context.Context, serviceID string) error {
|
||||
containers, err := de.ListServiceContainers(ctx, serviceID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, c := range containers {
|
||||
if c.State != "running" {
|
||||
if err := de.dockerClient.StartContainer(ctx, c.ID); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// RemoveServiceContainers deletes every replica of a service.
|
||||
func (de *DeploymentEngine) RemoveServiceContainers(ctx context.Context, serviceID string) error {
|
||||
containers, err := de.ListServiceContainers(ctx, serviceID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, c := range containers {
|
||||
_ = de.dockerClient.RemoveContainer(ctx, c.ID, true)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// RemoveProjectContainers deletes every managed container in a project.
|
||||
func (de *DeploymentEngine) RemoveProjectContainers(ctx context.Context, projectID string) error {
|
||||
containers, err := de.dockerClient.ListContainersFiltered(ctx, filters.NewArgs(
|
||||
filters.Arg("label", LabelProject+"="+projectID),
|
||||
), true)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, c := range containers {
|
||||
_ = de.dockerClient.RemoveContainer(ctx, c.ID, true)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// RemoveProjectNetwork removes the per-project bridge network if present.
|
||||
func (de *DeploymentEngine) RemoveProjectNetwork(ctx context.Context, projectID string) error {
|
||||
return de.dockerClient.RemoveNetwork(ctx, projectNetworkName(projectID))
|
||||
}
|
||||
|
||||
func replicaIndex(c container.Summary) int {
|
||||
replica := 0
|
||||
fmt.Sscanf(c.Labels[LabelReplica], "%d", &replica)
|
||||
return replica
|
||||
}
|
||||
|
||||
func specHash(spec RuntimeSpec) string {
|
||||
payload, _ := json.Marshal(struct {
|
||||
Image, Name, Domain, HealthPath, RestartPolicy string
|
||||
Command []string
|
||||
Env map[string]string
|
||||
Port int32
|
||||
MemoryBytes, NanoCPUs int64
|
||||
}{spec.Image, spec.Name, spec.Domain, spec.HealthPath, spec.RestartPolicy,
|
||||
spec.Command, spec.Env, spec.Port, spec.MemoryBytes, spec.NanoCPUs})
|
||||
sum := sha256.Sum256(payload)
|
||||
return hex.EncodeToString(sum[:8])
|
||||
}
|
||||
@@ -3,6 +3,7 @@ package docker
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"time"
|
||||
@@ -10,6 +11,7 @@ import (
|
||||
"github.com/docker/docker/api/types"
|
||||
"github.com/docker/docker/api/types/container"
|
||||
"github.com/docker/docker/api/types/events"
|
||||
"github.com/docker/docker/api/types/filters"
|
||||
"github.com/docker/docker/api/types/image"
|
||||
"github.com/docker/docker/api/types/network"
|
||||
"github.com/docker/docker/api/types/registry"
|
||||
@@ -50,6 +52,14 @@ func (c *Client) ListContainers(ctx context.Context, all bool) ([]types.Containe
|
||||
})
|
||||
}
|
||||
|
||||
// ListContainersFiltered returns containers matching the given filters.
|
||||
func (c *Client) ListContainersFiltered(ctx context.Context, f filters.Args, all bool) ([]types.Container, error) {
|
||||
return c.cli.ContainerList(ctx, container.ListOptions{
|
||||
All: all,
|
||||
Filters: f,
|
||||
})
|
||||
}
|
||||
|
||||
// GetContainer returns detailed information about a specific container
|
||||
func (c *Client) GetContainer(ctx context.Context, containerID string) (types.ContainerJSON, error) {
|
||||
return c.cli.ContainerInspect(ctx, containerID)
|
||||
@@ -63,6 +73,7 @@ func (c *Client) CreateContainer(ctx context.Context, config ContainerConfig) (s
|
||||
Env: config.Env,
|
||||
Labels: config.Labels,
|
||||
ExposedPorts: config.ExposedPorts,
|
||||
Healthcheck: config.Healthcheck,
|
||||
}
|
||||
|
||||
hostConfig := &container.HostConfig{
|
||||
@@ -168,7 +179,9 @@ func (c *Client) ListImages(ctx context.Context, all bool) ([]image.Summary, err
|
||||
})
|
||||
}
|
||||
|
||||
// PullImage pulls an image from a registry
|
||||
// PullImage pulls an image from a registry. The pull runs as the returned
|
||||
// stream is consumed — callers must drain and close it (or use
|
||||
// PullImageWait) or the pull may be aborted.
|
||||
func (c *Client) PullImage(ctx context.Context, ref string, auth registry.AuthConfig) (io.ReadCloser, error) {
|
||||
authStr, _ := registry.EncodeAuthConfig(auth)
|
||||
return c.cli.ImagePull(ctx, ref, image.PullOptions{
|
||||
@@ -176,6 +189,33 @@ func (c *Client) PullImage(ctx context.Context, ref string, auth registry.AuthCo
|
||||
})
|
||||
}
|
||||
|
||||
// PullImageWait pulls an image and blocks until the pull stream completes.
|
||||
func (c *Client) PullImageWait(ctx context.Context, ref string, auth registry.AuthConfig) error {
|
||||
reader, err := c.PullImage(ctx, ref, auth)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer reader.Close()
|
||||
// The daemon performs the pull while streaming progress; drain it fully
|
||||
// and surface any errorDetail the daemon reports in the stream.
|
||||
dec := json.NewDecoder(reader)
|
||||
var msg struct {
|
||||
ErrorDetail *struct {
|
||||
Message string `json:"message"`
|
||||
} `json:"errorDetail"`
|
||||
Error string `json:"error"`
|
||||
}
|
||||
for dec.Decode(&msg) == nil {
|
||||
if msg.ErrorDetail != nil {
|
||||
return fmt.Errorf("%s", msg.ErrorDetail.Message)
|
||||
}
|
||||
if msg.Error != "" {
|
||||
return fmt.Errorf("%s", msg.Error)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// BuildImage builds an image from a Dockerfile
|
||||
func (c *Client) BuildImage(ctx context.Context, buildContext io.Reader, options BuildOptions) (types.ImageBuildResponse, error) {
|
||||
return c.cli.ImageBuild(ctx, buildContext, types.ImageBuildOptions{
|
||||
|
||||
@@ -3,6 +3,7 @@ package docker
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/docker/docker/api/types/container"
|
||||
"github.com/docker/docker/api/types/filters"
|
||||
"github.com/docker/docker/api/types/mount"
|
||||
"github.com/docker/docker/api/types/network"
|
||||
@@ -24,6 +25,7 @@ type ContainerConfig struct {
|
||||
NanoCPUs int64
|
||||
NetworkMode string
|
||||
Networks map[string]*network.EndpointSettings
|
||||
Healthcheck *container.HealthConfig
|
||||
}
|
||||
|
||||
// LogOptions represents options for retrieving container logs
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
-- +goose Up
|
||||
-- Runtime spec for Railway-style services: replica count, the container port
|
||||
-- to expose, an optional public domain routed via Traefik, healthcheck path
|
||||
-- and restart policy.
|
||||
ALTER TABLE public.services ADD COLUMN IF NOT EXISTS replicas integer NOT NULL DEFAULT 1;
|
||||
ALTER TABLE public.services ADD COLUMN IF NOT EXISTS port integer NOT NULL DEFAULT 0;
|
||||
ALTER TABLE public.services ADD COLUMN IF NOT EXISTS domain character varying(500) NOT NULL DEFAULT '';
|
||||
ALTER TABLE public.services ADD COLUMN IF NOT EXISTS healthcheck_path character varying(500) NOT NULL DEFAULT '';
|
||||
ALTER TABLE public.services ADD COLUMN IF NOT EXISTS restart_policy character varying(50) NOT NULL DEFAULT 'unless-stopped';
|
||||
|
||||
-- +goose Down
|
||||
ALTER TABLE public.services DROP COLUMN IF EXISTS replicas;
|
||||
ALTER TABLE public.services DROP COLUMN IF EXISTS port;
|
||||
ALTER TABLE public.services DROP COLUMN IF EXISTS domain;
|
||||
ALTER TABLE public.services DROP COLUMN IF EXISTS healthcheck_path;
|
||||
ALTER TABLE public.services DROP COLUMN IF EXISTS restart_policy;
|
||||
@@ -29,6 +29,10 @@ import {
|
||||
execInService,
|
||||
rollbackDeployment,
|
||||
updateServiceVariables,
|
||||
updateService,
|
||||
restartService,
|
||||
stopService,
|
||||
getServiceRuntime,
|
||||
type CronJobEntity,
|
||||
} from '@/lib/api-client';
|
||||
import { getDemoProjectById, getDemoServiceById, getDemoCronJobsByService } from '@/lib/demo-data';
|
||||
@@ -365,6 +369,39 @@ export function ServiceDetailPage() {
|
||||
},
|
||||
});
|
||||
|
||||
const invalidateService = () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['service', serviceId] });
|
||||
queryClient.invalidateQueries({ queryKey: ['service-runtime', serviceId] });
|
||||
queryClient.invalidateQueries({ queryKey: ['project-services', projectId] });
|
||||
};
|
||||
|
||||
const restartMutation = useMutation({
|
||||
mutationFn: () => restartService(serviceId),
|
||||
onSuccess: invalidateService,
|
||||
});
|
||||
const stopMutation = useMutation({
|
||||
mutationFn: () => stopService(serviceId),
|
||||
onSuccess: invalidateService,
|
||||
});
|
||||
const updateServiceMutation = useMutation({
|
||||
mutationFn: (input: Parameters<typeof updateService>[1]) => updateService(serviceId, input),
|
||||
onSuccess: () => {
|
||||
setNetworkForm(null);
|
||||
invalidateService();
|
||||
},
|
||||
});
|
||||
|
||||
const runtimeQuery = useQuery({
|
||||
queryKey: ['service-runtime', serviceId],
|
||||
queryFn: () => getServiceRuntime(serviceId),
|
||||
enabled: Boolean(serviceId) && !isDemoMode,
|
||||
refetchInterval: 10_000,
|
||||
});
|
||||
|
||||
const [networkForm, setNetworkForm] = useState<{
|
||||
port: string; domain: string; healthcheckPath: string; restartPolicy: string; replicas: string;
|
||||
} | null>(null);
|
||||
|
||||
const project = isDemoMode ? getDemoProjectById(projectId) : projectQuery.data;
|
||||
const service = isDemoMode ? getDemoServiceById(serviceId) : serviceQuery.data;
|
||||
|
||||
@@ -558,15 +595,15 @@ export function ServiceDetailPage() {
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center" style={{ gap: '16px', marginTop: '4px' }}>
|
||||
{service.status === 'running' && (
|
||||
<a
|
||||
href={`https://${service.name}.containr.dev`}
|
||||
{(service.publicUrl || service.domain) && (
|
||||
<a
|
||||
href={service.publicUrl ?? `https://${service.domain}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center hover:text-[#9295a4] transition-colors"
|
||||
style={{ color: '#6b6e7d', fontSize: '12.5px', textDecoration: 'none', gap: '4px' }}
|
||||
>
|
||||
https://{service.name}.containr.dev
|
||||
{service.publicUrl ?? `https://${service.domain}`}
|
||||
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round">
|
||||
<path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"/>
|
||||
<polyline points="15 3 21 3 21 9"/>
|
||||
@@ -590,15 +627,26 @@ export function ServiceDetailPage() {
|
||||
{!isDemoMode && (
|
||||
<>
|
||||
<button
|
||||
onClick={() => deployMutation.mutate('restart')}
|
||||
disabled={deployMutation.isPending || service.status !== 'running'}
|
||||
onClick={() => stopMutation.mutate()}
|
||||
disabled={stopMutation.isPending || service.status !== 'running'}
|
||||
className={`btn-stop ${service.status !== 'running' ? 'disabled' : ''}`}
|
||||
>
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.2" strokeLinecap="round">
|
||||
<circle cx="12" cy="12" r="10"/>
|
||||
<circle cx="12" cy="12" r="3" fill="currentColor" stroke="none"/>
|
||||
</svg>
|
||||
STOP
|
||||
{stopMutation.isPending ? 'STOPPING…' : 'STOP'}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => restartMutation.mutate()}
|
||||
disabled={restartMutation.isPending || service.status !== 'running'}
|
||||
className={`btn-restart ${service.status !== 'running' ? 'disabled' : ''}`}
|
||||
>
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.2" strokeLinecap="round">
|
||||
<polyline points="1 4 1 10 7 10"/>
|
||||
<path d="M3.51 15a9 9 0 1 0 .49-4.5"/>
|
||||
</svg>
|
||||
{restartMutation.isPending ? 'RESTARTING…' : 'RESTART'}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => deployMutation.mutate('manual')}
|
||||
@@ -606,10 +654,10 @@ export function ServiceDetailPage() {
|
||||
className={`btn-restart ${deployMutation.isPending ? 'disabled' : ''}`}
|
||||
>
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.2" strokeLinecap="round">
|
||||
<polyline points="1 4 1 10 7 10"/>
|
||||
<path d="M3.51 15a9 9 0 1 0 .49-4.5"/>
|
||||
<path d="M4.5 16.5c-1.5 1.26-2 5-2 5s3.74-.5 5-2c.71-.84.7-2.13-.09-2.91a2.18 2.18 0 0 0-2.91-.09z"/>
|
||||
<path d="m12 15-3-3a22 22 0 0 1 2-3.95A12.88 12.88 0 0 1 22 2c0 2.72-.78 7.5-6 11a22.35 22.35 0 0 1-4 2z"/>
|
||||
</svg>
|
||||
RESTART
|
||||
{deployMutation.isPending ? 'DEPLOYING…' : 'DEPLOY'}
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
@@ -1078,7 +1126,11 @@ export function ServiceDetailPage() {
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-[var(--text-primary)]">Environment Variables</h2>
|
||||
<p className="text-sm text-[var(--text-secondary)]">Runtime values injected into the service container</p>
|
||||
<p className="text-sm text-[var(--text-secondary)]">
|
||||
Runtime values injected at deploy. Reference another service's variable with{' '}
|
||||
<code className="mono text-[var(--accent-primary)]">{'${{service.KEY}}'}</code> — e.g.{' '}
|
||||
<code className="mono text-[var(--accent-primary)]">{'${{postgres.POSTGRES_PASSWORD}}'}</code>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
@@ -1775,6 +1827,159 @@ export function ServiceDetailPage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{!isDemoMode && (
|
||||
<div className="mt-6 pt-6 border-t border-[var(--border-subtle)]">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h3 className="text-sm font-medium text-[var(--text-primary)]">Networking</h3>
|
||||
{networkForm === null && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
setNetworkForm({
|
||||
port: service.port ? String(service.port) : '',
|
||||
domain: service.domain ?? '',
|
||||
healthcheckPath: service.healthcheckPath ?? '',
|
||||
restartPolicy: service.restartPolicy ?? 'unless-stopped',
|
||||
replicas: String(service.replicas ?? 1),
|
||||
})
|
||||
}
|
||||
className="px-3 py-1.5 rounded-[var(--radius-md)] border border-[var(--border-subtle)] text-xs font-medium hover:border-[var(--border-default)] transition-colors"
|
||||
>
|
||||
Edit
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{runtimeQuery.data && (
|
||||
<div className="mb-4 rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-muted)]/40 p-3">
|
||||
<div className="flex items-center gap-4 text-xs">
|
||||
<span className="text-[var(--text-tertiary)]">
|
||||
Live: <span className="text-[var(--text-primary)] font-medium">{runtimeQuery.data.containers.filter((c) => c.state === 'running').length}/{runtimeQuery.data.desired}</span> replicas
|
||||
</span>
|
||||
<span className="text-[var(--text-tertiary)]">
|
||||
Internal: <span className="mono text-[var(--text-primary)]">{service.name}{service.port ? `:${service.port}` : ''}</span>
|
||||
</span>
|
||||
{runtimeQuery.data.urls.map((url) => (
|
||||
<a key={url} href={url} target="_blank" rel="noopener noreferrer" className="mono text-[var(--accent-primary)] hover:underline">
|
||||
{url}
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{networkForm === null ? (
|
||||
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3 text-xs">
|
||||
<div className="rounded-[var(--radius-md)] border border-[var(--border-primary)] p-3">
|
||||
<p className="text-[var(--text-tertiary)] uppercase tracking-wide">Port</p>
|
||||
<p className="mt-1 text-sm font-medium text-[var(--text-primary)]">{service.port || '—'}</p>
|
||||
</div>
|
||||
<div className="rounded-[var(--radius-md)] border border-[var(--border-primary)] p-3">
|
||||
<p className="text-[var(--text-tertiary)] uppercase tracking-wide">Domain</p>
|
||||
<p className="mt-1 text-sm font-medium text-[var(--text-primary)] truncate">{service.domain || '—'}</p>
|
||||
</div>
|
||||
<div className="rounded-[var(--radius-md)] border border-[var(--border-primary)] p-3">
|
||||
<p className="text-[var(--text-tertiary)] uppercase tracking-wide">Replicas</p>
|
||||
<p className="mt-1 text-sm font-medium text-[var(--text-primary)]">{service.replicas ?? 1}</p>
|
||||
</div>
|
||||
<div className="rounded-[var(--radius-md)] border border-[var(--border-primary)] p-3">
|
||||
<p className="text-[var(--text-tertiary)] uppercase tracking-wide">Restart</p>
|
||||
<p className="mt-1 text-sm font-medium text-[var(--text-primary)]">{service.restartPolicy || 'unless-stopped'}</p>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="rounded-[var(--radius-md)] border border-[var(--border-primary)] p-4 space-y-4">
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 gap-3">
|
||||
<div>
|
||||
<label className="block text-xs text-[var(--text-tertiary)] mb-1">Container port</label>
|
||||
<input
|
||||
type="number"
|
||||
min={0}
|
||||
max={65535}
|
||||
value={networkForm.port}
|
||||
onChange={(e) => setNetworkForm({ ...networkForm, port: e.target.value })}
|
||||
placeholder="e.g. 3000"
|
||||
className="w-full px-3 py-2 rounded-[var(--radius-md)] bg-[var(--bg-secondary)] border border-[var(--border-primary)] text-sm text-[var(--text-primary)]"
|
||||
/>
|
||||
<p className="mt-1 text-[10px] text-[var(--text-tertiary)]">Published on the host; routed via Traefik when a domain is set</p>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs text-[var(--text-tertiary)] mb-1">Domain</label>
|
||||
<input
|
||||
value={networkForm.domain}
|
||||
onChange={(e) => setNetworkForm({ ...networkForm, domain: e.target.value })}
|
||||
placeholder="app.example.com"
|
||||
className="w-full px-3 py-2 rounded-[var(--radius-md)] bg-[var(--bg-secondary)] border border-[var(--border-primary)] text-sm text-[var(--text-primary)]"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs text-[var(--text-tertiary)] mb-1">Health check path</label>
|
||||
<input
|
||||
value={networkForm.healthcheckPath}
|
||||
onChange={(e) => setNetworkForm({ ...networkForm, healthcheckPath: e.target.value })}
|
||||
placeholder="/health"
|
||||
className="w-full px-3 py-2 rounded-[var(--radius-md)] bg-[var(--bg-secondary)] border border-[var(--border-primary)] text-sm text-[var(--text-primary)]"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs text-[var(--text-tertiary)] mb-1">Replicas</label>
|
||||
<input
|
||||
type="number"
|
||||
min={1}
|
||||
max={20}
|
||||
value={networkForm.replicas}
|
||||
onChange={(e) => setNetworkForm({ ...networkForm, replicas: e.target.value })}
|
||||
className="w-full px-3 py-2 rounded-[var(--radius-md)] bg-[var(--bg-secondary)] border border-[var(--border-primary)] text-sm text-[var(--text-primary)]"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs text-[var(--text-tertiary)] mb-1">Restart policy</label>
|
||||
<select
|
||||
value={networkForm.restartPolicy}
|
||||
onChange={(e) => setNetworkForm({ ...networkForm, restartPolicy: e.target.value })}
|
||||
className="w-full px-3 py-2 rounded-[var(--radius-md)] bg-[var(--bg-secondary)] border border-[var(--border-primary)] text-sm text-[var(--text-primary)]"
|
||||
>
|
||||
<option value="unless-stopped">unless-stopped</option>
|
||||
<option value="always">always</option>
|
||||
<option value="on-failure">on-failure</option>
|
||||
<option value="no">no</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
disabled={updateServiceMutation.isPending}
|
||||
onClick={() =>
|
||||
updateServiceMutation.mutate({
|
||||
port: Number(networkForm.port) || 0,
|
||||
domain: networkForm.domain,
|
||||
healthcheck_path: networkForm.healthcheckPath,
|
||||
restart_policy: networkForm.restartPolicy,
|
||||
replicas: Math.max(1, Math.min(20, Number(networkForm.replicas) || 1)),
|
||||
})
|
||||
}
|
||||
className="px-4 py-2 rounded-[var(--radius-md)] bg-[var(--accent-primary)] text-[var(--accent-contrast)] text-sm font-medium disabled:opacity-50"
|
||||
>
|
||||
{updateServiceMutation.isPending ? 'Saving…' : 'Save'}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setNetworkForm(null)}
|
||||
className="px-4 py-2 rounded-[var(--radius-md)] border border-[var(--border-primary)] text-sm text-[var(--text-primary)]"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<p className="text-[10px] text-[var(--text-tertiary)]">Takes effect on next deploy or redeploy</p>
|
||||
</div>
|
||||
{updateServiceMutation.isError && (
|
||||
<p className="text-xs text-[var(--error)]">{(updateServiceMutation.error as Error).message}</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isDemoMode && (
|
||||
<div className="mt-6 pt-6 border-t border-[var(--border-subtle)]">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
|
||||
@@ -11,7 +11,15 @@ import {
|
||||
type NodeTypes,
|
||||
} from '@xyflow/react';
|
||||
import '@xyflow/react/dist/style.css';
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import type { ServiceEntity } from '@/lib/api-client';
|
||||
import {
|
||||
createDeployment,
|
||||
deleteService,
|
||||
restartService,
|
||||
stopService,
|
||||
} from '@/lib/api-client';
|
||||
import { useToast } from '@/shared/hooks/use-toast';
|
||||
import { inferAutoConnections, type ServiceVariable } from '../auto-connections';
|
||||
import {
|
||||
DEFAULT_VIEWPORT,
|
||||
@@ -21,7 +29,20 @@ import {
|
||||
} from '../model';
|
||||
import { canvasStorageKey, loadCanvasMetadata, saveCanvasMetadata } from '../storage';
|
||||
import { GroupNode, ServiceNode, type GroupNodeData, type ServiceNodeData } from './nodes';
|
||||
import { Plus, Layers, Maximize2, RotateCcw, Box, Link2 } from 'lucide-react';
|
||||
import {
|
||||
Plus,
|
||||
Layers,
|
||||
Maximize2,
|
||||
RotateCcw,
|
||||
Box,
|
||||
Link2,
|
||||
Play,
|
||||
Square,
|
||||
Rocket,
|
||||
Trash2,
|
||||
ArrowUpRight,
|
||||
Globe,
|
||||
} from 'lucide-react';
|
||||
|
||||
type CanvasProps = {
|
||||
projectId: string;
|
||||
@@ -46,6 +67,12 @@ const nodeTypes: NodeTypes = {
|
||||
groupNode: GroupNode,
|
||||
};
|
||||
|
||||
type ContextMenuState = {
|
||||
serviceId: string;
|
||||
x: number;
|
||||
y: number;
|
||||
} | null;
|
||||
|
||||
function toFlowNodes(metadata: ProjectCanvasMetadata, services: ServiceEntity[], onOpenService: CanvasProps['onOpenService']): CanvasNode[] {
|
||||
const groups = metadata.groups.map(
|
||||
(group): GroupCanvasNode => ({
|
||||
@@ -157,9 +184,24 @@ function CanvasInner({ projectId, services, variablesByService, onAddService, on
|
||||
const [nodes, setNodes, onNodesChange] = useNodesState<CanvasNode>([]);
|
||||
const [selectedServiceId, setSelectedServiceId] = useState<string | null>(null);
|
||||
const [viewportTick, setViewportTick] = useState(0);
|
||||
const [envFilter, setEnvFilter] = useState<string>('all');
|
||||
const [contextMenu, setContextMenu] = useState<ContextMenuState>(null);
|
||||
const queryClient = useQueryClient();
|
||||
const toast = useToast();
|
||||
|
||||
const { fitView, getInternalNode, getViewport, screenToFlowPosition, setViewport } = useReactFlow<CanvasNode, CanvasEdge>();
|
||||
|
||||
const environments = useMemo(() => {
|
||||
const envs = new Set(services.map((service) => service.environment ?? 'production'));
|
||||
return Array.from(envs).sort();
|
||||
}, [services]);
|
||||
|
||||
const visibleServices = useMemo(
|
||||
() => (envFilter === 'all' ? services : services.filter((service) => (service.environment ?? 'production') === envFilter)),
|
||||
[services, envFilter],
|
||||
);
|
||||
const visibleIds = useMemo(() => new Set(visibleServices.map((service) => service.id)), [visibleServices]);
|
||||
|
||||
const serviceFingerprint = useMemo(
|
||||
() => services.map((service) => service.id).sort().join('|'),
|
||||
[services],
|
||||
@@ -168,7 +210,53 @@ function CanvasInner({ projectId, services, variablesByService, onAddService, on
|
||||
() => inferAutoConnections(services, variablesByService),
|
||||
[services, variablesByService],
|
||||
);
|
||||
const edges = useMemo(() => toFlowEdges(inferredLinks), [inferredLinks]);
|
||||
const edges = useMemo(
|
||||
() => toFlowEdges(inferredLinks).filter((edge) => visibleIds.has(edge.source) && visibleIds.has(edge.target)),
|
||||
[inferredLinks, visibleIds],
|
||||
);
|
||||
|
||||
const invalidateServices = useCallback(() => {
|
||||
void queryClient.invalidateQueries({ queryKey: ['project-services', projectId] });
|
||||
void queryClient.invalidateQueries({ queryKey: ['service-runtime'] });
|
||||
}, [projectId, queryClient]);
|
||||
|
||||
const actionError = (error: unknown) =>
|
||||
toast.showToast(error instanceof Error ? error.message : 'Action failed', 'error');
|
||||
|
||||
const deployMutation = useMutation({
|
||||
mutationFn: (serviceId: string) => createDeployment(serviceId, { trigger: 'manual' }),
|
||||
onSuccess: () => {
|
||||
toast.showToast('Deployment started', 'success');
|
||||
invalidateServices();
|
||||
},
|
||||
onError: actionError,
|
||||
});
|
||||
const restartMutation = useMutation({
|
||||
mutationFn: (serviceId: string) => restartService(serviceId),
|
||||
onSuccess: () => {
|
||||
toast.showToast('Service restarted', 'success');
|
||||
invalidateServices();
|
||||
},
|
||||
onError: actionError,
|
||||
});
|
||||
const stopMutation = useMutation({
|
||||
mutationFn: (serviceId: string) => stopService(serviceId),
|
||||
onSuccess: () => {
|
||||
toast.showToast('Service stopped', 'success');
|
||||
invalidateServices();
|
||||
},
|
||||
onError: actionError,
|
||||
});
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: (serviceId: string) => deleteService(serviceId),
|
||||
onSuccess: () => {
|
||||
toast.showToast('Service deleted', 'success');
|
||||
invalidateServices();
|
||||
},
|
||||
onError: actionError,
|
||||
});
|
||||
const actionPending =
|
||||
deployMutation.isPending || restartMutation.isPending || stopMutation.isPending || deleteMutation.isPending;
|
||||
|
||||
useEffect(() => {
|
||||
const hasStoredViewport = localStorage.getItem(canvasStorageKey(projectId)) !== null;
|
||||
@@ -347,6 +435,35 @@ function CanvasInner({ projectId, services, variablesByService, onAddService, on
|
||||
]);
|
||||
}, [screenToFlowPosition, setNodes]);
|
||||
|
||||
// Env filter hides nodes via React Flow's `hidden` flag — positions and
|
||||
// persisted layout stay intact for services outside the current filter.
|
||||
const renderedNodes = useMemo(
|
||||
() =>
|
||||
nodes.map((node) =>
|
||||
node.type === 'serviceNode' && !visibleIds.has(node.id) ? { ...node, hidden: true } : node,
|
||||
),
|
||||
[nodes, visibleIds],
|
||||
);
|
||||
|
||||
const onNodeContextMenu = useCallback(
|
||||
(event: MouseEvent, node: CanvasNode) => {
|
||||
if (node.type !== 'serviceNode') {
|
||||
return;
|
||||
}
|
||||
event.preventDefault();
|
||||
const bounds = wrapperRef.current?.getBoundingClientRect();
|
||||
setSelectedServiceId(node.id);
|
||||
setContextMenu({
|
||||
serviceId: node.id,
|
||||
x: event.clientX - (bounds?.left ?? 0),
|
||||
y: event.clientY - (bounds?.top ?? 0),
|
||||
});
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const contextService = contextMenu ? services.find((service) => service.id === contextMenu.serviceId) ?? null : null;
|
||||
|
||||
const selectedService = services.find((service) => service.id === selectedServiceId) ?? null;
|
||||
const selectedServiceLinkCount = selectedService
|
||||
? inferredLinks.filter(
|
||||
@@ -377,7 +494,29 @@ function CanvasInner({ projectId, services, variablesByService, onAddService, on
|
||||
<Layers size={14} />
|
||||
Group
|
||||
</button>
|
||||
|
||||
|
||||
{environments.length > 1 && (
|
||||
<>
|
||||
<div className="w-px h-5 bg-[var(--border-subtle)] mx-1" />
|
||||
<div className="flex items-center gap-1 rounded-lg bg-[var(--surface-muted)] border border-[var(--border-subtle)] p-1">
|
||||
{['all', ...environments].map((env) => (
|
||||
<button
|
||||
key={env}
|
||||
type="button"
|
||||
onClick={() => setEnvFilter(env)}
|
||||
className={`px-2.5 py-1 rounded-md text-xs font-medium capitalize transition-all ${
|
||||
envFilter === env
|
||||
? 'bg-[var(--surface-card)] text-[var(--text-primary)] shadow-sm'
|
||||
: 'text-[var(--text-tertiary)] hover:text-[var(--text-secondary)]'
|
||||
}`}
|
||||
>
|
||||
{env}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
<div className="ml-auto flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
@@ -411,14 +550,19 @@ function CanvasInner({ projectId, services, variablesByService, onAddService, on
|
||||
</div>
|
||||
|
||||
{/* Canvas */}
|
||||
<div ref={wrapperRef} className="subtle-grid h-[66vh] min-h-[420px] bg-[var(--bg-void)]">
|
||||
<div ref={wrapperRef} className="subtle-grid h-[66vh] min-h-[420px] bg-[var(--bg-void)] relative">
|
||||
<ReactFlow
|
||||
nodes={nodes}
|
||||
nodes={renderedNodes}
|
||||
edges={edges}
|
||||
nodeTypes={nodeTypes}
|
||||
onNodesChange={onNodesChange}
|
||||
onPaneClick={() => setSelectedServiceId(null)}
|
||||
onPaneClick={() => {
|
||||
setSelectedServiceId(null);
|
||||
setContextMenu(null);
|
||||
}}
|
||||
onMoveStart={() => setContextMenu(null)}
|
||||
onNodeClick={(_event, node) => {
|
||||
setContextMenu(null);
|
||||
if (node.type === 'serviceNode') {
|
||||
setSelectedServiceId(node.id);
|
||||
}
|
||||
@@ -428,6 +572,7 @@ function CanvasInner({ projectId, services, variablesByService, onAddService, on
|
||||
onOpenService(node.id);
|
||||
}
|
||||
}}
|
||||
onNodeContextMenu={onNodeContextMenu}
|
||||
onNodeDragStop={onNodeDragStop}
|
||||
onMoveEnd={() => setViewportTick((value) => value + 1)}
|
||||
panOnDrag
|
||||
@@ -443,6 +588,118 @@ function CanvasInner({ projectId, services, variablesByService, onAddService, on
|
||||
</svg>
|
||||
<Background color="rgba(255,255,255,0.05)" gap={28} />
|
||||
</ReactFlow>
|
||||
|
||||
{/* Empty state */}
|
||||
{services.length === 0 && (
|
||||
<div className="absolute inset-0 flex items-center justify-center pointer-events-none">
|
||||
<div className="text-center pointer-events-auto">
|
||||
<div className="w-14 h-14 mx-auto mb-4 rounded-2xl bg-[var(--surface-card)] border border-[var(--border-subtle)] flex items-center justify-center">
|
||||
<Rocket size={24} className="text-[var(--accent-primary)]" />
|
||||
</div>
|
||||
<h3 className="text-base font-semibold text-[var(--text-primary)]">Deploy your first service</h3>
|
||||
<p className="text-sm text-[var(--text-tertiary)] mt-1 mb-4 max-w-xs">
|
||||
Add a service from a git repo or Docker image — it runs on a private network with the rest of this
|
||||
project.
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onAddService}
|
||||
className="inline-flex items-center gap-2 h-9 px-4 rounded-lg text-[var(--accent-on)] text-sm font-medium shadow-lg"
|
||||
style={{ background: 'var(--accent-primary)' }}
|
||||
>
|
||||
<Plus size={15} />
|
||||
Add Service
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Node context menu */}
|
||||
{contextMenu && contextService && (
|
||||
<div
|
||||
className="absolute z-50 min-w-[180px] rounded-[var(--radius-md)] border border-[var(--border-default)] bg-[var(--surface-card)] shadow-2xl py-1"
|
||||
style={{ left: contextMenu.x, top: contextMenu.y }}
|
||||
>
|
||||
<div className="px-3 py-1.5 border-b border-[var(--border-subtle)] mb-1">
|
||||
<p className="text-xs font-semibold text-[var(--text-primary)] truncate">{contextService.name}</p>
|
||||
<p className="text-[10px] text-[var(--text-tertiary)]">{contextService.status}</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setContextMenu(null);
|
||||
onOpenService(contextService.id);
|
||||
}}
|
||||
className="w-full flex items-center gap-2 px-3 py-1.5 text-xs text-[var(--text-secondary)] hover:bg-[var(--surface-muted)] hover:text-[var(--text-primary)] transition-colors"
|
||||
>
|
||||
<ArrowUpRight size={13} />
|
||||
Open service
|
||||
</button>
|
||||
{contextService.domain && (
|
||||
<a
|
||||
href={`https://${contextService.domain}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="w-full flex items-center gap-2 px-3 py-1.5 text-xs text-[var(--text-secondary)] hover:bg-[var(--surface-muted)] hover:text-[var(--text-primary)] transition-colors"
|
||||
>
|
||||
<Globe size={13} />
|
||||
Visit {contextService.domain}
|
||||
</a>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
disabled={actionPending}
|
||||
onClick={() => {
|
||||
setContextMenu(null);
|
||||
deployMutation.mutate(contextService.id);
|
||||
}}
|
||||
className="w-full flex items-center gap-2 px-3 py-1.5 text-xs text-[var(--text-secondary)] hover:bg-[var(--surface-muted)] hover:text-[var(--text-primary)] transition-colors disabled:opacity-50"
|
||||
>
|
||||
<Rocket size={13} />
|
||||
Deploy
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={actionPending || contextService.status !== 'running'}
|
||||
onClick={() => {
|
||||
setContextMenu(null);
|
||||
restartMutation.mutate(contextService.id);
|
||||
}}
|
||||
className="w-full flex items-center gap-2 px-3 py-1.5 text-xs text-[var(--text-secondary)] hover:bg-[var(--surface-muted)] hover:text-[var(--text-primary)] transition-colors disabled:opacity-50"
|
||||
>
|
||||
<Play size={13} />
|
||||
Restart
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={actionPending || contextService.status !== 'running'}
|
||||
onClick={() => {
|
||||
setContextMenu(null);
|
||||
stopMutation.mutate(contextService.id);
|
||||
}}
|
||||
className="w-full flex items-center gap-2 px-3 py-1.5 text-xs text-[var(--text-secondary)] hover:bg-[var(--surface-muted)] hover:text-[var(--text-primary)] transition-colors disabled:opacity-50"
|
||||
>
|
||||
<Square size={13} />
|
||||
Stop
|
||||
</button>
|
||||
<div className="border-t border-[var(--border-subtle)] mt-1 pt-1">
|
||||
<button
|
||||
type="button"
|
||||
disabled={actionPending}
|
||||
onClick={() => {
|
||||
setContextMenu(null);
|
||||
if (window.confirm(`Delete service "${contextService.name}"? Its containers will be removed.`)) {
|
||||
deleteMutation.mutate(contextService.id);
|
||||
}
|
||||
}}
|
||||
className="w-full flex items-center gap-2 px-3 py-1.5 text-xs text-[var(--error)] hover:bg-[var(--error-soft)] transition-colors disabled:opacity-50"
|
||||
>
|
||||
<Trash2 size={13} />
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
@@ -465,7 +722,7 @@ function CanvasInner({ projectId, services, variablesByService, onAddService, on
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-xs text-[var(--text-tertiary)] text-center">
|
||||
Click a service to select • Double-click to open • Drag to reposition • Connections auto-inferred from variables
|
||||
Click to select • Double-click to open • Right-click for actions • Connections auto-inferred from variables
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Handle, Position, type Node, type NodeProps } from '@xyflow/react';
|
||||
import type { ServiceEntity } from '@/lib/api-client';
|
||||
import { serviceStatusClass } from '@/lib/api-client';
|
||||
import { Box, ArrowUpRight, Globe, Database, Terminal, MoreHorizontal, ExternalLink } from 'lucide-react';
|
||||
import { Box, Globe, Database, Terminal, ExternalLink, Copy, Layers } from 'lucide-react';
|
||||
import { useState } from 'react';
|
||||
|
||||
export type ServiceNodeData = {
|
||||
@@ -33,24 +33,38 @@ function serviceTypeIcon(type: string): typeof Box {
|
||||
function serviceTypeColor(type: string): string {
|
||||
switch (type) {
|
||||
case 'web':
|
||||
return '#7ab8ff'; // Blue
|
||||
return '#7ab8ff';
|
||||
case 'database':
|
||||
return '#b4e34a'; // Purple
|
||||
return '#b4e34a';
|
||||
case 'worker':
|
||||
return 'var(--accent-primary)'; // Pink
|
||||
return 'var(--accent-primary)';
|
||||
default:
|
||||
return '#9295a4'; // Gray
|
||||
return '#9295a4';
|
||||
}
|
||||
}
|
||||
|
||||
function statusLabel(status: string): string {
|
||||
switch (status) {
|
||||
case 'running':
|
||||
return 'Online';
|
||||
case 'building':
|
||||
return 'Deploying';
|
||||
case 'failed':
|
||||
return 'Failed';
|
||||
default:
|
||||
return 'Stopped';
|
||||
}
|
||||
}
|
||||
|
||||
export function ServiceNode({ data }: NodeProps<ServiceNodeType>) {
|
||||
const { service, selected, onOpen } = data;
|
||||
const iconType = serviceTypeIcon(service.type);
|
||||
const { service, selected } = data;
|
||||
const Icon = serviceTypeIcon(service.type);
|
||||
const typeColor = serviceTypeColor(service.type);
|
||||
const isRunning = service.status === 'running';
|
||||
const replicas = service.replicas ?? 0;
|
||||
|
||||
// Generate a mock domain for display (in real app, this would come from service data)
|
||||
const domain = `${service.name}.containr.local`;
|
||||
const publicUrl = service.domain ? `https://${service.domain}` : service.publicUrl;
|
||||
const internalAddr = service.port ? `${service.name}:${service.port}` : service.name;
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -61,80 +75,88 @@ export function ServiceNode({ data }: NodeProps<ServiceNodeType>) {
|
||||
}`}
|
||||
style={{ minWidth: 220, maxWidth: 280 }}
|
||||
>
|
||||
{/* Ambient overlay */}
|
||||
<div
|
||||
className="absolute inset-0 opacity-0 group-hover:opacity-100 transition-opacity duration-300 pointer-events-none"
|
||||
style={{ background: `${typeColor}10` }}
|
||||
/>
|
||||
|
||||
{/* Invisible connection points - required by React Flow for edge attachment */}
|
||||
<Handle type="target" position={Position.Left} className="!opacity-0 !w-2 !h-2 !border-0" />
|
||||
<Handle type="source" position={Position.Right} className="!opacity-0 !w-2 !h-2 !border-0" />
|
||||
|
||||
{/* Status indicator bar at top */}
|
||||
<div
|
||||
<div
|
||||
className={`absolute top-0 left-0 right-0 h-0.5 transition-all duration-300 ${isRunning ? 'opacity-100' : 'opacity-40'}`}
|
||||
style={{ background: isRunning ? 'var(--success)' : 'var(--text-tertiary)' }}
|
||||
/>
|
||||
|
||||
{/* Action menu on hover */}
|
||||
<button className="absolute top-3 right-3 opacity-0 group-hover:opacity-100 transition-all duration-200 text-[var(--text-tertiary)] hover:text-[var(--text-primary)] p-1.5 rounded-lg hover:bg-[var(--surface-muted)]">
|
||||
<MoreHorizontal size={14} />
|
||||
</button>
|
||||
|
||||
<div className="relative p-4">
|
||||
{/* Header with icon and name */}
|
||||
<div className="flex items-start gap-3 mb-3">
|
||||
<div
|
||||
<div
|
||||
className={`w-10 h-10 rounded-xl flex items-center justify-center flex-shrink-0 transition-all duration-200 ${
|
||||
selected ? 'ring-2 ring-white/20' : ''
|
||||
}`}
|
||||
style={{
|
||||
style={{
|
||||
background: selected ? typeColor : `${typeColor}20`,
|
||||
color: selected ? 'white' : typeColor
|
||||
color: selected ? 'white' : typeColor,
|
||||
}}
|
||||
>
|
||||
{iconType === Globe && <Globe size={18} />}
|
||||
{iconType === Database && <Database size={18} />}
|
||||
{iconType === Terminal && <Terminal size={18} />}
|
||||
{iconType === Box && <Box size={18} />}
|
||||
<Icon size={18} />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1 pt-0.5">
|
||||
<h4 className="font-semibold text-sm text-[var(--text-primary)] truncate tracking-tight">{service.name}</h4>
|
||||
<p className="text-[11px] text-[var(--text-tertiary)] truncate mt-0.5">{service.type}</p>
|
||||
<p className="text-[11px] text-[var(--text-tertiary)] truncate mt-0.5">
|
||||
{service.type}
|
||||
{service.environment && service.environment !== 'production' ? ` · ${service.environment}` : ''}
|
||||
</p>
|
||||
</div>
|
||||
{replicas > 1 && (
|
||||
<span className="flex items-center gap-1 px-1.5 py-0.5 rounded bg-[var(--surface-muted)] text-[10px] font-semibold text-[var(--text-secondary)]">
|
||||
<Layers size={9} />
|
||||
{replicas}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Domain display - Railway style */}
|
||||
<div className="flex items-center gap-2 mb-3 px-2.5 py-1.5 rounded-lg bg-[var(--surface-muted)]/50 border border-[var(--border-subtle)]/50">
|
||||
<div className="flex items-center gap-2 mb-3 px-2.5 py-1.5 rounded-lg bg-[var(--surface-muted)]/50 border border-[var(--border-subtle)]/50 group/addr">
|
||||
<ExternalLink size={10} className="text-[var(--text-tertiary)] flex-shrink-0" />
|
||||
<span className="text-[10px] text-[var(--text-secondary)] truncate mono">{domain}</span>
|
||||
{publicUrl ? (
|
||||
<a
|
||||
href={publicUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className="text-[10px] text-[var(--accent-primary)] truncate mono hover:underline"
|
||||
>
|
||||
{publicUrl.replace(/^https?:\/\//, '')}
|
||||
</a>
|
||||
) : (
|
||||
<span className="text-[10px] text-[var(--text-secondary)] truncate mono" title="Internal network address">
|
||||
{internalAddr}
|
||||
</span>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
title="Copy internal address"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
void navigator.clipboard?.writeText(internalAddr);
|
||||
}}
|
||||
className="ml-auto opacity-0 group-hover/addr:opacity-100 transition-opacity text-[var(--text-tertiary)] hover:text-[var(--text-primary)]"
|
||||
>
|
||||
<Copy size={10} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Status and action row */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className={`status-dot ${serviceStatusClass(service.status)}`} />
|
||||
<span
|
||||
className="text-[11px] font-semibold tracking-wide uppercase"
|
||||
<span
|
||||
className="text-[11px] font-semibold tracking-wide uppercase"
|
||||
style={{ color: isRunning ? 'var(--success)' : 'var(--text-tertiary)' }}
|
||||
>
|
||||
{isRunning ? 'Online' : service.status}
|
||||
{statusLabel(service.status)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onOpen(service.id)}
|
||||
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-[11px] font-semibold transition-all duration-200 ${
|
||||
selected
|
||||
? 'bg-[var(--accent-primary)] text-[var(--accent-on)] shadow-lg shadow-[var(--accent-primary-glow)]'
|
||||
: 'border border-[var(--border-subtle)] text-[var(--text-secondary)] hover:border-[var(--accent-primary)] hover:text-[var(--accent-primary)]'
|
||||
}`}
|
||||
>
|
||||
Open
|
||||
<ArrowUpRight size={10} />
|
||||
</button>
|
||||
<span className="text-[10px] text-[var(--text-tertiary)]">Right-click for actions</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -147,46 +169,41 @@ export function GroupNode({ data }: NodeProps<GroupNodeType>) {
|
||||
return (
|
||||
<div
|
||||
className="h-full w-full rounded-[var(--radius-xl)] border-2 border-dashed transition-all duration-200 relative overflow-hidden"
|
||||
style={{
|
||||
style={{
|
||||
borderColor: isHovered ? 'var(--accent-primary)' : 'var(--border-default)',
|
||||
background: isHovered ? 'rgba(232, 49, 106, 0.03)' : 'var(--surface-muted)/30'
|
||||
background: isHovered ? 'rgba(232, 49, 106, 0.03)' : 'var(--surface-muted)/30',
|
||||
}}
|
||||
onMouseEnter={() => setIsHovered(true)}
|
||||
onMouseLeave={() => setIsHovered(false)}
|
||||
>
|
||||
{/* Ambient glow on hover */}
|
||||
<div
|
||||
<div
|
||||
className="absolute inset-0 opacity-0 transition-opacity duration-300 pointer-events-none"
|
||||
style={{
|
||||
style={{
|
||||
background: 'rgba(232,49,106,0.03)',
|
||||
opacity: isHovered ? 1 : 0
|
||||
opacity: isHovered ? 1 : 0,
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Header */}
|
||||
<div className="relative p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2.5">
|
||||
<div
|
||||
<div
|
||||
className="w-7 h-7 rounded-lg flex items-center justify-center transition-colors duration-200"
|
||||
style={{
|
||||
style={{
|
||||
background: isHovered ? 'var(--accent-primary-soft)' : 'var(--surface-card)',
|
||||
color: isHovered ? 'var(--accent-primary)' : 'var(--text-tertiary)'
|
||||
color: isHovered ? 'var(--accent-primary)' : 'var(--text-tertiary)',
|
||||
}}
|
||||
>
|
||||
<Box size={14} />
|
||||
</div>
|
||||
<h3 className="text-sm font-semibold text-[var(--text-primary)] tracking-tight">{data.title}</h3>
|
||||
</div>
|
||||
{isHovered && (
|
||||
<button className="text-[var(--text-tertiary)] hover:text-[var(--text-primary)] transition-colors p-1 rounded-lg hover:bg-[var(--surface-muted)]">
|
||||
<MoreHorizontal size={14} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Helper text */}
|
||||
<p className="text-[10px] text-[var(--text-tertiary)] mt-2 opacity-0 transition-opacity duration-200" style={{ opacity: isHovered ? 1 : 0 }}>
|
||||
<p
|
||||
className="text-[10px] text-[var(--text-tertiary)] mt-2 transition-opacity duration-200"
|
||||
style={{ opacity: isHovered ? 1 : 0 }}
|
||||
>
|
||||
Drag services here to group them
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -604,6 +604,216 @@ export interface paths {
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/services/{id}/runtime": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
/**
|
||||
* Service runtime state
|
||||
* @description Live container state — replicas, URLs, aggregate status
|
||||
*/
|
||||
get: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path: {
|
||||
id: string;
|
||||
};
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
/** @description Runtime state */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": {
|
||||
runtime?: {
|
||||
desired?: number;
|
||||
/** @enum {string} */
|
||||
status?: "running" | "degraded" | "stopped";
|
||||
urls?: string[];
|
||||
containers?: {
|
||||
id?: string;
|
||||
name?: string;
|
||||
state?: string;
|
||||
replica?: number;
|
||||
}[];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
put?: never;
|
||||
post?: never;
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/services/{id}/start": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
get?: never;
|
||||
put?: never;
|
||||
/**
|
||||
* Start service
|
||||
* @description Start all stopped replicas
|
||||
*/
|
||||
post: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path: {
|
||||
id: string;
|
||||
};
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
/** @description Service started */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content?: never;
|
||||
};
|
||||
};
|
||||
};
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/services/{id}/stop": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
get?: never;
|
||||
put?: never;
|
||||
/**
|
||||
* Stop service
|
||||
* @description Stop all replicas without removing them
|
||||
*/
|
||||
post: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path: {
|
||||
id: string;
|
||||
};
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
/** @description Service stopped */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content?: never;
|
||||
};
|
||||
};
|
||||
};
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/services/{id}/restart": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
get?: never;
|
||||
put?: never;
|
||||
/**
|
||||
* Restart service
|
||||
* @description Restart all replicas
|
||||
*/
|
||||
post: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path: {
|
||||
id: string;
|
||||
};
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
/** @description Service restarted */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content?: never;
|
||||
};
|
||||
};
|
||||
};
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/services/{id}/redeploy": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
get?: never;
|
||||
put?: never;
|
||||
/**
|
||||
* Redeploy service
|
||||
* @description Re-apply the current spec — pulls the image for image-sourced services, reuses the last deployed image otherwise
|
||||
*/
|
||||
post: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path: {
|
||||
id: string;
|
||||
};
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
/** @description Reconciled runtime state */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content?: never;
|
||||
};
|
||||
};
|
||||
};
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/services/{id}/deployments": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
@@ -6928,6 +7138,18 @@ export interface components {
|
||||
cpu?: string;
|
||||
/** @description Memory allocation */
|
||||
memory?: string;
|
||||
/** @description Desired replica count */
|
||||
replicas?: number;
|
||||
/** @description Container port to expose (published host port, or Traefik when domain set) */
|
||||
port?: number;
|
||||
/** @description Public hostname routed via Traefik */
|
||||
domain?: string;
|
||||
/** @description HTTP path probed on port for container health */
|
||||
healthcheck_path?: string;
|
||||
/** @description Docker restart policy (default unless-stopped) */
|
||||
restart_policy?: string;
|
||||
/** @description Computed public URL (published port or domain) */
|
||||
public_url?: string;
|
||||
/**
|
||||
* Format: date-time
|
||||
* @description Service creation timestamp
|
||||
@@ -6971,6 +7193,16 @@ export interface components {
|
||||
cpu?: string;
|
||||
/** @description Memory allocation */
|
||||
memory?: string;
|
||||
/** @description Desired replica count */
|
||||
replicas?: number;
|
||||
/** @description Container port to expose */
|
||||
port?: number;
|
||||
/** @description Public hostname routed via Traefik */
|
||||
domain?: string;
|
||||
/** @description HTTP path probed for container health */
|
||||
healthcheck_path?: string;
|
||||
/** @description Docker restart policy */
|
||||
restart_policy?: string;
|
||||
};
|
||||
UpdateServiceRequest: {
|
||||
/** @description Service name */
|
||||
@@ -6999,6 +7231,16 @@ export interface components {
|
||||
cpu?: string;
|
||||
/** @description Memory allocation */
|
||||
memory?: string;
|
||||
/** @description Desired replica count */
|
||||
replicas?: number;
|
||||
/** @description Container port to expose */
|
||||
port?: number;
|
||||
/** @description Public hostname routed via Traefik */
|
||||
domain?: string;
|
||||
/** @description HTTP path probed for container health */
|
||||
healthcheck_path?: string;
|
||||
/** @description Docker restart policy */
|
||||
restart_policy?: string;
|
||||
};
|
||||
Deployment: {
|
||||
/** @description Deployment ID */
|
||||
|
||||
@@ -48,6 +48,26 @@ export type ServiceEntity = {
|
||||
buildPath?: string;
|
||||
cpu?: string;
|
||||
memory?: string;
|
||||
replicas?: number;
|
||||
port?: number;
|
||||
domain?: string;
|
||||
healthcheckPath?: string;
|
||||
restartPolicy?: string;
|
||||
publicUrl?: string;
|
||||
};
|
||||
|
||||
export type RuntimeContainer = {
|
||||
id: string;
|
||||
name: string;
|
||||
state: string;
|
||||
replica: number;
|
||||
};
|
||||
|
||||
export type ServiceRuntime = {
|
||||
desired: number;
|
||||
status: 'running' | 'degraded' | 'stopped';
|
||||
urls: string[];
|
||||
containers: RuntimeContainer[];
|
||||
};
|
||||
|
||||
export type ServiceVariable = {
|
||||
@@ -343,6 +363,7 @@ type RawNodeAgent = components['schemas']['NodeAgent'];
|
||||
|
||||
export type CreateProjectInput = components['schemas']['CreateProjectRequest'];
|
||||
export type CreateServiceInput = components['schemas']['CreateServiceRequest'];
|
||||
export type UpdateServiceInput = components['schemas']['UpdateServiceRequest'];
|
||||
|
||||
export class ApiError extends Error {
|
||||
readonly status: number;
|
||||
@@ -515,6 +536,12 @@ function normalizeService(service: RawService): ServiceEntity | null {
|
||||
buildPath: service.build_path,
|
||||
cpu: service.cpu,
|
||||
memory: service.memory,
|
||||
replicas: service.replicas,
|
||||
port: service.port,
|
||||
domain: service.domain,
|
||||
healthcheckPath: service.healthcheck_path,
|
||||
restartPolicy: service.restart_policy,
|
||||
publicUrl: service.public_url,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -998,12 +1025,55 @@ export async function createService(projectId: string, input: CreateServiceInput
|
||||
return parsed;
|
||||
}
|
||||
|
||||
export async function updateService(serviceId: string, input: UpdateServiceInput): Promise<ServiceEntity> {
|
||||
const payload = await requestJson<RawService | { service?: RawService }>(`/services/${serviceId}`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(input),
|
||||
});
|
||||
|
||||
const row = (payload as { service?: RawService }).service ?? (payload as RawService);
|
||||
const parsed = normalizeService(row);
|
||||
|
||||
if (!parsed) {
|
||||
throw new ApiError('Update service response is invalid', 500);
|
||||
}
|
||||
|
||||
return parsed;
|
||||
}
|
||||
|
||||
export async function deleteService(serviceId: string): Promise<void> {
|
||||
await requestJson<{ message?: string }>(`/services/${serviceId}`, {
|
||||
method: 'DELETE',
|
||||
});
|
||||
}
|
||||
|
||||
export async function getServiceRuntime(serviceId: string): Promise<ServiceRuntime> {
|
||||
const payload = await requestJson<{ runtime?: ServiceRuntime }>(`/services/${serviceId}/runtime`);
|
||||
return (
|
||||
payload.runtime ?? {
|
||||
desired: 0,
|
||||
status: 'stopped',
|
||||
urls: [],
|
||||
containers: [],
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
async function serviceAction(serviceId: string, action: 'start' | 'stop' | 'restart'): Promise<void> {
|
||||
await requestJson<{ message?: string }>(`/services/${serviceId}/${action}`, { method: 'POST' });
|
||||
}
|
||||
|
||||
export const startService = (serviceId: string) => serviceAction(serviceId, 'start');
|
||||
export const stopService = (serviceId: string) => serviceAction(serviceId, 'stop');
|
||||
export const restartService = (serviceId: string) => serviceAction(serviceId, 'restart');
|
||||
|
||||
export async function redeployService(serviceId: string): Promise<ServiceRuntime> {
|
||||
const payload = await requestJson<{ runtime?: ServiceRuntime }>(`/services/${serviceId}/redeploy`, {
|
||||
method: 'POST',
|
||||
});
|
||||
return payload.runtime ?? { desired: 0, status: 'stopped', urls: [], containers: [] };
|
||||
}
|
||||
|
||||
function normalizeServiceVariables(rows: RawServiceVariable[] | undefined): ServiceVariable[] {
|
||||
const result: ServiceVariable[] = [];
|
||||
for (const row of rows ?? []) {
|
||||
|
||||
@@ -424,6 +424,126 @@ paths:
|
||||
'204':
|
||||
description: Service deleted successfully
|
||||
|
||||
/services/{id}/runtime:
|
||||
get:
|
||||
tags:
|
||||
- Services
|
||||
summary: Service runtime state
|
||||
description: Live container state — replicas, URLs, aggregate status
|
||||
security:
|
||||
- bearerAuth: []
|
||||
parameters:
|
||||
- name: id
|
||||
in: path
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
responses:
|
||||
'200':
|
||||
description: Runtime state
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
runtime:
|
||||
type: object
|
||||
properties:
|
||||
desired:
|
||||
type: integer
|
||||
status:
|
||||
type: string
|
||||
enum: [running, degraded, stopped]
|
||||
urls:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
containers:
|
||||
type: array
|
||||
items:
|
||||
type: object
|
||||
properties:
|
||||
id:
|
||||
type: string
|
||||
name:
|
||||
type: string
|
||||
state:
|
||||
type: string
|
||||
replica:
|
||||
type: integer
|
||||
|
||||
/services/{id}/start:
|
||||
post:
|
||||
tags:
|
||||
- Services
|
||||
summary: Start service
|
||||
description: Start all stopped replicas
|
||||
security:
|
||||
- bearerAuth: []
|
||||
parameters:
|
||||
- name: id
|
||||
in: path
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
responses:
|
||||
'200':
|
||||
description: Service started
|
||||
|
||||
/services/{id}/stop:
|
||||
post:
|
||||
tags:
|
||||
- Services
|
||||
summary: Stop service
|
||||
description: Stop all replicas without removing them
|
||||
security:
|
||||
- bearerAuth: []
|
||||
parameters:
|
||||
- name: id
|
||||
in: path
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
responses:
|
||||
'200':
|
||||
description: Service stopped
|
||||
|
||||
/services/{id}/restart:
|
||||
post:
|
||||
tags:
|
||||
- Services
|
||||
summary: Restart service
|
||||
description: Restart all replicas
|
||||
security:
|
||||
- bearerAuth: []
|
||||
parameters:
|
||||
- name: id
|
||||
in: path
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
responses:
|
||||
'200':
|
||||
description: Service restarted
|
||||
|
||||
/services/{id}/redeploy:
|
||||
post:
|
||||
tags:
|
||||
- Services
|
||||
summary: Redeploy service
|
||||
description: Re-apply the current spec — pulls the image for image-sourced services, reuses the last deployed image otherwise
|
||||
security:
|
||||
- bearerAuth: []
|
||||
parameters:
|
||||
- name: id
|
||||
in: path
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
responses:
|
||||
'200':
|
||||
description: Reconciled runtime state
|
||||
|
||||
/services/{id}/deployments:
|
||||
get:
|
||||
tags:
|
||||
@@ -4372,6 +4492,24 @@ components:
|
||||
memory:
|
||||
type: string
|
||||
description: Memory allocation
|
||||
replicas:
|
||||
type: integer
|
||||
description: Desired replica count
|
||||
port:
|
||||
type: integer
|
||||
description: Container port to expose (published host port, or Traefik when domain set)
|
||||
domain:
|
||||
type: string
|
||||
description: Public hostname routed via Traefik
|
||||
healthcheck_path:
|
||||
type: string
|
||||
description: HTTP path probed on port for container health
|
||||
restart_policy:
|
||||
type: string
|
||||
description: Docker restart policy (default unless-stopped)
|
||||
public_url:
|
||||
type: string
|
||||
description: Computed public URL (published port or domain)
|
||||
created_at:
|
||||
type: string
|
||||
format: date-time
|
||||
@@ -4424,6 +4562,21 @@ components:
|
||||
memory:
|
||||
type: string
|
||||
description: Memory allocation
|
||||
replicas:
|
||||
type: integer
|
||||
description: Desired replica count
|
||||
port:
|
||||
type: integer
|
||||
description: Container port to expose
|
||||
domain:
|
||||
type: string
|
||||
description: Public hostname routed via Traefik
|
||||
healthcheck_path:
|
||||
type: string
|
||||
description: HTTP path probed for container health
|
||||
restart_policy:
|
||||
type: string
|
||||
description: Docker restart policy
|
||||
|
||||
UpdateServiceRequest:
|
||||
type: object
|
||||
@@ -4460,6 +4613,21 @@ components:
|
||||
memory:
|
||||
type: string
|
||||
description: Memory allocation
|
||||
replicas:
|
||||
type: integer
|
||||
description: Desired replica count
|
||||
port:
|
||||
type: integer
|
||||
description: Container port to expose
|
||||
domain:
|
||||
type: string
|
||||
description: Public hostname routed via Traefik
|
||||
healthcheck_path:
|
||||
type: string
|
||||
description: HTTP path probed for container health
|
||||
restart_policy:
|
||||
type: string
|
||||
description: Docker restart policy
|
||||
|
||||
Deployment:
|
||||
type: object
|
||||
|
||||
Reference in new issue
Block a user