feat: implement split history view, jira tickets and description

This commit is contained in:
Jose Luis Montañes Ojados
2026-01-28 16:37:31 +01:00
parent d33b4e8676
commit 0b091c93db
9 changed files with 266 additions and 29 deletions

View File

@@ -10,6 +10,7 @@ import (
"envguard/internal/models" "envguard/internal/models"
"github.com/charmbracelet/bubbles/table" "github.com/charmbracelet/bubbles/table"
"github.com/charmbracelet/bubbles/textinput"
tea "github.com/charmbracelet/bubbletea" tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss" "github.com/charmbracelet/lipgloss"
) )
@@ -24,6 +25,7 @@ type viewState int
const ( const (
viewList viewState = iota viewList viewState = iota
viewDetail viewDetail
viewLockForm
) )
type model struct { type model struct {
@@ -37,6 +39,8 @@ type model struct {
state viewState state viewState
history []models.HistoryEntry history []models.HistoryEntry
historyTable table.Model historyTable table.Model
inputs []textinput.Model
focusIndex int
} }
type tickMsg time.Time type tickMsg time.Time
@@ -59,8 +63,14 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
var cmd tea.Cmd var cmd tea.Cmd
switch msg := msg.(type) { switch msg := msg.(type) {
case tickMsg: case tickMsg:
if m.state == viewLockForm {
return m, nil
}
return m, tea.Batch(fetchServices, tick()) return m, tea.Batch(fetchServices, tick())
case tea.KeyMsg: case tea.KeyMsg:
if m.state == viewLockForm {
return m.updateLockForm(msg)
}
switch msg.String() { switch msg.String() {
case "esc", "q", "ctrl+c": case "esc", "q", "ctrl+c":
if m.state == viewDetail { if m.state == viewDetail {
@@ -99,17 +109,18 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
} else { } else {
m.message = "Unlocked " + serviceName m.message = "Unlocked " + serviceName
} }
return m, fetchServices
} else { } else {
m.message = "Cannot unlock service locked by " + s.LockedBy m.message = "Cannot unlock service locked by " + s.LockedBy
} }
} else { } else {
// Lock // Open Lock Form
err := api.LockService(serviceName, m.user) m.state = viewLockForm
if err != nil { m.inputs[0].SetValue("")
m.message = "Error locking: " + err.Error() m.inputs[1].SetValue("")
} else { m.focusIndex = 0
m.message = "Locked " + serviceName m.inputs[0].Focus()
} return m, nil
} }
return m, fetchServices return m, fetchServices
} }
@@ -124,25 +135,64 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
// Calculate available dimensions // Calculate available dimensions
// Borders take 2 chars total width (1 left + 1 right) // Borders take 2 chars total width (1 left + 1 right)
// We add extra safety margin (total -6) to avoid last-column wrap issues // We add extra safety margin (total -6) to avoid last-column wrap issues
availWidth := m.width - 10 availWidth := m.width - 15
// Adjust column widths dynamically // Adjust column widths dynamically
// Total weight = 100% // Total weight = 100%
cols := m.table.Columns() cols := m.table.Columns()
// Calculate widths ensuring at least minimums // Calculate widths ensuring at least minimums
c0 := int(float64(availWidth) * 0.3) // Columns: SERVICE, STATUS, USER, TICKETS, TIME
c1 := int(float64(availWidth) * 0.2) // Main View
c2 := int(float64(availWidth) * 0.2) c0 := int(float64(availWidth) * 0.25) // SERVICE
c3 := availWidth - c0 - c1 - c2 c1 := int(float64(availWidth) * 0.15) // STATUS
c2 := int(float64(availWidth) * 0.15) // USER
c3 := int(float64(availWidth) * 0.20) // TICKETS
c4 := availWidth - c0 - c1 - c2 - c3 // TIME
cols[0].Width = c0 cols[0].Width = c0
cols[1].Width = c1 cols[1].Width = c1
cols[2].Width = c2 cols[2].Width = c2
cols[3].Width = c3 cols[3].Width = c3
cols[4].Width = c4
m.table.SetColumns(cols) m.table.SetColumns(cols)
m.table.SetWidth(availWidth) m.table.SetWidth(availWidth)
// History View (Split 50/50 approx)
// Left Pane (Table)
// Columns: ACTION, USER, TIME (Tickets and Desc moved to right pane)
// but wait, we need to match the history table initialization cols...
// Let's redefine history table cols to match the new layout
hWidth := availWidth / 2
hc0 := int(float64(hWidth) * 0.2) // ACTION
hc1 := int(float64(hWidth) * 0.3) // USER
hc2 := hWidth - hc0 - hc1 - 5 // TIME (approx)
hCols := m.historyTable.Columns()
// Ensure we have correct number of columns.
// Previous task added TICKETS and DESC columns. We will remove them from the table
// and show them in the right pane instead, to save space.
// Wait, I should not change the columns definition in main() if I don't want to re-init.
// Re-init in main() is better. I will do that in a separate chunk.
// For now, let's assume I will reduce the history table columns to 3.
// Actually, I can just hide or set width 0 for columns I don't want to see?
// No, better to stick to 3 visible columns for the table.
if len(hCols) >= 3 {
hCols[0].Width = hc0
hCols[1].Width = hc1
hCols[len(hCols)-1].Width = hc2
// Set middle columns to 0 if they exist (TICKETS, DESC)
for i := 2; i < len(hCols)-1; i++ {
hCols[i].Width = 0
}
}
m.historyTable.SetColumns(hCols)
m.historyTable.SetWidth(hWidth)
// Header + Footer + padding calculation // Header + Footer + padding calculation
availableHeight := m.height - 7 availableHeight := m.height - 7
if availableHeight < 5 { if availableHeight < 5 {
@@ -167,7 +217,7 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
timeStr = fmt.Sprintf("Hace %s", duration.Round(time.Second)) timeStr = fmt.Sprintf("Hace %s", duration.Round(time.Second))
} }
rows = append(rows, table.Row{s.Name, status, user, timeStr}) rows = append(rows, table.Row{s.Name, status, user, s.JiraTickets, timeStr})
} }
m.table.SetRows(rows) m.table.SetRows(rows)
m.message = "Refreshed" m.message = "Refreshed"
@@ -178,12 +228,37 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
rows = append(rows, table.Row{ rows = append(rows, table.Row{
h.Action, h.Action,
h.User, h.User,
h.JiraTickets,
h.Description,
h.Timestamp.Format("2006-01-02 15:04:05"), h.Timestamp.Format("2006-01-02 15:04:05"),
}) })
} }
m.historyTable.SetRows(rows) m.historyTable.SetRows(rows)
// Update footer logic to resize history table if needed m.historyTable.SetRows(rows)
m.historyTable.SetWidth(m.width - 10) // Resize handled in WindowSizeMsg now, but we need to trigger it if not triggered.
// Actually m.width is stored.
// Recalculate widths just in case
availWidth := m.width - 10
if availWidth > 0 {
hWidth := availWidth / 2
hc0 := int(float64(hWidth) * 0.2) // ACTION
hc1 := int(float64(hWidth) * 0.3) // USER
hc2 := hWidth - hc0 - hc1 - 5 // TIME
hCols := m.historyTable.Columns()
if len(hCols) >= 3 {
hCols[0].Width = hc0
hCols[1].Width = hc1
hCols[len(hCols)-1].Width = hc2
for i := 2; i < len(hCols)-1; i++ {
hCols[i].Width = 0
}
}
m.historyTable.SetColumns(hCols)
m.historyTable.SetWidth(hWidth)
}
m.historyTable.SetHeight(m.table.Height()) m.historyTable.SetHeight(m.table.Height())
case error: case error:
m.err = msg m.err = msg
@@ -197,6 +272,70 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
return m, cmd return m, cmd
} }
func (m model) updateLockForm(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case tea.KeyMsg:
switch msg.String() {
case "esc":
m.state = viewList
m.table.Focus()
return m, nil
case "tab", "shift+tab", "enter", "up", "down":
s := msg.String()
if s == "enter" && m.focusIndex == len(m.inputs)-1 {
// Submit
serviceName := m.table.SelectedRow()[0]
jira := m.inputs[0].Value()
desc := m.inputs[1].Value()
err := api.LockService(serviceName, m.user, jira, desc)
if err != nil {
m.message = "Error locking: " + err.Error()
} else {
m.message = "Locked " + serviceName
}
m.state = viewList
m.table.Focus()
return m, fetchServices
}
if s == "up" || s == "shift+tab" {
m.focusIndex--
} else {
m.focusIndex++
}
if m.focusIndex > len(m.inputs)-1 {
m.focusIndex = 0
} else if m.focusIndex < 0 {
m.focusIndex = len(m.inputs) - 1
}
cmds := make([]tea.Cmd, len(m.inputs))
for i := 0; i <= len(m.inputs)-1; i++ {
if i == m.focusIndex {
cmds[i] = m.inputs[i].Focus()
continue
}
m.inputs[i].Blur()
}
return m, tea.Batch(cmds...)
}
}
cmd := m.updateInputs(msg)
return m, cmd
}
func (m *model) updateInputs(msg tea.Msg) tea.Cmd {
cmds := make([]tea.Cmd, len(m.inputs))
for i := range m.inputs {
m.inputs[i], cmds[i] = m.inputs[i].Update(msg)
}
return tea.Batch(cmds...)
}
func (m model) View() string { func (m model) View() string {
if m.err != nil { if m.err != nil {
return fmt.Sprintf("Error: %v\nPress q to quit", m.err) return fmt.Sprintf("Error: %v\nPress q to quit", m.err)
@@ -245,11 +384,55 @@ func (m model) View() string {
var mainView string var mainView string
if m.state == viewList { if m.state == viewList {
mainView = m.table.View() mainView = m.table.View()
} else if m.state == viewLockForm {
mainView = lipgloss.JoinVertical(lipgloss.Left,
fmt.Sprintf("Locking service: %s", m.table.SelectedRow()[0]),
"",
"Jira Tickets:",
m.inputs[0].View(),
"",
"Description:",
m.inputs[1].View(),
"",
"(Enter to submit, Esc to cancel)",
)
} else { } else {
// History View // History View
mainView = lipgloss.JoinVertical(lipgloss.Left, // Left Pane: History Table
"History for selected service:", leftPane := m.historyTable.View()
m.historyTable.View(),
// Right Pane: Details
var rightPane string
selected := m.historyTable.SelectedRow()
if selected != nil {
// Extract data from selected row
// Columns: ACTION, USER, TICKETS, DESC, TIME
// Indices: 0, 1, 2, 3, 4
action := selected[0]
user := selected[1]
tickets := selected[2]
desc := selected[3]
timestamp := selected[4]
detailStyle := lipgloss.NewStyle().
Padding(1).
Border(lipgloss.RoundedBorder()).
BorderForeground(lipgloss.Color("63")).
Width(m.width/2 - 5).
Height(m.height - 8)
content := fmt.Sprintf(
"ACTION: %s\n\nUSER: %s\n\nTIME: %s\n\nJIRA TICKETS:\n%s\n\nDESCRIPTION:\n%s",
action, user, timestamp, tickets, desc,
)
rightPane = detailStyle.Render(content)
} else {
rightPane = "Select an entry to view details."
}
mainView = lipgloss.JoinHorizontal(lipgloss.Top,
leftPane,
rightPane,
) )
} }
@@ -263,8 +446,8 @@ func (m model) View() string {
// Create Border Style locally // Create Border Style locally
borderStyle := lipgloss.NewStyle(). borderStyle := lipgloss.NewStyle().
BorderStyle(lipgloss.NormalBorder()). // BorderStyle(lipgloss.NormalBorder()).
BorderForeground(lipgloss.Color("240")). // BorderForeground(lipgloss.Color("240")).
Width(appWidth). Width(appWidth).
Height(appHeight - 2) Height(appHeight - 2)
@@ -308,6 +491,7 @@ func main() {
{Title: "SERVICE", Width: 20}, {Title: "SERVICE", Width: 20},
{Title: "STATUS", Width: 15}, {Title: "STATUS", Width: 15},
{Title: "USER", Width: 15}, {Title: "USER", Width: 15},
{Title: "TICKETS", Width: 15},
{Title: "TIME", Width: 20}, {Title: "TIME", Width: 20},
} }
@@ -330,9 +514,13 @@ func main() {
t.SetStyles(s) t.SetStyles(s)
// History Table // History Table
// Reduced columns for split view
hCols := []table.Column{ hCols := []table.Column{
{Title: "ACTION", Width: 10}, {Title: "ACTION", Width: 10},
{Title: "USER", Width: 15}, {Title: "USER", Width: 15},
// Hidden columns for data storage in row, but width 0
{Title: "TICKETS", Width: 0},
{Title: "DESC", Width: 0},
{Title: "TIME", Width: 20}, {Title: "TIME", Width: 20},
} }
ht := table.New( ht := table.New(
@@ -342,10 +530,23 @@ func main() {
) )
ht.SetStyles(s) ht.SetStyles(s)
inputs := make([]textinput.Model, 2)
inputs[0] = textinput.New()
inputs[0].Placeholder = "D3A-XXXX"
inputs[0].Focus()
inputs[0].CharLimit = 50
inputs[0].Width = 30
inputs[1] = textinput.New()
inputs[1].Placeholder = "What are you doing?"
inputs[1].CharLimit = 100
inputs[1].Width = 50
m := model{ m := model{
table: t, table: t,
historyTable: ht, historyTable: ht,
user: user, user: user,
inputs: inputs,
} }
if _, err := tea.NewProgram(m, tea.WithAltScreen()).Run(); err != nil { if _, err := tea.NewProgram(m, tea.WithAltScreen()).Run(); err != nil {

View File

@@ -129,6 +129,8 @@ func handleLock(w http.ResponseWriter, r *http.Request) {
services[i].IsLocked = true services[i].IsLocked = true
services[i].LockedBy = req.User services[i].LockedBy = req.User
services[i].LockedAt = time.Now() services[i].LockedAt = time.Now()
services[i].JiraTickets = req.JiraTickets
services[i].Description = req.Description
if err := saveServices(); err != nil { if err := saveServices(); err != nil {
log.Printf("Error guardando estado: %v", err) log.Printf("Error guardando estado: %v", err)
@@ -140,6 +142,8 @@ func handleLock(w http.ResponseWriter, r *http.Request) {
Action: "LOCK", Action: "LOCK",
User: req.User, User: req.User,
Timestamp: time.Now(), Timestamp: time.Now(),
JiraTickets: req.JiraTickets,
Description: req.Description,
}) })
saveHistory() saveHistory()
@@ -179,6 +183,8 @@ func handleUnlock(w http.ResponseWriter, r *http.Request) {
services[i].IsLocked = false services[i].IsLocked = false
services[i].LockedBy = "" services[i].LockedBy = ""
services[i].LockedAt = time.Time{} services[i].LockedAt = time.Time{}
services[i].JiraTickets = ""
services[i].Description = ""
if err := saveServices(); err != nil { if err := saveServices(); err != nil {
log.Printf("Error guardando estado: %v", err) log.Printf("Error guardando estado: %v", err)

1
go.mod
View File

@@ -11,6 +11,7 @@ require (
) )
require ( require (
github.com/atotto/clipboard v0.1.4 // indirect
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc // 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/ansi v0.10.1 // indirect

2
go.sum
View File

@@ -1,3 +1,5 @@
github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4=
github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI=
github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= 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-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 h1:TK0fH4MteXUDspT88n8CKzvK0X9O2xu9yQjWpi6yML8=

View File

@@ -16,5 +16,25 @@
"action": "LOCK", "action": "LOCK",
"user": "carlos", "user": "carlos",
"timestamp": "2026-01-28T15:59:40.3011371+01:00" "timestamp": "2026-01-28T15:59:40.3011371+01:00"
},
{
"service_name": "analytics-etl",
"action": "UNLOCK",
"user": "carlos",
"timestamp": "2026-01-28T16:14:54.3730634+01:00"
},
{
"service_name": "analytics-etl",
"action": "LOCK",
"user": "carlos",
"timestamp": "2026-01-28T16:15:18.3090105+01:00",
"jira_tickets": "D3A-1234,d3a-2222",
"description": "Testing"
},
{
"service_name": "analytics-etl",
"action": "UNLOCK",
"user": "carlos",
"timestamp": "2026-01-28T16:32:50.4847519+01:00"
} }
] ]

View File

@@ -1,4 +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. - [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. - [ ] 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. - [x] 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.

View File

@@ -40,10 +40,12 @@ func GetServices() ([]models.Service, error) {
return services, nil return services, nil
} }
func LockService(serviceName, user string) error { func LockService(serviceName, user, jiraTickets, description string) error {
reqBody := models.LockRequest{ reqBody := models.LockRequest{
ServiceName: serviceName, ServiceName: serviceName,
User: user, User: user,
JiraTickets: jiraTickets,
Description: description,
} }
data, _ := json.Marshal(reqBody) data, _ := json.Marshal(reqBody)

View File

@@ -4,16 +4,20 @@ import "time"
// Service represents a microservice that can be locked. // Service represents a microservice that can be locked.
type Service struct { type Service struct {
Name string `json:"name"` Name string `json:"name"`
IsLocked bool `json:"is_locked"` IsLocked bool `json:"is_locked"`
LockedBy string `json:"locked_by,omitempty"` LockedBy string `json:"locked_by,omitempty"`
LockedAt time.Time `json:"locked_at,omitempty"` LockedAt time.Time `json:"locked_at,omitempty"`
JiraTickets string `json:"jira_tickets,omitempty"`
Description string `json:"description,omitempty"`
} }
// LockRequest is the payload to lock a service. // LockRequest is the payload to lock a service.
type LockRequest struct { type LockRequest struct {
ServiceName string `json:"service_name"` ServiceName string `json:"service_name"`
User string `json:"user"` User string `json:"user"`
JiraTickets string `json:"jira_tickets"`
Description string `json:"description"`
} }
// UnlockRequest is the payload to unlock a service. // UnlockRequest is the payload to unlock a service.
@@ -28,4 +32,6 @@ type HistoryEntry struct {
Action string `json:"action"` // "LOCK" or "UNLOCK" Action string `json:"action"` // "LOCK" or "UNLOCK"
User string `json:"user"` User string `json:"user"`
Timestamp time.Time `json:"timestamp"` Timestamp time.Time `json:"timestamp"`
JiraTickets string `json:"jira_tickets,omitempty"`
Description string `json:"description,omitempty"`
} }

View File

@@ -7,9 +7,8 @@
}, },
{ {
"name": "analytics-etl", "name": "analytics-etl",
"is_locked": true, "is_locked": false,
"locked_by": "carlos", "locked_at": "0001-01-01T00:00:00Z"
"locked_at": "2026-01-28T15:59:40.3006257+01:00"
}, },
{ {
"name": "d3a-ai", "name": "d3a-ai",