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.
This commit is contained in:
2026-05-14 11:45:59 -04:00
parent c24bb7a0d4
commit b7592ee9e5
5 changed files with 210 additions and 49 deletions
+35 -11
View File
@@ -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")
+47 -16
View File
@@ -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 {
+1
View File
@@ -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
+98 -13
View File
@@ -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 = ">"
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),
})
}
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)
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])
}
content += row + "\n"
return s
}
return content
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
}
+20
View File
@@ -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")