diff --git a/confs/repko.yaml b/confs/repko.yaml index 2100f38..d528539 100644 --- a/confs/repko.yaml +++ b/confs/repko.yaml @@ -93,6 +93,12 @@ sections: - "## Бла бла бла. \n lorem ipsum прочее прочее прочее прочее" - "### Бла бла бла бла. \n lorem ipsum прочее прочее прочее прочее" - "## Бла бла бла бла. \n lorem ipsum прочее прочее прочее прочее" + actions: + - name: "Test" + type: "command" + #command: "touch /etc/repko/{{.api_domain}}.txt" + command: "sleep 2" + error: "ошибка создания файла" - name: "user" description: "Настройка данных пользователя" fields: diff --git a/setup/condition.go b/setup/condition.go index edeeeaa..518ae85 100644 --- a/setup/condition.go +++ b/setup/condition.go @@ -437,26 +437,30 @@ func GetSystemUsers() []string { return users } +// ExecuteAction выполняет указанное действие с использованием шаблонов из expr и картой параметров func ExecuteAction(action *types.SectionAction, application *types.PackageApplication) error { + //Карта параметров vars := ParamsForCondition(application) switch action.Type { case "command": + //Формируем строку команды по шаблону с подставленными значениями и передаем её для выполнения cmdStr, cmdErr := utils.RenderTemplate(action.Command, vars) if cmdErr != nil { - return fmt.Errorf("ошибка создания шаблона команды: %v", cmdErr) + return fmt.Errorf("не удалось создать шаблон команды: %v", cmdErr) } return utils.RunCommand(cmdStr, action.RunAs) case "file_template": + //Формируем 2 строки пути, с подставленными значениями и передаем её для выполнения source, sourceErr := utils.RenderTemplate(action.FileSource, vars) if sourceErr != nil { - return fmt.Errorf("ошибка создания шаблона файла источника: %v", sourceErr) + return fmt.Errorf("не удалось создать шаблон файла источника: %v", sourceErr) } target, targetErr := utils.RenderTemplate(action.FileTarget, vars) if targetErr != nil { - return fmt.Errorf("ошибка создания шаблона файла цели: %v", targetErr) + return fmt.Errorf("не удалось создать шаблон файла цели: %v", targetErr) } return utils.RenderFileTemplate(source, target, vars) default: - return fmt.Errorf("неизвестный тип действия") + return fmt.Errorf("действие для выполнения '%s' не опознано", action.Type) } } diff --git a/setup/form.go b/setup/form.go index a401b24..7450a0b 100644 --- a/setup/form.go +++ b/setup/form.go @@ -107,7 +107,7 @@ func (m *FormModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { switch msg := msg.(type) { case tea.WindowSizeMsg: - //Отслеживаем и записываем новые размеры окна, обновляем рендер about + //Отслеживаем и записываем новые размеры окна, обновляем область действий m.width = msg.Width m.height = msg.Height m.initViewport(m.width, m.height) diff --git a/setup/log.go b/setup/log.go new file mode 100644 index 0000000..9723f78 --- /dev/null +++ b/setup/log.go @@ -0,0 +1,100 @@ +package setup + +import ( + "bubble/setup/ui" + "bubble/types" + "github.com/charmbracelet/bubbles/viewport" + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" + "github.com/muesli/reflow/wordwrap" + "strings" +) + +// LogModel структура описывающая модель отрисовки окна логирования +type LogModel struct { + logs []string //Массив строк логирования + viewport viewport.Model //Видимая область логов + width int //Ширина + height int //Высота +} + +// NewLogModel создает новую модель логирования +func NewLogModel(width, height int) *LogModel { + l := &LogModel{ + logs: make([]string, 0), + width: width, + height: height, + } + + if width > 0 && height > 0 { + l.initViewport(width, height) + } + return l +} + +func (m *LogModel) Init() tea.Cmd { + return nil +} + +// Update отслеживает действия логов +func (m *LogModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + var cmd tea.Cmd + switch msg := msg.(type) { + case tea.WindowSizeMsg: + //Отслеживает и записывает новый размеры окна, обновляет область действий + m.height = msg.Height + m.width = msg.Width + m.initViewport(m.width, m.height) + return m, nil + case types.ActionClearLogsMsg: + //Отслеживаем сообщение об очистке сообщений + m.logs = make([]string, 0) + m.rebuildContent(m.width) + case tea.KeyMsg: + //Передаем нажатия клавиш для обновления области действий + m.viewport, cmd = m.viewport.Update(msg) + return m, cmd + } + return m, cmd +} + +func (m *LogModel) View() string { + containerWidth, containerHeight, contentWidth, _ := ui.ContentSizes(m.width, m.height) + m.rebuildContent(contentWidth) + container := ui.AppContainer. + Width(containerWidth). + Height(containerHeight). + Render(m.viewport.View()) + + return lipgloss.Place( + m.width, m.height, + lipgloss.Center, lipgloss.Center, + container, + lipgloss.WithWhitespaceChars("&"), + ) +} + +func (m *LogModel) initViewport(width, height int) { + _, _, contentWidth, contentHeight := ui.ContentSizes(width, height) + m.viewport = viewport.New(contentWidth, contentHeight) + m.viewport.SetHorizontalStep(1) + m.rebuildContent(contentWidth) +} + +// rebuildContent обновляем содержимое логов +func (m *LogModel) rebuildContent(width int) { + var sb strings.Builder + for i, log := range m.logs { + //Строка логирования с переносом слов + logString := wordwrap.String(log, width) + //Рендер строки логов + logString = lipgloss.NewStyle().Foreground(lipgloss.Color("#fff")).Render(logString) + if i != len(m.logs)-1 { + logString += "\n\n" + } else { + logString += "\n" + } + sb.WriteString(logString) + } + m.viewport.SetContent(sb.String()) +} diff --git a/setup/main.go b/setup/main.go index d3756df..c5926dc 100644 --- a/setup/main.go +++ b/setup/main.go @@ -3,11 +3,16 @@ package setup import ( "bubble/bootstrap" "bubble/types" + "fmt" tea "github.com/charmbracelet/bubbletea" + "github.com/sirupsen/logrus" + "os/exec" ) func Start() { - p := tea.NewProgram(NewMain(), tea.WithAltScreen()) + m := NewMain() + p := tea.NewProgram(m, tea.WithAltScreen()) + m.p = p if _, err := p.Run(); err != nil { panic(err) } @@ -18,12 +23,15 @@ func Start() { type Main struct { current tea.Model //Текущая модель для отображения overlay tea.Model //Модель для отображения модальных окон + log tea.Model //Модель для вывода логов + showLog bool //Показывать ли лог Applications []types.PackageApplication //Список доступных приложений для настройки CurrentApplication *types.PackageApplication //Выбранное приложение для настройки CurrentSection *types.Section //Текущая секция CurrentSectionIdx int //Индекс текущей секции Width int //Ширина окна Height int //Высота окна + p *tea.Program } // NewMain создаем новый объект настройки @@ -36,6 +44,7 @@ func NewMain() *Main { } //Записываем в текущую модель список приложений m.current = NewListApplicationsModel(m.Applications, m.Width, m.Height) + m.log = NewLogModel(m.Width, m.Height) return m } @@ -56,6 +65,11 @@ func (m *Main) Update(msg tea.Msg) (tea.Model, tea.Cmd) { if cmd != nil { cmds = append(cmds, cmd) } + } else if m.showLog { //todo комментарии + m.log, cmd = m.log.Update(msg) + if cmd != nil { + cmds = append(cmds, cmd) + } } else { m.current, cmd = m.current.Update(msg) if cmd != nil { @@ -63,15 +77,25 @@ func (m *Main) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } } switch msg := msg.(type) { + case tea.WindowSizeMsg: //Отслеживаем и записываем новые размеры окна. //Передавать сообщение в модель текущую или модального окна для вызова их обработки изменения размеров не нужно, //они получили WindowSizeMsg выше, когда мы передаем им переменную msg. m.Width = msg.Width m.Height = msg.Height + if m.log != nil { + m.log, cmd = m.log.Update(msg) + if cmd != nil { + cmds = append(cmds, cmd) + } + } case tea.KeyMsg: //Отслеживаем нажатие клавиш приложения switch msg.String() { + case "ctrl+l": + //Обработчик клавиш ctrl+l показывает/скрывает окно логирования действий + m.showLog = !m.showLog case "ctrl+c": //Обработчик клавиш ctrl+c у приложения нужен для его закрытия. //Возвращаем команду по возврату сообщения о показе модального окна @@ -94,45 +118,86 @@ func (m *Main) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } } case types.NextSectionMsg: - // todo запуск действий //Получаем сообщение о переходе на следующую секцию настраиваемого приложения. - //Ищем индекс следующей секции, если он остался 0, то следующие секции недоступны. - //Возвращаем модальное окно с выходом к списку приложений - nextSectionIndex := 0 - for sectionIdx := m.CurrentSectionIdx + 1; sectionIdx < len(m.CurrentApplication.Sections); sectionIdx++ { - section := m.CurrentApplication.Sections[sectionIdx] - if CheckCondition(section.Condition, m.CurrentApplication) { - nextSectionIndex = sectionIdx - break + //Добавляем команду для возврата сообщения показа модального окна о выполнении действий секции + //todo добавить проверку на наличие действий, если действий нет, просто переходить к следующей секции, выдавать сообщение "Действий секции нет" + cmds = append(cmds, func() tea.Msg { + return types.ShowModalMsg{ + Type: types.ModalInfo, + Title: "Выполнение действий", + Message: "Ожидайте завершения действий секции", + ModalButtons: nil, + OnResult: nil, } - } - if nextSectionIndex == 0 { - return m, func() tea.Msg { - return types.ShowModalMsg{ - Type: types.ModalWarning, - Title: "Ошибка секций приложения", - Message: "Указанное приложение не имеет больше доступных секций для обработки", - ModalButtons: []types.ModalButton{{ - Text: "Вернутся", - Value: true, - }}, - OnResult: func(result any) tea.Cmd { - return func() tea.Msg { - return types.BackToListMsg{} - } - }, + }) + //Запуск функции выполнения действий в горутине + go m.executeSectionActions() + case types.ActionsCompleteMsg: + //Команда закрытия модального окна + cmds = append(cmds, func() tea.Msg { + return types.CloseModalMsg{} + }) + //Обновляем данные модели логирования + logModel := m.log.(*LogModel) + logModel.logs = append(logModel.logs, msg.Log...) + m.log, cmd = logModel.Update(msg) + if msg.Success { + //Если действие успешно, переходим к следующей секции. + //Ищем индекс следующей секции, если он остался 0, то следующие секции недоступны. + //Возвращаем модальное окно с выходом к списку приложений + nextSectionIndex := 0 + for sectionIdx := m.CurrentSectionIdx + 1; sectionIdx < len(m.CurrentApplication.Sections); sectionIdx++ { + section := m.CurrentApplication.Sections[sectionIdx] + if CheckCondition(section.Condition, m.CurrentApplication) { + nextSectionIndex = sectionIdx + break + } + } + if nextSectionIndex == 0 { + return m, func() tea.Msg { + return types.ShowModalMsg{ + Type: types.ModalWarning, + Title: "Ошибка секций приложения", + Message: "Указанное приложение не имеет больше доступных секций для обработки", + ModalButtons: []types.ModalButton{{ + Text: "Вернутся", + Value: true, + }}, + OnResult: func(result any) tea.Cmd { + return func() tea.Msg { + return types.BackToListMsg{} + } + }, + } } } - } - //Если следующая секция доступна записываем секцию и её индекс. - //Выводим либо финальную форму, если секция последняя и её тип SectionFinal, либо следующую простую секцию. - m.CurrentSectionIdx = nextSectionIndex - m.CurrentSection = &m.CurrentApplication.Sections[nextSectionIndex] - if nextSectionIndex == len(m.CurrentApplication.Sections)-1 && m.CurrentSection.Type == types.SectionFinal { - m.current = NewFinalModel(m.CurrentApplication.Variables, m.Width, m.Height) + //Если следующая секция доступна записываем секцию и её индекс. + //Выводим либо финальную форму, если секция последняя и её тип SectionFinal, либо следующую простую секцию. + m.CurrentSectionIdx = nextSectionIndex + m.CurrentSection = &m.CurrentApplication.Sections[nextSectionIndex] + if nextSectionIndex == len(m.CurrentApplication.Sections)-1 && m.CurrentSection.Type == types.SectionFinal { + m.current = NewFinalModel(m.CurrentApplication.Variables, m.Width, m.Height) + } else { + m.current = NewFormModel(m.CurrentApplication, m.CurrentSectionIdx, 0, m.Width, m.Height) + } } else { - m.current = NewFormModel(m.CurrentApplication, m.CurrentSectionIdx, 0, m.Width, m.Height) + //Если действие не удалось выполнить показываем модель логов принудительно, + //также возвращаем команду возврата сообщения показа модального окна + m.showLog = true + cmds = append(cmds, func() tea.Msg { + return types.ShowModalMsg{ + Type: types.ModalError, + Title: "Ошибка выполнения действий", + Message: "Ошибка выполнения действий секций, смотри логи", + ModalButtons: []types.ModalButton{ + {Text: "OK", Value: true}, + }, + OnResult: func(result any) tea.Cmd { + return nil + }, + } + }) } case types.SugarMsg: m.Sugar(msg) @@ -169,8 +234,9 @@ func (m *Main) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.current = NewFormModel(m.CurrentApplication, m.CurrentSectionIdx, 0, m.Width, m.Height) case types.BackToListMsg: //Получаем сообщение о возврате к списку приложений. - //Стираем текущие приложение, стираем текущую секцию, стираем индекс секции, записываем в текущую модель список приложений + //Стираем текущие приложение, стираем текущую секцию, стираем индекс секции, обновляем логирование действий, записываем в текущую модель список приложений m.CurrentApplication = nil + m.log, cmd = m.log.Update(types.ActionClearLogsMsg{}) m.CurrentSection = nil m.CurrentSectionIdx = 0 m.current = NewListApplicationsModel(m.Applications, m.Width, m.Height) @@ -195,6 +261,9 @@ func (m *Main) Update(msg tea.Msg) (tea.Model, tea.Cmd) { // View формирует строку на основе модального окна или текущей модели func (m *Main) View() string { view := m.current.View() + if m.showLog { + view = m.log.View() + } if m.overlay != nil { view = m.overlay.View() } @@ -261,3 +330,60 @@ func (m *Main) DeactivateField(variableName, fieldName string) { } } } + +// executeSectionActions запускает выполнение действий текущей секции +func (m *Main) executeSectionActions() { + var logBuffer []string + success := true + sectionLog := fmt.Sprintf("Выполнение действий секции %s, количество действий %d", m.CurrentSection.Name, len(m.CurrentSection.Actions)) + logrus.Infof(sectionLog) + logBuffer = append(logBuffer, sectionLog) + for _, action := range m.CurrentSection.Actions { + //Если условие для действия не выполняется, то пропускаем + if !CheckCondition(action.Condition, m.CurrentApplication) { + return + } + sectionActionLog := fmt.Sprintf("Выполнение действия: %s", action.Name) + logrus.Infof(sectionActionLog) + logBuffer = append(logBuffer, sectionActionLog) + //Проверяем, что все нужные пакеты для действия установлены + if err := m.InstallPackage(action.RequirePackages); err != nil { + errLog := fmt.Sprintf("Ошибка установки пакета: %v", err) + logrus.Errorf(errLog) + logBuffer = append(logBuffer, errLog) + success = false + break + } + //Выполняем действие + if err := ExecuteAction(&action, m.CurrentApplication); err != nil { + errLog := fmt.Sprintf("Ошибка выполнения действия: %v", err) + logrus.Errorf(errLog) + logBuffer = append(logBuffer, errLog) + success = false + break + } + successMsg := fmt.Sprintf("Действие %s успешно выполнено", action.Name) + logBuffer = append(logBuffer, successMsg) + logrus.Infof(successMsg) + } + //Отправляем в программу сигнал об успешном завершении действия + m.p.Send(types.ActionsCompleteMsg{ + Success: success, + Log: logBuffer, + }) +} + +// InstallPackage проверяет установлен ли пакет, устанавливает при отсутствии +func (m *Main) InstallPackage(packages []string) error { + for _, pkg := range packages { + cmd := exec.Command("dpkg", "-s", pkg) + if err := cmd.Run(); err != nil { + installCmd := exec.Command("apt", "install", "-y", pkg) + out, err := installCmd.CombinedOutput() + if err != nil { + return fmt.Errorf("пакет %s: %v\nOutput: %s", pkg, err, out) + } + } + } + return nil +} diff --git a/setup/utils/utils.go b/setup/utils/utils.go index d8a7c49..f83f062 100644 --- a/setup/utils/utils.go +++ b/setup/utils/utils.go @@ -44,12 +44,14 @@ func CalculateSelectOptionsIndex(focus, visibleCount, optionsCount int) (int, in return start, end } +// RenderFileTemplate создает целевой файл на основе файла источника с подставленными значениями в нем func RenderFileTemplate(source, target string, vars map[string]interface{}) error { if source == "" || target == "" { return fmt.Errorf("source или target пустые") } var data []byte var err error + //Получаем данные файла источника if strings.HasPrefix(source, "http") { resp, err := http.Get(source) if err != nil { @@ -63,11 +65,15 @@ func RenderFileTemplate(source, target string, vars map[string]interface{}) erro if err != nil { return fmt.Errorf("ошибка чтения данных файла %s, err: %v", source, err) } + + //Формируем файл с переменными tmpl, err := template.New("").Parse(string(data)) if err != nil { return fmt.Errorf("ошибка формирования шаблона строки: %v", err) } var rendered bytes.Buffer + + //В файл помещаем переменные if err = tmpl.Execute(&rendered, vars); err != nil { return fmt.Errorf("ошибка подстановки значений в шаблон") } @@ -79,6 +85,7 @@ func RenderFileTemplate(source, target string, vars map[string]interface{}) erro logrus.Infof("Бекап %s создан", backup) } } + //Создаем папку для целевого файла и записываем в него данные if err = os.MkdirAll(filepath.Dir(target), 0755); err != nil { return fmt.Errorf("ошибка создания директории для файла %s, err: %v", target, err) } @@ -88,6 +95,7 @@ func RenderFileTemplate(source, target string, vars map[string]interface{}) erro return nil } +// RenderTemplate формирует строку с подставленными параметрами func RenderTemplate(tmpl string, data map[string]interface{}) (string, error) { if tmpl == "" { return "", nil @@ -103,6 +111,7 @@ func RenderTemplate(tmpl string, data map[string]interface{}) (string, error) { return buf.String(), nil } +// RunCommand запускает выполнение переданной команды от указанного лица func RunCommand(cmdStr, runAs string) error { if cmdStr == "" { return nil @@ -111,7 +120,7 @@ func RunCommand(cmdStr, runAs string) error { if runAs != "" && runAs != "root" { cmd = exec.Command("su", "-c", cmdStr, runAs) } else { - cmd = exec.Command("bach", "-c", cmdStr) + cmd = exec.Command("bash", "-c", cmdStr) } output, err := cmd.CombinedOutput() if err != nil { diff --git a/types/msg.go b/types/msg.go index 72748e8..7f4a274 100644 --- a/types/msg.go +++ b/types/msg.go @@ -42,3 +42,17 @@ type CloseModalMsg struct{} type ViewportScrollMsg struct { Direction string //Направление прокрутки } + +type ActionProgressMsg struct { + ActionName string + Status string + Message string +} + +type ActionsCompleteMsg struct { + Success bool + Log []string +} + +type ActionClearLogsMsg struct { +}