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

368 lines
12 KiB
Go

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"
)
// validNameErr renders a consistent error for group/host/service names that
// fail model.ValidName — these names end up as ssh_config/authorized_keys
// tokens, where spaces or "#" would corrupt the generated file.
func validNameErr(kind, name string) string {
return fmt.Sprintf("%s %q: имя не должно содержать пробелов и спецсимволов (разрешены латинские буквы, цифры, точка, подчёркивание, дефис)", kind, name)
}
// --- catalog: hosts ---
func (m *Model) refreshCatalogHostsList() {
st := m.loadState()
items := make([]list.Item, 0, len(st.Hosts))
for _, h := range st.Hosts {
desc := fmt.Sprintf("%s — %d сервисов", h.Address, len(h.Services))
if h.Description != "" {
desc += " (" + h.Description + ")"
}
items = append(items, simpleItem{id: h.Name, title: h.Name, desc: desc})
}
m.catalogHostsList = newList(items,
"Хосты и сервисы [n]новый [e]изменить [enter]сервисы [d]удалить [esc]назад",
m.catalogHostsList.Width(), m.catalogHostsList.Height())
}
func (m Model) updateCatalogHosts(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.hostFormEditTarget = ""
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 = "описание (необязательно)"
m.hostFormFocus = 0
m.hostForm[0].Focus()
m.err = ""
m.screen = scrHostForm
return m, nil
case "e":
sel, ok := m.catalogHostsList.SelectedItem().(simpleItem)
if !ok {
return m, nil
}
st := m.loadState()
h := st.FindHost(sel.id)
if h == nil {
return m, nil
}
m.hostFormEditTarget = h.Name
for i := range m.hostForm {
m.hostForm[i] = textinput.New()
}
m.hostForm[0].SetValue(h.Name)
m.hostForm[1].SetValue(h.Address)
m.hostForm[2].SetValue(h.Description)
m.hostFormFocus = 0
m.hostForm[0].Focus()
m.err = ""
m.screen = scrHostForm
return m, nil
case "enter":
sel, ok := m.catalogHostsList.SelectedItem().(simpleItem)
if !ok {
return m, nil
}
m.selectedHost = sel.id
m.screen = scrCatalogServices
m.refreshCatalogServicesList()
return m, nil
case "d":
sel, ok := m.catalogHostsList.SelectedItem().(simpleItem)
if !ok {
return m, nil
}
st := m.loadState()
affected := st.UsernamesUsingHost(sel.id)
groups := st.GroupNamesReferencingHost(sel.id)
msg := fmt.Sprintf(
"Удалить хост %q из каталога?\n\n"+
"Он используется в группах: %v.\n"+
"Это затронет пользователей: %d — они немедленно потеряют доступ ко всем\n"+
"сервисам этого хоста.",
sel.id, groups, len(affected))
m = m.startConfirm(confirmDeleteHost, sel.id, msg, scrCatalogHosts)
return m, nil
}
}
var cmd tea.Cmd
m.catalogHostsList, cmd = m.catalogHostsList.Update(msg)
return m, cmd
}
func (m Model) viewCatalogHosts() string {
return borderStyle.Render(m.catalogHostsList.View())
}
func (m Model) updateHostForm(msg tea.Msg) (tea.Model, tea.Cmd) {
if key, ok := msg.(tea.KeyMsg); ok {
switch key.String() {
case "esc":
m.screen = scrCatalogHosts
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()
desc := m.hostForm[2].Value()
if name == "" || addr == "" {
m.err = "имя и адрес обязательны"
return m, nil
}
if !model.ValidName(name) {
m.err = validNameErr("хост", name)
return m, nil
}
target := m.hostFormEditTarget
m.err = ""
if target == "" {
m.mutate("добавлен хост "+name, func(st *model.State) error {
if st.FindHost(name) != nil {
return fmt.Errorf("хост %q уже существует", name)
}
st.Hosts = append(st.Hosts, model.Host{Name: name, Address: addr, Description: desc})
return nil
})
} else {
m.mutate("изменён хост "+target, func(st *model.State) error {
if name != target && st.FindHost(name) != nil {
return fmt.Errorf("хост %q уже существует", name)
}
if name != target {
st.RenameHost(target, name)
}
h := st.FindHost(name)
if h == nil {
return fmt.Errorf("host %s not found", name)
}
h.Address = addr
h.Description = desc
return resyncHostUsers(st, name)
})
}
m.selectedHost = name
m.screen = scrCatalogHosts
m.refreshCatalogHostsList()
return m, nil
}
}
var cmd tea.Cmd
m.hostForm[m.hostFormFocus], cmd = m.hostForm[m.hostFormFocus].Update(msg)
return m, cmd
}
func (m Model) viewHostForm() string {
title := "Новый хост в каталоге"
if m.hostFormEditTarget != "" {
title = fmt.Sprintf("Изменить хост %q", m.hostFormEditTarget)
}
body := fmt.Sprintf("%s\n\nИмя: %s\nАдрес: %s\nОписание: %s\n\n[tab] след. поле [enter] сохранить [esc] отмена",
title, m.hostForm[0].View(), m.hostForm[1].View(), m.hostForm[2].View())
return borderStyle.Render(body)
}
// --- catalog: services within the selected host ---
func (m *Model) refreshCatalogServicesList() {
st := m.loadState()
h := st.FindHost(m.selectedHost)
var items []list.Item
if h != nil {
for _, svc := range h.Services {
items = append(items, simpleItem{
id: svc.Name,
title: fmt.Sprintf("%s (порт %d)", svc.Name, svc.Port),
desc: svc.Description,
})
}
}
m.catalogServicesList = newList(items,
fmt.Sprintf("Сервисы хоста %q [n]добавить [e]изменить [d]удалить [esc]назад", m.selectedHost),
m.catalogServicesList.Width(), m.catalogServicesList.Height())
}
func (m Model) updateCatalogServices(msg tea.Msg) (tea.Model, tea.Cmd) {
if key, ok := msg.(tea.KeyMsg); ok {
switch key.String() {
case "esc":
m.screen = scrCatalogHosts
m.refreshCatalogHostsList()
return m, nil
case "n":
m.serviceFormEditTarget = ""
for i := range m.serviceForm {
m.serviceForm[i] = textinput.New()
}
m.serviceForm[0].Placeholder = "имя сервиса, например ssh или web-api (без пробелов)"
m.serviceForm[1].Placeholder = "порт на хосте, например 22 или 7888"
m.serviceForm[2].Placeholder = "описание (необязательно)"
m.serviceFormFocus = 0
m.serviceForm[0].Focus()
m.err = ""
m.screen = scrServiceForm
return m, nil
case "e":
sel, ok := m.catalogServicesList.SelectedItem().(simpleItem)
if !ok {
return m, nil
}
st := m.loadState()
h := st.FindHost(m.selectedHost)
if h == nil {
return m, nil
}
var target *model.Service
for i := range h.Services {
if h.Services[i].Name == sel.id {
target = &h.Services[i]
break
}
}
if target == nil {
return m, nil
}
m.serviceFormEditTarget = target.Name
for i := range m.serviceForm {
m.serviceForm[i] = textinput.New()
}
m.serviceForm[0].SetValue(target.Name)
m.serviceForm[1].SetValue(strconv.Itoa(target.Port))
m.serviceForm[2].SetValue(target.Description)
m.serviceFormFocus = 0
m.serviceForm[0].Focus()
m.err = ""
m.screen = scrServiceForm
return m, nil
case "d":
sel, ok := m.catalogServicesList.SelectedItem().(simpleItem)
if !ok {
return m, nil
}
st := m.loadState()
affected := st.UsernamesUsingService(m.selectedHost, sel.id)
groups := st.GroupNamesReferencingService(m.selectedHost, sel.id)
msg := fmt.Sprintf(
"Удалить сервис %q у хоста %q?\n\n"+
"Он используется в группах: %v.\n"+
"Это затронет пользователей: %d — они немедленно потеряют доступ к этому\n"+
"сервису.",
sel.id, m.selectedHost, groups, len(affected))
m = m.startConfirm(confirmDeleteService, sel.id, msg, scrCatalogServices)
return m, nil
}
}
var cmd tea.Cmd
m.catalogServicesList, cmd = m.catalogServicesList.Update(msg)
return m, cmd
}
func (m Model) viewCatalogServices() string {
return borderStyle.Render(m.catalogServicesList.View())
}
func (m Model) updateServiceForm(msg tea.Msg) (tea.Model, tea.Cmd) {
if key, ok := msg.(tea.KeyMsg); ok {
switch key.String() {
case "esc":
m.screen = scrCatalogServices
return m, nil
case "tab", "down":
m.serviceForm[m.serviceFormFocus].Blur()
m.serviceFormFocus = (m.serviceFormFocus + 1) % len(m.serviceForm)
m.serviceForm[m.serviceFormFocus].Focus()
return m, nil
case "enter":
name := m.serviceForm[0].Value()
portStr := m.serviceForm[1].Value()
desc := m.serviceForm[2].Value()
if name == "" || portStr == "" {
m.err = "имя и порт обязательны"
return m, nil
}
if !model.ValidName(name) {
m.err = validNameErr("сервис", name)
return m, nil
}
port, err := strconv.Atoi(portStr)
if err != nil || port <= 0 || port > 65535 {
m.err = "порт должен быть числом от 1 до 65535"
return m, nil
}
hostName := m.selectedHost
target := m.serviceFormEditTarget
m.err = ""
m.mutate(fmt.Sprintf("сохранён сервис %s хоста %s", name, hostName), func(st *model.State) error {
h := st.FindHost(hostName)
if h == nil {
return fmt.Errorf("host %s not found", hostName)
}
if target == "" {
for _, svc := range h.Services {
if svc.Name == name {
return fmt.Errorf("сервис %q уже существует на этом хосте", name)
}
}
h.Services = append(h.Services, model.Service{Name: name, Port: port, Description: desc})
} else {
if name != target {
for _, svc := range h.Services {
if svc.Name == name {
return fmt.Errorf("сервис %q уже существует на этом хосте", name)
}
}
st.RenameServiceRefs(hostName, target, name)
}
for i := range h.Services {
if h.Services[i].Name == target {
h.Services[i].Name = name
h.Services[i].Port = port
h.Services[i].Description = desc
break
}
}
}
return resyncHostUsers(st, hostName)
})
m.screen = scrCatalogServices
m.refreshCatalogServicesList()
return m, nil
}
}
var cmd tea.Cmd
m.serviceForm[m.serviceFormFocus], cmd = m.serviceForm[m.serviceFormFocus].Update(msg)
return m, cmd
}
func (m Model) viewServiceForm() string {
title := fmt.Sprintf("Новый сервис на хосте %q", m.selectedHost)
if m.serviceFormEditTarget != "" {
title = fmt.Sprintf("Изменить сервис %q на хосте %q", m.serviceFormEditTarget, m.selectedHost)
}
body := fmt.Sprintf("%s\n\nИмя: %s\nПорт: %s\nОписание: %s\n\n[tab] след. поле [enter] сохранить [esc] отмена",
title, m.serviceForm[0].View(), m.serviceForm[1].View(), m.serviceForm[2].View())
return borderStyle.Render(body)
}