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 @@

{{if .IsEdit}}Edit App{{else}}Deploy App{{end}}

{{end}}
-
+
+

Basic

@@ -31,24 +32,25 @@

{{if .IsEdit}}Edit App{{else}}Deploy App{{end}}

-
- +
+ Rename the first service to match the app name to avoid conflicts with other apps on the same server
-
+ -
+
{{if .IsEdit}} - Docker Compose +

Docker Compose

{{else}} - - - - +

Compose Mode

+
+ + +
@@ -60,43 +62,12 @@

{{if .IsEdit}}Edit App{{else}}Deploy App{{end}}

{{end}} -
- -
- Git (optional — for auto-deploy on push) -
- - -
-
- -
- Basic Auth (optional — protects the app with a username/password) -
- - -
-
+ -
- Secrets (optional — use ${KEY} in compose to reference) +
+

Secrets (optional — use ${KEY} in compose to reference)

- {{if .IsEdit}} - {{if .Secrets}} - {{range .Secrets}} -
- {{.Key}} - •••••••• - - -
- {{end}} - {{else}} -

No secrets configured.

- {{end}} - {{else}}

No secrets added yet.

- {{end}}
@@ -104,138 +75,63 @@

{{if .IsEdit}}Edit App{{else}}Deploy App{{end}}

-
- Import from .env -
+
+ Import from .env +
- +
-
+ -
- Config Files (optional — mount via ./filename:/container/path in compose) +
+

Config Files (optional — mount via ./filename:/container/path in compose)

{{if .Files}} {{range .Files}} -
- +
+ - - + +
{{end}} {{end}}
-
+ + +
+

Git (optional — for auto-deploy on push)

+
+ + +
+
- +
+

Basic Auth (optional — protects the app with a username/password)

+
+ + +
+
+ +
+ + {{if .IsEdit}}Cancel{{end}} +
{{template "scripts" .}} 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}} {{template "nav" .}}
@@ -35,65 +35,44 @@

Docker Compose

-
-

Secrets

-

- Use ${SECRET_KEY} in your compose to reference these values. -

-
- {{if .Secrets}} - {{range .Secrets}} -
- {{.Key}} - •••••••• - - +
+

Secrets

+

+ Use ${SECRET_KEY} in your compose to reference these values. +

+
+ {{if .Secrets}} + {{range .Secrets}} +
+ {{.Key}} + •••••••• +
+ {{end}} + {{else}} +

No secrets configured.

+ {{end}}
- {{end}} - {{else}} -

No secrets configured.

- {{end}} +

Secrets can be added or modified in Edit mode.

-
- - - -
-
- Import from .env -
- - - Adds as secrets only. Use Edit to auto-inject into compose. -
-
-
-
-

Config Files

-

- Files written to the app directory before deploy. Mount them via ./filename:/container/path in compose. -

-
- {{if .Files}} - {{range .Files}} -
- {{.Path}} - +
+

Config Files

+

+ Files written to the app directory before deploy. Mount them via ./filename:/container/path in compose. +

+
+ {{if .Files}} + {{range .Files}} +
+ {{.Path}} +
+ {{end}} + {{else}} +

No config files.

+ {{end}}
- {{end}} - {{else}} -

No config files.

- {{end}} +

Config files can be added or modified in Edit mode.

-
- - - - - -
-
{{if eq .App.Status "running"}} @@ -162,141 +141,7 @@

Logs

