Compare commits

...

4 Commits

Author SHA1 Message Date
Jose Luis Montañes Ojados
c1c4dc62fb feat: add update target to Makefile for server binary updates 2026-01-28 18:03:05 +01:00
Jose Luis Montañes Ojados
126ec47b30 feat: add Makefile and systemd service for linux deployment 2026-01-28 17:35:17 +01:00
Jose Luis Montañes Ojados
0b091c93db feat: implement split history view, jira tickets and description 2026-01-28 16:37:31 +01:00
Jose Luis Montañes Ojados
d33b4e8676 feat: implement service history view and tracking 2026-01-28 16:01:45 +01:00
11 changed files with 573 additions and 40 deletions

57
Makefile Normal file
View 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!"

View File

@@ -10,14 +10,24 @@ import (
"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
@@ -26,6 +36,11 @@ type model struct {
message string
width int
height int
state viewState
history []models.HistoryEntry
historyTable table.Model
inputs []textinput.Model
focusIndex int
}
type tickMsg time.Time
@@ -37,6 +52,10 @@ func tick() 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())
}
@@ -44,12 +63,35 @@ 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 {
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
@@ -67,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
}
@@ -92,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 {
@@ -135,17 +217,125 @@ 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"
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)
@@ -191,18 +381,73 @@ func (m model) View() string {
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),
m.table.View(),
footerStyle.Render("[↑/↓] Navigate [Enter] Toggle Lock [r] Refresh [q] Quit"),
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")).
// BorderStyle(lipgloss.NormalBorder()).
// BorderForeground(lipgloss.Color("240")).
Width(appWidth).
Height(appHeight - 2)
@@ -221,6 +466,16 @@ func fetchServices() tea.Msg {
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()
@@ -236,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},
}
@@ -257,9 +513,40 @@ func main() {
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 {

View File

@@ -14,8 +14,10 @@ import (
var (
services = []models.Service{}
history = []models.HistoryEntry{}
mu sync.Mutex
dataFile = "services.json"
historyFile = "history.json"
apiToken = "ENVGUARD_SECRET_TOKEN"
)
@@ -33,7 +35,14 @@ func main() {
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("/history", authMiddleware(handleHistory))
http.HandleFunc("/lock", authMiddleware(handleLock))
http.HandleFunc("/unlock", authMiddleware(handleUnlock))
@@ -64,6 +73,30 @@ func saveServices() error {
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) {
mu.Lock()
defer mu.Unlock()
@@ -96,11 +129,24 @@ 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)
}
// 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)
return
}
@@ -137,11 +183,22 @@ 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)
}
// Add to history
history = append(history, models.HistoryEntry{
ServiceName: req.ServiceName,
Action: "UNLOCK",
User: req.User,
Timestamp: time.Now(),
})
saveHistory()
w.WriteHeader(http.StatusOK)
return
}
@@ -150,6 +207,27 @@ func handleUnlock(w http.ResponseWriter, r *http.Request) {
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 {
return func(w http.ResponseWriter, r *http.Request) {
token := r.Header.Get("X-API-Key")

26
env-guard.service Normal file
View 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
View File

@@ -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
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/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8=
github.com/aymanbagabas/go-udiff v0.2.0 h1:TK0fH4MteXUDspT88n8CKzvK0X9O2xu9yQjWpi6yML8=

40
history.json Normal file
View 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
View 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.

View File

@@ -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)
@@ -91,3 +93,23 @@ func UnlockService(serviceName, user string) error {
}
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
}

View File

@@ -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.
@@ -21,3 +25,13 @@ type UnlockRequest struct {
ServiceName string `json:"service_name"`
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"`
}

View File

@@ -28,13 +28,15 @@
},
{
"name": "d3a-ai-coder",
"is_locked": false,
"locked_at": "0001-01-01T00:00:00Z"
"is_locked": true,
"locked_by": "jose.montanes",
"locked_at": "2026-01-28T15:44:15.0392434+01:00"
},
{
"name": "d3a-ai-mentor",
"is_locked": false,
"locked_at": "0001-01-01T00:00:00Z"
"is_locked": true,
"locked_by": "jose.montanes",
"locked_at": "2026-01-28T15:44:10.7852949+01:00"
},
{
"name": "d3a-ai-stream",