356 lines
7.6 KiB
Go
356 lines
7.6 KiB
Go
package main
|
|
|
|
import (
|
|
"flag"
|
|
"fmt"
|
|
"os"
|
|
"time"
|
|
|
|
"envguard/internal/api"
|
|
"envguard/internal/models"
|
|
|
|
"github.com/charmbracelet/bubbles/table"
|
|
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
|
|
)
|
|
|
|
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
|
|
}
|
|
|
|
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()
|
|
}
|
|
return tea.Batch(fetchServices, tick())
|
|
}
|
|
|
|
func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|
var cmd tea.Cmd
|
|
switch msg := msg.(type) {
|
|
case tickMsg:
|
|
return m, tea.Batch(fetchServices, tick())
|
|
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
|
|
}
|
|
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
|
|
}
|
|
} 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
|
|
}
|
|
}
|
|
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 - 10
|
|
|
|
// 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
|
|
|
|
cols[0].Width = c0
|
|
cols[1].Width = c1
|
|
cols[2].Width = c2
|
|
cols[3].Width = c3
|
|
|
|
m.table.SetColumns(cols)
|
|
m.table.SetWidth(availWidth)
|
|
|
|
// 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, 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.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
|
|
}
|
|
|
|
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) 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 {
|
|
// 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),
|
|
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() {
|
|
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: "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
|
|
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,
|
|
historyTable: ht,
|
|
user: user,
|
|
}
|
|
|
|
if _, err := tea.NewProgram(m, tea.WithAltScreen()).Run(); err != nil {
|
|
fmt.Println("Error running program:", err)
|
|
os.Exit(1)
|
|
}
|
|
}
|