{{end}}
- + {{template "scripts" .}} diff --git a/internal/http/templates/layout.html b/internal/http/templates/layout.html index b05c156..73e2e20 100644 --- a/internal/http/templates/layout.html +++ b/internal/http/templates/layout.html @@ -107,7 +107,7 @@ margin-bottom: 0.8em; overflow: hidden; } -.card h3 { font-size: 0.82em; color: var(--text-muted); text-transform: uppercase; letter-spacing: 0.05em; margin-bottom: 0.6em; font-weight: 600; } +.card h3 { font-size: 0.87em; color: var(--text-muted); text-transform: uppercase; letter-spacing: 0.05em; margin-bottom: 0.8em; font-weight: 600; } .card p { font-size: 0.85em; } /* Grid */ @@ -137,6 +137,9 @@ .btn-ghost:hover { color: var(--text); } .btn-red { color: var(--red); } .btn-red:hover { border-color: var(--red); color: var(--red); background: transparent; } +.btn-icon { padding: 0.3em; line-height: 0; border-color: transparent; background: transparent; } +.btn-icon:hover { border-color: transparent; background: rgba(128,128,128,0.1); } +.btn-icon.btn-red:hover { background: rgba(220,38,38,0.1); color: var(--red); border-color: transparent; } /* Tables */ table { width: 100%; border-collapse: collapse; } diff --git a/internal/server/service.go b/internal/server/service.go index e65827a..e33280d 100644 --- a/internal/server/service.go +++ b/internal/server/service.go @@ -17,12 +17,20 @@ const ( ) type Service struct { - repo *Repository - monitor *Monitor + repo *Repository + monitor *Monitor + connFactory ssh.Factory } func NewService(repo *Repository) *Service { - return &Service{repo: repo} + return &Service{ + repo: repo, + connFactory: ssh.RealFactory(), + } +} + +func (s *Service) SetConnFactory(f ssh.Factory) { + s.connFactory = f } func (s *Service) List() ([]Server, error) { @@ -60,7 +68,7 @@ func (s *Service) TestConnection(id int64) error { return fmt.Errorf("server not found") } - client, err := ssh.Connect(server.Host, server.Port, server.User, server.SSHKey) + client, err := s.connFactory(server.Host, server.Port, server.User, server.SSHKey) if err != nil { s.repo.UpdateStatus(id, StatusOffline) return fmt.Errorf("SSH connect failed: %w", err) @@ -89,7 +97,7 @@ func (s *Service) InitWorker(id int64) error { s.repo.UpdateStatus(id, "initializing") - client, err := ssh.Connect(server.Host, server.Port, server.User, server.SSHKey) + client, err := s.connFactory(server.Host, server.Port, server.User, server.SSHKey) if err != nil { s.repo.UpdateStatus(id, StatusError) return fmt.Errorf("SSH connect: %w", err) @@ -143,7 +151,7 @@ func (s *Service) RefreshResources(id int64) error { return err } - client, err := ssh.Connect(server.Host, server.Port, server.User, server.SSHKey) + client, err := s.connFactory(server.Host, server.Port, server.User, server.SSHKey) if err != nil { s.repo.UpdateStatus(id, StatusOffline) return err @@ -167,7 +175,7 @@ func (s *Service) StartMonitor() { go s.monitor.Run() } -func parseCPUCount(c *ssh.Client) (int, error) { +func parseCPUCount(c ssh.Connector) (int, error) { out, err := c.Exec("nproc") if err != nil { return 0, err @@ -177,7 +185,7 @@ func parseCPUCount(c *ssh.Client) (int, error) { return count, nil } -func parseRAM(c *ssh.Client) (int, error) { +func parseRAM(c ssh.Connector) (int, error) { out, err := c.Exec("free -m | awk '/Mem:/ {print $2}'") if err != nil { return 0, err @@ -187,7 +195,7 @@ func parseRAM(c *ssh.Client) (int, error) { return mb, nil } -func parseDisk(c *ssh.Client) (int, error) { +func parseDisk(c ssh.Connector) (int, error) { out, err := c.Exec("df -BG / | awk 'NR==2 {gsub(/G/,\"\"); print $2}'") if err != nil { return 0, err @@ -197,7 +205,7 @@ func parseDisk(c *ssh.Client) (int, error) { return gb, nil } -func parseCPUUsage(c *ssh.Client) (float64, error) { +func parseCPUUsage(c ssh.Connector) (float64, error) { out, err := c.Exec("awk '/^cpu / {printf \"%.1f\", ($2+$4)*100/($2+$4+$5)}' /proc/stat") if err != nil { return 0, err @@ -207,7 +215,7 @@ func parseCPUUsage(c *ssh.Client) (float64, error) { return usage, nil } -func parseRAMUsage(c *ssh.Client) (float64, error) { +func parseRAMUsage(c ssh.Connector) (float64, error) { out, err := c.Exec("free -m | awk '/Mem:/ {printf \"%.1f\", $3/$2 * 100.0}'") if err != nil { return 0, err @@ -217,7 +225,7 @@ func parseRAMUsage(c *ssh.Client) (float64, error) { return usage, nil } -func parseDiskUsage(c *ssh.Client) (float64, error) { +func parseDiskUsage(c ssh.Connector) (float64, error) { out, err := c.Exec("df -BG / | awk 'NR==2 {gsub(/%/,\"\"); print $5}'") if err != nil { return 0, err diff --git a/internal/ssh/client.go b/internal/ssh/client.go index 7f71c3c..188e475 100644 --- a/internal/ssh/client.go +++ b/internal/ssh/client.go @@ -78,3 +78,10 @@ func (c *Client) Close() error { } return nil } + +func RealFactory() Factory { + return func(host string, port int, user, keyPath string) (Connector, error) { + return Connect(host, port, user, keyPath) + } +} + diff --git a/internal/ssh/interface.go b/internal/ssh/interface.go new file mode 100644 index 0000000..fd7cfc6 --- /dev/null +++ b/internal/ssh/interface.go @@ -0,0 +1,11 @@ +package ssh + +import "os" + +type Connector interface { + Exec(cmd string) (string, error) + WriteFile(path, content string, mode os.FileMode) error + Close() error +} + +type Factory func(host string, port int, user, keyPath string) (Connector, error) diff --git a/internal/ssh/mock.go b/internal/ssh/mock.go new file mode 100644 index 0000000..66ae2ab --- /dev/null +++ b/internal/ssh/mock.go @@ -0,0 +1,48 @@ +package ssh + +import ( + "os" + "strings" +) + +type MockClient struct { + responses map[string]string +} + +func NewMockClient() *MockClient { + return &MockClient{ + responses: map[string]string{ + "uptime": "10:00:00 up 30 days, 0 users, load average: 0.08, 0.03, 0.01", + "nproc": "4", + "Mem:": "16000", + "NR==2 {gsub(/G/,\"\"); print $2}": "100", + "cpu /": "23.5", + "$3/$2 * 100": "45.2", + "gsub(/%/,\"\"); print $5}": "67", + "docker": "Docker version 28.0.4, build b8034c0", + }, + } +} + +func (m *MockClient) Exec(cmd string) (string, error) { + for pattern, response := range m.responses { + if strings.Contains(cmd, pattern) { + return response, nil + } + } + return "", nil +} + +func (m *MockClient) WriteFile(path, content string, mode os.FileMode) error { + return nil +} + +func (m *MockClient) Close() error { + return nil +} + +func MockFactory() Factory { + return func(host string, port int, user, keyPath string) (Connector, error) { + return NewMockClient(), nil + } +}