feat: implement split history view, jira tickets and description
This commit is contained in:
241
cmd/cli/main.go
241
cmd/cli/main.go
@@ -10,6 +10,7 @@ import (
|
||||
"envguard/internal/models"
|
||||
|
||||
"github.com/charmbracelet/bubbles/table"
|
||||
"github.com/charmbracelet/bubbles/textinput"
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
"github.com/charmbracelet/lipgloss"
|
||||
)
|
||||
@@ -24,6 +25,7 @@ type viewState int
|
||||
const (
|
||||
viewList viewState = iota
|
||||
viewDetail
|
||||
viewLockForm
|
||||
)
|
||||
|
||||
type model struct {
|
||||
@@ -37,6 +39,8 @@ type model struct {
|
||||
state viewState
|
||||
history []models.HistoryEntry
|
||||
historyTable table.Model
|
||||
inputs []textinput.Model
|
||||
focusIndex int
|
||||
}
|
||||
|
||||
type tickMsg time.Time
|
||||
@@ -59,8 +63,14 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
var cmd tea.Cmd
|
||||
switch msg := msg.(type) {
|
||||
case tickMsg:
|
||||
if m.state == viewLockForm {
|
||||
return m, nil
|
||||
}
|
||||
return m, tea.Batch(fetchServices, tick())
|
||||
case tea.KeyMsg:
|
||||
if m.state == viewLockForm {
|
||||
return m.updateLockForm(msg)
|
||||
}
|
||||
switch msg.String() {
|
||||
case "esc", "q", "ctrl+c":
|
||||
if m.state == viewDetail {
|
||||
@@ -99,17 +109,18 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
} else {
|
||||
m.message = "Unlocked " + serviceName
|
||||
}
|
||||
return m, fetchServices
|
||||
} 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
|
||||
}
|
||||
// Open Lock Form
|
||||
m.state = viewLockForm
|
||||
m.inputs[0].SetValue("")
|
||||
m.inputs[1].SetValue("")
|
||||
m.focusIndex = 0
|
||||
m.inputs[0].Focus()
|
||||
return m, nil
|
||||
}
|
||||
return m, fetchServices
|
||||
}
|
||||
@@ -124,25 +135,64 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
// 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
|
||||
availWidth := m.width - 15
|
||||
|
||||
// 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
|
||||
// Columns: SERVICE, STATUS, USER, TICKETS, TIME
|
||||
// Main View
|
||||
c0 := int(float64(availWidth) * 0.25) // SERVICE
|
||||
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[1].Width = c1
|
||||
cols[2].Width = c2
|
||||
cols[3].Width = c3
|
||||
cols[4].Width = c4
|
||||
|
||||
m.table.SetColumns(cols)
|
||||
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
|
||||
availableHeight := m.height - 7
|
||||
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))
|
||||
}
|
||||
|
||||
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.message = "Refreshed"
|
||||
@@ -178,12 +228,37 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
rows = append(rows, table.Row{
|
||||
h.Action,
|
||||
h.User,
|
||||
h.JiraTickets,
|
||||
h.Description,
|
||||
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.SetRows(rows)
|
||||
// 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())
|
||||
case error:
|
||||
m.err = msg
|
||||
@@ -197,6 +272,70 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.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 {
|
||||
if m.err != nil {
|
||||
return fmt.Sprintf("Error: %v\nPress q to quit", m.err)
|
||||
@@ -245,11 +384,55 @@ func (m model) View() string {
|
||||
var mainView string
|
||||
if m.state == viewList {
|
||||
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 {
|
||||
// History View
|
||||
mainView = lipgloss.JoinVertical(lipgloss.Left,
|
||||
"History for selected service:",
|
||||
m.historyTable.View(),
|
||||
// Left Pane: History Table
|
||||
leftPane := 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
|
||||
borderStyle := lipgloss.NewStyle().
|
||||
BorderStyle(lipgloss.NormalBorder()).
|
||||
BorderForeground(lipgloss.Color("240")).
|
||||
// BorderStyle(lipgloss.NormalBorder()).
|
||||
// BorderForeground(lipgloss.Color("240")).
|
||||
Width(appWidth).
|
||||
Height(appHeight - 2)
|
||||
|
||||
@@ -308,6 +491,7 @@ func main() {
|
||||
{Title: "SERVICE", Width: 20},
|
||||
{Title: "STATUS", Width: 15},
|
||||
{Title: "USER", Width: 15},
|
||||
{Title: "TICKETS", Width: 15},
|
||||
{Title: "TIME", Width: 20},
|
||||
}
|
||||
|
||||
@@ -330,9 +514,13 @@ func main() {
|
||||
t.SetStyles(s)
|
||||
|
||||
// History Table
|
||||
// Reduced columns for split view
|
||||
hCols := []table.Column{
|
||||
{Title: "ACTION", Width: 10},
|
||||
{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},
|
||||
}
|
||||
ht := table.New(
|
||||
@@ -342,10 +530,23 @@ func main() {
|
||||
)
|
||||
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{
|
||||
table: t,
|
||||
historyTable: ht,
|
||||
user: user,
|
||||
inputs: inputs,
|
||||
}
|
||||
|
||||
if _, err := tea.NewProgram(m, tea.WithAltScreen()).Run(); err != nil {
|
||||
|
||||
@@ -129,6 +129,8 @@ func handleLock(w http.ResponseWriter, r *http.Request) {
|
||||
services[i].IsLocked = true
|
||||
services[i].LockedBy = req.User
|
||||
services[i].LockedAt = time.Now()
|
||||
services[i].JiraTickets = req.JiraTickets
|
||||
services[i].Description = req.Description
|
||||
|
||||
if err := saveServices(); err != nil {
|
||||
log.Printf("Error guardando estado: %v", err)
|
||||
@@ -140,6 +142,8 @@ func handleLock(w http.ResponseWriter, r *http.Request) {
|
||||
Action: "LOCK",
|
||||
User: req.User,
|
||||
Timestamp: time.Now(),
|
||||
JiraTickets: req.JiraTickets,
|
||||
Description: req.Description,
|
||||
})
|
||||
saveHistory()
|
||||
|
||||
@@ -179,6 +183,8 @@ func handleUnlock(w http.ResponseWriter, r *http.Request) {
|
||||
services[i].IsLocked = false
|
||||
services[i].LockedBy = ""
|
||||
services[i].LockedAt = time.Time{}
|
||||
services[i].JiraTickets = ""
|
||||
services[i].Description = ""
|
||||
|
||||
if err := saveServices(); err != nil {
|
||||
log.Printf("Error guardando estado: %v", err)
|
||||
|
||||
1
go.mod
1
go.mod
@@ -11,6 +11,7 @@ require (
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/atotto/clipboard v0.1.4 // 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/x/ansi v0.10.1 // indirect
|
||||
|
||||
2
go.sum
2
go.sum
@@ -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/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8=
|
||||
github.com/aymanbagabas/go-udiff v0.2.0 h1:TK0fH4MteXUDspT88n8CKzvK0X9O2xu9yQjWpi6yML8=
|
||||
|
||||
20
history.json
20
history.json
@@ -16,5 +16,25 @@
|
||||
"action": "LOCK",
|
||||
"user": "carlos",
|
||||
"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"
|
||||
}
|
||||
]
|
||||
|
||||
2
ideas.md
2
ideas.md
@@ -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.
|
||||
- [ ] 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.
|
||||
@@ -40,10 +40,12 @@ func GetServices() ([]models.Service, error) {
|
||||
return services, nil
|
||||
}
|
||||
|
||||
func LockService(serviceName, user string) error {
|
||||
func LockService(serviceName, user, jiraTickets, description string) error {
|
||||
reqBody := models.LockRequest{
|
||||
ServiceName: serviceName,
|
||||
User: user,
|
||||
JiraTickets: jiraTickets,
|
||||
Description: description,
|
||||
}
|
||||
data, _ := json.Marshal(reqBody)
|
||||
|
||||
|
||||
@@ -8,12 +8,16 @@ type Service struct {
|
||||
IsLocked bool `json:"is_locked"`
|
||||
LockedBy string `json:"locked_by,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.
|
||||
type LockRequest struct {
|
||||
ServiceName string `json:"service_name"`
|
||||
User string `json:"user"`
|
||||
JiraTickets string `json:"jira_tickets"`
|
||||
Description string `json:"description"`
|
||||
}
|
||||
|
||||
// UnlockRequest is the payload to unlock a service.
|
||||
@@ -28,4 +32,6 @@ type HistoryEntry struct {
|
||||
Action string `json:"action"` // "LOCK" or "UNLOCK"
|
||||
User string `json:"user"`
|
||||
Timestamp time.Time `json:"timestamp"`
|
||||
JiraTickets string `json:"jira_tickets,omitempty"`
|
||||
Description string `json:"description,omitempty"`
|
||||
}
|
||||
|
||||
@@ -7,9 +7,8 @@
|
||||
},
|
||||
{
|
||||
"name": "analytics-etl",
|
||||
"is_locked": true,
|
||||
"locked_by": "carlos",
|
||||
"locked_at": "2026-01-28T15:59:40.3006257+01:00"
|
||||
"is_locked": false,
|
||||
"locked_at": "0001-01-01T00:00:00Z"
|
||||
},
|
||||
{
|
||||
"name": "d3a-ai",
|
||||
|
||||
Reference in New Issue
Block a user