Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 9 additions & 8 deletions backend/internal/models/project.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,14 +13,15 @@ const (
)

type Project struct {
Name string `json:"name" sortable:"true"`
DirName *string `json:"dir_name"`
Path string `json:"path" sortable:"true" gorm:"uniqueIndex"`
Status ProjectStatus `json:"status" sortable:"true"`
StatusReason *string `json:"status_reason"`
ServiceCount int `json:"service_count" sortable:"true"`
RunningCount int `json:"running_count" sortable:"true"`
GitOpsManagedBy *string `json:"gitops_managed_by,omitempty" gorm:"column:gitops_managed_by"`
Name string `json:"name" sortable:"true"`
DirName *string `json:"dir_name"`
Path string `json:"path" sortable:"true" gorm:"uniqueIndex"`
Status ProjectStatus `json:"status" sortable:"true"`
StatusReason *string `json:"status_reason"`
ServiceCount int `json:"service_count" sortable:"true"`
RunningCount int `json:"running_count" sortable:"true"`
GitOpsManagedBy *string `json:"gitops_managed_by,omitempty" gorm:"column:gitops_managed_by"`
ComposeProjectName *string `json:"compose_project_name,omitempty" gorm:"column:compose_project_name"`

BaseModel
}
Expand Down
87 changes: 74 additions & 13 deletions backend/internal/services/project_service.go
Original file line number Diff line number Diff line change
Expand Up @@ -239,6 +239,16 @@ func buildProjectImagePullPlan(services composetypes.Services) map[string]imageP
return plan
}

func ptrStringEqualInternal(a, b *string) bool {
if a == nil && b == nil {
return true
}
if a == nil || b == nil {
return false
}
return *a == *b
}

