From ccdfdbfcda7c78de26c28456a7fad1b31ee55529 Mon Sep 17 00:00:00 2001 From: Indra Gunawan <1284811+coderbuzz@users.noreply.github.com> Date: Tue, 30 Jun 2026 17:58:14 +0700 Subject: [PATCH] feat: mock SSH client for dev mode + UI refinements - Add Connector interface + MockClient for dev without real workers - Add DOCKIFY_DEV_MOCK env var to enable mock mode - Refactor app and caddy services to use Connector interface - Fix saveFormSecrets not called in edit mode - Fix injectEnvToCompose list/map format detection - Fix inline secret edit (pencil icon, cancel, Enter key) - Fix Unique service name checkbox value ordering - Reorder panels: secrets/config files after compose mode - Update section styling: fieldset -> card pattern, legend -> h3 - Make app detail page read-only (secrets/files CRUD via edit only) --- cmd/dockify/main.go | 7 + internal/app/handler.go | 6 +- internal/app/service.go | 23 +- internal/caddy/client.go | 4 +- internal/config/config.go | 4 + internal/http/templates/apps_add.html | 285 +++++++++-------------- internal/http/templates/apps_detail.html | 225 +++--------------- internal/http/templates/layout.html | 5 +- internal/server/service.go | 32 ++- internal/ssh/client.go | 7 + internal/ssh/interface.go | 11 + internal/ssh/mock.go | 48 ++++ 12 files changed, 270 insertions(+), 387 deletions(-) create mode 100644 internal/ssh/interface.go create mode 100644 internal/ssh/mock.go diff --git a/cmd/dockify/main.go b/cmd/dockify/main.go index 7db3c06..eb13870 100644 --- a/cmd/dockify/main.go +++ b/cmd/dockify/main.go @@ -16,6 +16,7 @@ import ( httppkg "github.com/coderbuzz/dockify/internal/http" "github.com/coderbuzz/dockify/internal/scheduler" "github.com/coderbuzz/dockify/internal/server" + "github.com/coderbuzz/dockify/internal/ssh" "github.com/coderbuzz/dockify/internal/settings" ) @@ -61,6 +62,12 @@ func main() { appRepo := app.NewRepository(database) appSvc := app.NewService(appRepo, serverRepo, cfClient, sch) + if cfg.DevMock { + svc.SetConnFactory(ssh.MockFactory()) + appSvc.SetConnFactory(ssh.MockFactory()) + log.Println("DEV MOCK MODE: using mock SSH client") + } + settingsSvc := settings.NewService(database, version) settingsHandler := settings.NewHandler(settingsSvc) diff --git a/internal/app/handler.go b/internal/app/handler.go index fa0449e..f46dc07 100644 --- a/internal/app/handler.go +++ b/internal/app/handler.go @@ -352,7 +352,7 @@ func (h *WebHandler) AppAddForm(w http.ResponseWriter, r *http.Request, render R envVars := strings.TrimSpace(r.FormValue("env_vars")) volumes := strings.TrimSpace(r.FormValue("volumes")) - if compose == "" && image != "" { + if image != "" && (compose == "" || !strings.Contains(compose, "services:")) { compose = generateCompose(image, port, envVars, volumes) } @@ -461,7 +461,7 @@ func (h *WebHandler) AppEditForm(w http.ResponseWriter, r *http.Request, render envVars := strings.TrimSpace(r.FormValue("env_vars")) volumes := strings.TrimSpace(r.FormValue("volumes")) - if compose == "" && image != "" { + if image != "" && (compose == "" || !strings.Contains(compose, "services:")) { compose = generateCompose(image, port, envVars, volumes) } @@ -503,6 +503,7 @@ func (h *WebHandler) AppEditForm(w http.ResponseWriter, r *http.Request, render return } + saveFormSecrets(r, h.service, id) saveFormFiles(r, h.service, id) go h.service.Redeploy(id) @@ -590,6 +591,7 @@ func (h *WebHandler) AppStartWeb(w http.ResponseWriter, r *http.Request, render } func saveFormSecrets(r *http.Request, svc *Service, appID int64) { + svc.DeleteSecrets(appID) keys := r.Form["secret_key"] vals := r.Form["secret_val"] for i, k := range keys { diff --git a/internal/app/service.go b/internal/app/service.go index f6c0fe3..b81a0b6 100644 --- a/internal/app/service.go +++ b/internal/app/service.go @@ -26,10 +26,15 @@ type Service struct { serverRepo *server.Repository cf *cloudflare.Client scheduler *scheduler.Scheduler + connFactory ssh.Factory +} + +func (s *Service) SetConnFactory(f ssh.Factory) { + s.connFactory = f } func NewService(repo *Repository, serverRepo *server.Repository, cf *cloudflare.Client, sch *scheduler.Scheduler) *Service { - return &Service{repo: repo, serverRepo: serverRepo, cf: cf, scheduler: sch} + return &Service{repo: repo, serverRepo: serverRepo, cf: cf, scheduler: sch, connFactory: ssh.RealFactory()} } func (s *Service) List() ([]App, error) { @@ -60,6 +65,10 @@ func (s *Service) DeleteSecret(appID int64, key string) error { return s.repo.DeleteSecret(appID, key) } +func (s *Service) DeleteSecrets(appID int64) error { + return s.repo.DeleteSecrets(appID) +} + func (s *Service) ListFiles(appID int64) ([]AppFile, error) { return s.repo.ListFiles(appID) } @@ -119,7 +128,7 @@ func (s *Service) deployWithCommit(id int64, commitSHA string) { return } - client, err := ssh.Connect(svr.Host, svr.Port, svr.User, svr.SSHKey) + client, err := s.connFactory(svr.Host, svr.Port, svr.User, svr.SSHKey) if err != nil { s.recordDeployment(id, svr.ID, StatusFailed, fmt.Sprintf("SSH connect: %v", err), commitSHA, app.Compose) s.repo.UpdateStatus(id, StatusFailed) @@ -258,7 +267,7 @@ func (s *Service) FetchLogs(id int64, tail int) (string, error) { return "", fmt.Errorf("server not found") } - client, err := ssh.Connect(svr.Host, svr.Port, svr.User, svr.SSHKey) + client, err := s.connFactory(svr.Host, svr.Port, svr.User, svr.SSHKey) if err != nil { return "", fmt.Errorf("SSH connect: %w", err) } @@ -301,7 +310,7 @@ func (s *Service) Undeploy(id int64) error { return nil } - client, err := ssh.Connect(svr.Host, svr.Port, svr.User, svr.SSHKey) + client, err := s.connFactory(svr.Host, svr.Port, svr.User, svr.SSHKey) if err != nil { log.Printf("Undeploy %q: SSH connect failed, cleaning up DB: %v", app.Name, err) s.repo.DeleteDNSRecords(app.ID) @@ -357,7 +366,7 @@ func (s *Service) Stop(id int64) error { return fmt.Errorf("server not found") } - client, err := ssh.Connect(svr.Host, svr.Port, svr.User, svr.SSHKey) + client, err := s.connFactory(svr.Host, svr.Port, svr.User, svr.SSHKey) if err != nil { return fmt.Errorf("SSH connect: %w", err) } @@ -387,7 +396,7 @@ func (s *Service) Start(id int64) error { return fmt.Errorf("server not found") } - client, err := ssh.Connect(svr.Host, svr.Port, svr.User, svr.SSHKey) + client, err := s.connFactory(svr.Host, svr.Port, svr.User, svr.SSHKey) if err != nil { return fmt.Errorf("SSH connect: %w", err) } @@ -464,7 +473,7 @@ func (s *Service) GetDeployment(id int64) (*Deployment, error) { var _ = time.Now -func dockerComposeCmd(c *ssh.Client) string { +func dockerComposeCmd(c ssh.Connector) string { out, err := c.Exec("command -v docker-compose 2>/dev/null || echo docker compose") if err != nil { return "docker compose" diff --git a/internal/caddy/client.go b/internal/caddy/client.go index f0a9e56..b8e26f6 100644 --- a/internal/caddy/client.go +++ b/internal/caddy/client.go @@ -31,10 +31,10 @@ type upstream struct { } type Client struct { - ssh *ssh.Client + ssh ssh.Connector } -func NewClient(sshClient *ssh.Client) *Client { +func NewClient(sshClient ssh.Connector) *Client { return &Client{ssh: sshClient} } diff --git a/internal/config/config.go b/internal/config/config.go index 051d923..8c35e80 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -16,6 +16,8 @@ type Config struct { CloudflareAPIToken string CloudflareZoneID string + + DevMock bool } func Load() *Config { @@ -28,6 +30,8 @@ func Load() *Config { AdminPass: os.Getenv("DOCKIFY_ADMIN_PASSWORD"), CloudflareAPIToken: os.Getenv("CLOUDFLARE_API_TOKEN"), CloudflareZoneID: os.Getenv("CLOUDFLARE_ZONE_ID"), + + DevMock: os.Getenv("DOCKIFY_DEV_MOCK") == "true", } os.MkdirAll(cfg.DataDir, 0700) diff --git a/internal/http/templates/apps_add.html b/internal/http/templates/apps_add.html index ffa4318..22614f6 100644 --- a/internal/http/templates/apps_add.html +++ b/internal/http/templates/apps_add.html @@ -14,7 +14,8 @@
- Use ${SECRET_KEY} in your compose to reference these values.
-
{{.Key}}
- ••••••••
-
-
+
+ Use ${SECRET_KEY} in your compose to reference these values.
+
{{.Key}}
+ ••••••••
+ No secrets configured.
+ {{end}}No secrets configured.
- {{end}} +Secrets can be added or modified in Edit mode.
- Files written to the app directory before deploy. Mount them via ./filename:/container/path in compose.
-
{{.Path}}
-
+
+ Files written to the app directory before deploy. Mount them via ./filename:/container/path in compose.
+
{{.Path}}
+ No config files.
+ {{end}}No config files.
- {{end}} +Config files can be added or modified in Edit mode.
diff --git a/internal/http/templates/apps_detail.html b/internal/http/templates/apps_detail.html index 0cf90b7..db0e966 100644 --- a/internal/http/templates/apps_detail.html +++ b/internal/http/templates/apps_detail.html @@ -1,6 +1,6 @@ -
{{template "head" .}} +
{{template "head" .}}{{if eq .App.Status "deploying"}}{{end}}