This commit is contained in:
redlineo committed 2026-07-22 14:14:53 +03:00
commit 23c7133fa9
33 files changed
+2896

No files matched your search

+251
View File
@@ -0,0 +1,251 @@
package ui
import (
"fmt"
"strconv"
"github.com/charmbracelet/bubbles/list"
"github.com/charmbracelet/bubbles/textinput"
tea "github.com/charmbracelet/bubbletea"
"ssh-access-tui/internal/model"
"ssh-access-tui/internal/sysadm"
)
func (m *Model) refreshGroupsList() {
st := m.loadState()
items := make([]list.Item, 0, len(st.Groups))
for _, g := range st.Groups {
items = append(items, simpleItem{id: g.Name, title: g.Name, desc: fmt.Sprintf("%d хостов", len(g.Hosts))})
}
l := list.New(items, list.NewDefaultDelegate(), m.groupsList.Width(), m.groupsList.Height())
l.Title = "Группы доступа [n]новая группа [enter]хосты группы [esc]назад"
l.SetShowStatusBar(false)
m.groupsList = l
m.awaitingGroup = false
}
// contains a small state flag reused from Model: awaitingGroup + groupNewName input
func (m Model) updateGroups(msg tea.Msg) (tea.Model, tea.Cmd) {
if m.awaitingGroup {
if key, ok := msg.(tea.KeyMsg); ok {
switch key.String() {
case "esc":
m.awaitingGroup = false
return m, nil
case "enter":
name := m.groupNewName.Value()
if name == "" {
m.err = "имя группы не может быть пустым"
return m, nil
}
st := m.loadState()
if st.FindGroup(name) != nil {
m.err = fmt.Sprintf("группа %q уже существует", name)
return m, nil
}
m.mutate("создана группа "+name, func(st *model.State) error {
st.Groups = append(st.Groups, model.Group{Name: name})
return nil
})
m.awaitingGroup = false
m.refreshGroupsList()
return m, nil
}
}
var cmd tea.Cmd
m.groupNewName, cmd = m.groupNewName.Update(msg)
return m, cmd
}
if key, ok := msg.(tea.KeyMsg); ok {
switch key.String() {
case "esc":
m.screen = scrMenu
m.err, m.status = "", ""
return m, nil
case "n":
m.awaitingGroup = true
m.groupNewName = textinput.New()
m.groupNewName.Placeholder = "имя группы, например team-billing"
m.groupNewName.Focus()
m.err = ""
return m, nil
case "enter":
sel, ok := m.groupsList.SelectedItem().(simpleItem)
if !ok {
return m, nil
}
m.selectedGroup = sel.id
m.screen = scrGroupHosts
m.refreshHostsList()
return m, nil
}
}
var cmd tea.Cmd
m.groupsList, cmd = m.groupsList.Update(msg)
return m, cmd
}
func (m Model) viewGroups() string {
if m.awaitingGroup {
return borderStyle.Render("Новая группа:\n\n" + m.groupNewName.View() + "\n\n[enter] создать [esc] отмена")
}
return borderStyle.Render(m.groupsList.View())
}
// --- hosts within a group ---
func (m *Model) refreshHostsList() {
st := m.loadState()
g := st.FindGroup(m.selectedGroup)
var items []list.Item
if g != nil {
for _, h := range g.Hosts {
items = append(items, simpleItem{
id: h.Name,
title: h.Name,
desc: fmt.Sprintf("%s:%d %s", h.Address, h.Port, h.Description),
})
}
}
l := list.New(items, list.NewDefaultDelegate(), m.hostsList.Width(), m.hostsList.Height())
l.Title = fmt.Sprintf("Хосты группы %q [n]добавить [d]удалить [esc]назад", m.selectedGroup)
l.SetShowStatusBar(false)
m.hostsList = l
}
// resyncGroupUsers rewrites authorized_keys permitopen= restrictions for
// every active user whose groups include groupName, reflecting the group's
// current host list — the only place server-side enforcement is updated
// when the allowed-host list changes over time.
func resyncGroupUsers(st *model.State, groupName string) error {
for i := range st.Users {
u := &st.Users[i]
if u.PublicKey == "" || u.Status != model.StatusActive {
continue
}
inGroup := false
for _, g := range u.Groups {
if g == groupName {
inGroup = true
break
}
}
if !inGroup {
continue
}
if err := sysadm.WriteAuthorizedKeys(u.Username, u.PublicKey, st.AllowedHosts(u)); err != nil {
return fmt.Errorf("resync %s: %w", u.Username, err)
}
}
return nil
}
func (m Model) updateGroupHosts(msg tea.Msg) (tea.Model, tea.Cmd) {
if key, ok := msg.(tea.KeyMsg); ok {
switch key.String() {
case "esc":
m.screen = scrGroups
m.refreshGroupsList()
return m, nil
case "n":
m.screen = scrHostNew
for i := range m.hostForm {
m.hostForm[i] = textinput.New()
}
m.hostForm[0].Placeholder = "короткое имя, например db-prod-1"
m.hostForm[1].Placeholder = "адрес (hostname или IP), как виден с jump-хоста"
m.hostForm[2].Placeholder = "порт, например 5432"
m.hostForm[3].Placeholder = "описание (необязательно)"
m.hostFormFocus = 0
m.hostForm[0].Focus()
return m, nil
case "d":
sel, ok := m.hostsList.SelectedItem().(simpleItem)
if !ok {
return m, nil
}
groupName := m.selectedGroup
m.mutate(fmt.Sprintf("удалён хост %s из группы %s", sel.id, groupName), func(st *model.State) error {
g := st.FindGroup(groupName)
if g == nil {
return fmt.Errorf("group %s not found", groupName)
}
for i := range g.Hosts {
if g.Hosts[i].Name == sel.id {
g.Hosts = append(g.Hosts[:i], g.Hosts[i+1:]...)
break
}
}
return resyncGroupUsers(st, groupName)
})
m.refreshHostsList()
return m, nil
}
}
var cmd tea.Cmd
m.hostsList, cmd = m.hostsList.Update(msg)
return m, cmd
}
func (m Model) viewGroupHosts() string {
return borderStyle.Render(m.hostsList.View())
}
func (m Model) updateHostNew(msg tea.Msg) (tea.Model, tea.Cmd) {
if key, ok := msg.(tea.KeyMsg); ok {
switch key.String() {
case "esc":
m.screen = scrGroupHosts
return m, nil
case "tab", "down":
m.hostForm[m.hostFormFocus].Blur()
m.hostFormFocus = (m.hostFormFocus + 1) % len(m.hostForm)
m.hostForm[m.hostFormFocus].Focus()
return m, nil
case "enter":
name := m.hostForm[0].Value()
addr := m.hostForm[1].Value()
portStr := m.hostForm[2].Value()
desc := m.hostForm[3].Value()
if name == "" || addr == "" || portStr == "" {
m.err = "имя, адрес и порт обязательны"
return m, nil
}
port, err := strconv.Atoi(portStr)
if err != nil || port <= 0 || port > 65535 {
m.err = "порт должен быть числом от 1 до 65535"
return m, nil
}
groupName := m.selectedGroup
m.err = ""
m.mutate(fmt.Sprintf("добавлен хост %s в группу %s", name, groupName), func(st *model.State) error {
g := st.FindGroup(groupName)
if g == nil {
return fmt.Errorf("group %s not found", groupName)
}
for _, h := range g.Hosts {
if h.Name == name {
return fmt.Errorf("host %q already exists in group %q", name, groupName)
}
}
g.Hosts = append(g.Hosts, model.Host{Name: name, Address: addr, Port: port, Description: desc})
return resyncGroupUsers(st, groupName)
})
m.screen = scrGroupHosts
m.refreshHostsList()
return m, nil
}
}
var cmd tea.Cmd
m.hostForm[m.hostFormFocus], cmd = m.hostForm[m.hostFormFocus].Update(msg)
return m, cmd
}
func (m Model) viewHostNew() string {
body := fmt.Sprintf("Новый хост в группе %q\n\nИмя: %s\nАдрес: %s\nПорт: %s\nОписание: %s\n\n[tab] след. поле [enter] сохранить [esc] отмена",
m.selectedGroup, m.hostForm[0].View(), m.hostForm[1].View(), m.hostForm[2].View(), m.hostForm[3].View())
return borderStyle.Render(body)
}