Files
env-guard/cmd/cli/main.go

557 lines
13 KiB
Go
Raw Normal View History

package main
import (
2026-01-28 15:20:53 +01:00
"flag"
"fmt"
"os"
"time"
"envguard/internal/api"
"envguard/internal/models"
"github.com/charmbracelet/bubbles/table"
"github.com/charmbracelet/bubbles/textinput"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
)
var baseStyle = lipgloss.NewStyle().
BorderStyle(lipgloss.NormalBorder()).
BorderStyle(lipgloss.NormalBorder()).
BorderForeground(lipgloss.Color("240"))
type viewState int
const (
viewList viewState = iota
viewDetail
viewLockForm
)
type model struct {
table table.Model
services []models.Service
user string
err error
message string
width int
height int
state viewState
history []models.HistoryEntry
historyTable table.Model
inputs []textinput.Model
focusIndex int
}
2026-01-28 15:43:53 +01:00
type tickMsg time.Time
func tick() tea.Cmd {
return tea.Tick(5*time.Second, func(t time.Time) tea.Msg {
return tickMsg(t)
})
}
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()
}
2026-01-28 15:43:53 +01:00
return tea.Batch(fetchServices, tick())
}
func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
var cmd tea.Cmd
switch msg := msg.(type) {
2026-01-28 15:43:53 +01:00
case tickMsg:
if m.state == viewLockForm {
return m, nil
}
2026-01-28 15:43:53 +01:00
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 {
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
}
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
}
return m, fetchServices
} else {
m.message = "Cannot unlock service locked by " + s.LockedBy
}
} else {
// 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
}
}
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 - 15
// Adjust column widths dynamically
// Total weight = 100%
cols := m.table.Columns()
// Calculate widths ensuring at least minimums
// 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 {
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, s.JiraTickets, timeStr})
}
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.JiraTickets,
h.Description,
h.Timestamp.Format("2006-01-02 15:04:05"),
})
}
m.historyTable.SetRows(rows)
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
}
if m.state == viewList {
m.table, cmd = m.table.Update(msg)
} else {
m.historyTable, cmd = m.historyTable.Update(msg)
}
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)
}
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)
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
// 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,
)
}
// Render Content
content := lipgloss.JoinVertical(lipgloss.Left,
headerStyle.Render("🛡️ ENV-GUARD v1.0"),
infoStyle.Render(userInfo),
mainView,
footerStyle.Render("[↑/↓] Navigate [Enter] Toggle Lock [d] History [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 fetchHistoryCmd(serviceName string) tea.Cmd {
return func() tea.Msg {
hist, err := api.GetHistory(serviceName)
if err != nil {
return err
}
return hist
}
}
func main() {
2026-01-28 15:20:53 +01:00
serverURL := flag.String("server", "http://localhost:8080", "URL of the EnvGuard server")
flag.Parse()
api.SetBaseURL(*serverURL)
user := os.Getenv("USER")
if user == "" {
user = "guest"
}
columns := []table.Column{
{Title: "SERVICE", Width: 20},
{Title: "STATUS", Width: 15},
{Title: "USER", Width: 15},
{Title: "TICKETS", 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)
// 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(
table.WithColumns(hCols),
table.WithFocused(true),
table.WithHeight(10),
)
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 {
fmt.Println("Error running program:", err)
os.Exit(1)
}
}