init
This commit is contained in:
commit
23c7133fa9
33 files changed
+2896
No files matched your search
@@ -0,0 +1,220 @@
|
||||
// Package ui implements the interactive terminal UI: a single bubbletea
|
||||
// Model with a small screen stack, backed by the real system/store/keymgr
|
||||
// operations — every action taken here (create user, lock, edit group
|
||||
// hosts, export) performs the actual privileged operation immediately, then
|
||||
// records it to the audit log and (for state changes) commits it to the
|
||||
// git-backed state store.
|
||||
package ui
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/charmbracelet/bubbles/list"
|
||||
"github.com/charmbracelet/bubbles/textinput"
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
"github.com/charmbracelet/lipgloss"
|
||||
|
||||
"ssh-access-tui/internal/audit"
|
||||
"ssh-access-tui/internal/config"
|
||||
"ssh-access-tui/internal/keymgr"
|
||||
"ssh-access-tui/internal/model"
|
||||
"ssh-access-tui/internal/store"
|
||||
)
|
||||
|
||||
type screen int
|
||||
|
||||
const (
|
||||
scrMenu screen = iota
|
||||
scrUsers
|
||||
scrUserNew
|
||||
scrUserNewGroups
|
||||
scrGroups
|
||||
scrGroupHosts
|
||||
scrHostNew
|
||||
scrExportSelect
|
||||
scrExportResult
|
||||
scrAudit
|
||||
)
|
||||
|
||||
var (
|
||||
titleStyle = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("212")).Padding(0, 1)
|
||||
errStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("196"))
|
||||
okStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("42"))
|
||||
helpStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("240"))
|
||||
warnStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("214"))
|
||||
borderStyle = lipgloss.NewStyle().Border(lipgloss.RoundedBorder()).BorderForeground(lipgloss.Color("240")).Padding(1, 2)
|
||||
)
|
||||
|
||||
// Model is the root bubbletea model for the whole application.
|
||||
type Model struct {
|
||||
cfg config.Config
|
||||
st *store.Store
|
||||
|
||||
screen screen
|
||||
err string
|
||||
status string
|
||||
|
||||
menu list.Model
|
||||
|
||||
usersList list.Model
|
||||
|
||||
userNewName textinput.Model
|
||||
userNewComment textinput.Model
|
||||
userNewFocus int
|
||||
userNewGroupsSel map[string]bool
|
||||
userNewGroupsOrder []string
|
||||
userNewCursor int
|
||||
|
||||
groupsList list.Model
|
||||
groupNewName textinput.Model
|
||||
awaitingGroup bool
|
||||
selectedGroup string
|
||||
hostsList list.Model
|
||||
hostForm [4]textinput.Model // name, address, port, description
|
||||
hostFormFocus int
|
||||
|
||||
exportSel map[string]bool
|
||||
exportOrder []string
|
||||
exportCursor int
|
||||
exportRes []keymgr.BuildResult
|
||||
exportPass string
|
||||
exportErr string
|
||||
|
||||
onlineSessions []audit.OnlineSession
|
||||
recentEvents []audit.Event
|
||||
|
||||
width, height int
|
||||
}
|
||||
|
||||
// New builds the initial Model rooted at the given config and store.
|
||||
//
|
||||
// All list.Model fields are initialized here via list.New (not left as the
|
||||
// zero value), even for screens the user hasn't visited yet: bubbletea
|
||||
// delivers a WindowSizeMsg immediately on startup, which fans out to
|
||||
// SetSize on every list in Update — and list.Model.SetSize on an
|
||||
// uninitialized zero-value list panics (nil pagination model).
|
||||
func New(cfg config.Config, st *store.Store) Model {
|
||||
m := Model{cfg: cfg, st: st}
|
||||
m.menu = newMenuList()
|
||||
m.usersList = emptyList("Пользователи")
|
||||
m.groupsList = emptyList("Группы доступа")
|
||||
m.hostsList = emptyList("Хосты")
|
||||
return m
|
||||
}
|
||||
|
||||
func emptyList(title string) list.Model {
|
||||
l := list.New(nil, list.NewDefaultDelegate(), 0, 0)
|
||||
l.Title = title
|
||||
l.SetShowStatusBar(false)
|
||||
return l
|
||||
}
|
||||
|
||||
func (m Model) Init() tea.Cmd { return nil }
|
||||
|
||||
func (m *Model) loadState() *model.State {
|
||||
st, err := m.st.Load()
|
||||
if err != nil {
|
||||
m.err = err.Error()
|
||||
return &model.State{}
|
||||
}
|
||||
return st
|
||||
}
|
||||
|
||||
func (m *Model) mutate(commitMsg string, fn func(st *model.State) error) {
|
||||
err := m.st.WithLock(func() error {
|
||||
st, err := m.st.Load()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := fn(st); err != nil {
|
||||
return err
|
||||
}
|
||||
return m.st.Save(st, commitMsg)
|
||||
})
|
||||
if err != nil {
|
||||
m.err = err.Error()
|
||||
} else {
|
||||
m.err = ""
|
||||
m.status = commitMsg
|
||||
}
|
||||
}
|
||||
|
||||
func (m *Model) logAudit(user, action, detail string) {
|
||||
_ = audit.Append(m.cfg.AuditLog, audit.Event{Time: time.Now(), User: user, Action: action, Detail: detail})
|
||||
}
|
||||
|
||||
func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
switch msg := msg.(type) {
|
||||
case tea.WindowSizeMsg:
|
||||
m.width, m.height = msg.Width, msg.Height
|
||||
listW, listH := msg.Width-4, msg.Height-8
|
||||
m.menu.SetSize(listW, listH)
|
||||
m.usersList.SetSize(listW, listH)
|
||||
m.groupsList.SetSize(listW, listH)
|
||||
m.hostsList.SetSize(listW, listH)
|
||||
return m, nil
|
||||
case tea.KeyMsg:
|
||||
if msg.String() == "ctrl+c" {
|
||||
return m, tea.Quit
|
||||
}
|
||||
}
|
||||
|
||||
switch m.screen {
|
||||
case scrMenu:
|
||||
return m.updateMenu(msg)
|
||||
case scrUsers:
|
||||
return m.updateUsers(msg)
|
||||
case scrUserNew:
|
||||
return m.updateUserNew(msg)
|
||||
case scrUserNewGroups:
|
||||
return m.updateUserNewGroups(msg)
|
||||
case scrGroups:
|
||||
return m.updateGroups(msg)
|
||||
case scrGroupHosts:
|
||||
return m.updateGroupHosts(msg)
|
||||
case scrHostNew:
|
||||
return m.updateHostNew(msg)
|
||||
case scrExportSelect:
|
||||
return m.updateExportSelect(msg)
|
||||
case scrExportResult:
|
||||
return m.updateExportResult(msg)
|
||||
case scrAudit:
|
||||
return m.updateAudit(msg)
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
|
||||
func (m Model) View() string {
|
||||
var body string
|
||||
switch m.screen {
|
||||
case scrMenu:
|
||||
body = m.viewMenu()
|
||||
case scrUsers:
|
||||
body = m.viewUsers()
|
||||
case scrUserNew:
|
||||
body = m.viewUserNew()
|
||||
case scrUserNewGroups:
|
||||
body = m.viewUserNewGroups()
|
||||
case scrGroups:
|
||||
body = m.viewGroups()
|
||||
case scrGroupHosts:
|
||||
body = m.viewGroupHosts()
|
||||
case scrHostNew:
|
||||
body = m.viewHostNew()
|
||||
case scrExportSelect:
|
||||
body = m.viewExportSelect()
|
||||
case scrExportResult:
|
||||
body = m.viewExportResult()
|
||||
case scrAudit:
|
||||
body = m.viewAudit()
|
||||
}
|
||||
|
||||
footer := ""
|
||||
if m.err != "" {
|
||||
footer = errStyle.Render("ошибка: " + m.err)
|
||||
} else if m.status != "" {
|
||||
footer = okStyle.Render(m.status)
|
||||
}
|
||||
return fmt.Sprintf("%s\n\n%s\n", body, footer)
|
||||
}
|
||||
Reference in new issue
Block a user