From d33b4e86765d7844f5de5f77964bac5ccaeb9b54 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jose=20Luis=20Monta=C3=B1es=20Ojados?= Date: Wed, 28 Jan 2026 16:01:45 +0100 Subject: [PATCH] feat: implement service history view and tracking --- cmd/cli/main.go | 110 +++++++++++++++++++++++++++++++++----- cmd/server/main.go | 80 +++++++++++++++++++++++++-- history.json | 20 +++++++ ideas.md | 4 ++ internal/api/client.go | 20 +++++++ internal/models/models.go | 16 ++++-- services.json | 15 +++--- 7 files changed, 239 insertions(+), 26 deletions(-) create mode 100644 history.json create mode 100644 ideas.md diff --git a/cmd/cli/main.go b/cmd/cli/main.go index 8046a6b..6f72279 100644 --- a/cmd/cli/main.go +++ b/cmd/cli/main.go @@ -15,17 +15,28 @@ import ( ) var baseStyle = lipgloss.NewStyle(). + BorderStyle(lipgloss.NormalBorder()). BorderStyle(lipgloss.NormalBorder()). BorderForeground(lipgloss.Color("240")) +type viewState int + +const ( + viewList viewState = iota + viewDetail +) + type model struct { - table table.Model - services []models.Service - user string - err error - message string - width int - height int + table table.Model + services []models.Service + user string + err error + message string + width int + height int + state viewState + history []models.HistoryEntry + historyTable table.Model } type tickMsg time.Time @@ -37,6 +48,10 @@ func tick() tea.Cmd { } func (m model) Init() tea.Cmd { + // If in detail view, only tick (no auto refresh of history for now to keep it simple) + if m.state == viewDetail { + return tick() + } return tea.Batch(fetchServices, tick()) } @@ -48,8 +63,25 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case tea.KeyMsg: switch msg.String() { case "esc", "q", "ctrl+c": + if m.state == viewDetail { + m.state = viewList + m.table.Focus() + return m, nil + } return m, tea.Quit + case "d": + if m.state == viewList { + selected := m.table.SelectedRow() + if selected != nil { + serviceName := selected[0] + m.state = viewDetail + return m, fetchHistoryCmd(serviceName) + } + } case "enter": + if m.state == viewDetail { + return m, nil + } selected := m.table.SelectedRow() if selected == nil { return m, nil @@ -139,10 +171,29 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } m.table.SetRows(rows) m.message = "Refreshed" + case []models.HistoryEntry: + m.history = msg + rows := []table.Row{} + for _, h := range msg { + rows = append(rows, table.Row{ + h.Action, + h.User, + h.Timestamp.Format("2006-01-02 15:04:05"), + }) + } + m.historyTable.SetRows(rows) + // Update footer logic to resize history table if needed + m.historyTable.SetWidth(m.width - 10) + m.historyTable.SetHeight(m.table.Height()) case error: m.err = msg } - m.table, cmd = m.table.Update(msg) + + if m.state == viewList { + m.table, cmd = m.table.Update(msg) + } else { + m.historyTable, cmd = m.historyTable.Update(msg) + } return m, cmd } @@ -191,12 +242,23 @@ func (m model) View() string { Align(lipgloss.Center). Width(contentWidth) + var mainView string + if m.state == viewList { + mainView = m.table.View() + } else { + // History View + mainView = lipgloss.JoinVertical(lipgloss.Left, + "History for selected service:", + m.historyTable.View(), + ) + } + // 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"), + mainView, + footerStyle.Render("[↑/↓] Navigate [Enter] Toggle Lock [d] History [r] Refresh [q] Quit"), ) // Create Border Style locally @@ -221,6 +283,16 @@ func fetchServices() tea.Msg { return services } +func fetchHistoryCmd(serviceName string) tea.Cmd { + return func() tea.Msg { + hist, err := api.GetHistory(serviceName) + if err != nil { + return err + } + return hist + } +} + func main() { serverURL := flag.String("server", "http://localhost:8080", "URL of the EnvGuard server") flag.Parse() @@ -257,9 +329,23 @@ func main() { Bold(false) t.SetStyles(s) + // History Table + hCols := []table.Column{ + {Title: "ACTION", Width: 10}, + {Title: "USER", Width: 15}, + {Title: "TIME", Width: 20}, + } + ht := table.New( + table.WithColumns(hCols), + table.WithFocused(true), + table.WithHeight(10), + ) + ht.SetStyles(s) + m := model{ - table: t, - user: user, + table: t, + historyTable: ht, + user: user, } if _, err := tea.NewProgram(m, tea.WithAltScreen()).Run(); err != nil { diff --git a/cmd/server/main.go b/cmd/server/main.go index 8e19959..23b117c 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -13,10 +13,12 @@ import ( ) var ( - services = []models.Service{} - mu sync.Mutex - dataFile = "services.json" - apiToken = "ENVGUARD_SECRET_TOKEN" + services = []models.Service{} + history = []models.HistoryEntry{} + mu sync.Mutex + dataFile = "services.json" + historyFile = "history.json" + apiToken = "ENVGUARD_SECRET_TOKEN" ) func main() { @@ -33,7 +35,14 @@ func main() { fmt.Printf("✅ %d servicios cargados desde disco\n", len(services)) } + if err := loadHistory(); err != nil { + log.Printf("⚠️ No se pudo cargar history.json: %v", err) + } else { + fmt.Printf("📜 %d entradas de historial cargadas\n", len(history)) + } + http.HandleFunc("/services", authMiddleware(handleServices)) + http.HandleFunc("/history", authMiddleware(handleHistory)) http.HandleFunc("/lock", authMiddleware(handleLock)) http.HandleFunc("/unlock", authMiddleware(handleUnlock)) @@ -64,6 +73,30 @@ func saveServices() error { return encoder.Encode(services) } +func loadHistory() error { + file, err := os.Open(historyFile) + if err != nil { + if os.IsNotExist(err) { + return nil + } + return err + } + defer file.Close() + return json.NewDecoder(file).Decode(&history) +} + +func saveHistory() error { + file, err := os.Create(historyFile) + if err != nil { + return err + } + defer file.Close() + + encoder := json.NewEncoder(file) + encoder.SetIndent("", " ") + return encoder.Encode(history) +} + func handleServices(w http.ResponseWriter, r *http.Request) { mu.Lock() defer mu.Unlock() @@ -101,6 +134,15 @@ func handleLock(w http.ResponseWriter, r *http.Request) { log.Printf("Error guardando estado: %v", err) } + // Add to history + history = append(history, models.HistoryEntry{ + ServiceName: req.ServiceName, + Action: "LOCK", + User: req.User, + Timestamp: time.Now(), + }) + saveHistory() + w.WriteHeader(http.StatusOK) return } @@ -142,6 +184,15 @@ func handleUnlock(w http.ResponseWriter, r *http.Request) { log.Printf("Error guardando estado: %v", err) } + // Add to history + history = append(history, models.HistoryEntry{ + ServiceName: req.ServiceName, + Action: "UNLOCK", + User: req.User, + Timestamp: time.Now(), + }) + saveHistory() + w.WriteHeader(http.StatusOK) return } @@ -150,6 +201,27 @@ func handleUnlock(w http.ResponseWriter, r *http.Request) { http.Error(w, "Service not found", http.StatusNotFound) } +func handleHistory(w http.ResponseWriter, r *http.Request) { + serviceName := r.URL.Query().Get("service") + mu.Lock() + defer mu.Unlock() + + var result []models.HistoryEntry + for _, h := range history { + if serviceName == "" || h.ServiceName == serviceName { + result = append(result, h) + } + } + + // Reverse order (newest first) + for i, j := 0, len(result)-1; i < j; i, j = i+1, j-1 { + result[i], result[j] = result[j], result[i] + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(result) +} + func authMiddleware(next http.HandlerFunc) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { token := r.Header.Get("X-API-Key") diff --git a/history.json b/history.json new file mode 100644 index 0000000..437d84d --- /dev/null +++ b/history.json @@ -0,0 +1,20 @@ +[ + { + "service_name": "d3a-ai-assistant", + "action": "LOCK", + "user": "carlos", + "timestamp": "2026-01-28T15:59:26.6472616+01:00" + }, + { + "service_name": "d3a-ai-assistant", + "action": "UNLOCK", + "user": "carlos", + "timestamp": "2026-01-28T15:59:27.3797643+01:00" + }, + { + "service_name": "analytics-etl", + "action": "LOCK", + "user": "carlos", + "timestamp": "2026-01-28T15:59:40.3011371+01:00" + } +] diff --git a/ideas.md b/ideas.md new file mode 100644 index 0000000..80b99fd --- /dev/null +++ b/ideas.md @@ -0,0 +1,4 @@ + +- [x] Estaria bien poder apretar por ejemplo "d" en un servicio y abrir una vista detalle, donde se muestre un historial de los locks y que usuario lo hizo. +- [ ] Sé que windows tiene un byte que cuando se imprime por console hace un Beep... estaria bien poder "reclamar" el lock, haciendo que se emita una notificacion al usuario que lo tiene y pueda cederlo o no. +- [ ] Que se pueda asociar una "sesion de lock" a uno o más tareas de jira, estilo poner "D3A-3242" y que se pueda poner una descripcion, de esta forma se puede saber que se estaba haciendo cuando se bloqueó el servicio. \ No newline at end of file diff --git a/internal/api/client.go b/internal/api/client.go index d4b4121..bed4104 100644 --- a/internal/api/client.go +++ b/internal/api/client.go @@ -91,3 +91,23 @@ func UnlockService(serviceName, user string) error { } return nil } + +func GetHistory(serviceName string) ([]models.HistoryEntry, error) { + req, err := http.NewRequest("GET", baseURL+"/history?service="+serviceName, nil) + if err != nil { + return nil, err + } + req.Header.Set("X-API-Key", apiToken) + + resp, err := client.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + var history []models.HistoryEntry + if err := json.NewDecoder(resp.Body).Decode(&history); err != nil { + return nil, err + } + return history, nil +} diff --git a/internal/models/models.go b/internal/models/models.go index 7fd5261..0c6aecb 100644 --- a/internal/models/models.go +++ b/internal/models/models.go @@ -4,10 +4,10 @@ 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"` + 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. @@ -21,3 +21,11 @@ type UnlockRequest struct { ServiceName string `json:"service_name"` User string `json:"user"` } + +// HistoryEntry represents a historical event for a service. +type HistoryEntry struct { + ServiceName string `json:"service_name"` + Action string `json:"action"` // "LOCK" or "UNLOCK" + User string `json:"user"` + Timestamp time.Time `json:"timestamp"` +} diff --git a/services.json b/services.json index 2bf95b7..7fa4dcb 100644 --- a/services.json +++ b/services.json @@ -7,8 +7,9 @@ }, { "name": "analytics-etl", - "is_locked": false, - "locked_at": "0001-01-01T00:00:00Z" + "is_locked": true, + "locked_by": "carlos", + "locked_at": "2026-01-28T15:59:40.3006257+01:00" }, { "name": "d3a-ai", @@ -28,13 +29,15 @@ }, { "name": "d3a-ai-coder", - "is_locked": false, - "locked_at": "0001-01-01T00:00:00Z" + "is_locked": true, + "locked_by": "jose.montanes", + "locked_at": "2026-01-28T15:44:15.0392434+01:00" }, { "name": "d3a-ai-mentor", - "is_locked": false, - "locked_at": "0001-01-01T00:00:00Z" + "is_locked": true, + "locked_by": "jose.montanes", + "locked_at": "2026-01-28T15:44:10.7852949+01:00" }, { "name": "d3a-ai-stream",