From b9900c35fc13648c7ba8fecc9c4cfa7a29f75357 Mon Sep 17 00:00:00 2001 From: Andrii Yurchuk Date: Tue, 28 Oct 2025 08:46:07 +0100 Subject: [PATCH] Separate auto-deployment from watching for changes --- db/migrations.go | 159 ++++++++- db/migrations_test.go | 331 ++++++++++++++---- db/models.go | 3 +- domain/project.go | 60 ++-- project/integration_test.go | 2 +- repository/mapper.go | 50 +-- watcher/watcher.go | 52 +-- web/actions/actions.go | 30 +- web/actions/actions_internal.go | 52 +-- web/assets/css/output.css | 3 + web/components/forms/project-form.templ | 4 +- web/components/forms/project-form_templ.go | 28 +- web/components/modals/create-project.templ | 2 +- web/components/modals/create-project_templ.go | 4 +- web/components/modals/edit-project.templ | 2 +- web/components/modals/edit-project_templ.go | 26 +- web/components/project/card.templ | 29 +- web/components/project/card_templ.go | 154 ++++++-- web/components/project/grid.templ | 19 +- web/components/project/grid_templ.go | 27 +- web/handlers/handlers.go | 27 +- 21 files changed, 758 insertions(+), 306 deletions(-) diff --git a/db/migrations.go b/db/migrations.go index ec9e3ba..892932b 100644 --- a/db/migrations.go +++ b/db/migrations.go @@ -1,11 +1,39 @@ package db import ( + "fmt" "time" "gorm.io/gorm" ) +// Migration represents a single database migration +type Migration struct { + ID int + Name string + Up func(*gorm.DB) error +} + +// allMigrations is the ordered list of all migrations +// Each migration has a unique ID and is applied in order +var allMigrations = []Migration{ + { + ID: 1, + Name: "0001_rename_last_commit_to_local_commit", + Up: migration0001RenameLastCommitToLocalCommit, + }, + { + ID: 2, + Name: "0002_rename_watcher_enabled_to_auto_deploy_enabled", + Up: migration0002RenameWatcherEnabledToAutoDeployEnabled, + }, + { + ID: 3, + Name: "0003_cleanup_old_migration_records", + Up: migration0003CleanupOldMigrationRecords, + }, +} + // AllModels returns all the models that need to be migrated // This is the single source of truth for database migrations func AllModels() []any { @@ -18,16 +46,13 @@ func AllModels() []any { // AutoMigrateAll runs auto-migration for all application models func AutoMigrateAll(db *gorm.DB) error { - // Run manual migrations BEFORE AutoMigrate to avoid conflicts - // (e.g., AutoMigrate would create local_commit if we haven't renamed last_commit yet) - // First, ensure migrations table exists if err := db.AutoMigrate(&MigrationModel{}); err != nil { return err } - // Run manual migrations - if err := migrateLastCommitToLocalCommit(db); err != nil { + // Run all manual migrations in order + if err := RunMigrations(db, len(allMigrations)); err != nil { return err } @@ -39,6 +64,41 @@ func AutoMigrateAll(db *gorm.DB) error { return nil } +// RunMigrations runs all migrations up to and including the specified ID +// If targetID is 0 or negative, all migrations are run +func RunMigrations(db *gorm.DB, targetID int) error { + if targetID <= 0 { + targetID = len(allMigrations) + } + + for _, migration := range allMigrations { + if migration.ID > targetID { + break + } + + // Check if migration has already been applied + applied, err := migrationApplied(db, migration.Name) + if err != nil { + return fmt.Errorf("failed to check migration %s: %w", migration.Name, err) + } + if applied { + continue + } + + // Run the migration + if err := migration.Up(db); err != nil { + return fmt.Errorf("failed to apply migration %s: %w", migration.Name, err) + } + + // Record that migration was applied + if err := recordMigration(db, migration.Name); err != nil { + return fmt.Errorf("failed to record migration %s: %w", migration.Name, err) + } + } + + return nil +} + // migrationApplied checks if a migration has already been applied func migrationApplied(db *gorm.DB, name string) (bool, error) { var count int64 @@ -58,30 +118,91 @@ func recordMigration(db *gorm.DB, name string) error { return db.Create(&migration).Error } -// migrateLastCommitToLocalCommit handles the rename from last_commit to local_commit -func migrateLastCommitToLocalCommit(db *gorm.DB) error { - migrationName := "rename_last_commit_to_local_commit" +// Schema snapshots represent the database state at each migration point +// These are used by tests to create databases at specific migration versions - // Check if migration has already been applied - applied, err := migrationApplied(db, migrationName) - if err != nil { +// CreateSchemaAtMigration creates the database schema as it existed at a specific migration version +// migrationID 0 = initial schema before any migrations +// migrationID N = schema after applying migrations 1 through N +func CreateSchemaAtMigration(db *gorm.DB, migrationID int) error { + // First ensure migrations table exists + if err := db.AutoMigrate(&MigrationModel{}); err != nil { + return err + } + + // Create initial schema (before any migrations) + if err := createInitialSchema(db); err != nil { return err } - if applied { - return nil // Migration already done + + // Apply migrations up to the target + if migrationID > 0 { + return RunMigrations(db, migrationID) } + return nil +} + +// createInitialSchema creates the schema as it existed before any migrations (migration 0) +func createInitialSchema(db *gorm.DB) error { + // Create projects table with original column names + return db.Exec(` + CREATE TABLE IF NOT EXISTS projects ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL UNIQUE, + git_url TEXT NOT NULL, + git_branch TEXT NOT NULL, + git_auth_type TEXT, + git_auth_credentials TEXT, + working_dir TEXT NOT NULL, + compose_files TEXT NOT NULL, + compose_override TEXT, + variables TEXT NOT NULL, + status TEXT NOT NULL, + last_commit TEXT, + remote_commit TEXT, + watcher_enabled INTEGER NOT NULL, + created_at DATETIME, + updated_at DATETIME + ) + `).Error +} + +// migration0001RenameLastCommitToLocalCommit handles the rename from last_commit to local_commit +func migration0001RenameLastCommitToLocalCommit(db *gorm.DB) error { // Check if old column exists - if not, this is a fresh database and nothing to migrate if !db.Migrator().HasColumn(&ProjectModel{}, "last_commit") { - // Record migration as applied anyway to prevent future attempts - return recordMigration(db, migrationName) + return nil // Nothing to migrate } // Rename column directly (requires SQLite 3.25.0+) - if err := db.Exec("ALTER TABLE projects RENAME COLUMN last_commit TO local_commit").Error; err != nil { - return err + return db.Exec("ALTER TABLE projects RENAME COLUMN last_commit TO local_commit").Error +} + +// migration0002RenameWatcherEnabledToAutoDeployEnabled handles the rename from watcher_enabled to auto_deploy_enabled +func migration0002RenameWatcherEnabledToAutoDeployEnabled(db *gorm.DB) error { + // Check if old column exists - if not, this is a fresh database and nothing to migrate + if !db.Migrator().HasColumn(&ProjectModel{}, "watcher_enabled") { + return nil // Nothing to migrate } - // Record that migration was applied - return recordMigration(db, migrationName) + // Rename column directly (requires SQLite 3.25.0+) + return db.Exec("ALTER TABLE projects RENAME COLUMN watcher_enabled TO auto_deploy_enabled").Error +} + +// migration0003CleanupOldMigrationRecords removes duplicate migration records from the old naming scheme +func migration0003CleanupOldMigrationRecords(db *gorm.DB) error { + // Delete old migration records that used the old naming convention (without number prefix) + oldMigrationNames := []string{ + "rename_last_commit_to_local_commit", + "rename_watcher_enabled_to_auto_deploy_enabled", + } + + for _, name := range oldMigrationNames { + if err := db.Where("name = ?", name).Delete(&MigrationModel{}).Error; err != nil { + return err + } + } + + return nil } diff --git a/db/migrations_test.go b/db/migrations_test.go index 8a9a0d3..366f563 100644 --- a/db/migrations_test.go +++ b/db/migrations_test.go @@ -2,6 +2,7 @@ package db import ( "testing" + "time" "github.com/google/uuid" "github.com/stretchr/testify/assert" @@ -9,47 +10,20 @@ import ( "gorm.io/gorm/logger" ) -func TestMigrateLastCommitToLocalCommit(t *testing.T) { - // Create in-memory database +// TestMigration0001RenameLastCommitToLocalCommit tests migration 1 +func TestMigration0001RenameLastCommitToLocalCommit(t *testing.T) { + // Create database at migration 0 (before this migration) db, err := InitDatabase(DBConfig{ Path: ":memory:", LogLevel: logger.Silent, }) require.NoError(t, err) - // Create migrations table first (simulating AutoMigrate) - err = db.AutoMigrate(&MigrationModel{}) + // Create schema at migration 0 + err = CreateSchemaAtMigration(db, 0) require.NoError(t, err) - // Create old schema with last_commit column - err = db.Exec(` - CREATE TABLE projects ( - id TEXT PRIMARY KEY, - name TEXT NOT NULL UNIQUE, - git_url TEXT NOT NULL, - git_branch TEXT NOT NULL, - git_auth_type TEXT, - git_auth_credentials TEXT, - working_dir TEXT NOT NULL, - compose_files TEXT NOT NULL, - compose_override TEXT, - variables TEXT NOT NULL, - status TEXT NOT NULL, - last_commit TEXT, - watcher_enabled INTEGER NOT NULL, - created_at DATETIME, - updated_at DATETIME, - CHECK(name <> ''), - CHECK(git_url <> ''), - CHECK(git_branch <> ''), - CHECK(working_dir <> ''), - CHECK(compose_files <> ''), - CHECK(status <> '') - ) - `).Error - require.NoError(t, err) - - // Insert test data with last_commit values + // Insert test data with old column names testID1 := uuid.New() testID2 := uuid.New() testID3 := uuid.New() @@ -65,23 +39,21 @@ func TestMigrateLastCommitToLocalCommit(t *testing.T) { `, testID1, testID2, testID3).Error require.NoError(t, err) - // Verify old data exists in last_commit column - var count int64 - err = db.Raw("SELECT COUNT(*) FROM projects WHERE last_commit IS NOT NULL").Scan(&count).Error - require.NoError(t, err) - assert.Equal(t, int64(2), count, "Should have 2 projects with last_commit values") + // Verify old column exists + hasLastCommit := db.Migrator().HasColumn(&ProjectModel{}, "last_commit") + assert.True(t, hasLastCommit, "last_commit column should exist before migration") - // Run the migration (which will rename last_commit to local_commit) - err = migrateLastCommitToLocalCommit(db) + // Apply migration 1 + err = RunMigrations(db, 1) require.NoError(t, err) // Verify last_commit column no longer exists (was renamed) - hasLastCommit := db.Migrator().HasColumn(&ProjectModel{}, "last_commit") - assert.False(t, hasLastCommit, "last_commit column should not exist after rename") + hasLastCommit = db.Migrator().HasColumn(&ProjectModel{}, "last_commit") + assert.False(t, hasLastCommit, "last_commit column should not exist after migration") // Verify local_commit column exists hasLocalCommit := db.Migrator().HasColumn(&ProjectModel{}, "local_commit") - assert.True(t, hasLocalCommit, "local_commit column should exist") + assert.True(t, hasLocalCommit, "local_commit column should exist after migration") // Verify data was migrated correctly type Result struct { @@ -108,31 +80,114 @@ func TestMigrateLastCommitToLocalCommit(t *testing.T) { assert.Equal(t, testID3.String(), results[2].ID) assert.Nil(t, results[2].LocalCommit) - // Verify migration was recorded in migrations table + // Verify migration was recorded + var migrationCount int64 + err = db.Model(&MigrationModel{}). + Where("name = ?", "0001_rename_last_commit_to_local_commit"). + Count(&migrationCount). + Error + require.NoError(t, err) + assert.Equal(t, int64(1), migrationCount, "Migration should be recorded once") + + // Verify idempotency - running again should not fail + err = RunMigrations(db, 1) + assert.NoError(t, err, "Migration should be idempotent") + + // Verify migration is still recorded only once + err = db.Model(&MigrationModel{}). + Where("name = ?", "0001_rename_last_commit_to_local_commit"). + Count(&migrationCount). + Error + require.NoError(t, err) + assert.Equal(t, int64(1), migrationCount, "Migration should still be recorded only once") +} + +// TestMigration0002RenameWatcherEnabledToAutoDeployEnabled tests migration 2 +func TestMigration0002RenameWatcherEnabledToAutoDeployEnabled(t *testing.T) { + // Create database at migration 1 (before this migration) + db, err := InitDatabase(DBConfig{ + Path: ":memory:", + LogLevel: logger.Silent, + }) + require.NoError(t, err) + + // Create schema at migration 1 (has local_commit but still has watcher_enabled) + err = CreateSchemaAtMigration(db, 1) + require.NoError(t, err) + + // Insert test data with column names as they exist after migration 1 + testID1 := uuid.New() + testID2 := uuid.New() + + err = db.Exec(` + INSERT INTO projects ( + id, name, git_url, git_branch, working_dir, compose_files, + variables, status, local_commit, watcher_enabled, created_at, updated_at + ) VALUES + (?, 'project1', 'https://example.com/repo1.git', 'main', '/path1', 'compose.yml', '', 'stopped', 'abc123', 1, datetime('now'), datetime('now')), + (?, 'project2', 'https://example.com/repo2.git', 'main', '/path2', 'compose.yml', '', 'running', 'def456', 0, datetime('now'), datetime('now')) + `, testID1, testID2).Error + require.NoError(t, err) + + // Verify old column exists + hasWatcherEnabled := db.Migrator().HasColumn(&ProjectModel{}, "watcher_enabled") + assert.True(t, hasWatcherEnabled, "watcher_enabled column should exist before migration") + + // Apply migration 2 + err = RunMigrations(db, 2) + require.NoError(t, err) + + // Verify watcher_enabled column no longer exists (was renamed) + hasWatcherEnabled = db.Migrator().HasColumn(&ProjectModel{}, "watcher_enabled") + assert.False(t, hasWatcherEnabled, "watcher_enabled column should not exist after migration") + + // Verify auto_deploy_enabled column exists + hasAutoDeployEnabled := db.Migrator().HasColumn(&ProjectModel{}, "auto_deploy_enabled") + assert.True(t, hasAutoDeployEnabled, "auto_deploy_enabled column should exist after migration") + + // Verify data was migrated correctly + type Result struct { + ID string + AutoDeployEnabled bool + } + + var results []Result + err = db.Raw("SELECT id, auto_deploy_enabled FROM projects ORDER BY name").Scan(&results).Error + require.NoError(t, err) + require.Len(t, results, 2) + + // Check project1 - should have watcher_enabled=1 migrated to auto_deploy_enabled=true + assert.Equal(t, testID1.String(), results[0].ID) + assert.True(t, results[0].AutoDeployEnabled) + + // Check project2 - should have watcher_enabled=0 migrated to auto_deploy_enabled=false + assert.Equal(t, testID2.String(), results[1].ID) + assert.False(t, results[1].AutoDeployEnabled) + + // Verify migration was recorded var migrationCount int64 err = db.Model(&MigrationModel{}). - Where("name = ?", "rename_last_commit_to_local_commit"). + Where("name = ?", "0002_rename_watcher_enabled_to_auto_deploy_enabled"). Count(&migrationCount). Error require.NoError(t, err) assert.Equal(t, int64(1), migrationCount, "Migration should be recorded once") - // Verify migration is idempotent - running again should not fail and should not duplicate - err = migrateLastCommitToLocalCommit(db) + // Verify idempotency - running again should not fail + err = RunMigrations(db, 2) assert.NoError(t, err, "Migration should be idempotent") // Verify migration is still recorded only once err = db.Model(&MigrationModel{}). - Where("name = ?", "rename_last_commit_to_local_commit"). + Where("name = ?", "0002_rename_watcher_enabled_to_auto_deploy_enabled"). Count(&migrationCount). Error require.NoError(t, err) assert.Equal(t, int64(1), migrationCount, "Migration should still be recorded only once") } -// TestAutoMigrateAllWithOldSchema tests the real upgrade scenario where -// a database with old schema (last_commit) goes through AutoMigrateAll -func TestAutoMigrateAllWithOldSchema(t *testing.T) { +// TestAutoMigrateAllFreshDatabase tests AutoMigrateAll on a fresh database +func TestAutoMigrateAllFreshDatabase(t *testing.T) { // Create in-memory database db, err := InitDatabase(DBConfig{ Path: ":memory:", @@ -140,15 +195,115 @@ func TestAutoMigrateAllWithOldSchema(t *testing.T) { }) require.NoError(t, err) - // Create migrations table and other tables using GORM (to get exact schema GORM expects) - err = db.AutoMigrate(&MigrationModel{}, &ProjectModel{}, &DeploymentModel{}) + // Run AutoMigrateAll on fresh database + err = AutoMigrateAll(db) + require.NoError(t, err) + + // Verify tables exist with correct schema + hasProjectsTable := db.Migrator().HasTable(&ProjectModel{}) + assert.True(t, hasProjectsTable, "projects table should exist") + + hasDeploymentsTable := db.Migrator().HasTable(&DeploymentModel{}) + assert.True(t, hasDeploymentsTable, "deployments table should exist") + + // Verify new column names exist + hasLocalCommit := db.Migrator().HasColumn(&ProjectModel{}, "local_commit") + assert.True(t, hasLocalCommit, "local_commit column should exist") + + hasAutoDeployEnabled := db.Migrator().HasColumn(&ProjectModel{}, "auto_deploy_enabled") + assert.True(t, hasAutoDeployEnabled, "auto_deploy_enabled column should exist") + + // Verify old column names do not exist + hasLastCommit := db.Migrator().HasColumn(&ProjectModel{}, "last_commit") + assert.False(t, hasLastCommit, "last_commit column should not exist") + + hasWatcherEnabled := db.Migrator().HasColumn(&ProjectModel{}, "watcher_enabled") + assert.False(t, hasWatcherEnabled, "watcher_enabled column should not exist") + + // Verify migrations were recorded + var count int64 + err = db.Model(&MigrationModel{}).Count(&count).Error + require.NoError(t, err) + assert.Equal(t, int64(3), count, "Should have 3 migration records") +} + +// TestMigration0003CleanupOldMigrationRecords tests migration 3 +func TestMigration0003CleanupOldMigrationRecords(t *testing.T) { + // Create database at migration 2 (before this migration) + db, err := InitDatabase(DBConfig{ + Path: ":memory:", + LogLevel: logger.Silent, + }) + require.NoError(t, err) + + // Create schema at migration 2 + err = CreateSchemaAtMigration(db, 2) + require.NoError(t, err) + + // Manually insert old-style migration records to simulate an upgraded database + err = db.Create(&MigrationModel{ + Name: "rename_last_commit_to_local_commit", + AppliedAt: time.Now(), + }).Error + require.NoError(t, err) + + err = db.Create(&MigrationModel{ + Name: "rename_watcher_enabled_to_auto_deploy_enabled", + AppliedAt: time.Now(), + }).Error + require.NoError(t, err) + + // Verify we have 4 migration records (2 new-style + 2 old-style) + var countBefore int64 + err = db.Model(&MigrationModel{}).Count(&countBefore).Error + require.NoError(t, err) + assert.Equal(t, int64(4), countBefore, "Should have 4 migration records before cleanup") + + // Apply migration 3 + err = RunMigrations(db, 3) + require.NoError(t, err) + + // Verify old migration records were deleted + var countAfter int64 + err = db.Model(&MigrationModel{}).Count(&countAfter).Error + require.NoError(t, err) + assert.Equal(t, int64(3), countAfter, "Should have 3 migration records after cleanup (0001, 0002, 0003)") + + // Verify old-style records are gone + var oldStyleCount int64 + err = db.Model(&MigrationModel{}). + Where("name IN ?", []string{ + "rename_last_commit_to_local_commit", + "rename_watcher_enabled_to_auto_deploy_enabled", + }). + Count(&oldStyleCount). + Error + require.NoError(t, err) + assert.Equal(t, int64(0), oldStyleCount, "Old-style migration records should be deleted") + + // Verify new-style records are still present + var newStyleCount int64 + err = db.Model(&MigrationModel{}). + Where("name LIKE ?", "000%"). + Count(&newStyleCount). + Error require.NoError(t, err) + assert.Equal(t, int64(3), newStyleCount, "All new-style migration records should be present") +} - // Now rename local_commit back to last_commit to simulate old schema - err = db.Exec("ALTER TABLE projects RENAME COLUMN local_commit TO last_commit").Error +// TestIncrementalMigration tests applying all migrations incrementally +func TestIncrementalMigration(t *testing.T) { + // Create database at migration 0 + db, err := InitDatabase(DBConfig{ + Path: ":memory:", + LogLevel: logger.Silent, + }) require.NoError(t, err) - // Insert test data + err = CreateSchemaAtMigration(db, 0) + require.NoError(t, err) + + // Insert test data at migration 0 testID := uuid.New() err = db.Exec(` INSERT INTO projects ( @@ -158,36 +313,60 @@ func TestAutoMigrateAllWithOldSchema(t *testing.T) { `, testID).Error require.NoError(t, err) - // Run AutoMigrateAll (this is what happens on app startup with old database) - // This should: 1) create migrations table, 2) run migration to rename column, 3) run AutoMigrate - err = AutoMigrateAll(db) - require.NoError(t, err, "AutoMigrateAll should handle old schema gracefully") - - // Verify last_commit column no longer exists + // Verify initial state (migration 0) hasLastCommit := db.Migrator().HasColumn(&ProjectModel{}, "last_commit") - assert.False(t, hasLastCommit, "last_commit column should not exist after migration") + assert.True(t, hasLastCommit, "Should have last_commit at migration 0") + + hasWatcherEnabled := db.Migrator().HasColumn(&ProjectModel{}, "watcher_enabled") + assert.True(t, hasWatcherEnabled, "Should have watcher_enabled at migration 0") + + // Apply migration 1 + err = RunMigrations(db, 1) + require.NoError(t, err) + + // Verify state after migration 1 + hasLastCommit = db.Migrator().HasColumn(&ProjectModel{}, "last_commit") + assert.False(t, hasLastCommit, "Should not have last_commit after migration 1") - // Verify local_commit column exists hasLocalCommit := db.Migrator().HasColumn(&ProjectModel{}, "local_commit") - assert.True(t, hasLocalCommit, "local_commit column should exist after migration") + assert.True(t, hasLocalCommit, "Should have local_commit after migration 1") + + hasWatcherEnabled = db.Migrator().HasColumn(&ProjectModel{}, "watcher_enabled") + assert.True(t, hasWatcherEnabled, "Should still have watcher_enabled after migration 1") + + // Apply migration 2 + err = RunMigrations(db, 2) + require.NoError(t, err) + + // Verify state after migration 2 + hasWatcherEnabled = db.Migrator().HasColumn(&ProjectModel{}, "watcher_enabled") + assert.False(t, hasWatcherEnabled, "Should not have watcher_enabled after migration 2") - // Verify data was preserved + hasAutoDeployEnabled := db.Migrator().HasColumn(&ProjectModel{}, "auto_deploy_enabled") + assert.True(t, hasAutoDeployEnabled, "Should have auto_deploy_enabled after migration 2") + + // Verify data was preserved through all migrations type Result struct { - ID string - LocalCommit *string + ID string + LocalCommit *string + AutoDeployEnabled bool } var result Result - err = db.Raw("SELECT id, local_commit FROM projects WHERE id = ?", testID.String()).Scan(&result).Error + err = db.Raw("SELECT id, local_commit, auto_deploy_enabled FROM projects WHERE id = ?", testID.String()). + Scan(&result). + Error require.NoError(t, err) require.NotNil(t, result.LocalCommit) - assert.Equal(t, "abc123", *result.LocalCommit, "Data should be preserved during migration") + assert.Equal(t, "abc123", *result.LocalCommit, "Data should be preserved") + assert.True(t, result.AutoDeployEnabled, "watcher_enabled=1 should become auto_deploy_enabled=true") - // Verify migration was recorded + // Apply migration 3 + err = RunMigrations(db, 3) + require.NoError(t, err) + + // Verify all migrations were recorded var migrationCount int64 - err = db.Model(&MigrationModel{}). - Where("name = ?", "rename_last_commit_to_local_commit"). - Count(&migrationCount). - Error + err = db.Model(&MigrationModel{}).Count(&migrationCount).Error require.NoError(t, err) - assert.Equal(t, int64(1), migrationCount, "Migration should be recorded") + assert.Equal(t, int64(3), migrationCount, "Should have 3 migration records") } diff --git a/db/models.go b/db/models.go index 519689d..ead32a4 100644 --- a/db/models.go +++ b/db/models.go @@ -31,7 +31,8 @@ type ProjectModel struct { Variables string `gorm:"not null"` // Variables separated by null character (\0) Status string `gorm:"not null;check:status <> ''"` // running, stopped, error LocalCommit *string - WatcherEnabled bool `gorm:"not null"` // Enable automatic deployments on git changes + RemoteCommit *string + AutoDeployEnabled bool `gorm:"not null"` // Enable automatic deployments on git changes Deployments []DeploymentModel `gorm:"foreignKey:ProjectID;constraint:OnDelete:CASCADE"` } diff --git a/domain/project.go b/domain/project.go index ae18cc0..712b270 100644 --- a/domain/project.go +++ b/domain/project.go @@ -65,20 +65,21 @@ func ParseGitAuthType(s string) (GitAuthType, error) { } type Project struct { - ID uuid.UUID - Name string - GitURL string - GitBranch string // Git branch to use (never empty, always set to default branch if not specified) - GitAuth *GitAuthConfig // Git authentication configuration - WorkingDir string - ComposeFiles []string - ComposeOverride *string // Optional Docker Compose override content - Variables []string // Variables in .env format, one per string - Status ProjectStatus - LocalCommit *string - WatcherEnabled bool // Enable automatic deployments on git changes - CreatedAt time.Time - UpdatedAt time.Time + ID uuid.UUID + Name string + GitURL string + GitBranch string // Git branch to use (never empty, always set to default branch if not specified) + GitAuth *GitAuthConfig // Git authentication configuration + WorkingDir string + ComposeFiles []string + ComposeOverride *string // Optional Docker Compose override content + Variables []string // Variables in .env format, one per string + Status ProjectStatus + LocalCommit *string + RemoteCommit *string + AutoDeployEnabled bool // Enable automatic deployments on git changes + CreatedAt time.Time + UpdatedAt time.Time } func (p *Project) GitDir() (string, error) { @@ -95,6 +96,21 @@ func (p *Project) LocalCommitStr() string { return *p.LocalCommit } +func (p *Project) RemoteCommitStr() string { + if p.RemoteCommit == nil { + return "" + } + return *p.RemoteCommit +} + +// IsOutdated returns true if the remote has commits that are not yet deployed locally +func (p *Project) IsOutdated() bool { + if p.RemoteCommit == nil || p.LocalCommit == nil { + return false + } + return *p.RemoteCommit != *p.LocalCommit +} + // GetDeletedDirectoryPath calculates the path where a project directory will be moved when deleted func GetDeletedDirectoryPath(workingDir string) string { deletedDirName := fmt.Sprintf("deleted-%s", filepath.Base(workingDir)) @@ -103,13 +119,13 @@ func GetDeletedDirectoryPath(workingDir string) string { func NewProject(name, gitURL string, composeFiles []string, variables []string) Project { return Project{ - ID: uuid.New(), - Name: name, - GitURL: gitURL, - GitBranch: "", // Default to repository's default branch - ComposeFiles: composeFiles, - Variables: variables, - Status: ProjectStatusStopped, - WatcherEnabled: true, // Default to enabled + ID: uuid.New(), + Name: name, + GitURL: gitURL, + GitBranch: "", // Default to repository's default branch + ComposeFiles: composeFiles, + Variables: variables, + Status: ProjectStatusStopped, + AutoDeployEnabled: true, // Default to enabled } } diff --git a/project/integration_test.go b/project/integration_test.go index af95017..e144760 100644 --- a/project/integration_test.go +++ b/project/integration_test.go @@ -76,7 +76,7 @@ func TestCompleteLifecycle(t *testing.T) { assert.Equal(t, domain.ProjectStatusStopped, createdProject.Status) assert.Equal(t, localCommit, remoteCommit) assert.Equal(t, remoteCommit, createdProject.LocalCommitStr()) - assert.False(t, createdProject.WatcherEnabled) + assert.False(t, createdProject.AutoDeployEnabled) assert.NotNil(t, createdProject.CreatedAt) assert.NotNil(t, createdProject.UpdatedAt) diff --git a/repository/mapper.go b/repository/mapper.go index ca5b06b..60a55a3 100644 --- a/repository/mapper.go +++ b/repository/mapper.go @@ -42,20 +42,21 @@ func (m *ProjectMapper) ToDomain(p *db.ProjectModel) *domain.Project { } return &domain.Project{ - ID: p.ID, - Name: p.Name, - GitURL: p.GitURL, - GitBranch: p.GitBranch, - GitAuth: gitAuth, - WorkingDir: p.WorkingDir, - ComposeFiles: parseFiles(p.ComposeFiles), - ComposeOverride: p.ComposeOverride, - Variables: parseFiles(p.Variables), - Status: status, - LocalCommit: p.LocalCommit, - WatcherEnabled: p.WatcherEnabled, - CreatedAt: p.CreatedAt, - UpdatedAt: p.UpdatedAt, + ID: p.ID, + Name: p.Name, + GitURL: p.GitURL, + GitBranch: p.GitBranch, + GitAuth: gitAuth, + WorkingDir: p.WorkingDir, + ComposeFiles: parseFiles(p.ComposeFiles), + ComposeOverride: p.ComposeOverride, + Variables: parseFiles(p.Variables), + Status: status, + LocalCommit: p.LocalCommit, + RemoteCommit: p.RemoteCommit, + AutoDeployEnabled: p.AutoDeployEnabled, + CreatedAt: p.CreatedAt, + UpdatedAt: p.UpdatedAt, } } @@ -66,16 +67,17 @@ func (m *ProjectMapper) ToModel(p *domain.Project) *db.ProjectModel { CreatedAt: p.CreatedAt, UpdatedAt: p.UpdatedAt, }, - Name: p.Name, - GitURL: p.GitURL, - GitBranch: p.GitBranch, - WorkingDir: p.WorkingDir, - ComposeFiles: serializeFiles(p.ComposeFiles), - ComposeOverride: p.ComposeOverride, - Variables: serializeFiles(p.Variables), - Status: p.Status.String(), - LocalCommit: p.LocalCommit, - WatcherEnabled: p.WatcherEnabled, + Name: p.Name, + GitURL: p.GitURL, + GitBranch: p.GitBranch, + WorkingDir: p.WorkingDir, + ComposeFiles: serializeFiles(p.ComposeFiles), + ComposeOverride: p.ComposeOverride, + Variables: serializeFiles(p.Variables), + Status: p.Status.String(), + LocalCommit: p.LocalCommit, + RemoteCommit: p.RemoteCommit, + AutoDeployEnabled: p.AutoDeployEnabled, } // Encrypt authentication data if present diff --git a/watcher/watcher.go b/watcher/watcher.go index b81adaf..eac9748 100644 --- a/watcher/watcher.go +++ b/watcher/watcher.go @@ -73,28 +73,19 @@ func (w *WatcherService) checkAllProjects(ctx context.Context) error { "error", err) } - // Only check git changes for watcher-enabled projects - if project.WatcherEnabled { - if project.Status == domain.ProjectStatusStopped { - slog.Info("Project is stopped - skipping git check", - "project_id", project.ID, - "project_name", project.Name, - "status", project.Status.String()) - continue - } + // Check git changes for all projects to keep RemoteCommit updated + slog.Debug("Checking project", + "project_id", project.ID, + "project_name", project.Name, + "status", project.Status.String(), + "auto_deploy_enabled", project.AutoDeployEnabled) - slog.Info("Checking project", + projectsChecked++ + if err := w.checkProject(ctx, project); err != nil { + slog.Error("Failed to check project", "project_id", project.ID, "project_name", project.Name, - "status", project.Status.String()) - - projectsChecked++ - if err := w.checkProject(ctx, project); err != nil { - slog.Error("Failed to check project", - "project_id", project.ID, - "project_name", project.Name, - "error", err) - } + "error", err) } } @@ -124,18 +115,33 @@ func (w *WatcherService) checkProject(ctx context.Context, project *domain.Proje return fmt.Errorf("failed to get remote commit: %w", err) } + // Update RemoteCommit for all projects (informational, independent of auto-deploy) + project.RemoteCommit = &remoteCommit + if err := w.projectService.Update(project); err != nil { + slog.Error("Failed to update project RemoteCommit", + "project_id", project.ID, + "project_name", project.Name, + "remote_commit", remoteCommit, + "error", err) + // Don't return error here - RemoteCommit update is informational + // Continue to check if we should auto-deploy + } + // Log git check results slog.Info("Git check completed", "project_id", project.ID, "project_name", project.Name, - "current_commit", currentCommit, + "local_commit", currentCommit, "remote_commit", remoteCommit, - "has_updates", currentCommit != remoteCommit) + "has_updates", currentCommit != remoteCommit, + "auto_deploy_enabled", project.AutoDeployEnabled) - // Deploy if there are git changes OR if project is not in running/stopped state + // Only deploy if auto-deploy is enabled AND project is not stopped AND (there are git changes OR project is in error state) + // We don't auto-deploy stopped projects even if they have updates - user explicitly stopped them hasGitChanges := currentCommit != remoteCommit isInErrorState := project.Status != domain.ProjectStatusRunning && project.Status != domain.ProjectStatusStopped - shouldDeploy := hasGitChanges || isInErrorState + shouldDeploy := project.AutoDeployEnabled && project.Status != domain.ProjectStatusStopped && + (hasGitChanges || isInErrorState) if shouldDeploy { var reason string diff --git a/web/actions/actions.go b/web/actions/actions.go index 078f36e..321dc64 100644 --- a/web/actions/actions.go +++ b/web/actions/actions.go @@ -16,14 +16,14 @@ import ( func CreateProject(r *http.Request) error { // Extract form data into request struct req := &ProjectCreateRequest{ - Name: r.FormValue("name"), - GitURL: r.FormValue("git_url"), - GitBranch: r.FormValue("git_branch"), - ComposeFiles: r.FormValue("compose_files"), - ComposeOverride: r.FormValue("compose_override"), - Variables: r.FormValue("variables"), - GitAuth: handlers.BuildGitAuthConfig(r), - WatcherEnabled: r.FormValue("watcher_enabled") == "on", + Name: r.FormValue("name"), + GitURL: r.FormValue("git_url"), + GitBranch: r.FormValue("git_branch"), + ComposeFiles: r.FormValue("compose_files"), + ComposeOverride: r.FormValue("compose_override"), + Variables: r.FormValue("variables"), + GitAuth: handlers.BuildGitAuthConfig(r), + AutoDeployEnabled: r.FormValue("watcher_enabled") == "on", } // Validate request @@ -49,13 +49,13 @@ func UpdateProject(r *http.Request) error { // Extract form data into request struct req := &ProjectUpdateRequest{ - ID: projectID, - Name: r.FormValue("name"), - ComposeFiles: r.FormValue("compose_files"), - ComposeOverride: r.FormValue("compose_override"), - Variables: r.FormValue("variables"), - GitAuth: handlers.BuildGitAuthConfig(r), - WatcherEnabled: r.FormValue("watcher_enabled") == "on", + ID: projectID, + Name: r.FormValue("name"), + ComposeFiles: r.FormValue("compose_files"), + ComposeOverride: r.FormValue("compose_override"), + Variables: r.FormValue("variables"), + GitAuth: handlers.BuildGitAuthConfig(r), + AutoDeployEnabled: r.FormValue("watcher_enabled") == "on", } // Validate request diff --git a/web/actions/actions_internal.go b/web/actions/actions_internal.go index d7f0f47..c30b4a5 100644 --- a/web/actions/actions_internal.go +++ b/web/actions/actions_internal.go @@ -10,25 +10,25 @@ import ( // ProjectCreateRequest represents the data needed to create a project type ProjectCreateRequest struct { - Name string - GitURL string - GitBranch string - ComposeFiles string - ComposeOverride string - Variables string - GitAuth *domain.GitAuthConfig - WatcherEnabled bool + Name string + GitURL string + GitBranch string + ComposeFiles string + ComposeOverride string + Variables string + GitAuth *domain.GitAuthConfig + AutoDeployEnabled bool } // ProjectUpdateRequest represents the data needed to update a project type ProjectUpdateRequest struct { - ID uuid.UUID - Name string - ComposeFiles string - ComposeOverride string - Variables string - GitAuth *domain.GitAuthConfig - WatcherEnabled bool + ID uuid.UUID + Name string + ComposeFiles string + ComposeOverride string + Variables string + GitAuth *domain.GitAuthConfig + AutoDeployEnabled bool } // validateProjectCreateRequest validates a project creation request @@ -81,16 +81,16 @@ func buildProjectFromCreateRequest(req *ProjectCreateRequest) *domain.Project { } return &domain.Project{ - ID: uuid.New(), - Name: req.Name, - GitURL: req.GitURL, - GitBranch: req.GitBranch, - GitAuth: req.GitAuth, - ComposeFiles: parseComposeFiles(req.ComposeFiles), - ComposeOverride: composeOverride, - Variables: parseVariables(req.Variables), - Status: domain.ProjectStatusStopped, - WatcherEnabled: req.WatcherEnabled, + ID: uuid.New(), + Name: req.Name, + GitURL: req.GitURL, + GitBranch: req.GitBranch, + GitAuth: req.GitAuth, + ComposeFiles: parseComposeFiles(req.ComposeFiles), + ComposeOverride: composeOverride, + Variables: parseVariables(req.Variables), + Status: domain.ProjectStatusStopped, + AutoDeployEnabled: req.AutoDeployEnabled, } } @@ -107,5 +107,5 @@ func applyProjectUpdateRequest(project *domain.Project, req *ProjectUpdateReques project.ComposeFiles = parseComposeFiles(req.ComposeFiles) project.ComposeOverride = composeOverride project.Variables = parseVariables(req.Variables) - project.WatcherEnabled = req.WatcherEnabled + project.AutoDeployEnabled = req.AutoDeployEnabled } diff --git a/web/assets/css/output.css b/web/assets/css/output.css index 1503915..6ddd23d 100644 --- a/web/assets/css/output.css +++ b/web/assets/css/output.css @@ -681,6 +681,9 @@ .text-gray-900 { color: var(--color-gray-900); } + .text-green-600 { + color: var(--color-green-600); + } .text-green-700 { color: var(--color-green-700); } diff --git a/web/components/forms/project-form.templ b/web/components/forms/project-form.templ index 72ca6b0..bb397eb 100644 --- a/web/components/forms/project-form.templ +++ b/web/components/forms/project-form.templ @@ -18,7 +18,7 @@ type ProjectFormData struct { ComposeFiles string ComposeOverride string Variables string - WatcherEnabled bool + AutoDeployEnabled bool } // ProjectForm renders the project form with all required fields @@ -174,7 +174,7 @@ templ ProjectForm(data ProjectFormData) { id="watcher_enabled" name="watcher_enabled" class="mr-2" - checked?={ data.WatcherEnabled } + checked?={ data.AutoDeployEnabled } /> Automatic deployment diff --git a/web/components/forms/project-form_templ.go b/web/components/forms/project-form_templ.go index 36063ad..37d0884 100644 --- a/web/components/forms/project-form_templ.go +++ b/web/components/forms/project-form_templ.go @@ -14,19 +14,19 @@ import ( // ProjectFormData holds the form data for project forms type ProjectFormData struct { - IsEdit bool - ProjectID string // Only used for edit mode - Name string - GitURL string - GitBranch string - AuthMethod string // "none", "http", "ssh" - Username string - Password string - PrivateKey string - ComposeFiles string - ComposeOverride string - Variables string - WatcherEnabled bool + IsEdit bool + ProjectID string // Only used for edit mode + Name string + GitURL string + GitBranch string + AuthMethod string // "none", "http", "ssh" + Username string + Password string + PrivateKey string + ComposeFiles string + ComposeOverride string + Variables string + AutoDeployEnabled bool } // ProjectForm renders the project form with all required fields @@ -248,7 +248,7 @@ func ProjectForm(data ProjectFormData) templ.Component { if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - if data.WatcherEnabled { + if data.AutoDeployEnabled { templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 25, " checked") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err diff --git a/web/components/modals/create-project.templ b/web/components/modals/create-project.templ index 7e2060d..53c6ded 100644 --- a/web/components/modals/create-project.templ +++ b/web/components/modals/create-project.templ @@ -11,7 +11,7 @@ templ CreateProjectModal() { templ createProjectBody() { @forms.ProjectForm(forms.ProjectFormData{ IsEdit: false, - WatcherEnabled: true, // Default to enabled for new projects + AutoDeployEnabled: true, // Default to enabled for new projects }) } diff --git a/web/components/modals/create-project_templ.go b/web/components/modals/create-project_templ.go index 712c2c5..4827911 100644 --- a/web/components/modals/create-project_templ.go +++ b/web/components/modals/create-project_templ.go @@ -63,8 +63,8 @@ func createProjectBody() templ.Component { } ctx = templ.ClearChildren(ctx) templ_7745c5c3_Err = forms.ProjectForm(forms.ProjectFormData{ - IsEdit: false, - WatcherEnabled: true, // Default to enabled for new projects + IsEdit: false, + AutoDeployEnabled: true, // Default to enabled for new projects }).Render(ctx, templ_7745c5c3_Buffer) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err diff --git a/web/components/modals/edit-project.templ b/web/components/modals/edit-project.templ index 69e73e1..54c43d5 100644 --- a/web/components/modals/edit-project.templ +++ b/web/components/modals/edit-project.templ @@ -23,7 +23,7 @@ templ editProjectBody(proj project.ProjectView) { ComposeFiles: joinStringSlice(proj.ComposeFiles, "\n"), ComposeOverride: getComposeOverrideFromProject(proj), Variables: joinStringSlice(proj.Variables, "\n"), - WatcherEnabled: proj.WatcherEnabled, + AutoDeployEnabled: proj.AutoDeployEnabled, }) } diff --git a/web/components/modals/edit-project_templ.go b/web/components/modals/edit-project_templ.go index 787e8a8..4d9ea2e 100644 --- a/web/components/modals/edit-project_templ.go +++ b/web/components/modals/edit-project_templ.go @@ -64,19 +64,19 @@ func editProjectBody(proj project.ProjectView) templ.Component { } ctx = templ.ClearChildren(ctx) templ_7745c5c3_Err = forms.ProjectForm(forms.ProjectFormData{ - IsEdit: true, - ProjectID: proj.ID.String(), - Name: proj.Name, - GitURL: proj.GitURL, - GitBranch: proj.GitBranch, - AuthMethod: getAuthMethodFromProject(proj), - Username: getUsernameFromProject(proj), - Password: getPasswordFromProject(proj), - PrivateKey: getPrivateKeyFromProject(proj), - ComposeFiles: joinStringSlice(proj.ComposeFiles, "\n"), - ComposeOverride: getComposeOverrideFromProject(proj), - Variables: joinStringSlice(proj.Variables, "\n"), - WatcherEnabled: proj.WatcherEnabled, + IsEdit: true, + ProjectID: proj.ID.String(), + Name: proj.Name, + GitURL: proj.GitURL, + GitBranch: proj.GitBranch, + AuthMethod: getAuthMethodFromProject(proj), + Username: getUsernameFromProject(proj), + Password: getPasswordFromProject(proj), + PrivateKey: getPrivateKeyFromProject(proj), + ComposeFiles: joinStringSlice(proj.ComposeFiles, "\n"), + ComposeOverride: getComposeOverrideFromProject(proj), + Variables: joinStringSlice(proj.Variables, "\n"), + AutoDeployEnabled: proj.AutoDeployEnabled, }).Render(ctx, templ_7745c5c3_Buffer) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err diff --git a/web/components/project/card.templ b/web/components/project/card.templ index 08ff67a..74c8a61 100644 --- a/web/components/project/card.templ +++ b/web/components/project/card.templ @@ -13,14 +13,14 @@ templ ProjectCard(project ProjectView) {
@StatusPill(project.ID.String(), project.Status) -
- if project.WatcherEnabled { +
+ if project.AutoDeployEnabled {
- @icons.Eye("icon-sm") + @icons.Rocket("icon-sm")
} else {
- @icons.EyeOff("icon-sm") + @icons.Rocket("icon-sm")
}
@@ -59,7 +59,11 @@ templ ProjectCard(project ProjectView) {
- @ActionButton("deploy", "Deploy", "rocket", "btn-link-primary", fmt.Sprintf("/projects/%s/deploy", project.ID.String())) + if project.IsOutdated { + @ActionButtonWithIconClass("deploy", "Deploy", "Deploy (updates available)", "rocket", "text-green-600", "btn-link-primary", fmt.Sprintf("/projects/%s/deploy", project.ID.String())) + } else { + @ActionButton("deploy", "Deploy", "rocket", "btn-link-primary", fmt.Sprintf("/projects/%s/deploy", project.ID.String())) + } @ActionButton("stop", "Stop", "circle-stop", "btn-link-warning", fmt.Sprintf("/projects/%s/stop", project.ID.String())) @ActionButton("edit", "Edit", "square-pen", "btn-link", fmt.Sprintf("/projects/%s/edit", project.ID.String())) @ActionButton("deployments", "Deployments", "list-checks", "btn-link", fmt.Sprintf("/projects/%s/deployments", project.ID.String())) @@ -95,6 +99,21 @@ templ ActionButton(action, label, iconName, buttonClass, url string) { } +// ActionButtonWithIconClass renders a clickable action button with custom icon styling +templ ActionButtonWithIconClass(action, label, tooltip, iconName, iconClass, buttonClass, url string) { + +} + // ActionButtonDisabled renders a disabled action button templ ActionButtonDisabled(action, label, iconName string) { ") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + return nil + }) +} + +// ActionButtonDisabled renders a disabled action button +func ActionButtonDisabled(action, label, iconName string) templ.Component { + return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { + templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context + if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil { + return templ_7745c5c3_CtxErr + } + templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) + if !templ_7745c5c3_IsBuffer { + defer func() { + templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer) + if templ_7745c5c3_Err == nil { + templ_7745c5c3_Err = templ_7745c5c3_BufErr + } + }() + } + ctx = templ.InitializeContext(ctx) + templ_7745c5c3_Var25 := templ.GetChildren(ctx) + if templ_7745c5c3_Var25 == nil { + templ_7745c5c3_Var25 = templ.NopComponent + } + ctx = templ.ClearChildren(ctx) + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 33, "") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 36, "") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } diff --git a/web/components/project/grid.templ b/web/components/project/grid.templ index e4d18e1..2801a1a 100644 --- a/web/components/project/grid.templ +++ b/web/components/project/grid.templ @@ -47,15 +47,16 @@ type ProjectView struct { Name string GitURL string GitBranch string - GitAuth *GitAuthConfig // Git authentication configuration - Status string // "running", "stopped", "error" (string representation) - LocalCommit *string // Git commit SHA (first 8 chars) - ComposeFiles []string - ComposeOverride *string - Variables []string - WatcherEnabled bool - CreatedAt time.Time - UpdatedAt time.Time + GitAuth *GitAuthConfig // Git authentication configuration + Status string // "running", "stopped", "error" (string representation) + LocalCommit *string // Git commit SHA (first 8 chars) + ComposeFiles []string + ComposeOverride *string + Variables []string + AutoDeployEnabled bool + IsOutdated bool // Whether remote has new commits not yet deployed locally + CreatedAt time.Time + UpdatedAt time.Time } // GitAuthConfig holds Git authentication configuration for a project diff --git a/web/components/project/grid_templ.go b/web/components/project/grid_templ.go index bb0129e..687b6fb 100644 --- a/web/components/project/grid_templ.go +++ b/web/components/project/grid_templ.go @@ -109,19 +109,20 @@ func EmptyState() templ.Component { // ProjectView represents the frontend view data for a project (simplified from backend Project) type ProjectView struct { - ID uuid.UUID - Name string - GitURL string - GitBranch string - GitAuth *GitAuthConfig // Git authentication configuration - Status string // "running", "stopped", "error" (string representation) - LocalCommit *string // Git commit SHA (first 8 chars) - ComposeFiles []string - ComposeOverride *string - Variables []string - WatcherEnabled bool - CreatedAt time.Time - UpdatedAt time.Time + ID uuid.UUID + Name string + GitURL string + GitBranch string + GitAuth *GitAuthConfig // Git authentication configuration + Status string // "running", "stopped", "error" (string representation) + LocalCommit *string // Git commit SHA (first 8 chars) + ComposeFiles []string + ComposeOverride *string + Variables []string + AutoDeployEnabled bool + IsOutdated bool // Whether remote has new commits not yet deployed locally + CreatedAt time.Time + UpdatedAt time.Time } // GitAuthConfig holds Git authentication configuration for a project diff --git a/web/handlers/handlers.go b/web/handlers/handlers.go index 5daa785..2d6555a 100644 --- a/web/handlers/handlers.go +++ b/web/handlers/handlers.go @@ -75,19 +75,20 @@ func BuildGitAuthConfig(r *http.Request) *domain.GitAuthConfig { // ConvertProjectToView converts a backend Project to frontend ProjectView func ConvertProjectToView(p *domain.Project) projectcomponent.ProjectView { return projectcomponent.ProjectView{ - ID: p.ID, - Name: p.Name, - GitURL: p.GitURL, - GitBranch: p.GitBranch, - GitAuth: ConvertGitAuthConfig(p.GitAuth), - Status: p.Status.String(), - LocalCommit: p.LocalCommit, - ComposeFiles: p.ComposeFiles, - ComposeOverride: p.ComposeOverride, - Variables: p.Variables, - WatcherEnabled: p.WatcherEnabled, - CreatedAt: p.CreatedAt, - UpdatedAt: p.UpdatedAt, + ID: p.ID, + Name: p.Name, + GitURL: p.GitURL, + GitBranch: p.GitBranch, + GitAuth: ConvertGitAuthConfig(p.GitAuth), + Status: p.Status.String(), + LocalCommit: p.LocalCommit, + ComposeFiles: p.ComposeFiles, + ComposeOverride: p.ComposeOverride, + Variables: p.Variables, + AutoDeployEnabled: p.AutoDeployEnabled, + IsOutdated: p.IsOutdated(), + CreatedAt: p.CreatedAt, + UpdatedAt: p.UpdatedAt, } }