func normalizeComposeProjectName(name string) string {
if name == "" {
return ""
Expand Down Expand Up @@ -934,23 +944,20 @@ func (s *ProjectService) upsertProjectForDir(ctx context.Context, dirName, dirPa
Where("path = ?", dirPath).
First(&existing).Error

filesystemProject := models.Project{
Name: dirName,
Path: dirPath,
}
serviceCount, serviceCountErr := s.countServicesFromCompose(ctx, filesystemProject)
serviceCount, composeProjectName, serviceCountErr := s.loadComposeMetadataForSyncInternal(ctx, dirPath, dirName)

if errors.Is(err, gorm.ErrRecordNotFound) {
// Create a minimal project entry
reason := "Project discovered from filesystem, status pending Docker service query"
proj := &models.Project{
Name: dirName,
DirName: new(dirName),
Path: dirPath,
Status: models.ProjectStatusUnknown,
StatusReason: new(reason),
ServiceCount: serviceCount,
RunningCount: 0,
Name: dirName,
DirName: new(dirName),
Path: dirPath,
Status: models.ProjectStatusUnknown,
StatusReason: new(reason),
ServiceCount: serviceCount,
RunningCount: 0,
ComposeProjectName: composeProjectName,
}
slog.InfoContext(ctx, "Discovered new project with unknown status",
"project", dirName,
Expand Down Expand Up @@ -980,6 +987,9 @@ func (s *ProjectService) upsertProjectForDir(ctx context.Context, dirName, dirPa
} else if serviceCountErr != nil {
slog.WarnContext(ctx, "failed to refresh compose service count during project sync", "projectID", existing.ID, "path", dirPath, "error", serviceCountErr)
}
if !ptrStringEqualInternal(existing.ComposeProjectName, composeProjectName) {
updates["compose_project_name"] = composeProjectName
}
if len(updates) == 0 {
return nil
}
Expand Down Expand Up @@ -1143,6 +1153,9 @@ func (s *ProjectService) GetProjectStatusCounts(ctx context.Context) (folderCoun
for _, p := range projectsList {
normName := normalizeComposeProjectName(p.Name)
projectContainers := containersByProject[normName]
if len(projectContainers) == 0 && p.ComposeProjectName != nil && *p.ComposeProjectName != normName {
projectContainers = containersByProject[*p.ComposeProjectName]
}

// Convert to ProjectServiceInfo (minimal needed for calculateProjectStatus)
var services []ProjectServiceInfo
Expand Down Expand Up @@ -2997,9 +3010,14 @@ func (s *ProjectService) mapProjectToDto(ctx context.Context, projectsDir string
resp.IconURL = meta.ProjectIconURL
resp.URLs = meta.ProjectURLS

// Find containers for this project
// Find containers for this project. Try the normalized directory name first,
// then fall back to the effective compose project name (from COMPOSE_PROJECT_NAME
// in .env) which may differ from the directory name.
normName := normalizeComposeProjectName(p.Name)
projectContainers := containersByProject[normName]
if len(projectContainers) == 0 && p.ComposeProjectName != nil && *p.ComposeProjectName != normName {
projectContainers = containersByProject[*p.ComposeProjectName]
}

var services []ProjectServiceInfo
runningCount := 0
Expand Down Expand Up @@ -3128,6 +3146,49 @@ func (s *ProjectService) countServicesFromCompose(ctx context.Context, p models.
return len(proj.Services), nil
}

// loadComposeMetadataForSyncInternal loads the compose file once and returns
// both the service count and the effective compose project name. This avoids
// parsing the compose file twice during project sync (once for service count
// and once for the project name).
// The effective name is nil when it matches the normalized directory name.
func (s *ProjectService) loadComposeMetadataForSyncInternal(ctx context.Context, dirPath, dirName string) (serviceCount int, composeProjectName *string, err error) {
projectsDirSetting := s.settingsService.GetStringSetting(ctx, "projectsDirectory", "/app/data/projects")
projectsDirectory, pErr := projects.GetProjectsDirectory(ctx, strings.TrimSpace(projectsDirSetting))
if pErr != nil {
return 0, nil, pErr
}

pathMapper, pmErr := s.getPathMapper(ctx)
if pmErr != nil {
slog.WarnContext(ctx, "failed to create path mapper, continuing without translation", "error", pmErr)
}

normName := normalizeComposeProjectName(dirName)
autoInjectEnv := s.settingsService.GetBoolSetting(ctx, "autoInjectEnv", false)

// First, try loading without forcing a project name so compose-go can
// resolve COMPOSE_PROJECT_NAME from the .env file. If this fails (e.g.
// no .env and directory name is not a valid compose project name), fall
// back to the normalized directory name.
proj, _, err := projects.LoadComposeProjectFromDir(ctx, dirPath, "", projectsDirectory, autoInjectEnv, pathMapper)
if err != nil {
proj, _, err = projects.LoadComposeProjectFromDir(ctx, dirPath, normName, projectsDirectory, autoInjectEnv, pathMapper)
if err != nil {
return 0, nil, err
}
}

serviceCount = len(proj.Services)

// If compose-go resolved a different name (from COMPOSE_PROJECT_NAME),
// store it so we can match containers correctly.
if proj.Name != "" && proj.Name != normName {
composeProjectName = new(proj.Name)
}

return serviceCount, composeProjectName, nil
}

func (s *ProjectService) calculateProjectStatus(services []ProjectServiceInfo) models.ProjectStatus {
if len(services) == 0 {
return models.ProjectStatusUnknown
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
ALTER TABLE projects DROP COLUMN compose_project_name;
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
ALTER TABLE projects ADD COLUMN compose_project_name TEXT;
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
PRAGMA foreign_keys=OFF;

ALTER TABLE projects RENAME TO projects_old;

CREATE TABLE projects (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
dir_name TEXT,
path TEXT NOT NULL,
status TEXT NOT NULL,
service_count INTEGER NOT NULL DEFAULT 0,
running_count INTEGER NOT NULL DEFAULT 0,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME,
status_reason TEXT,
gitops_managed_by TEXT
);

INSERT INTO projects (
id, name, dir_name, path, status, service_count, running_count,
created_at, updated_at, status_reason, gitops_managed_by
)
SELECT
id, name, dir_name, path, status, service_count, running_count,
created_at, updated_at, status_reason, gitops_managed_by
FROM projects_old;

DROP TABLE projects_old;

CREATE INDEX IF NOT EXISTS idx_projects_status ON projects(status);
CREATE INDEX IF NOT EXISTS idx_projects_name ON projects(name);
CREATE INDEX IF NOT EXISTS idx_projects_gitops_managed_by ON projects(gitops_managed_by);
CREATE INDEX IF NOT EXISTS idx_projects_dir_name_not_null ON projects(dir_name) WHERE dir_name IS NOT NULL;
CREATE UNIQUE INDEX IF NOT EXISTS idx_projects_path_unique ON projects(path);

PRAGMA foreign_keys=ON;
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
ALTER TABLE projects ADD COLUMN compose_project_name TEXT;
Loading