From 60e2b941073dca71def1be53962e5330d4eebd17 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jose=20Luis=20Monta=C3=B1es=20Ojados?= Date: Wed, 28 Jan 2026 15:13:51 +0100 Subject: [PATCH] feat: initial implementation of EnvGuard with improved TUI layout --- cmd/cli/main.go | 253 ++++++++++++++++++++++++++++++++++++++ cmd/server/main.go | 112 +++++++++++++++++ go.mod | 31 +++++ go.sum | 49 ++++++++ internal/api/client.go | 67 ++++++++++ internal/models/models.go | 23 ++++ 6 files changed, 535 insertions(+) create mode 100644 cmd/cli/main.go create mode 100644 cmd/server/main.go create mode 100644 go.mod create mode 100644 go.sum create mode 100644 internal/api/client.go create mode 100644 internal/models/models.go diff --git a/cmd/cli/main.go b/cmd/cli/main.go new file mode 100644 index 0000000..84f1482 --- /dev/null +++ b/cmd/cli/main.go @@ -0,0 +1,253 @@ +package main + +import ( + "fmt" + "os" + "time" + + "envguard/internal/api" + "envguard/internal/models" + + "github.com/charmbracelet/bubbles/table" + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" +) + +var baseStyle = lipgloss.NewStyle(). + BorderStyle(lipgloss.NormalBorder()). + BorderForeground(lipgloss.Color("240")) + +type model struct { + table table.Model + services []models.Service + user string + err error + message string + width int + height int +} + +func (m model) Init() tea.Cmd { + return fetchServices +} + +func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + var cmd tea.Cmd + switch msg := msg.(type) { + case tea.KeyMsg: + switch msg.String() { + case "esc", "q", "ctrl+c": + return m, tea.Quit + case "enter": + selected := m.table.SelectedRow() + if selected == nil { + return m, nil + } + serviceName := selected[0] + // Find service state + for _, s := range m.services { + if s.Name == serviceName { + if s.IsLocked { + if s.LockedBy == m.user { + // Unlock + err := api.UnlockService(serviceName, m.user) + if err != nil { + m.message = "Error unlocking: " + err.Error() + } else { + m.message = "Unlocked " + serviceName + } + } else { + m.message = "Cannot unlock service locked by " + s.LockedBy + } + } else { + // Lock + err := api.LockService(serviceName, m.user) + if err != nil { + m.message = "Error locking: " + err.Error() + } else { + m.message = "Locked " + serviceName + } + } + return m, fetchServices + } + } + case "r": + return m, fetchServices + } + case tea.WindowSizeMsg: + m.width = msg.Width + m.height = msg.Height + + // Calculate available dimensions + // Borders take 2 chars total width (1 left + 1 right) + // We add extra safety margin (total -6) to avoid last-column wrap issues + availWidth := m.width - 10 + + // Adjust column widths dynamically + // Total weight = 100% + cols := m.table.Columns() + // Calculate widths ensuring at least minimums + c0 := int(float64(availWidth) * 0.3) + c1 := int(float64(availWidth) * 0.2) + c2 := int(float64(availWidth) * 0.2) + c3 := availWidth - c0 - c1 - c2 + + cols[0].Width = c0 + cols[1].Width = c1 + cols[2].Width = c2 + cols[3].Width = c3 + + m.table.SetColumns(cols) + m.table.SetWidth(availWidth) + + // Header + Footer + padding calculation + availableHeight := m.height - 7 + if availableHeight < 5 { + availableHeight = 5 + } + m.table.SetHeight(availableHeight) + case []models.Service: + m.services = msg + rows := []table.Row{} + for _, s := range msg { + status := "🟢 LIBRE" + if s.IsLocked { + status = "🔴 LOCKED" + } + user := s.LockedBy + if user == "" { + user = "--" + } + timeStr := "--" + if s.IsLocked { + duration := time.Since(s.LockedAt) + timeStr = fmt.Sprintf("Hace %s", duration.Round(time.Second)) + } + + rows = append(rows, table.Row{s.Name, status, user, timeStr}) + } + m.table.SetRows(rows) + m.message = "Refreshed" + case error: + m.err = msg + } + m.table, cmd = m.table.Update(msg) + return m, cmd +} + +func (m model) View() string { + if m.err != nil { + return fmt.Sprintf("Error: %v\nPress q to quit", m.err) + } + + if m.width == 0 { + return "Loading..." + } + + userInfo := fmt.Sprintf("User: %s", m.user) + if m.message != "" { + userInfo += " | " + m.message + } + + // Dimensions + appWidth := m.width - 2 + appHeight := m.height - 2 + if appWidth < 20 { + appWidth = 20 + } + if appHeight < 10 { + appHeight = 10 + } + + // Content inside border + contentWidth := appWidth - 2 + + // Local Styles + headerStyle := lipgloss.NewStyle(). + Bold(true). + Foreground(lipgloss.Color("252")). + PaddingLeft(1). + PaddingBottom(1). + Width(contentWidth) + + infoStyle := lipgloss.NewStyle(). + PaddingLeft(1). + Foreground(lipgloss.Color("241")). + Width(contentWidth) + + footerStyle := lipgloss.NewStyle(). + Foreground(lipgloss.Color("240")). + Align(lipgloss.Center). + Width(contentWidth) + + // Render Content + content := lipgloss.JoinVertical(lipgloss.Left, + headerStyle.Render("🛡️ ENV-GUARD v1.0"), + infoStyle.Render(userInfo), + m.table.View(), + footerStyle.Render("[↑/↓] Navigate [Enter] Toggle Lock [r] Refresh [q] Quit"), + ) + + // Create Border Style locally + borderStyle := lipgloss.NewStyle(). + BorderStyle(lipgloss.NormalBorder()). + BorderForeground(lipgloss.Color("240")). + Width(appWidth). + Height(appHeight - 2) + + // Wrap in Border + gui := borderStyle.Render(content) + + // Place in center of screen + return lipgloss.Place(m.width, m.height, lipgloss.Center, lipgloss.Center, gui) +} + +func fetchServices() tea.Msg { + services, err := api.GetServices() + if err != nil { + return err + } + return services +} + +func main() { + user := os.Getenv("USER") + if user == "" { + user = "guest" + } + + columns := []table.Column{ + {Title: "SERVICE", Width: 20}, + {Title: "STATUS", Width: 15}, + {Title: "USER", Width: 15}, + {Title: "TIME", Width: 20}, + } + + t := table.New( + table.WithColumns(columns), + table.WithFocused(true), + table.WithHeight(10), + ) + + s := table.DefaultStyles() + s.Header = s.Header. + BorderStyle(lipgloss.NormalBorder()). + BorderForeground(lipgloss.Color("240")). + BorderBottom(true). + Bold(true) + s.Selected = s.Selected. + Foreground(lipgloss.Color("229")). + Background(lipgloss.Color("57")). + Bold(false) + t.SetStyles(s) + + m := model{ + table: t, + user: user, + } + + if _, err := tea.NewProgram(m, tea.WithAltScreen()).Run(); err != nil { + fmt.Println("Error running program:", err) + os.Exit(1) + } +} diff --git a/cmd/server/main.go b/cmd/server/main.go new file mode 100644 index 0000000..1e0757c --- /dev/null +++ b/cmd/server/main.go @@ -0,0 +1,112 @@ +package main + +import ( + "encoding/json" + "fmt" + "log" + "net/http" + "sync" + "time" + + "envguard/internal/models" +) + +var ( + services = []models.Service{ + {Name: "auth-service"}, + {Name: "payments-api"}, + {Name: "user-db"}, + {Name: "notifications"}, + {Name: "front-web"}, + } + mu sync.Mutex +) + +func main() { + http.HandleFunc("/services", handleServices) + http.HandleFunc("/lock", handleLock) + http.HandleFunc("/unlock", handleUnlock) + + fmt.Println("🚦 Lock Server corriendo en :8080") + if err := http.ListenAndServe(":8080", nil); err != nil { + log.Fatal(err) + } +} + +func handleServices(w http.ResponseWriter, r *http.Request) { + mu.Lock() + defer mu.Unlock() + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(services) +} + +func handleLock(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + return + } + + var req models.LockRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + + mu.Lock() + defer mu.Unlock() + + for i := range services { + if services[i].Name == req.ServiceName { + if services[i].IsLocked { + http.Error(w, "Service already locked", http.StatusConflict) + return + } + services[i].IsLocked = true + services[i].LockedBy = req.User + services[i].LockedAt = time.Now() + + w.WriteHeader(http.StatusOK) + return + } + } + + http.Error(w, "Service not found", http.StatusNotFound) +} + +func handleUnlock(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + return + } + + var req models.UnlockRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + + mu.Lock() + defer mu.Unlock() + + for i := range services { + if services[i].Name == req.ServiceName { + if !services[i].IsLocked { + http.Error(w, "Service is not locked", http.StatusBadRequest) + return + } + if services[i].LockedBy != req.User { + http.Error(w, "You cannot unlock a service locked by someone else", http.StatusForbidden) + return + } + services[i].IsLocked = false + services[i].LockedBy = "" + services[i].LockedAt = time.Time{} + + w.WriteHeader(http.StatusOK) + return + } + } + + http.Error(w, "Service not found", http.StatusNotFound) +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..a0e14a6 --- /dev/null +++ b/go.mod @@ -0,0 +1,31 @@ +module envguard + +go 1.24.0 + +toolchain go1.24.12 + +require ( + github.com/charmbracelet/bubbles v0.21.0 + github.com/charmbracelet/bubbletea v1.3.10 + github.com/charmbracelet/lipgloss v1.1.0 +) + +require ( + github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect + github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc // indirect + github.com/charmbracelet/x/ansi v0.10.1 // indirect + github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd // indirect + github.com/charmbracelet/x/term v0.2.1 // indirect + github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect + github.com/lucasb-eyer/go-colorful v1.2.0 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/mattn/go-localereader v0.0.1 // indirect + github.com/mattn/go-runewidth v0.0.16 // indirect + github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect + github.com/muesli/cancelreader v0.2.2 // indirect + github.com/muesli/termenv v0.16.0 // indirect + github.com/rivo/uniseg v0.4.7 // indirect + github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect + golang.org/x/sys v0.36.0 // indirect + golang.org/x/text v0.3.8 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..f40532c --- /dev/null +++ b/go.sum @@ -0,0 +1,49 @@ +github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= +github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= +github.com/aymanbagabas/go-udiff v0.2.0 h1:TK0fH4MteXUDspT88n8CKzvK0X9O2xu9yQjWpi6yML8= +github.com/aymanbagabas/go-udiff v0.2.0/go.mod h1:RE4Ex0qsGkTAJoQdQQCA0uG+nAzJO/pI/QwceO5fgrA= +github.com/charmbracelet/bubbles v0.21.0 h1:9TdC97SdRVg/1aaXNVWfFH3nnLAwOXr8Fn6u6mfQdFs= +github.com/charmbracelet/bubbles v0.21.0/go.mod h1:HF+v6QUR4HkEpz62dx7ym2xc71/KBHg+zKwJtMw+qtg= +github.com/charmbracelet/bubbletea v1.3.10 h1:otUDHWMMzQSB0Pkc87rm691KZ3SWa4KUlvF9nRvCICw= +github.com/charmbracelet/bubbletea v1.3.10/go.mod h1:ORQfo0fk8U+po9VaNvnV95UPWA1BitP1E0N6xJPlHr4= +github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc h1:4pZI35227imm7yK2bGPcfpFEmuY1gc2YSTShr4iJBfs= +github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc/go.mod h1:X4/0JoqgTIPSFcRA/P6INZzIuyqdFY5rm8tb41s9okk= +github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY= +github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30= +github.com/charmbracelet/x/ansi v0.10.1 h1:rL3Koar5XvX0pHGfovN03f5cxLbCF2YvLeyz7D2jVDQ= +github.com/charmbracelet/x/ansi v0.10.1/go.mod h1:3RQDQ6lDnROptfpWuUVIUG64bD2g2BgntdxH0Ya5TeE= +github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd h1:vy0GVL4jeHEwG5YOXDmi86oYw2yuYUGqz6a8sLwg0X8= +github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd/go.mod h1:xe0nKWGd3eJgtqZRaN9RjMtK7xUYchjzPr7q6kcvCCs= +github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91 h1:payRxjMjKgx2PaCWLZ4p3ro9y97+TVLZNaRZgJwSVDQ= +github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91/go.mod h1:wDlXFlCrmJ8J+swcL/MnGUuYnqgQdW9rhSD61oNMb6U= +github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ= +github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg= +github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4= +github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM= +github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY= +github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4= +github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88= +github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc= +github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= +github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI= +github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo= +github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA= +github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo= +github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc= +github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk= +github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= +github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= +github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= +github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= +github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= +golang.org/x/exp v0.0.0-20220909182711-5c715a9e8561 h1:MDc5xs78ZrZr3HMQugiXOAkSZtfTpbJLDr/lwfgO53E= +golang.org/x/exp v0.0.0-20220909182711-5c715a9e8561/go.mod h1:cyybsKvd6eL0RnXn6p/Grxp8F5bW7iYuBgsNCOHpMYE= +golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.36.0 h1:KVRy2GtZBrk1cBYA7MKu5bEZFxQk4NIDV6RLVcC8o0k= +golang.org/x/sys v0.36.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/text v0.3.8 h1:nAL+RVCQ9uMn3vJZbV+MRnydTJFPf8qqY42YiA6MrqY= +golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ= diff --git a/internal/api/client.go b/internal/api/client.go new file mode 100644 index 0000000..db99a09 --- /dev/null +++ b/internal/api/client.go @@ -0,0 +1,67 @@ +package api + +import ( + "bytes" + "encoding/json" + "fmt" + "net/http" + "time" + + "envguard/internal/models" +) + +const baseURL = "http://localhost:8080" + +var client = &http.Client{Timeout: 5 * time.Second} + +func GetServices() ([]models.Service, error) { + resp, err := client.Get(baseURL + "/services") + if err != nil { + return nil, err + } + defer resp.Body.Close() + + var services []models.Service + if err := json.NewDecoder(resp.Body).Decode(&services); err != nil { + return nil, err + } + return services, nil +} + +func LockService(serviceName, user string) error { + reqBody := models.LockRequest{ + ServiceName: serviceName, + User: user, + } + data, _ := json.Marshal(reqBody) + + resp, err := client.Post(baseURL+"/lock", "application/json", bytes.NewBuffer(data)) + if err != nil { + return err + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return fmt.Errorf("failed to lock: status %d", resp.StatusCode) + } + return nil +} + +func UnlockService(serviceName, user string) error { + reqBody := models.UnlockRequest{ + ServiceName: serviceName, + User: user, + } + data, _ := json.Marshal(reqBody) + + resp, err := client.Post(baseURL+"/unlock", "application/json", bytes.NewBuffer(data)) + if err != nil { + return err + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return fmt.Errorf("failed to unlock: status %d", resp.StatusCode) + } + return nil +} diff --git a/internal/models/models.go b/internal/models/models.go new file mode 100644 index 0000000..7fd5261 --- /dev/null +++ b/internal/models/models.go @@ -0,0 +1,23 @@ +package models + +import "time" + +// Service represents a microservice that can be locked. +type Service struct { + Name string `json:"name"` + IsLocked bool `json:"is_locked"` + LockedBy string `json:"locked_by,omitempty"` + LockedAt time.Time `json:"locked_at,omitempty"` +} + +// LockRequest is the payload to lock a service. +type LockRequest struct { + ServiceName string `json:"service_name"` + User string `json:"user"` +} + +// UnlockRequest is the payload to unlock a service. +type UnlockRequest struct { + ServiceName string `json:"service_name"` + User string `json:"user"` +}