Files
2026-07-22 17:13:35 +03:00

313 lines
8.9 KiB
Go

package ui
import (
"fmt"
"strings"
"github.com/charmbracelet/bubbles/list"
"github.com/charmbracelet/bubbles/textinput"
tea "github.com/charmbracelet/bubbletea"
"ssh-access-tui/internal/model"
)
func (m *Model) refreshGroupsList() {
st := m.loadState()
items := make([]list.Item, 0, len(st.Groups))
for _, g := range st.Groups {
hosts := map[string]bool{}
for _, ref := range g.Services {
hosts[ref.Host] = true
}
desc := fmt.Sprintf("%d сервисов на %d хостах", len(g.Services), len(hosts))
if g.Description != "" {
desc += " — " + g.Description
}
items = append(items, simpleItem{id: g.Name, title: g.Name, desc: desc})
}
m.groupsList = newList(items,
"Группы [n]новая [e]изменить [enter]сервисы группы [d]удалить [esc]назад",
m.groupsList.Width(), m.groupsList.Height())
}
func (m Model) updateGroups(msg tea.Msg) (tea.Model, tea.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.groupFormEditTarget = ""
for i := range m.groupForm {
m.groupForm[i] = textinput.New()
}
m.groupForm[0].Placeholder = "имя группы, например team-billing"
m.groupForm[1].Placeholder = "описание (необязательно)"
m.groupFormFocus = 0
m.groupForm[0].Focus()
m.err = ""
m.screen = scrGroupForm
return m, nil
case "e":
sel, ok := m.groupsList.SelectedItem().(simpleItem)
if !ok {
return m, nil
}
st := m.loadState()
g := st.FindGroup(sel.id)
if g == nil {
return m, nil
}
m.groupFormEditTarget = g.Name
for i := range m.groupForm {
m.groupForm[i] = textinput.New()
}
m.groupForm[0].SetValue(g.Name)
m.groupForm[1].SetValue(g.Description)
m.groupFormFocus = 0
m.groupForm[0].Focus()
m.err = ""
m.screen = scrGroupForm
return m, nil
case "enter":
sel, ok := m.groupsList.SelectedItem().(simpleItem)
if !ok {
return m, nil
}
m.selectedGroup = sel.id
st := m.loadState()
g := st.FindGroup(sel.id)
m.groupPickSel = map[string]bool{}
if g != nil {
for _, ref := range g.Services {
m.groupPickSel[ref.Host+"/"+ref.Service] = true
}
}
m.groupPickRows = nil
m.groupPickServices = map[string][]string{}
for _, h := range st.Hosts {
m.groupPickRows = append(m.groupPickRows, pickRow{isHeader: true, host: h.Name})
var svcNames []string
for _, svc := range h.Services {
m.groupPickRows = append(m.groupPickRows, pickRow{host: h.Name, service: svc.Name})
svcNames = append(svcNames, svc.Name)
}
m.groupPickServices[h.Name] = svcNames
}
m.groupPickCursor = 0
m.screen = scrGroupHostsPick
return m, nil
case "d":
sel, ok := m.groupsList.SelectedItem().(simpleItem)
if !ok {
return m, nil
}
st := m.loadState()
affected := st.UsernamesInGroup(sel.id)
msg := fmt.Sprintf(
"Удалить группу %q?\n\n"+
"Это затронет пользователей: %d — группа будет снята с них, и они\n"+
"немедленно потеряют доступ ко всем сервисам, которые давала только эта\n"+
"группа.",
sel.id, len(affected))
m = m.startConfirm(confirmDeleteGroup, sel.id, msg, scrGroups)
return m, nil
}
}
var cmd tea.Cmd
m.groupsList, cmd = m.groupsList.Update(msg)
return m, cmd
}
func (m Model) viewGroups() string {
return borderStyle.Render(m.groupsList.View())
}
func (m Model) updateGroupForm(msg tea.Msg) (tea.Model, tea.Cmd) {
if key, ok := msg.(tea.KeyMsg); ok {
switch key.String() {
case "esc":
m.screen = scrGroups
return m, nil
case "tab", "down":
m.groupForm[m.groupFormFocus].Blur()
m.groupFormFocus = (m.groupFormFocus + 1) % len(m.groupForm)
m.groupForm[m.groupFormFocus].Focus()
return m, nil
case "enter":
name := m.groupForm[0].Value()
desc := m.groupForm[1].Value()
if name == "" {
m.err = "имя группы не может быть пустым"
return m, nil
}
if !model.ValidName(name) {
m.err = validNameErr("группа", name)
return m, nil
}
target := m.groupFormEditTarget
m.err = ""
if target == "" {
m.mutate("создана группа "+name, func(st *model.State) error {
if st.FindGroup(name) != nil {
return fmt.Errorf("группа %q уже существует", name)
}
st.Groups = append(st.Groups, model.Group{Name: name, Description: desc})
return nil
})
} else {
m.mutate("изменена группа "+target, func(st *model.State) error {
if name != target && st.FindGroup(name) != nil {
return fmt.Errorf("группа %q уже существует", name)
}
if name != target {
st.RenameGroup(target, name)
}
g := st.FindGroup(name)
if g == nil {
return fmt.Errorf("group %s not found", name)
}
g.Description = desc
return nil
})
}
m.screen = scrGroups
m.refreshGroupsList()
return m, nil
}
}
var cmd tea.Cmd
m.groupForm[m.groupFormFocus], cmd = m.groupForm[m.groupFormFocus].Update(msg)
return m, cmd
}
func (m Model) viewGroupForm() string {
title := "Новая группа"
if m.groupFormEditTarget != "" {
title = fmt.Sprintf("Изменить группу %q", m.groupFormEditTarget)
}
body := fmt.Sprintf("%s\n\nИмя: %s\nОписание: %s\n\n[tab] след. поле [enter] сохранить [esc] отмена",
title, m.groupForm[0].View(), m.groupForm[1].View())
return borderStyle.Render(body)
}
// --- pick which catalog services belong to the selected group, shown as a
// host -> services tree: a host row toggles all of its services at once,
// a service row toggles just that one, so access is granted per-service
// (not blanket per-host) while still being quick to pick a whole host. ---
// pickRow is one line of the host/service picker tree: either a host
// header (isHeader, service == "") or a service leaf under that host.
type pickRow struct {
isHeader bool
host string
service string
}
func (m Model) updateGroupHostsPick(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 "up", "k":
if m.groupPickCursor > 0 {
m.groupPickCursor--
}
return m, nil
case "down", "j":
if m.groupPickCursor < len(m.groupPickRows)-1 {
m.groupPickCursor++
}
return m, nil
case " ":
if len(m.groupPickRows) == 0 {
return m, nil
}
row := m.groupPickRows[m.groupPickCursor]
if row.isHeader {
allSelected := true
for _, svc := range m.groupPickServices[row.host] {
if !m.groupPickSel[row.host+"/"+svc] {
allSelected = false
break
}
}
for _, svc := range m.groupPickServices[row.host] {
m.groupPickSel[row.host+"/"+svc] = !allSelected
}
} else {
key := row.host + "/" + row.service
m.groupPickSel[key] = !m.groupPickSel[key]
}
return m, nil
case "enter":
var refs []model.ServiceRef
for key, on := range m.groupPickSel {
if !on {
continue
}
host, svc, found := strings.Cut(key, "/")
if !found {
continue
}
refs = append(refs, model.ServiceRef{Host: host, Service: svc})
}
groupName := m.selectedGroup
m.mutate("изменён список сервисов группы "+groupName, func(st *model.State) error {
g := st.FindGroup(groupName)
if g == nil {
return fmt.Errorf("group %s not found", groupName)
}
g.Services = refs
return resyncGroupUsers(st, groupName)
})
m.screen = scrGroups
m.refreshGroupsList()
return m, nil
}
}
return m, nil
}
func (m Model) viewGroupHostsPick() string {
body := fmt.Sprintf("Какие сервисы каталога входят в группу %q\n\n", m.selectedGroup)
if len(m.groupPickRows) == 0 {
body += warnStyle.Render("В каталоге ещё нет хостов — сначала добавьте их в разделе «Хосты и сервисы».") + "\n"
}
for i, row := range m.groupPickRows {
cursor := " "
if i == m.groupPickCursor {
cursor = "> "
}
if row.isHeader {
svcs := m.groupPickServices[row.host]
selected := 0
for _, svc := range svcs {
if m.groupPickSel[row.host+"/"+svc] {
selected++
}
}
check := "[ ]"
switch {
case len(svcs) > 0 && selected == len(svcs):
check = "[x]"
case selected > 0:
check = "[~]"
}
body += fmt.Sprintf("%s%s %s\n", cursor, check, row.host)
} else {
check := "[ ]"
if m.groupPickSel[row.host+"/"+row.service] {
check = "[x]"
}
body += fmt.Sprintf("%s %s %s\n", cursor, check, row.service)
}
}
body += "\n[space] отметить (на хосте — все его сервисы) [enter] сохранить [esc] назад"
return borderStyle.Render(body)
}