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) }