feat: implement service history view and tracking
This commit is contained in:
@@ -15,9 +15,17 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
var baseStyle = lipgloss.NewStyle().
|
var baseStyle = lipgloss.NewStyle().
|
||||||
|
BorderStyle(lipgloss.NormalBorder()).
|
||||||
BorderStyle(lipgloss.NormalBorder()).
|
BorderStyle(lipgloss.NormalBorder()).
|
||||||
BorderForeground(lipgloss.Color("240"))
|
BorderForeground(lipgloss.Color("240"))
|
||||||
|
|
||||||
|
type viewState int
|
||||||
|
|
||||||
|
const (
|
||||||
|
viewList viewState = iota
|
||||||
|
viewDetail
|
||||||
|
)
|
||||||
|
|
||||||
type model struct {
|
type model struct {
|
||||||
table table.Model
|
table table.Model
|
||||||
services []models.Service
|
services []models.Service
|
||||||
@@ -26,6 +34,9 @@ type model struct {
|
|||||||
message string
|
message string
|
||||||
width int
|
width int
|
||||||
height int
|
height int
|
||||||
|
state viewState
|
||||||
|
history []models.HistoryEntry
|
||||||
|
historyTable table.Model
|
||||||
}
|
}
|
||||||
|
|
||||||
type tickMsg time.Time
|
type tickMsg time.Time
|
||||||
@@ -37,6 +48,10 @@ func tick() tea.Cmd {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (m model) Init() 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())
|
return tea.Batch(fetchServices, tick())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -48,8 +63,25 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||||||
case tea.KeyMsg:
|
case tea.KeyMsg:
|
||||||
switch msg.String() {
|
switch msg.String() {
|
||||||
case "esc", "q", "ctrl+c":
|
case "esc", "q", "ctrl+c":
|
||||||
|
if m.state == viewDetail {
|
||||||
|
m.state = viewList
|
||||||
|
m.table.Focus()
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
return m, tea.Quit
|
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":
|
case "enter":
|
||||||
|
if m.state == viewDetail {
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
selected := m.table.SelectedRow()
|
selected := m.table.SelectedRow()
|
||||||
if selected == nil {
|
if selected == nil {
|
||||||
return m, nil
|
return m, nil
|
||||||
@@ -139,10 +171,29 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||||||
}
|
}
|
||||||
m.table.SetRows(rows)
|
m.table.SetRows(rows)
|
||||||
m.message = "Refreshed"
|
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:
|
case error:
|
||||||
m.err = msg
|
m.err = msg
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if m.state == viewList {
|
||||||
m.table, cmd = m.table.Update(msg)
|
m.table, cmd = m.table.Update(msg)
|
||||||
|
} else {
|
||||||
|
m.historyTable, cmd = m.historyTable.Update(msg)
|
||||||
|
}
|
||||||
return m, cmd
|
return m, cmd
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -191,12 +242,23 @@ func (m model) View() string {
|
|||||||
Align(lipgloss.Center).
|
Align(lipgloss.Center).
|
||||||
Width(contentWidth)
|
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
|
// Render Content
|
||||||
content := lipgloss.JoinVertical(lipgloss.Left,
|
content := lipgloss.JoinVertical(lipgloss.Left,
|
||||||
headerStyle.Render("🛡️ ENV-GUARD v1.0"),
|
headerStyle.Render("🛡️ ENV-GUARD v1.0"),
|
||||||
infoStyle.Render(userInfo),
|
infoStyle.Render(userInfo),
|
||||||
m.table.View(),
|
mainView,
|
||||||
footerStyle.Render("[↑/↓] Navigate [Enter] Toggle Lock [r] Refresh [q] Quit"),
|
footerStyle.Render("[↑/↓] Navigate [Enter] Toggle Lock [d] History [r] Refresh [q] Quit"),
|
||||||
)
|
)
|
||||||
|
|
||||||
// Create Border Style locally
|
// Create Border Style locally
|
||||||
@@ -221,6 +283,16 @@ func fetchServices() tea.Msg {
|
|||||||
return services
|
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() {
|
func main() {
|
||||||
serverURL := flag.String("server", "http://localhost:8080", "URL of the EnvGuard server")
|
serverURL := flag.String("server", "http://localhost:8080", "URL of the EnvGuard server")
|
||||||
flag.Parse()
|
flag.Parse()
|
||||||
@@ -257,8 +329,22 @@ func main() {
|
|||||||
Bold(false)
|
Bold(false)
|
||||||
t.SetStyles(s)
|
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{
|
m := model{
|
||||||
table: t,
|
table: t,
|
||||||
|
historyTable: ht,
|
||||||
user: user,
|
user: user,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -14,8 +14,10 @@ import (
|
|||||||
|
|
||||||
var (
|
var (
|
||||||
services = []models.Service{}
|
services = []models.Service{}
|
||||||
|
history = []models.HistoryEntry{}
|
||||||
mu sync.Mutex
|
mu sync.Mutex
|
||||||
dataFile = "services.json"
|
dataFile = "services.json"
|
||||||
|
historyFile = "history.json"
|
||||||
apiToken = "ENVGUARD_SECRET_TOKEN"
|
apiToken = "ENVGUARD_SECRET_TOKEN"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -33,7 +35,14 @@ func main() {
|
|||||||
fmt.Printf("✅ %d servicios cargados desde disco\n", len(services))
|
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("/services", authMiddleware(handleServices))
|
||||||
|
http.HandleFunc("/history", authMiddleware(handleHistory))
|
||||||
http.HandleFunc("/lock", authMiddleware(handleLock))
|
http.HandleFunc("/lock", authMiddleware(handleLock))
|
||||||
http.HandleFunc("/unlock", authMiddleware(handleUnlock))
|
http.HandleFunc("/unlock", authMiddleware(handleUnlock))
|
||||||
|
|
||||||
@@ -64,6 +73,30 @@ func saveServices() error {
|
|||||||
return encoder.Encode(services)
|
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) {
|
func handleServices(w http.ResponseWriter, r *http.Request) {
|
||||||
mu.Lock()
|
mu.Lock()
|
||||||
defer mu.Unlock()
|
defer mu.Unlock()
|
||||||
@@ -101,6 +134,15 @@ func handleLock(w http.ResponseWriter, r *http.Request) {
|
|||||||
log.Printf("Error guardando estado: %v", err)
|
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)
|
w.WriteHeader(http.StatusOK)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -142,6 +184,15 @@ func handleUnlock(w http.ResponseWriter, r *http.Request) {
|
|||||||
log.Printf("Error guardando estado: %v", err)
|
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)
|
w.WriteHeader(http.StatusOK)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -150,6 +201,27 @@ func handleUnlock(w http.ResponseWriter, r *http.Request) {
|
|||||||
http.Error(w, "Service not found", http.StatusNotFound)
|
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 {
|
func authMiddleware(next http.HandlerFunc) http.HandlerFunc {
|
||||||
return func(w http.ResponseWriter, r *http.Request) {
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
token := r.Header.Get("X-API-Key")
|
token := r.Header.Get("X-API-Key")
|
||||||
|
|||||||
20
history.json
Normal file
20
history.json
Normal file
@@ -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"
|
||||||
|
}
|
||||||
|
]
|
||||||
4
ideas.md
Normal file
4
ideas.md
Normal file
@@ -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.
|
||||||
@@ -91,3 +91,23 @@ func UnlockService(serviceName, user string) error {
|
|||||||
}
|
}
|
||||||
return nil
|
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
|
||||||
|
}
|
||||||
|
|||||||
@@ -21,3 +21,11 @@ type UnlockRequest struct {
|
|||||||
ServiceName string `json:"service_name"`
|
ServiceName string `json:"service_name"`
|
||||||
User string `json:"user"`
|
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"`
|
||||||
|
}
|
||||||
|
|||||||
@@ -7,8 +7,9 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "analytics-etl",
|
"name": "analytics-etl",
|
||||||
"is_locked": false,
|
"is_locked": true,
|
||||||
"locked_at": "0001-01-01T00:00:00Z"
|
"locked_by": "carlos",
|
||||||
|
"locked_at": "2026-01-28T15:59:40.3006257+01:00"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "d3a-ai",
|
"name": "d3a-ai",
|
||||||
@@ -28,13 +29,15 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "d3a-ai-coder",
|
"name": "d3a-ai-coder",
|
||||||
"is_locked": false,
|
"is_locked": true,
|
||||||
"locked_at": "0001-01-01T00:00:00Z"
|
"locked_by": "jose.montanes",
|
||||||
|
"locked_at": "2026-01-28T15:44:15.0392434+01:00"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "d3a-ai-mentor",
|
"name": "d3a-ai-mentor",
|
||||||
"is_locked": false,
|
"is_locked": true,
|
||||||
"locked_at": "0001-01-01T00:00:00Z"
|
"locked_by": "jose.montanes",
|
||||||
|
"locked_at": "2026-01-28T15:44:10.7852949+01:00"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "d3a-ai-stream",
|
"name": "d3a-ai-stream",
|
||||||
|
|||||||
Reference in New Issue
Block a user