From b7592ee9e5e193d6a8d50c348d3384757d669b58 Mon Sep 17 00:00:00 2001 From: Tyler Koenig Date: Thu, 14 May 2026 11:45:59 -0400 Subject: [PATCH] feat(tui): upgrade users tab with lipgloss table, edit support, role select Users tab now matches sites/alerts quality: lipgloss bordered table, click-to-select zones, edit form with role picker, and UpdateUser support across both store backends. --- internal/store/postgres.go | 52 ++++++++++++----- internal/store/sqlite.go | 67 +++++++++++++++------ internal/store/store.go | 5 +- internal/tui/tab_users.go | 115 ++++++++++++++++++++++++++++++++----- internal/tui/tui.go | 20 +++++++ 5 files changed, 210 insertions(+), 49 deletions(-) diff --git a/internal/store/postgres.go b/internal/store/postgres.go index 9db92d0..e7135fa 100644 --- a/internal/store/postgres.go +++ b/internal/store/postgres.go @@ -4,7 +4,7 @@ import ( "database/sql" "encoding/json" "go-upkeep/internal/models" - + _ "github.com/lib/pq" ) @@ -16,7 +16,9 @@ type PostgresStore struct { func (p *PostgresStore) Init() error { var err error p.db, err = sql.Open("postgres", p.ConnStr) - if err != nil { return err } + if err != nil { + return err + } queries := []string{ `CREATE TABLE IF NOT EXISTS alerts ( @@ -45,7 +47,9 @@ func (p *PostgresStore) Init() error { );`, } for _, q := range queries { - if _, err := p.db.Exec(q); err != nil { return err } + if _, err := p.db.Exec(q); err != nil { + return err + } } return nil } @@ -53,7 +57,9 @@ func (p *PostgresStore) Init() error { // ... [CRUD Methods are identical to Phase 4, keeping them concise here] ... func (p *PostgresStore) GetSites() []models.Site { rows, err := p.db.Query("SELECT id, COALESCE(name, url), url, COALESCE(type, 'http'), COALESCE(token, ''), interval, alert_id, check_ssl, threshold, max_retries FROM sites") - if err != nil { return []models.Site{} } + if err != nil { + return []models.Site{} + } defer rows.Close() var sites []models.Site for rows.Next() { @@ -65,23 +71,30 @@ func (p *PostgresStore) GetSites() []models.Site { } func (p *PostgresStore) AddSite(name, url, sType string, interval, alertID int, checkSSL bool, threshold, retries int) { token := "" - if sType == "push" { token = generateToken() } + if sType == "push" { + token = generateToken() + } p.db.Exec("INSERT INTO sites (name, url, type, token, interval, alert_id, check_ssl, threshold, max_retries) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)", name, url, sType, token, interval, alertID, checkSSL, threshold, retries) } func (p *PostgresStore) UpdateSite(id int, name, url, sType string, interval, alertID int, checkSSL bool, threshold, retries int) { var existingToken string p.db.QueryRow("SELECT token FROM sites WHERE id=$1", id).Scan(&existingToken) - if sType == "push" && existingToken == "" { existingToken = generateToken() } + if sType == "push" && existingToken == "" { + existingToken = generateToken() + } p.db.Exec("UPDATE sites SET name=$1, url=$2, type=$3, token=$4, interval=$5, alert_id=$6, check_ssl=$7, threshold=$8, max_retries=$9 WHERE id=$10", name, url, sType, existingToken, interval, alertID, checkSSL, threshold, retries, id) } func (p *PostgresStore) DeleteSite(id int) { p.db.Exec("DELETE FROM sites WHERE id=$1", id) } func (p *PostgresStore) GetAllAlerts() []models.AlertConfig { rows, err := p.db.Query("SELECT id, name, type, settings FROM alerts") - if err != nil { return []models.AlertConfig{} } + if err != nil { + return []models.AlertConfig{} + } defer rows.Close() var alerts []models.AlertConfig for rows.Next() { - var a models.AlertConfig; var settingsJSON string + var a models.AlertConfig + var settingsJSON string rows.Scan(&a.ID, &a.Name, &a.Type, &settingsJSON) json.Unmarshal([]byte(settingsJSON), &a.Settings) alerts = append(alerts, a) @@ -89,9 +102,12 @@ func (p *PostgresStore) GetAllAlerts() []models.AlertConfig { return alerts } func (p *PostgresStore) GetAlert(id int) (models.AlertConfig, bool) { - var a models.AlertConfig; var settingsJSON string + var a models.AlertConfig + var settingsJSON string err := p.db.QueryRow("SELECT id, name, type, settings FROM alerts WHERE id = $1", id).Scan(&a.ID, &a.Name, &a.Type, &settingsJSON) - if err != nil { return a, false } + if err != nil { + return a, false + } json.Unmarshal([]byte(settingsJSON), &a.Settings) return a, true } @@ -106,7 +122,9 @@ func (p *PostgresStore) UpdateAlert(id int, name, aType string, settings map[str func (p *PostgresStore) DeleteAlert(id int) { p.db.Exec("DELETE FROM alerts WHERE id=$1", id) } func (p *PostgresStore) GetAllUsers() []models.User { rows, err := p.db.Query("SELECT id, username, public_key, role FROM users") - if err != nil { return []models.User{} } + if err != nil { + return []models.User{} + } defer rows.Close() var users []models.User for rows.Next() { @@ -120,6 +138,10 @@ func (p *PostgresStore) AddUser(username, publicKey, role string) error { _, err := p.db.Exec("INSERT INTO users (username, public_key, role) VALUES ($1, $2, $3)", username, publicKey, role) return err } +func (p *PostgresStore) UpdateUser(id int, username, publicKey, role string) error { + _, err := p.db.Exec("UPDATE users SET username=$1, public_key=$2, role=$3 WHERE id=$4", username, publicKey, role, id) + return err +} func (p *PostgresStore) DeleteUser(id int) error { _, err := p.db.Exec("DELETE FROM users WHERE id=$1", id) return err @@ -137,7 +159,9 @@ func (p *PostgresStore) ExportData() models.Backup { func (p *PostgresStore) ImportData(data models.Backup) error { tx, err := p.db.Begin() - if err != nil { return err } + if err != nil { + return err + } tx.Exec("TRUNCATE TABLE sites RESTART IDENTITY CASCADE") tx.Exec("TRUNCATE TABLE alerts RESTART IDENTITY CASCADE") @@ -154,10 +178,10 @@ func (p *PostgresStore) ImportData(data models.Backup) error { tx.Exec("INSERT INTO sites (id, name, url, type, token, interval, alert_id, check_ssl, threshold, max_retries) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)", st.ID, st.Name, st.URL, st.Type, st.Token, st.Interval, st.AlertID, st.CheckSSL, st.ExpiryThreshold, st.MaxRetries) } - + tx.Exec("SELECT setval('sites_id_seq', (SELECT MAX(id) FROM sites))") tx.Exec("SELECT setval('alerts_id_seq', (SELECT MAX(id) FROM alerts))") tx.Exec("SELECT setval('users_id_seq', (SELECT MAX(id) FROM users))") return tx.Commit() -} \ No newline at end of file +} diff --git a/internal/store/sqlite.go b/internal/store/sqlite.go index 1ba90ed..c653351 100644 --- a/internal/store/sqlite.go +++ b/internal/store/sqlite.go @@ -6,7 +6,7 @@ import ( "encoding/hex" "encoding/json" "go-upkeep/internal/models" - + _ "github.com/mattn/go-sqlite3" ) @@ -18,7 +18,9 @@ type SQLiteStore struct { func (s *SQLiteStore) Init() error { var err error s.db, err = sql.Open("sqlite3", s.DBPath) - if err != nil { return err } + if err != nil { + return err + } createTables := ` CREATE TABLE IF NOT EXISTS alerts ( @@ -51,13 +53,17 @@ func (s *SQLiteStore) Init() error { func generateToken() string { b := make([]byte, 16) - rand.Read(b) + if _, err := rand.Read(b); err != nil { + panic("crypto/rand failed: " + err.Error()) + } return hex.EncodeToString(b) } func (s *SQLiteStore) GetSites() []models.Site { rows, err := s.db.Query("SELECT id, COALESCE(name, url), url, COALESCE(type, 'http'), COALESCE(token, ''), interval, alert_id, check_ssl, threshold, max_retries FROM sites") - if err != nil { return []models.Site{} } + if err != nil { + return []models.Site{} + } defer rows.Close() var sites []models.Site for rows.Next() { @@ -69,28 +75,37 @@ func (s *SQLiteStore) GetSites() []models.Site { } func (s *SQLiteStore) AddSite(name, url, sType string, interval, alertID int, checkSSL bool, threshold, retries int) { token := "" - if sType == "push" { token = generateToken() } + if sType == "push" { + token = generateToken() + } s.db.Exec("INSERT INTO sites (name, url, type, token, interval, alert_id, check_ssl, threshold, max_retries) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)", name, url, sType, token, interval, alertID, checkSSL, threshold, retries) } func (s *SQLiteStore) UpdateSite(id int, name, url, sType string, interval, alertID int, checkSSL bool, threshold, retries int) { var existingToken string s.db.QueryRow("SELECT token FROM sites WHERE id=?", id).Scan(&existingToken) - if sType == "push" && existingToken == "" { existingToken = generateToken() } + if sType == "push" && existingToken == "" { + existingToken = generateToken() + } s.db.Exec("UPDATE sites SET name=?, url=?, type=?, token=?, interval=?, alert_id=?, check_ssl=?, threshold=?, max_retries=? WHERE id=?", name, url, sType, existingToken, interval, alertID, checkSSL, threshold, retries, id) } func (s *SQLiteStore) DeleteSite(id int) { s.db.Exec("DELETE FROM sites WHERE id=?", id) var count int s.db.QueryRow("SELECT COUNT(*) FROM sites").Scan(&count) - if count == 0 { s.db.Exec("DELETE FROM sqlite_sequence WHERE name='sites'") } + if count == 0 { + s.db.Exec("DELETE FROM sqlite_sequence WHERE name='sites'") + } } func (s *SQLiteStore) GetAllAlerts() []models.AlertConfig { rows, err := s.db.Query("SELECT id, name, type, settings FROM alerts") - if err != nil { return []models.AlertConfig{} } + if err != nil { + return []models.AlertConfig{} + } defer rows.Close() var alerts []models.AlertConfig for rows.Next() { - var a models.AlertConfig; var settingsJSON string + var a models.AlertConfig + var settingsJSON string rows.Scan(&a.ID, &a.Name, &a.Type, &settingsJSON) json.Unmarshal([]byte(settingsJSON), &a.Settings) alerts = append(alerts, a) @@ -98,9 +113,12 @@ func (s *SQLiteStore) GetAllAlerts() []models.AlertConfig { return alerts } func (s *SQLiteStore) GetAlert(id int) (models.AlertConfig, bool) { - var a models.AlertConfig; var settingsJSON string + var a models.AlertConfig + var settingsJSON string err := s.db.QueryRow("SELECT id, name, type, settings FROM alerts WHERE id = ?", id).Scan(&a.ID, &a.Name, &a.Type, &settingsJSON) - if err != nil { return a, false } + if err != nil { + return a, false + } json.Unmarshal([]byte(settingsJSON), &a.Settings) return a, true } @@ -116,11 +134,15 @@ func (s *SQLiteStore) DeleteAlert(id int) { s.db.Exec("DELETE FROM alerts WHERE id=?", id) var count int s.db.QueryRow("SELECT COUNT(*) FROM alerts").Scan(&count) - if count == 0 { s.db.Exec("DELETE FROM sqlite_sequence WHERE name='alerts'") } + if count == 0 { + s.db.Exec("DELETE FROM sqlite_sequence WHERE name='alerts'") + } } func (s *SQLiteStore) GetAllUsers() []models.User { rows, err := s.db.Query("SELECT id, username, public_key, role FROM users") - if err != nil { return []models.User{} } + if err != nil { + return []models.User{} + } defer rows.Close() var users []models.User for rows.Next() { @@ -134,6 +156,10 @@ func (s *SQLiteStore) AddUser(username, publicKey, role string) error { _, err := s.db.Exec("INSERT INTO users (username, public_key, role) VALUES (?, ?, ?)", username, publicKey, role) return err } +func (s *SQLiteStore) UpdateUser(id int, username, publicKey, role string) error { + _, err := s.db.Exec("UPDATE users SET username=?, public_key=?, role=? WHERE id=?", username, publicKey, role, id) + return err +} func (s *SQLiteStore) DeleteUser(id int) error { _, err := s.db.Exec("DELETE FROM users WHERE id=?", id) return err @@ -151,12 +177,17 @@ func (s *SQLiteStore) ExportData() models.Backup { func (s *SQLiteStore) ImportData(data models.Backup) error { tx, err := s.db.Begin() - if err != nil { return err } + if err != nil { + return err + } // Wipe Existing - tx.Exec("DELETE FROM sites"); tx.Exec("DELETE FROM sqlite_sequence WHERE name='sites'") - tx.Exec("DELETE FROM alerts"); tx.Exec("DELETE FROM sqlite_sequence WHERE name='alerts'") - tx.Exec("DELETE FROM users"); tx.Exec("DELETE FROM sqlite_sequence WHERE name='users'") + tx.Exec("DELETE FROM sites") + tx.Exec("DELETE FROM sqlite_sequence WHERE name='sites'") + tx.Exec("DELETE FROM alerts") + tx.Exec("DELETE FROM sqlite_sequence WHERE name='alerts'") + tx.Exec("DELETE FROM users") + tx.Exec("DELETE FROM sqlite_sequence WHERE name='users'") // Insert New for _, u := range data.Users { @@ -172,4 +203,4 @@ func (s *SQLiteStore) ImportData(data models.Backup) error { } return tx.Commit() -} \ No newline at end of file +} diff --git a/internal/store/store.go b/internal/store/store.go index 1af1aa1..6afbb07 100644 --- a/internal/store/store.go +++ b/internal/store/store.go @@ -6,7 +6,7 @@ import ( type Store interface { Init() error - + // Sites GetSites() []models.Site AddSite(name, url, sType string, interval, alertID int, checkSSL bool, threshold, retries int) @@ -23,6 +23,7 @@ type Store interface { // Users GetAllUsers() []models.User AddUser(username, publicKey, role string) error + UpdateUser(id int, username, publicKey, role string) error DeleteUser(id int) error // Phase 5: Backup & Restore @@ -38,4 +39,4 @@ func SetGlobal(s Store) { func Get() Store { return Current -} \ No newline at end of file +} diff --git a/internal/tui/tab_users.go b/internal/tui/tab_users.go index a87c008..48fd9a3 100644 --- a/internal/tui/tab_users.go +++ b/internal/tui/tab_users.go @@ -7,38 +7,113 @@ import ( tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/huh" "github.com/charmbracelet/lipgloss" + "github.com/charmbracelet/lipgloss/table" +) + +var ( + userHeaderStyle = lipgloss.NewStyle(). + Foreground(lipgloss.Color("#7D56F4")). + Bold(true). + Padding(0, 1) + + userCellStyle = lipgloss.NewStyle().Padding(0, 1) + + userSelectedStyle = lipgloss.NewStyle(). + Padding(0, 1). + Bold(true). + Foreground(lipgloss.Color("#ffffff")). + Background(lipgloss.Color("#3b3b5c")) + + userBorderStyle = lipgloss.NewStyle(). + Foreground(lipgloss.Color("#444")) + + userColWidths = []int{4, 16, 10, 44} ) type userFormData struct { Username string PublicKey string + Role string +} + +func fmtRole(role string) string { + if role == "admin" { + return specialStyle.Render(role) + } + return role +} + +func fmtKey(key string) string { + if len(key) > 40 { + return key[:20] + "..." + key[len(key)-17:] + } + return key } func (m Model) viewUsersTab() string { - var content string - content += fmt.Sprintf("\n%-3s %-15s %-10s %s\n", "ID", "USER", "ROLE", "KEY") - content += subtleStyle.Render("----------------------------------------------------------------") + "\n" + if len(m.users) == 0 { + return "\n No users configured. Press [n] to add one." + } + end := m.tableOffset + m.maxTableRows if end > len(m.users) { end = len(m.users) } + + selectedVisual := m.cursor - m.tableOffset + + var rows [][]string for i := m.tableOffset; i < end; i++ { u := m.users[i] - cursor := " " - if m.cursor == i { - cursor = ">" - } - row := fmt.Sprintf("%s %-3d %-15s %-10s %s", cursor, u.ID, limitStr(u.Username, 15), u.Role, limitStr(u.PublicKey, 40)) - if m.cursor == i { - row = lipgloss.NewStyle().Bold(true).Render(row) - } - content += row + "\n" + rows = append(rows, []string{ + fmt.Sprintf("%d", u.ID), + m.zones.Mark(fmt.Sprintf("user-%d", i), limitStr(u.Username, 15)), + fmtRole(u.Role), + fmtKey(u.PublicKey), + }) } - return content + + t := table.New(). + Border(lipgloss.RoundedBorder()). + BorderStyle(userBorderStyle). + Headers("ID", "USERNAME", "ROLE", "PUBLIC KEY"). + Rows(rows...). + StyleFunc(func(row, col int) lipgloss.Style { + if row == table.HeaderRow { + s := userHeaderStyle + if col < len(userColWidths) { + s = s.Width(userColWidths[col]) + } + return s + } + s := userCellStyle + if row == selectedVisual { + s = userSelectedStyle + } + if col < len(userColWidths) { + s = s.Width(userColWidths[col]) + } + return s + }) + + return "\n" + t.Render() } func (m *Model) initUserHuhForm() tea.Cmd { - m.userFormData = &userFormData{} + m.userFormData = &userFormData{ + Role: "user", + } + + if m.editID > 0 { + for _, u := range m.users { + if u.ID == m.editID { + m.userFormData.Username = u.Username + m.userFormData.PublicKey = u.PublicKey + m.userFormData.Role = u.Role + break + } + } + } m.huhForm = huh.NewForm( huh.NewGroup( @@ -60,6 +135,11 @@ func (m *Model) initUserHuhForm() tea.Cmd { } return nil }), + huh.NewSelect[string]().Title("Role"). + Options( + huh.NewOption("User", "user"), + huh.NewOption("Admin", "admin"), + ).Value(&m.userFormData.Role), ).Title("SSH Access"), ).WithTheme(huh.ThemeDracula()) @@ -67,6 +147,11 @@ func (m *Model) initUserHuhForm() tea.Cmd { } func (m *Model) submitUserForm() { - store.Get().AddUser(m.userFormData.Username, m.userFormData.PublicKey, "user") + d := m.userFormData + if m.editID > 0 { + store.Get().UpdateUser(m.editID, d.Username, d.PublicKey, d.Role) + } else { + store.Get().AddUser(d.Username, d.PublicKey, d.Role) + } m.state = stateUsers } diff --git a/internal/tui/tui.go b/internal/tui/tui.go index 4dcf90b..db2faa0 100644 --- a/internal/tui/tui.go +++ b/internal/tui/tui.go @@ -218,6 +218,10 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.editID = m.alerts[m.cursor].ID m.state = stateFormAlert return m, m.initAlertHuhForm() + } else if m.currentTab == 3 && m.isAdmin && len(m.users) > 0 { + m.editID = m.users[m.cursor].ID + m.state = stateFormUser + return m, m.initUserHuhForm() } case "d", "backspace": if m.currentTab == 1 && len(m.alerts) > 0 { @@ -264,6 +268,19 @@ func (m *Model) handleClick(msg tea.MouseMsg) (tea.Model, tea.Cmd) { } } + if m.currentTab == 3 { + end := m.tableOffset + m.maxTableRows + if end > len(m.users) { + end = len(m.users) + } + for i := m.tableOffset; i < end; i++ { + if m.zones.Get(fmt.Sprintf("user-%d", i)).InBounds(msg) { + m.cursor = i + return m, nil + } + } + } + return m, nil } @@ -366,6 +383,9 @@ func (m Model) View() string { } case stateFormUser: title = "Add User" + if m.editID > 0 { + title = fmt.Sprintf("Edit User #%d", m.editID) + } } header := titleStyle.Render(title) footer := subtleStyle.Render("\n[Esc] Cancel")