Compare commits
4 Commits
4b41a85a5b
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c1c4dc62fb | ||
|
|
126ec47b30 | ||
|
|
0b091c93db | ||
|
|
d33b4e8676 |
57
Makefile
Normal file
57
Makefile
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
BINARY_NAME=env-guard-server
|
||||||
|
SERVICE_NAME=env-guard.service
|
||||||
|
INSTALL_DIR=/usr/local/bin
|
||||||
|
SYSTEMD_DIR=/etc/systemd/system
|
||||||
|
WORK_DIR=/var/lib/env-guard
|
||||||
|
|
||||||
|
.PHONY: all build build-linux clean install uninstall
|
||||||
|
|
||||||
|
all: build
|
||||||
|
|
||||||
|
build:
|
||||||
|
go build -o $(BINARY_NAME) ./cmd/server
|
||||||
|
|
||||||
|
# Cross-compilation for Linux (useful if running this on Windows/Mac)
|
||||||
|
build-linux:
|
||||||
|
GOOS=linux GOARCH=amd64 go build -o $(BINARY_NAME) ./cmd/server
|
||||||
|
|
||||||
|
clean:
|
||||||
|
rm -f $(BINARY_NAME)
|
||||||
|
|
||||||
|
install: build-linux
|
||||||
|
@echo "Installing server..."
|
||||||
|
mkdir -p $(INSTALL_DIR)
|
||||||
|
cp $(BINARY_NAME) $(INSTALL_DIR)/
|
||||||
|
chmod +x $(INSTALL_DIR)/$(BINARY_NAME)
|
||||||
|
|
||||||
|
@echo "Creating working directory..."
|
||||||
|
mkdir -p $(WORK_DIR)
|
||||||
|
# Copy initial data files if they exist locally and not on target,
|
||||||
|
# otherwise app will create default/empty ones based on its logic.
|
||||||
|
# if [ -f services.json ]; then cp -n services.json $(WORK_DIR)/; fi
|
||||||
|
# if [ -f history.json ]; then cp -n history.json $(WORK_DIR)/; fi
|
||||||
|
|
||||||
|
@echo "Installing systemd service..."
|
||||||
|
cp $(SERVICE_NAME) $(SYSTEMD_DIR)/
|
||||||
|
systemctl daemon-reload
|
||||||
|
systemctl enable $(SERVICE_NAME)
|
||||||
|
systemctl start $(SERVICE_NAME)
|
||||||
|
@echo "Service installed and started!"
|
||||||
|
|
||||||
|
uninstall:
|
||||||
|
@echo "Uninstalling..."
|
||||||
|
systemctl stop $(SERVICE_NAME) || true
|
||||||
|
systemctl disable $(SERVICE_NAME) || true
|
||||||
|
rm -f $(SYSTEMD_DIR)/$(SERVICE_NAME)
|
||||||
|
rm -f $(INSTALL_DIR)/$(BINARY_NAME)
|
||||||
|
systemctl daemon-reload
|
||||||
|
@echo "Uninstalled."
|
||||||
|
|
||||||
|
update:
|
||||||
|
@echo "Updating server..."
|
||||||
|
systemctl stop $(SERVICE_NAME)
|
||||||
|
cp $(BINARY_NAME) $(INSTALL_DIR)/
|
||||||
|
chmod +x $(INSTALL_DIR)/$(BINARY_NAME)
|
||||||
|
systemctl start $(SERVICE_NAME)
|
||||||
|
systemctl status $(SERVICE_NAME)
|
||||||
|
@echo "Server updated!"
|
||||||
341
cmd/cli/main.go
341
cmd/cli/main.go
@@ -10,22 +10,37 @@ 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"
|
||||||
)
|
)
|
||||||
|
|
||||||
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
|
||||||
|
viewLockForm
|
||||||
|
)
|
||||||
|
|
||||||
type model struct {
|
type model struct {
|
||||||
table table.Model
|
table table.Model
|
||||||
services []models.Service
|
services []models.Service
|
||||||
user string
|
user string
|
||||||
err error
|
err error
|
||||||
message string
|
message string
|
||||||
width int
|
width int
|
||||||
height int
|
height int
|
||||||
|
state viewState
|
||||||
|
history []models.HistoryEntry
|
||||||
|
historyTable table.Model
|
||||||
|
inputs []textinput.Model
|
||||||
|
focusIndex int
|
||||||
}
|
}
|
||||||
|
|
||||||
type tickMsg time.Time
|
type tickMsg time.Time
|
||||||
@@ -37,6 +52,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())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -44,12 +63,35 @@ 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 {
|
||||||
|
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
|
||||||
@@ -67,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
|
||||||
}
|
}
|
||||||
@@ -92,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 {
|
||||||
@@ -135,17 +217,125 @@ 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"
|
||||||
|
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:
|
case error:
|
||||||
m.err = msg
|
m.err = msg
|
||||||
}
|
}
|
||||||
m.table, cmd = m.table.Update(msg)
|
|
||||||
|
if m.state == viewList {
|
||||||
|
m.table, cmd = m.table.Update(msg)
|
||||||
|
} else {
|
||||||
|
m.historyTable, cmd = m.historyTable.Update(msg)
|
||||||
|
}
|
||||||
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)
|
||||||
@@ -191,18 +381,73 @@ 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 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
|
// 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
|
||||||
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)
|
||||||
|
|
||||||
@@ -221,6 +466,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()
|
||||||
@@ -236,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},
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -257,9 +513,40 @@ func main() {
|
|||||||
Bold(false)
|
Bold(false)
|
||||||
t.SetStyles(s)
|
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{
|
m := model{
|
||||||
table: t,
|
table: t,
|
||||||
user: user,
|
historyTable: ht,
|
||||||
|
user: user,
|
||||||
|
inputs: inputs,
|
||||||
}
|
}
|
||||||
|
|
||||||
if _, err := tea.NewProgram(m, tea.WithAltScreen()).Run(); err != nil {
|
if _, err := tea.NewProgram(m, tea.WithAltScreen()).Run(); err != nil {
|
||||||
|
|||||||
@@ -13,10 +13,12 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
var (
|
var (
|
||||||
services = []models.Service{}
|
services = []models.Service{}
|
||||||
mu sync.Mutex
|
history = []models.HistoryEntry{}
|
||||||
dataFile = "services.json"
|
mu sync.Mutex
|
||||||
apiToken = "ENVGUARD_SECRET_TOKEN"
|
dataFile = "services.json"
|
||||||
|
historyFile = "history.json"
|
||||||
|
apiToken = "ENVGUARD_SECRET_TOKEN"
|
||||||
)
|
)
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
@@ -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()
|
||||||
@@ -96,11 +129,24 @@ 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)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Add to history
|
||||||
|
history = append(history, models.HistoryEntry{
|
||||||
|
ServiceName: req.ServiceName,
|
||||||
|
Action: "LOCK",
|
||||||
|
User: req.User,
|
||||||
|
Timestamp: time.Now(),
|
||||||
|
JiraTickets: req.JiraTickets,
|
||||||
|
Description: req.Description,
|
||||||
|
})
|
||||||
|
saveHistory()
|
||||||
|
|
||||||
w.WriteHeader(http.StatusOK)
|
w.WriteHeader(http.StatusOK)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -137,11 +183,22 @@ 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)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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 +207,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")
|
||||||
|
|||||||
26
env-guard.service
Normal file
26
env-guard.service
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
[Unit]
|
||||||
|
Description=EnvGuard Server
|
||||||
|
After=network.target
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
# User/Group: Run as root by default to ensure permission to write to /var/lib/env-guard
|
||||||
|
# You can change this to a specific user if preferred (e.g., User=envguard),
|
||||||
|
# but remember to 'chown -R envguard:envguard /var/lib/env-guard'
|
||||||
|
User=root
|
||||||
|
Group=root
|
||||||
|
|
||||||
|
# Path to the binary
|
||||||
|
ExecStart=/usr/local/bin/env-guard-server
|
||||||
|
|
||||||
|
# Directory where services.json and history.json will be stored
|
||||||
|
WorkingDirectory=/var/lib/env-guard
|
||||||
|
|
||||||
|
# Restart policy
|
||||||
|
Restart=always
|
||||||
|
RestartSec=5
|
||||||
|
|
||||||
|
# Environment variables if needed
|
||||||
|
# Environment=ENVGUARD_SECRET_TOKEN=your-secret-token
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
1
go.mod
1
go.mod
@@ -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
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 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=
|
||||||
|
|||||||
40
history.json
Normal file
40
history.json
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
[
|
||||||
|
{
|
||||||
|
"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"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"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"
|
||||||
|
}
|
||||||
|
]
|
||||||
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.
|
||||||
|
- [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
|
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)
|
||||||
|
|
||||||
@@ -91,3 +93,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
|
||||||
|
}
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -21,3 +25,13 @@ 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"`
|
||||||
|
JiraTickets string `json:"jira_tickets,omitempty"`
|
||||||
|
Description string `json:"description,omitempty"`
|
||||||
|
}
|
||||||
|
|||||||
@@ -28,13 +28,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