From 1d1bd142b77d581938b513541583b33e870fc1d2 Mon Sep 17 00:00:00 2001 From: Hung Pham Date: Tue, 28 Apr 2026 21:55:58 +0200 Subject: [PATCH] feat: add endpoint to wire services together --- .../internal/api/http/handler/service/main.go | 3 + .../handler/service/service_dependency.go | 160 ++++++ backend/internal/api/http/route/routes.go | 9 + backend/internal/domain/entity/service.go | 17 + .../domain/repository/service_repository.go | 3 + .../public/model/service_dependencies.go | 27 + .../public/table/service_dependencies.go | 108 ++++ .../devhub/public/table/table_use_schema.go | 1 + .../repository/service/create_dependency.go | 60 +++ .../repository/service/delete_dependency.go | 46 ++ .../db/repository/service/dependency_model.go | 110 ++++ .../repository/service/find_dependencies.go | 43 ++ backend/internal/usecase/service/main.go | 3 + .../usecase/service/service_dependency.go | 124 +++++ ...1738_add_wire_service_dependecies.down.sql | 3 + ...281738_add_wire_service_dependecies.up.sql | 28 + frontend/src/api/services/index.ts | 34 ++ frontend/src/views/services/detail.vue | 480 +++++++++--------- 18 files changed, 1014 insertions(+), 245 deletions(-) create mode 100644 backend/internal/api/http/handler/service/service_dependency.go create mode 100644 backend/internal/infra/db/model_gen/devhub/public/model/service_dependencies.go create mode 100644 backend/internal/infra/db/model_gen/devhub/public/table/service_dependencies.go create mode 100644 backend/internal/infra/db/repository/service/create_dependency.go create mode 100644 backend/internal/infra/db/repository/service/delete_dependency.go create mode 100644 backend/internal/infra/db/repository/service/dependency_model.go create mode 100644 backend/internal/infra/db/repository/service/find_dependencies.go create mode 100644 backend/internal/usecase/service/service_dependency.go create mode 100644 backend/migrations/202604281738_add_wire_service_dependecies.down.sql create mode 100644 backend/migrations/202604281738_add_wire_service_dependecies.up.sql diff --git a/backend/internal/api/http/handler/service/main.go b/backend/internal/api/http/handler/service/main.go index 9803340..b0426e9 100644 --- a/backend/internal/api/http/handler/service/main.go +++ b/backend/internal/api/http/handler/service/main.go @@ -9,6 +9,9 @@ import ( type ServiceHandler interface { FindAllServices(c *gin.Context) + FindServiceDependencies(c *gin.Context) + CreateServiceDependency(c *gin.Context) + DeleteServiceDependency(c *gin.Context) } type serviceHandler struct { diff --git a/backend/internal/api/http/handler/service/service_dependency.go b/backend/internal/api/http/handler/service/service_dependency.go new file mode 100644 index 0000000..edffe42 --- /dev/null +++ b/backend/internal/api/http/handler/service/service_dependency.go @@ -0,0 +1,160 @@ +package handler + +import ( + "net/http" + + "devhub-backend/internal/domain/entity" + "devhub-backend/internal/domain/errs" + serviceUsecase "devhub-backend/internal/usecase/service" + "devhub-backend/internal/util/httpresponse" + "devhub-backend/internal/util/misc" + + "github.com/gin-gonic/gin" +) + +type serviceDependencyRequest struct { + DependsOnServiceID string `json:"depends_on_service_id" binding:"required"` + Type string `json:"type" binding:"required"` + Protocol string `json:"protocol"` + Port *int `json:"port"` + Path string `json:"path"` + Config map[string]any `json:"config"` +} + +type serviceDependencyResponse struct { + ID string `json:"id"` + ServiceID string `json:"service_id"` + DependsOnServiceID string `json:"depends_on_service_id"` + DependsOnService *findAllServicesResponse `json:"depends_on_service,omitempty"` + Type string `json:"type"` + Protocol string `json:"protocol"` + Port *int `json:"port"` + Path string `json:"path"` + Config map[string]any `json:"config"` + CreatedBy string `json:"created_by"` +} + +// @Summary List Service Dependencies +// @Description List services that the selected service depends on +// @Tags Service +// @Produce json +// @Success 200 {object} httpresponse.SuccessResponse{data=[]serviceDependencyResponse,metadata=nil} "List of service dependencies" +// @Failure 400 {object} httpresponse.ErrorResponse{data=nil} "Bad request" +// @Failure 500 {object} httpresponse.ErrorResponse{data=nil} "Internal server error" +// @Router /services/:service/dependencies [get] +func (h *serviceHandler) FindServiceDependencies(c *gin.Context) { + dependencies, err := h.serviceUsecase.FindServiceDependencies(c.Request.Context(), serviceUsecase.FindServiceDependenciesInput{ + ServiceID: c.Param("service"), + }) + if err != nil { + httpresponse.Error(c, err) + return + } + + httpresponse.Success(c, h.newServiceDependencyResponses(dependencies)) +} + +// @Summary Create Service Dependency +// @Description Wire one service to another service in the same project +// @Tags Service +// @Accept json +// @Produce json +// @Param request body serviceDependencyRequest true "Service dependency input" +// @Success 201 {object} httpresponse.SuccessResponse{data=serviceDependencyResponse,metadata=nil} "Service dependency created" +// @Failure 400 {object} httpresponse.ErrorResponse{data=nil} "Bad request" +// @Failure 500 {object} httpresponse.ErrorResponse{data=nil} "Internal server error" +// @Router /services/:service/dependencies [post] +func (h *serviceHandler) CreateServiceDependency(c *gin.Context) { + userID, exists := c.Get("user_id") + if !exists { + httpresponse.Error(c, errs.NewBadRequestError("unauthorized", nil)) + return + } + + var input serviceDependencyRequest + if err := c.ShouldBindJSON(&input); err != nil { + httpresponse.Error(c, misc.WrapError(err, errs.NewBadRequestError("unable to parse request", map[string]string{"details": err.Error()}))) + return + } + + dependency, err := h.serviceUsecase.CreateServiceDependency(c.Request.Context(), serviceUsecase.CreateServiceDependencyInput{ + ServiceID: c.Param("service"), + DependsOnServiceID: input.DependsOnServiceID, + Type: input.Type, + Protocol: input.Protocol, + Port: input.Port, + Path: input.Path, + Config: input.Config, + CreatedBy: userID.(string), + }) + if err != nil { + httpresponse.Error(c, err) + return + } + + httpresponse.SuccessWithStatus(c, http.StatusCreated, h.newServiceDependencyResponse(dependency)) +} + +// @Summary Delete Service Dependency +// @Description Remove a service-to-service wiring entry +// @Tags Service +// @Produce json +// @Success 200 {object} httpresponse.SuccessResponse{data=serviceDependencyResponse,metadata=nil} "Service dependency deleted" +// @Failure 400 {object} httpresponse.ErrorResponse{data=nil} "Bad request" +// @Failure 404 {object} httpresponse.ErrorResponse{data=nil} "Not found" +// @Failure 500 {object} httpresponse.ErrorResponse{data=nil} "Internal server error" +// @Router /services/:service/dependencies/:dependency [delete] +func (h *serviceHandler) DeleteServiceDependency(c *gin.Context) { + dependency, err := h.serviceUsecase.DeleteServiceDependency(c.Request.Context(), serviceUsecase.DeleteServiceDependencyInput{ + ServiceID: c.Param("service"), + DependencyID: c.Param("dependency"), + }) + if err != nil { + httpresponse.Error(c, err) + return + } + + httpresponse.Success(c, h.newServiceDependencyResponse(dependency)) +} + +func (h *serviceHandler) newServiceDependencyResponses(dependencies entity.ServiceDependencies) []serviceDependencyResponse { + if len(dependencies) == 0 { + return []serviceDependencyResponse{} + } + + response := make([]serviceDependencyResponse, 0, len(dependencies)) + for _, dependency := range dependencies { + response = append(response, h.newServiceDependencyResponse(&dependency)) + } + + return response +} + +func (h *serviceHandler) newServiceDependencyResponse(dependency *entity.ServiceDependency) serviceDependencyResponse { + if dependency == nil { + return serviceDependencyResponse{} + } + + var dependsOn *findAllServicesResponse + if dependency.DependsOnService != nil { + dependsOn = &findAllServicesResponse{ + ID: dependency.DependsOnService.ID.String(), + ProjectID: dependency.DependsOnService.ProjectID.String(), + Name: dependency.DependsOnService.Name, + RepoURL: dependency.DependsOnService.RepoURL, + } + } + + return serviceDependencyResponse{ + ID: dependency.ID.String(), + ServiceID: dependency.ServiceID.String(), + DependsOnServiceID: dependency.DependsOnServiceID.String(), + DependsOnService: dependsOn, + Type: dependency.Type, + Protocol: dependency.Protocol, + Port: dependency.Port, + Path: dependency.Path, + Config: dependency.Config, + CreatedBy: dependency.CreatedBy.String(), + } +} diff --git a/backend/internal/api/http/route/routes.go b/backend/internal/api/http/route/routes.go index 51e7d19..8781507 100644 --- a/backend/internal/api/http/route/routes.go +++ b/backend/internal/api/http/route/routes.go @@ -205,6 +205,15 @@ func (r *router) applyProjectRoutes(router *gin.Engine) { r.Middleware.RequirePermissions(entity.PermissionReleaseWrite), r.ReleaseHandler.CreateRelease, ) + serviceProtectedRoute.GET("/:service/dependencies", r.ServiceHandler.FindServiceDependencies) + serviceProtectedRoute.POST("/:service/dependencies", + r.Middleware.RequirePermissions(entity.PermissionProjectWrite), + r.ServiceHandler.CreateServiceDependency, + ) + serviceProtectedRoute.DELETE("/:service/dependencies/:dependency", + r.Middleware.RequirePermissions(entity.PermissionProjectWrite), + r.ServiceHandler.DeleteServiceDependency, + ) } } diff --git a/backend/internal/domain/entity/service.go b/backend/internal/domain/entity/service.go index 605f779..5c19a38 100644 --- a/backend/internal/domain/entity/service.go +++ b/backend/internal/domain/entity/service.go @@ -17,3 +17,20 @@ type Service struct { } type Services []Service + +type ServiceDependency struct { + ID uuid.UUID + ServiceID uuid.UUID + DependsOnServiceID uuid.UUID + DependsOnService *Service + Type string + Protocol string + Port *int + Path string + Config map[string]any + CreatedBy uuid.UUID + CreatedAt time.Time + UpdatedAt time.Time +} + +type ServiceDependencies []ServiceDependency diff --git a/backend/internal/domain/repository/service_repository.go b/backend/internal/domain/repository/service_repository.go index 7a78633..b9829e5 100644 --- a/backend/internal/domain/repository/service_repository.go +++ b/backend/internal/domain/repository/service_repository.go @@ -14,6 +14,9 @@ type ServiceRepository interface { FindOne(ctx context.Context, id uuid.UUID) (*entity.Service, error) FindAll(ctx context.Context, filter FindAllServicesFilter) (*entity.Services, int64, error) DeleteOne(ctx context.Context, id uuid.UUID) (*entity.Service, error) + CreateDependency(ctx context.Context, dependency *entity.ServiceDependency) (*entity.ServiceDependency, error) + FindDependencies(ctx context.Context, serviceID uuid.UUID) (*entity.ServiceDependencies, error) + DeleteDependency(ctx context.Context, serviceID uuid.UUID, dependencyID uuid.UUID) (*entity.ServiceDependency, error) } type FindAllServicesFilter struct { diff --git a/backend/internal/infra/db/model_gen/devhub/public/model/service_dependencies.go b/backend/internal/infra/db/model_gen/devhub/public/model/service_dependencies.go new file mode 100644 index 0000000..f1c00ef --- /dev/null +++ b/backend/internal/infra/db/model_gen/devhub/public/model/service_dependencies.go @@ -0,0 +1,27 @@ +// +// Code generated by go-jet DO NOT EDIT. +// +// WARNING: Changes to this file may cause incorrect behavior +// and will be lost if the code is regenerated +// + +package model + +import ( + "github.com/google/uuid" + "time" +) + +type ServiceDependencies struct { + ID uuid.UUID `sql:"primary_key" db:"service_dependencies.id"` + ServiceID uuid.UUID `db:"service_dependencies.service_id"` + DependsOnServiceID uuid.UUID `db:"service_dependencies.depends_on_service_id"` + Type string `db:"service_dependencies.type"` + Protocol *string `db:"service_dependencies.protocol"` + Port *int32 `db:"service_dependencies.port"` + Path *string `db:"service_dependencies.path"` + Config string `db:"service_dependencies.config"` + CreatedBy uuid.UUID `db:"service_dependencies.created_by"` + CreatedAt time.Time `db:"service_dependencies.created_at"` + UpdatedAt time.Time `db:"service_dependencies.updated_at"` +} diff --git a/backend/internal/infra/db/model_gen/devhub/public/table/service_dependencies.go b/backend/internal/infra/db/model_gen/devhub/public/table/service_dependencies.go new file mode 100644 index 0000000..69a125b --- /dev/null +++ b/backend/internal/infra/db/model_gen/devhub/public/table/service_dependencies.go @@ -0,0 +1,108 @@ +// +// Code generated by go-jet DO NOT EDIT. +// +// WARNING: Changes to this file may cause incorrect behavior +// and will be lost if the code is regenerated +// + +package table + +import ( + "github.com/go-jet/jet/v2/postgres" +) + +var ServiceDependencies = newServiceDependenciesTable("public", "service_dependencies", "") + +type serviceDependenciesTable struct { + postgres.Table + + // Columns + ID postgres.ColumnString + ServiceID postgres.ColumnString + DependsOnServiceID postgres.ColumnString + Type postgres.ColumnString + Protocol postgres.ColumnString + Port postgres.ColumnInteger + Path postgres.ColumnString + Config postgres.ColumnString + CreatedBy postgres.ColumnString + CreatedAt postgres.ColumnTimestamp + UpdatedAt postgres.ColumnTimestamp + + AllColumns postgres.ColumnList + MutableColumns postgres.ColumnList + DefaultColumns postgres.ColumnList +} + +type ServiceDependenciesTable struct { + serviceDependenciesTable + + EXCLUDED serviceDependenciesTable +} + +// AS creates new ServiceDependenciesTable with assigned alias +func (a ServiceDependenciesTable) AS(alias string) *ServiceDependenciesTable { + return newServiceDependenciesTable(a.SchemaName(), a.TableName(), alias) +} + +// Schema creates new ServiceDependenciesTable with assigned schema name +func (a ServiceDependenciesTable) FromSchema(schemaName string) *ServiceDependenciesTable { + return newServiceDependenciesTable(schemaName, a.TableName(), a.Alias()) +} + +// WithPrefix creates new ServiceDependenciesTable with assigned table prefix +func (a ServiceDependenciesTable) WithPrefix(prefix string) *ServiceDependenciesTable { + return newServiceDependenciesTable(a.SchemaName(), prefix+a.TableName(), a.TableName()) +} + +// WithSuffix creates new ServiceDependenciesTable with assigned table suffix +func (a ServiceDependenciesTable) WithSuffix(suffix string) *ServiceDependenciesTable { + return newServiceDependenciesTable(a.SchemaName(), a.TableName()+suffix, a.TableName()) +} + +func newServiceDependenciesTable(schemaName, tableName, alias string) *ServiceDependenciesTable { + return &ServiceDependenciesTable{ + serviceDependenciesTable: newServiceDependenciesTableImpl(schemaName, tableName, alias), + EXCLUDED: newServiceDependenciesTableImpl("", "excluded", ""), + } +} + +func newServiceDependenciesTableImpl(schemaName, tableName, alias string) serviceDependenciesTable { + var ( + IDColumn = postgres.StringColumn("id") + ServiceIDColumn = postgres.StringColumn("service_id") + DependsOnServiceIDColumn = postgres.StringColumn("depends_on_service_id") + TypeColumn = postgres.StringColumn("type") + ProtocolColumn = postgres.StringColumn("protocol") + PortColumn = postgres.IntegerColumn("port") + PathColumn = postgres.StringColumn("path") + ConfigColumn = postgres.StringColumn("config") + CreatedByColumn = postgres.StringColumn("created_by") + CreatedAtColumn = postgres.TimestampColumn("created_at") + UpdatedAtColumn = postgres.TimestampColumn("updated_at") + allColumns = postgres.ColumnList{IDColumn, ServiceIDColumn, DependsOnServiceIDColumn, TypeColumn, ProtocolColumn, PortColumn, PathColumn, ConfigColumn, CreatedByColumn, CreatedAtColumn, UpdatedAtColumn} + mutableColumns = postgres.ColumnList{ServiceIDColumn, DependsOnServiceIDColumn, TypeColumn, ProtocolColumn, PortColumn, PathColumn, ConfigColumn, CreatedByColumn, CreatedAtColumn, UpdatedAtColumn} + defaultColumns = postgres.ColumnList{IDColumn, ConfigColumn, CreatedAtColumn, UpdatedAtColumn} + ) + + return serviceDependenciesTable{ + Table: postgres.NewTable(schemaName, tableName, alias, allColumns...), + + //Columns + ID: IDColumn, + ServiceID: ServiceIDColumn, + DependsOnServiceID: DependsOnServiceIDColumn, + Type: TypeColumn, + Protocol: ProtocolColumn, + Port: PortColumn, + Path: PathColumn, + Config: ConfigColumn, + CreatedBy: CreatedByColumn, + CreatedAt: CreatedAtColumn, + UpdatedAt: UpdatedAtColumn, + + AllColumns: allColumns, + MutableColumns: mutableColumns, + DefaultColumns: defaultColumns, + } +} diff --git a/backend/internal/infra/db/model_gen/devhub/public/table/table_use_schema.go b/backend/internal/infra/db/model_gen/devhub/public/table/table_use_schema.go index be2d1c1..bf88196 100644 --- a/backend/internal/infra/db/model_gen/devhub/public/table/table_use_schema.go +++ b/backend/internal/infra/db/model_gen/devhub/public/table/table_use_schema.go @@ -23,6 +23,7 @@ func UseSchema(schema string) { Roles = Roles.FromSchema(schema) ScaffoldRequests = ScaffoldRequests.FromSchema(schema) SchemaMigrations = SchemaMigrations.FromSchema(schema) + ServiceDependencies = ServiceDependencies.FromSchema(schema) Services = Services.FromSchema(schema) Teams = Teams.FromSchema(schema) Users = Users.FromSchema(schema) diff --git a/backend/internal/infra/db/repository/service/create_dependency.go b/backend/internal/infra/db/repository/service/create_dependency.go new file mode 100644 index 0000000..c1f3419 --- /dev/null +++ b/backend/internal/infra/db/repository/service/create_dependency.go @@ -0,0 +1,60 @@ +package service + +import ( + "context" + "encoding/json" + "strings" + + "devhub-backend/internal/domain/entity" + "devhub-backend/internal/domain/errs" + "devhub-backend/internal/infra/db/model_gen/devhub/public/model" + table "devhub-backend/internal/infra/db/model_gen/devhub/public/table" + "devhub-backend/internal/util/misc" +) + +func (r *serviceRepositoryImpl) CreateDependency(ctx context.Context, input *entity.ServiceDependency) (dependency *entity.ServiceDependency, err error) { + const errLocation = "[repository service/create_dependency CreateDependency] " + defer misc.WrapErrorWithPrefix(errLocation, &err) + + config, err := json.Marshal(input.Config) + if err != nil { + return nil, misc.WrapError(err, errs.NewBadRequestError("invalid dependency config", nil)) + } + + var port *int32 + if input.Port != nil { + value := int32(*input.Port) + port = &value + } + + serviceDependenciesTable := table.ServiceDependencies + stmt := serviceDependenciesTable.INSERT( + serviceDependenciesTable.AllColumns.Except(serviceDependenciesTable.DefaultColumns), + ).MODEL(model.ServiceDependencies{ + ServiceID: input.ServiceID, + DependsOnServiceID: input.DependsOnServiceID, + Type: input.Type, + Protocol: misc.ToPointer(input.Protocol), + Port: port, + Path: misc.ToPointer(input.Path), + Config: string(config), + CreatedBy: input.CreatedBy, + }).RETURNING(serviceDependenciesTable.AllColumns) + query, args := stmt.Sql() + + var model ServiceDependency + err = r.execer.GetContext(ctx, &model, query, args...) + if err != nil { + if strings.Contains(strings.ToLower(err.Error()), "duplicate key") { + return nil, misc.WrapError(err, errs.NewConflictError("service dependency already exists", nil)) + } + return nil, misc.WrapError(err, errs.NewDatabaseError("error while creating service dependency", err.Error())) + } + + dependency = model.ToEntity() + if dependency == nil { + return nil, errs.NewInternalServerError("failed to convert service dependency model to entity", nil) + } + + return dependency, nil +} diff --git a/backend/internal/infra/db/repository/service/delete_dependency.go b/backend/internal/infra/db/repository/service/delete_dependency.go new file mode 100644 index 0000000..8b652e8 --- /dev/null +++ b/backend/internal/infra/db/repository/service/delete_dependency.go @@ -0,0 +1,46 @@ +package service + +import ( + "context" + "database/sql" + "errors" + + "devhub-backend/internal/domain/entity" + "devhub-backend/internal/domain/errs" + table "devhub-backend/internal/infra/db/model_gen/devhub/public/table" + "devhub-backend/internal/util/misc" + + postgres "github.com/go-jet/jet/v2/postgres" + "github.com/google/uuid" +) + +func (r *serviceRepositoryImpl) DeleteDependency(ctx context.Context, serviceID uuid.UUID, dependencyID uuid.UUID) (dependency *entity.ServiceDependency, err error) { + const errLocation = "[repository service/delete_dependency DeleteDependency] " + defer misc.WrapErrorWithPrefix(errLocation, &err) + + serviceDependenciesTable := table.ServiceDependencies + stmt := serviceDependenciesTable.DELETE(). + WHERE( + serviceDependenciesTable.ID.EQ(postgres.UUID(dependencyID)). + AND(serviceDependenciesTable.ServiceID.EQ(postgres.UUID(serviceID))), + ). + RETURNING(serviceDependenciesTable.AllColumns) + query, args := stmt.Sql() + + var model ServiceDependency + err = r.execer.GetContext(ctx, &model, query, args...) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + return nil, errs.NewNotFoundError("service dependency not found", nil) + } + + return nil, misc.WrapError(err, errs.NewDatabaseError("error while deleting service dependency", err.Error())) + } + + dependency = model.ToEntity() + if dependency == nil { + return nil, errs.NewInternalServerError("failed to convert service dependency model to entity", nil) + } + + return dependency, nil +} diff --git a/backend/internal/infra/db/repository/service/dependency_model.go b/backend/internal/infra/db/repository/service/dependency_model.go new file mode 100644 index 0000000..9d3b2f1 --- /dev/null +++ b/backend/internal/infra/db/repository/service/dependency_model.go @@ -0,0 +1,110 @@ +package service + +import ( + "encoding/json" + + "devhub-backend/internal/domain/entity" + "devhub-backend/internal/infra/db/model_gen/devhub/public/model" + "devhub-backend/internal/util/misc" + + "github.com/google/uuid" +) + +type ServiceDependency struct { + model.ServiceDependencies +} + +func (d *ServiceDependency) ToEntity() *entity.ServiceDependency { + if d == nil { + return nil + } + + config := map[string]any{} + if len(d.Config) > 0 { + _ = json.Unmarshal([]byte(d.Config), &config) + } + + dependency := &entity.ServiceDependency{ + ID: d.ID, + ServiceID: d.ServiceID, + DependsOnServiceID: d.DependsOnServiceID, + Type: d.Type, + Protocol: misc.GetValue(d.Protocol), + Port: dependencyPortToEntity(d.Port), + Path: misc.GetValue(d.Path), + Config: config, + CreatedBy: d.CreatedBy, + CreatedAt: d.CreatedAt, + UpdatedAt: d.UpdatedAt, + } + + return dependency +} + +type ServiceDependencies []ServiceDependency + +func (ds ServiceDependencies) ToEntities() *entity.ServiceDependencies { + dependencies := make(entity.ServiceDependencies, 0, len(ds)) + for _, d := range ds { + dependency := d.ToEntity() + if dependency == nil { + continue + } + dependencies = append(dependencies, misc.GetValue(dependency)) + } + + return misc.ToPointer(dependencies) +} + +type ServiceDependencyWithService struct { + ServiceDependency + DependsOnProjectID *uuid.UUID `db:"depends_on_project_id"` + DependsOnName *string `db:"depends_on_name"` + DependsOnRepoURL *string `db:"depends_on_repo_url"` +} + +func (d *ServiceDependencyWithService) ToEntity() *entity.ServiceDependency { + if d == nil { + return nil + } + + dependency := d.ServiceDependency.ToEntity() + if dependency == nil { + return nil + } + + if d.DependsOnProjectID != nil && d.DependsOnName != nil && d.DependsOnRepoURL != nil { + dependency.DependsOnService = &entity.Service{ + ID: d.DependsOnServiceID, + ProjectID: *d.DependsOnProjectID, + Name: *d.DependsOnName, + RepoURL: *d.DependsOnRepoURL, + } + } + + return dependency +} + +type ServiceDependenciesWithService []ServiceDependencyWithService + +func (ds ServiceDependenciesWithService) ToEntities() *entity.ServiceDependencies { + dependencies := make(entity.ServiceDependencies, 0, len(ds)) + for _, d := range ds { + dependency := d.ToEntity() + if dependency == nil { + continue + } + dependencies = append(dependencies, misc.GetValue(dependency)) + } + + return misc.ToPointer(dependencies) +} + +func dependencyPortToEntity(port *int32) *int { + if port == nil { + return nil + } + + value := int(*port) + return &value +} diff --git a/backend/internal/infra/db/repository/service/find_dependencies.go b/backend/internal/infra/db/repository/service/find_dependencies.go new file mode 100644 index 0000000..aee6737 --- /dev/null +++ b/backend/internal/infra/db/repository/service/find_dependencies.go @@ -0,0 +1,43 @@ +package service + +import ( + "context" + + "devhub-backend/internal/domain/entity" + "devhub-backend/internal/domain/errs" + table "devhub-backend/internal/infra/db/model_gen/devhub/public/table" + "devhub-backend/internal/util/misc" + + postgres "github.com/go-jet/jet/v2/postgres" + "github.com/google/uuid" +) + +func (r *serviceRepositoryImpl) FindDependencies(ctx context.Context, serviceID uuid.UUID) (dependencies *entity.ServiceDependencies, err error) { + const errLocation = "[repository service/find_dependencies FindDependencies] " + defer misc.WrapErrorWithPrefix(errLocation, &err) + + serviceDependenciesTable := table.ServiceDependencies + dependsOnServicesTable := table.Services.AS("depends_on_services") + stmt := postgres.SELECT( + serviceDependenciesTable.AllColumns, + dependsOnServicesTable.ProjectID.AS("depends_on_project_id"), + dependsOnServicesTable.Name.AS("depends_on_name"), + dependsOnServicesTable.RepoURL.AS("depends_on_repo_url"), + ). + FROM( + serviceDependenciesTable.INNER_JOIN( + dependsOnServicesTable, + serviceDependenciesTable.DependsOnServiceID.EQ(dependsOnServicesTable.ID), + ), + ). + WHERE(serviceDependenciesTable.ServiceID.EQ(postgres.UUID(serviceID))). + ORDER_BY(serviceDependenciesTable.CreatedAt.DESC()) + query, args := stmt.Sql() + + var models ServiceDependenciesWithService + if err := r.execer.SelectContext(ctx, &models, query, args...); err != nil { + return nil, misc.WrapError(err, errs.NewDatabaseError("error while querying service dependencies", err.Error())) + } + + return models.ToEntities(), nil +} diff --git a/backend/internal/usecase/service/main.go b/backend/internal/usecase/service/main.go index 3ffbe1b..7184847 100644 --- a/backend/internal/usecase/service/main.go +++ b/backend/internal/usecase/service/main.go @@ -10,6 +10,9 @@ import ( type ServiceUsecase interface { FindAllServices(ctx context.Context, input FindAllServicesInput) (entity.Services, error) + FindServiceDependencies(ctx context.Context, input FindServiceDependenciesInput) (entity.ServiceDependencies, error) + CreateServiceDependency(ctx context.Context, input CreateServiceDependencyInput) (*entity.ServiceDependency, error) + DeleteServiceDependency(ctx context.Context, input DeleteServiceDependencyInput) (*entity.ServiceDependency, error) } type serviceUsecase struct { diff --git a/backend/internal/usecase/service/service_dependency.go b/backend/internal/usecase/service/service_dependency.go new file mode 100644 index 0000000..4cabfd6 --- /dev/null +++ b/backend/internal/usecase/service/service_dependency.go @@ -0,0 +1,124 @@ +package usecase + +import ( + "context" + "strings" + + "devhub-backend/internal/domain/entity" + "devhub-backend/internal/domain/errs" + "devhub-backend/internal/util/misc" + "devhub-backend/pkg/validator" + + "github.com/google/uuid" +) + +type FindServiceDependenciesInput struct { + ServiceID string `json:"service_id" validate:"required,uuid"` +} + +type CreateServiceDependencyInput struct { + ServiceID string `json:"service_id" validate:"required,uuid"` + DependsOnServiceID string `json:"depends_on_service_id" validate:"required,uuid"` + Type string `json:"type" validate:"required,oneof=http grpc queue database"` + Protocol string `json:"protocol" validate:"omitempty,oneof=http https grpc tcp udp"` + Port *int `json:"port" validate:"omitempty,min=1,max=65535"` + Path string `json:"path" validate:"omitempty"` + Config map[string]any `json:"config"` + CreatedBy string `json:"created_by" validate:"required,uuid"` +} + +type DeleteServiceDependencyInput struct { + ServiceID string `json:"service_id" validate:"required,uuid"` + DependencyID string `json:"dependency_id" validate:"required,uuid"` +} + +func (u *serviceUsecase) FindServiceDependencies(ctx context.Context, input FindServiceDependenciesInput) (entity.ServiceDependencies, error) { + if err := validateServiceDependencyInput(input); err != nil { + return nil, err + } + + serviceID := uuid.MustParse(input.ServiceID) + dependencies, err := u.serviceRepository.FindDependencies(ctx, serviceID) + if err != nil { + return nil, misc.WrapError(err, errs.NewInternalServerError("failed to fetch service dependencies", nil)) + } + if dependencies == nil { + return entity.ServiceDependencies{}, nil + } + + return misc.GetValue(dependencies), nil +} + +func (u *serviceUsecase) CreateServiceDependency(ctx context.Context, input CreateServiceDependencyInput) (*entity.ServiceDependency, error) { + if err := validateServiceDependencyInput(input); err != nil { + return nil, err + } + + serviceID := uuid.MustParse(input.ServiceID) + dependsOnServiceID := uuid.MustParse(input.DependsOnServiceID) + if serviceID == dependsOnServiceID { + return nil, errs.NewBadRequestError("a service cannot depend on itself", nil) + } + + service, err := u.serviceRepository.FindOne(ctx, serviceID) + if err != nil { + return nil, misc.WrapError(err, errs.NewBadRequestError("invalid service", nil)) + } + + dependsOnService, err := u.serviceRepository.FindOne(ctx, dependsOnServiceID) + if err != nil { + return nil, misc.WrapError(err, errs.NewBadRequestError("invalid dependent service", nil)) + } + + if service.ProjectID != dependsOnService.ProjectID { + return nil, errs.NewBadRequestError("service dependencies must stay within the same project", nil) + } + + config := input.Config + if config == nil { + config = map[string]any{} + } + + dependency, err := u.serviceRepository.CreateDependency(ctx, &entity.ServiceDependency{ + ServiceID: serviceID, + DependsOnServiceID: dependsOnServiceID, + Type: strings.TrimSpace(input.Type), + Protocol: strings.TrimSpace(input.Protocol), + Port: input.Port, + Path: strings.TrimSpace(input.Path), + Config: config, + CreatedBy: uuid.MustParse(input.CreatedBy), + }) + if err != nil { + return nil, err + } + + dependency.DependsOnService = dependsOnService + return dependency, nil +} + +func (u *serviceUsecase) DeleteServiceDependency(ctx context.Context, input DeleteServiceDependencyInput) (*entity.ServiceDependency, error) { + if err := validateServiceDependencyInput(input); err != nil { + return nil, err + } + + dependency, err := u.serviceRepository.DeleteDependency(ctx, uuid.MustParse(input.ServiceID), uuid.MustParse(input.DependencyID)) + if err != nil { + return nil, err + } + + return dependency, nil +} + +func validateServiceDependencyInput(input any) error { + vInstance, err := validator.NewValidator(validator.WithTagNameFunc(validator.JSONTagNameFunc)) + if err != nil { + return misc.WrapError(err, errs.NewInternalServerError("failed to create validator", nil)) + } + + if err := vInstance.Struct(input); err != nil { + return misc.WrapError(err, errs.NewBadRequestError("the request is invalid", map[string]string{"details": err.Error()})) + } + + return nil +} diff --git a/backend/migrations/202604281738_add_wire_service_dependecies.down.sql b/backend/migrations/202604281738_add_wire_service_dependecies.down.sql new file mode 100644 index 0000000..4168ce2 --- /dev/null +++ b/backend/migrations/202604281738_add_wire_service_dependecies.down.sql @@ -0,0 +1,3 @@ +-- 202604281738_add_wire_service_dependecies.down.sql + +DROP TABLE IF EXISTS service_dependencies; \ No newline at end of file diff --git a/backend/migrations/202604281738_add_wire_service_dependecies.up.sql b/backend/migrations/202604281738_add_wire_service_dependecies.up.sql new file mode 100644 index 0000000..3a1ad04 --- /dev/null +++ b/backend/migrations/202604281738_add_wire_service_dependecies.up.sql @@ -0,0 +1,28 @@ +-- 202604281738_add_wire_service_dependecies.up.sql + +CREATE TABLE service_dependencies ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + + service_id UUID NOT NULL REFERENCES services(id) ON DELETE CASCADE, + depends_on_service_id UUID NOT NULL REFERENCES services(id) ON DELETE CASCADE, + + type VARCHAR(32) NOT NULL, -- http, grpc, queue, database + protocol VARCHAR(32), -- http, https, grpc + port INT, + path TEXT, + + config JSONB NOT NULL DEFAULT '{}', + + created_by UUID NOT NULL REFERENCES users(id), + created_at TIMESTAMP NOT NULL DEFAULT now(), + updated_at TIMESTAMP NOT NULL DEFAULT now(), + + CHECK (service_id <> depends_on_service_id), + UNIQUE(service_id, depends_on_service_id, type) +); + +CREATE INDEX idx_service_dependencies_service_id + ON service_dependencies(service_id); + +CREATE INDEX idx_service_dependencies_depends_on_service_id + ON service_dependencies(depends_on_service_id); diff --git a/frontend/src/api/services/index.ts b/frontend/src/api/services/index.ts index 5373069..31831a3 100644 --- a/frontend/src/api/services/index.ts +++ b/frontend/src/api/services/index.ts @@ -8,6 +8,40 @@ export interface Service { repo_url: string } +export interface ServiceDependency { + id: string + service_id: string + depends_on_service_id: string + depends_on_service?: Service + type: string + protocol?: string + port?: number | null + path?: string + config?: Record + created_by: string +} + +export interface CreateServiceDependencyPayload { + depends_on_service_id: string + type: string + protocol?: string + port?: number | null + path?: string + config?: Record +} + export function fetchProjectServices(projectId: string) { return api.get(`${apiBaseURL.projects}/${projectId}/services`) } + +export function fetchServiceDependencies(serviceId: string) { + return api.get(`${apiBaseURL.services}/${serviceId}/dependencies`) +} + +export function createServiceDependency(serviceId: string, payload: CreateServiceDependencyPayload) { + return api.post(`${apiBaseURL.services}/${serviceId}/dependencies`, payload) +} + +export function deleteServiceDependency(serviceId: string, dependencyId: string) { + return api.delete(`${apiBaseURL.services}/${serviceId}/dependencies/${dependencyId}`) +} diff --git a/frontend/src/views/services/detail.vue b/frontend/src/views/services/detail.vue index 38e2541..9becff4 100644 --- a/frontend/src/views/services/detail.vue +++ b/frontend/src/views/services/detail.vue @@ -2,13 +2,13 @@ import { NButton, NCard, - NCheckbox, NDataTable, NForm, NFormItem, NInput, NInputNumber, NModal, + NPopconfirm, NSelect, NStatistic, NTag, @@ -18,18 +18,16 @@ import { computed, h, onMounted, reactive, ref } from 'vue' import { useRoute, useRouter } from 'vue-router' import { permission } from '@/services/access/rbac' -import { - applyScaffoldSuggestionToForm, - generateScaffoldSuggestion as requestScaffoldSuggestion, -} from '@/services/scaffold-request' import PageHeader from '@/components/page-header.vue' import { createDeployment, - createScaffoldRequest, + createServiceDependency, + deleteServiceDependency, fetchDeploymentById, fetchPlugins, fetchProjectById, fetchProjects, + fetchServiceDependencies, fetchProjectServices, fetchServiceDeployments, fetchServiceReleases, @@ -39,13 +37,12 @@ import { useAuthStore } from '@/stores/modules/auth' import { getEnvironmentTagColor } from '@/theme/environment' import type { CreateDeploymentPayload, - CreateScaffoldRequestPayload, Deployment, PluginRecord, Project, Release, - ScaffoldRequestSuggestion, Service, + ServiceDependency, } from '@/api' const route = useRoute() @@ -54,24 +51,31 @@ const message = useMessage() const authStore = useAuthStore() const serviceId = computed(() => route.params.serviceId as string) -const projectId = computed(() => project.value?.id || service.value?.project_id || '') const loading = ref(false) const deploymentSubmitting = ref(false) const deploymentLogLoading = ref(false) -const scaffoldSuggestionLoading = ref(false) -const scaffoldSubmitting = ref(false) const deploymentModalOpen = ref(false) const deploymentLogModalOpen = ref(false) -const scaffoldModalOpen = ref(false) +const dependencyModalOpen = ref(false) const project = ref(null) const service = ref(null) +const projectServices = ref([]) +const dependencies = ref([]) const releases = ref([]) const deployments = ref([]) const plugins = ref([]) -const scaffoldPrompt = ref('') const selectedReleaseTag = ref(null) const selectedDeployment = ref(null) +const dependencySubmitting = ref(false) + +const dependencyForm = reactive({ + depends_on_service_id: '', + type: 'http', + protocol: 'http', + port: null as number | null, + path: '', +}) const deploymentForm = reactive({ plugin_id: '', @@ -79,20 +83,6 @@ const deploymentForm = reactive({ version: '', }) -const scaffoldForm = reactive({ - plugin_id: '', - environment: 'dev', - variables: { - service_name: '', - module_path: '', - port: 8080, - database: 'postgres', - enable_logging: true, - }, -}) - -const scaffoldSuggestion = ref(null) - const successfulReleases = computed(() => releases.value.filter(item => item.status === 'completed').length, ) @@ -115,12 +105,6 @@ const deployerOptions = computed(() => .map(plugin => ({ label: plugin.name, value: plugin.id })), ) -const scaffolderOptions = computed(() => - plugins.value - .filter(plugin => plugin.type === 'scaffolder' && plugin.enabled !== false) - .map(plugin => ({ label: plugin.name, value: plugin.id })), -) - const environmentSelectOptions = computed(() => { const values = project.value?.environments?.length ? project.value.environments : ['dev', 'staging', 'prod'] return values.map(value => ({ label: value, value })) @@ -130,8 +114,8 @@ const canCreateDeployment = computed(() => authStore.canAccess({ permissions: [permission.deploymentWrite] }), ) -const canCreateScaffoldRequest = computed(() => - authStore.canAccess({ permissions: [permission.scaffoldRequestWrite] }), +const canWireService = computed(() => + authStore.canAccess({ permissions: [permission.projectWrite] }), ) const selectedRelease = computed(() => @@ -144,82 +128,55 @@ const visibleDeployments = computed(() => : deployments.value, ) -function resetDeploymentForm() { - deploymentForm.plugin_id = deployerOptions.value[0]?.value || '' - deploymentForm.environment = project.value?.environments?.[0] || 'dev' - deploymentForm.version = selectedRelease.value?.tag || '' -} - -function resetScaffoldForm() { - scaffoldForm.plugin_id = scaffolderOptions.value[0]?.value || '' - scaffoldForm.environment = project.value?.environments?.[0] || 'dev' - scaffoldForm.variables.service_name = normalizeServiceName(service.value?.name || project.value?.name || 'new-service') - scaffoldForm.variables.module_path = inferModulePath(service.value?.repo_url || '') - scaffoldForm.variables.port = suggestPort(scaffoldForm.variables.service_name) - scaffoldForm.variables.database = 'postgres' - scaffoldForm.variables.enable_logging = true - scaffoldPrompt.value = '' - scaffoldSuggestion.value = null -} - -function openScaffoldModal() { - resetScaffoldForm() - scaffoldModalOpen.value = true -} - -function normalizeServiceName(value: string) { - return value - .trim() - .toLowerCase() - .replace(/[^a-z0-9]+/g, '-') - .replace(/^-+|-+$/g, '') - || 'new-service' -} +const dependencyOptions = computed(() => { + const wiredServiceIds = new Set(dependencies.value.map(item => item.depends_on_service_id)) -function inferModulePath(repoUrl: string) { - const trimmed = repoUrl.trim().replace(/\.git$/, '') - if (!trimmed) return '' - return trimmed - .replace(/^https?:\/\//, '') - .replace(/^git@/, '') - .replace(':', '/') -} + return projectServices.value + .filter(item => item.id !== serviceId.value && !wiredServiceIds.has(item.id)) + .map(item => ({ label: item.name, value: item.id })) +}) -function suggestPort(name: string) { - const hash = [...name].reduce((total, char) => total + char.charCodeAt(0), 0) - return 8000 + (hash % 1000) -} +const dependencyTypeOptions = [ + { label: 'HTTP', value: 'http' }, + { label: 'gRPC', value: 'grpc' }, + { label: 'Queue', value: 'queue' }, + { label: 'Database', value: 'database' }, +] -async function generateScaffoldSuggestion() { - if (!projectId.value) { - message.warning('Project context is not available for scaffold suggestions.') - return +const dependencyProtocolOptions = computed(() => { + if (dependencyForm.type === 'grpc') { + return [{ label: 'gRPC', value: 'grpc' }] } - if (!scaffoldPrompt.value.trim()) { - message.warning('Describe the service first so AI can suggest a scaffold request.') - return + if (dependencyForm.type === 'queue' || dependencyForm.type === 'database') { + return [ + { label: 'TCP', value: 'tcp' }, + { label: 'UDP', value: 'udp' }, + ] } - scaffoldSuggestionLoading.value = true + return [ + { label: 'HTTP', value: 'http' }, + { label: 'HTTPS', value: 'https' }, + ] +}) - try { - scaffoldSuggestion.value = await requestScaffoldSuggestion({ - projectId: projectId.value, - prompt: scaffoldPrompt.value, - }) - } catch (error) { - message.warning(error instanceof ApiError - ? `Unable to suggest scaffold request: ${error.message}` - : 'Unable to suggest scaffold request.') - } finally { - scaffoldSuggestionLoading.value = false - } +function resetDeploymentForm() { + deploymentForm.plugin_id = deployerOptions.value[0]?.value || '' + deploymentForm.environment = project.value?.environments?.[0] || 'dev' + deploymentForm.version = selectedRelease.value?.tag || '' } -function applyScaffoldSuggestion() { - if (!scaffoldSuggestion.value) return +function resetDependencyForm() { + dependencyForm.depends_on_service_id = dependencyOptions.value[0]?.value || '' + dependencyForm.type = 'http' + dependencyForm.protocol = 'http' + dependencyForm.port = null + dependencyForm.path = '' +} - applyScaffoldSuggestionToForm(scaffoldForm, scaffoldSuggestion.value) +function openDependencyModal() { + resetDependencyForm() + dependencyModalOpen.value = true } function selectRelease(row: Release) { @@ -377,15 +334,76 @@ const deploymentColumns = [ }, ] +const dependencyColumns = computed(() => { + const columns = [ + { + title: 'Depends on', + key: 'depends_on_service', + render: (row: ServiceDependency) => row.depends_on_service?.name || row.depends_on_service_id, + }, + { + title: 'Type', + key: 'type', + render: (row: ServiceDependency) => + h( + NTag, + { bordered: false, type: row.type === 'database' ? 'warning' : 'info' }, + { default: () => row.type }, + ), + }, + { + title: 'Endpoint', + key: 'endpoint', + render: (row: ServiceDependency) => { + const protocol = row.protocol || 'default' + const port = row.port ? `:${row.port}` : '' + const path = row.path || '' + return `${protocol}${port}${path}` + }, + }, + ] + + if (canWireService.value) { + columns.push({ + title: 'Actions', + key: 'actions', + render: (row: ServiceDependency) => + h( + NPopconfirm, + { + onPositiveClick: () => removeDependency(row), + }, + { + trigger: () => + h( + NButton, + { + size: 'small', + type: 'error', + ghost: true, + onClick: (event: MouseEvent) => event.stopPropagation(), + }, + { default: () => 'Remove' }, + ), + default: () => 'Remove this service dependency?', + }, + ), + }) + } + + return columns +}) + async function loadServiceDetails() { loading.value = true try { - const [serviceContext, pluginData, serviceReleases, serviceDeployments] = await Promise.all([ + const [serviceContext, pluginData, serviceReleases, serviceDeployments, serviceDependencies] = await Promise.all([ findServiceContext(), fetchPlugins(), fetchServiceReleases(serviceId.value), fetchServiceDeployments(serviceId.value, { limit: 10, sortBy: 'date', sortOrder: 'desc' }), + fetchServiceDependencies(serviceId.value), ]) if (!serviceContext) { @@ -396,10 +414,11 @@ async function loadServiceDetails() { project.value = serviceContext.project service.value = serviceContext.service + projectServices.value = serviceContext.services plugins.value = pluginData releases.value = serviceReleases deployments.value = serviceDeployments - resetScaffoldForm() + dependencies.value = serviceDependencies } catch (error) { message.error(error instanceof ApiError ? error.message : 'Unable to load service details.') } finally { @@ -407,7 +426,7 @@ async function loadServiceDetails() { } } -async function findServiceContext(): Promise<{ project: Project, service: Service } | null> { +async function findServiceContext(): Promise<{ project: Project, service: Service, services: Service[] } | null> { const routeProjectId = route.params.projectId as string | undefined if (routeProjectId) { @@ -416,7 +435,7 @@ async function findServiceContext(): Promise<{ project: Project, service: Servic fetchProjectServices(routeProjectId), ]) const matchedService = serviceRows.find(item => item.id === serviceId.value) || null - return matchedService ? { project: projectData, service: matchedService } : null + return matchedService ? { project: projectData, service: matchedService, services: serviceRows } : null } const projects = await fetchProjects() @@ -424,42 +443,47 @@ async function findServiceContext(): Promise<{ project: Project, service: Servic projects.map(async (projectData: Project) => { const serviceRows = await fetchProjectServices(projectData.id) const matchedService = serviceRows.find(item => item.id === serviceId.value) || null - return matchedService ? { project: projectData, service: matchedService } : null + return matchedService ? { project: projectData, service: matchedService, services: serviceRows } : null }), ) return serviceGroups.find(Boolean) || null } -async function submitScaffoldRequest() { - if (!scaffoldForm.plugin_id || !scaffoldForm.environment || !scaffoldForm.variables.service_name.trim()) { - message.warning('Complete the scaffold request form before submitting.') +async function submitDependency() { + if (!dependencyForm.depends_on_service_id || !dependencyForm.type) { + message.warning('Choose a target service and dependency type before wiring.') return } - scaffoldSubmitting.value = true + dependencySubmitting.value = true try { - if (!projectId.value) { - message.warning('Project context is not available for this scaffold request.') - return - } - - await createScaffoldRequest(projectId.value, { - ...scaffoldForm, - variables: { - ...scaffoldForm.variables, - service_name: scaffoldForm.variables.service_name.trim(), - module_path: scaffoldForm.variables.module_path.trim(), - }, + await createServiceDependency(serviceId.value, { + depends_on_service_id: dependencyForm.depends_on_service_id, + type: dependencyForm.type, + protocol: dependencyForm.protocol, + port: dependencyForm.port, + path: dependencyForm.path.trim(), + config: {}, }) - message.success('Scaffold request created successfully.') - scaffoldModalOpen.value = false - resetScaffoldForm() + message.success('Service dependency wired successfully.') + dependencyModalOpen.value = false + dependencies.value = await fetchServiceDependencies(serviceId.value) } catch (error) { - message.error(error instanceof ApiError ? error.message : 'Unable to create scaffold request.') + message.error(error instanceof ApiError ? error.message : 'Unable to wire service dependency.') } finally { - scaffoldSubmitting.value = false + dependencySubmitting.value = false + } +} + +async function removeDependency(row: ServiceDependency) { + try { + await deleteServiceDependency(serviceId.value, row.id) + dependencies.value = dependencies.value.filter(item => item.id !== row.id) + message.success('Service dependency removed.') + } catch (error) { + message.error(error instanceof ApiError ? error.message : 'Unable to remove service dependency.') } } @@ -512,11 +536,12 @@ onMounted(loadServiceDetails) Open repository --> - New scaffold request + Wire service @@ -570,6 +595,32 @@ onMounted(loadServiceDetails) + + + + + +
@@ -621,37 +672,55 @@ onMounted(loadServiceDetails)
- + - + - + + + + + + + + +
@@ -659,139 +728,60 @@ onMounted(loadServiceDetails)
-
-
-
-
-

AI-assisted scaffold suggestions

- - {{ scaffoldSuggestion.source }} - -
-

Describe the service, then AI will choose a scaffold plugin and variables.

-
-
- - Analyze prompt - - - Apply - -
-
- - - -
-
-

Suggested plugin

-

{{ scaffoldSuggestion.plugin_name || 'Select manually' }} ยท {{ Math.round(scaffoldSuggestion.confidence * 100) }}%

-
-
-

Suggested service

-

{{ scaffoldSuggestion.variables.service_name }}

-
-
-

Suggested port

-

{{ scaffoldSuggestion.variables.port }}

-
-
-

Environment

-

{{ scaffoldSuggestion.environment }}

-
-
-

Module path

-

{{ scaffoldSuggestion.variables.module_path || 'Manual input needed' }}

-
-
-

Reasoning

-
    -
  • {{ item }}
  • -
-
-
-
-
- + - - - - - - - - - - - - - - - - - - - Enabled - + +