package main import ( "bytes" "encoding/json" "fmt" "os/exec" "sort" "strings" "time" "github.com/charmbracelet/bubbles/table" "github.com/charmbracelet/bubbles/textinput" "github.com/charmbracelet/bubbles/viewport" tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" ) // ============================================================================= // dockerview — live TUI для Docker-контейнеров (аналог lazydocker) // ============================================================================= // // В отличие от dockercheck.go (-d/-dc, снапшот + диффы состояния для cron), // этот модуль — интерактивный, с автообновлением и прямыми действиями: // stop/start/restart/remove, просмотр логов (docker logs -f) и подключение // к шеллу контейнера (docker exec -it), плюс просмотр docker inspect. // // Переиспользует dcNormalizeStatus/dcGetHealth/dcStatusEmoji из dockercheck.go // (тот же пакет main) — не дублируем парсинг статуса. // ============================================================================= // ── Типы ───────────────────────────────────────────────────────────────────── type dvContainerEntry struct { ID string Name string Image string Status string Health string Ports string } type dvLoadedMsg struct { entries []dvContainerEntry err error } type dvAutoTickMsg struct{} type dvActionResultMsg struct { action string target string output string err error } type dvInspectMsg struct { content string err error } type dvExecFinishedMsg struct { action string err error } type dvModel struct { table table.Model textInput textinput.Model viewport viewport.Model entries []dvContainerEntry filtered []dvContainerEntry err error status string width, height int isFiltering bool loading bool autoRefresh bool lastRefresh time.Time showInspect bool loadingInspect bool confirmAction string // "stop" | "start" | "restart" | "remove" confirmTarget dvContainerEntry } // ── Стили ──────────────────────────────────────────────────────────────────── var ( dvBaseStyle = lipgloss.NewStyle(). BorderStyle(lipgloss.RoundedBorder()). BorderForeground(lipgloss.Color("#6c7086")). Padding(1, 2) dvLogoStyle = lipgloss.NewStyle(). Bold(true). Foreground(lipgloss.Color("#89b4fa")). Padding(0, 1) dvHelpStyle = lipgloss.NewStyle(). Foreground(lipgloss.Color("#6c7086")). Padding(0, 1) dvStatusStyle = lipgloss.NewStyle(). Foreground(lipgloss.Color("#a6e3a1")). Bold(true) dvErrorStatusStyle = lipgloss.NewStyle(). Foreground(lipgloss.Color("#f38ba8")). Bold(true) dvInputStyle = lipgloss.NewStyle(). BorderStyle(lipgloss.RoundedBorder()). BorderForeground(lipgloss.Color("#89b4fa")). Padding(0, 1) dvDetailsStyle = lipgloss.NewStyle(). BorderStyle(lipgloss.RoundedBorder()). BorderForeground(lipgloss.Color("#89b4fa")). Padding(1, 2). Width(100) dvDetailsTitleStyle = lipgloss.NewStyle(). Bold(true). Foreground(lipgloss.Color("#89b4fa")). Padding(0, 0, 1, 0) dvConfirmStyle = lipgloss.NewStyle(). BorderStyle(lipgloss.RoundedBorder()). BorderForeground(lipgloss.Color("#f38ba8")). Padding(1, 3) dvConfirmTitleStyle = lipgloss.NewStyle(). Bold(true). Foreground(lipgloss.Color("#f38ba8")). Padding(0, 0, 1, 0) dvLoadingStyle = lipgloss.NewStyle(). Foreground(lipgloss.Color("#f9e2af")). Bold(true) ) // ── Точка входа ────────────────────────────────────────────────────────────── func runDockerTUI() error { if err := dcCheckDockerAvailable(); err != nil { return fmt.Errorf("docker недоступен: %v", err) } columns := []table.Column{ {Title: "Имя", Width: 22}, {Title: "Статус", Width: 14}, {Title: "Health", Width: 10}, {Title: "Образ", Width: 26}, {Title: "Порты", Width: 22}, {Title: "ID", Width: 12}, } t := table.New( table.WithColumns(columns), table.WithFocused(true), table.WithHeight(12), ) s := table.DefaultStyles() s.Header = s.Header. BorderStyle(lipgloss.NormalBorder()). BorderForeground(lipgloss.Color("#6c7086")). BorderBottom(true). Bold(true) s.Selected = s.Selected. Foreground(lipgloss.Color("#cdd6f4")). Background(lipgloss.Color("#313244")). Bold(false) t.SetStyles(s) ti := textinput.New() ti.Placeholder = "Поиск по имени, образу, статусу..." ti.CharLimit = 156 ti.Width = 40 vp := viewport.New(96, 20) m := dvModel{ table: t, textInput: ti, viewport: vp, autoRefresh: true, } if _, err := tea.NewProgram(m, tea.WithAltScreen()).Run(); err != nil { return fmt.Errorf("ошибка запуска TUI: %w", err) } return nil } // ── Tea Model ──────────────────────────────────────────────────────────────── func (m dvModel) Init() tea.Cmd { return tea.Batch(dvLoadAsync, textinput.Blink, dvTickAuto) } func dvTickAuto() tea.Msg { time.Sleep(3 * time.Second) return dvAutoTickMsg{} } func dvLoadAsync() tea.Msg { entries, err := dvGetContainers() return dvLoadedMsg{entries: entries, err: err} } func (m dvModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { var cmd tea.Cmd // Автообновление — работает в фоне независимо от текущего режима. if _, ok := msg.(dvAutoTickMsg); ok { var cmds []tea.Cmd if m.autoRefresh && !m.loading && time.Since(m.lastRefresh) > 3*time.Second { cmds = append(cmds, dvLoadAsync) } cmds = append(cmds, dvTickAuto) return m, tea.Batch(cmds...) } // ── Просмотр docker inspect ────────────────────────────────────────── if m.showInspect { switch msg := msg.(type) { case tea.KeyMsg: if msg.String() == "esc" || msg.String() == "q" { m.showInspect = false m.loadingInspect = false return m, nil } case dvInspectMsg: m.loadingInspect = false if msg.err != nil { m.viewport.SetContent(fmt.Sprintf("Ошибка: %v", msg.err)) } else { m.viewport.SetContent(msg.content) } return m, nil } m.viewport, cmd = m.viewport.Update(msg) return m, cmd } // ── Диалог подтверждения (stop/start/restart/remove) ───────────────── if m.confirmAction != "" { if keyMsg, ok := msg.(tea.KeyMsg); ok { switch keyMsg.String() { case "y", "Y", "enter": action := m.confirmAction target := m.confirmTarget m.confirmAction = "" m.status = fmt.Sprintf("⏳ %s: %s...", action, target.Name) return m, dvRunAction(action, target.Name) case "n", "N", "esc": m.status = "Отменено" m.confirmAction = "" return m, nil } } return m, nil } // ── Строка поиска ───────────────────────────────────────────────────── if m.isFiltering { if keyMsg, ok := msg.(tea.KeyMsg); ok { if keyMsg.String() == "enter" || keyMsg.String() == "esc" { m.isFiltering = false m.table.Focus() return m, nil } } m.textInput, cmd = m.textInput.Update(msg) m.dvFilter() return m, cmd } switch msg := msg.(type) { case tea.KeyMsg: switch msg.String() { case "q", "ctrl+c": return m, tea.Quit case "/": m.isFiltering = true m.textInput.Focus() m.textInput.SetValue("") return m, textinput.Blink case "r": if !m.loading { m.status = "Обновляю..." m.loading = true return m, dvLoadAsync } case "a": m.autoRefresh = !m.autoRefresh if m.autoRefresh { m.status = "Автообновление: включено" } else { m.status = "Автообновление: выключено" } case "s": // stop if e, ok := m.dvSelected(); ok { m.confirmAction = "stop" m.confirmTarget = e } case "t": // start if e, ok := m.dvSelected(); ok { m.confirmAction = "start" m.confirmTarget = e } case "R": // restart (заглавная, чтобы не конфликтовать с "r" = refresh) if e, ok := m.dvSelected(); ok { m.confirmAction = "restart" m.confirmTarget = e } case "d": // remove if e, ok := m.dvSelected(); ok { m.confirmAction = "remove" m.confirmTarget = e } case "l": // logs -f (передаём терминал docker logs) if e, ok := m.dvSelected(); ok { return m, dvExecLogs(e.Name) } case "e": // shell в контейнер if e, ok := m.dvSelected(); ok { return m, dvExecShell(e.Name) } case "i": // docker inspect if e, ok := m.dvSelected(); ok { m.showInspect = true m.loadingInspect = true m.viewport.SetContent("Загрузка...") return m, dvInspectCmd(e.Name) } } case tea.WindowSizeMsg: m.width = msg.Width m.height = msg.Height avail := m.height - 7 dvBaseStyle = dvBaseStyle.Width(m.width - 2).Height(avail) m.table.SetWidth(m.width - 4) th := avail - 2 if th < 2 { th = 2 } m.table.SetHeight(th) m.viewport.Width = m.width - 8 m.viewport.Height = avail - 2 case dvLoadedMsg: m.loading = false m.lastRefresh = time.Now() if msg.err != nil { m.err = msg.err } else { m.entries = msg.entries m.err = nil m.dvFilter() if m.status == "Обновляю..." { m.status = "Обновлено" } } case dvActionResultMsg: if msg.err != nil { m.status = fmt.Sprintf("❌ %s (%s): %v — %s", msg.action, msg.target, msg.err, msg.output) } else { m.status = fmt.Sprintf("✅ %s (%s) выполнено", msg.action, msg.target) } return m, dvLoadAsync case dvExecFinishedMsg: switch msg.action { case "logs": m.status = "Просмотр логов завершён" case "exec": if msg.err != nil { m.status = "Шелл завершён с ошибкой (см. вывод выше)" } else { m.status = "Шелл закрыт" } } return m, dvLoadAsync case error: m.err = msg m.loading = false } m.table, cmd = m.table.Update(msg) return m, cmd } func (m *dvModel) dvSelected() (dvContainerEntry, bool) { idx := m.table.Cursor() if idx < 0 || idx >= len(m.filtered) { return dvContainerEntry{}, false } return m.filtered[idx], true } func (m *dvModel) dvFilter() { q := strings.ToLower(m.textInput.Value()) m.filtered = nil for _, e := range m.entries { if q == "" || strings.Contains(strings.ToLower(e.Name), q) || strings.Contains(strings.ToLower(e.Image), q) || strings.Contains(strings.ToLower(e.Status), q) || strings.Contains(strings.ToLower(e.Ports), q) { m.filtered = append(m.filtered, e) } } m.dvUpdateTable() } func (m *dvModel) dvUpdateTable() { rows := []table.Row{} for _, e := range m.filtered { icon := dcStatusEmoji(e.Status, e.Health) health := e.Health if health == "" { health = "none" } rows = append(rows, table.Row{ e.Name, icon + " " + e.Status, health, e.Image, e.Ports, e.ID, }) } m.table.SetRows(rows) } // ── Отрисовка ──────────────────────────────────────────────────────────────── func (m dvModel) View() string { if m.err != nil { return fmt.Sprintf("Ошибка: %v\nНажмите 'q' для выхода", m.err) } if m.showInspect { title := dvDetailsTitleStyle.Render("🔍 docker inspect") body := title + "\n" if m.loadingInspect { body += dvLoadingStyle.Render("Загрузка...") + "\n" } else { body += m.viewport.View() } body += "\n\n" + dvHelpStyle.Render("↑/↓ PgUp/PgDn: Прокрутка • Esc/q: Закрыть") box := dvDetailsStyle.Render(body) return lipgloss.Place(m.width, m.height, lipgloss.Center, lipgloss.Center, box) } if m.confirmAction != "" { box := dvConfirmStyle.Render(dvConfirmMessage(m.confirmAction, m.confirmTarget)) return lipgloss.Place(m.width, m.height, lipgloss.Center, lipgloss.Center, box) } logo := dvLogoStyle.Render("🐳 Auspex — Docker Live") if m.loading { logo += " " + dvLoadingStyle.Render("●") } if m.autoRefresh { logo += " " + dvHelpStyle.Render("[AUTO]") } tableView := dvBaseStyle.Render(m.table.View()) controls := "↑/↓: Навигация • /: Поиск • l: Логи • e: Шелл • i: Inspect • " + "s: Stop • t: Start • R: Restart • d: Remove • r: Refresh • a: Auto • q: Выход" if m.isFiltering { inputView := dvInputStyle.Render(m.textInput.View()) return fmt.Sprintf("%s\n%s\n%s\n%s", logo, tableView, inputView, dvHelpStyle.Render("Введите текст для поиска • Esc/Enter: Готово")) } status := m.status renderedControls := dvHelpStyle.Render(controls) if status != "" { style := dvStatusStyle if strings.HasPrefix(status, "❌") { style = dvErrorStatusStyle } renderedControls = style.Render(status) + " • " + renderedControls } return fmt.Sprintf("%s\n%s\n%s", logo, tableView, renderedControls) } func dvConfirmMessage(action string, t dvContainerEntry) string { var verb, cmdStr string switch action { case "stop": verb, cmdStr = "ОСТАНОВИТЬ", fmt.Sprintf("docker stop %s", t.Name) case "start": verb, cmdStr = "ЗАПУСТИТЬ", fmt.Sprintf("docker start %s", t.Name) case "restart": verb, cmdStr = "ПЕРЕЗАПУСТИТЬ", fmt.Sprintf("docker restart %s", t.Name) case "remove": verb, cmdStr = "УДАЛИТЬ (force)", fmt.Sprintf("docker rm -f %s", t.Name) } title := dvConfirmTitleStyle.Render(fmt.Sprintf("⚠️ %s контейнер?", verb)) return fmt.Sprintf("%s\nКонтейнер: %s\nКоманда: %s\n\n%s", title, t.Name, cmdStr, dvHelpStyle.Render("y: Да • n/Esc: Отмена")) } // ── Системные функции ──────────────────────────────────────────────────────── // dvGetContainers получает список контейнеров с портами. // Health переиспользует dcGetHealth, статус — dcNormalizeStatus (dockercheck.go). func dvGetContainers() ([]dvContainerEntry, error) { out, err := exec.Command( "docker", "ps", "-a", "--format", "{{.ID}}|{{.Names}}|{{.Status}}|{{.Image}}|{{.Ports}}", ).CombinedOutput() if err != nil { return nil, fmt.Errorf("docker ps: %v\n%s", err, string(out)) } var entries []dvContainerEntry for _, line := range strings.Split(strings.TrimSpace(string(out)), "\n") { if line == "" { continue } parts := strings.SplitN(line, "|", 5) if len(parts) < 4 { continue } e := dvContainerEntry{ ID: strings.TrimSpace(parts[0]), Name: strings.TrimSpace(parts[1]), Status: dcNormalizeStatus(parts[2]), Image: strings.TrimSpace(parts[3]), } if len(parts) == 5 { e.Ports = strings.TrimSpace(parts[4]) } entries = append(entries, e) } for i := range entries { entries[i].Health = dcGetHealth(entries[i].ID) } sort.Slice(entries, func(i, j int) bool { return strings.ToLower(entries[i].Name) < strings.ToLower(entries[j].Name) }) return entries, nil } // dvRunAction выполняет docker stop/start/restart/rm -f и возвращает результат. func dvRunAction(action, name string) tea.Cmd { return func() tea.Msg { var cmd *exec.Cmd switch action { case "stop": cmd = exec.Command("docker", "stop", name) case "start": cmd = exec.Command("docker", "start", name) case "restart": cmd = exec.Command("docker", "restart", name) case "remove": cmd = exec.Command("docker", "rm", "-f", name) default: return dvActionResultMsg{action: action, target: name, err: fmt.Errorf("неизвестное действие")} } out, err := cmd.CombinedOutput() return dvActionResultMsg{ action: action, target: name, output: strings.TrimSpace(string(out)), err: err, } } } // dvInspectCmd получает docker inspect и красиво форматирует JSON. func dvInspectCmd(name string) tea.Cmd { return func() tea.Msg { out, err := exec.Command("docker", "inspect", name).Output() if err != nil { return dvInspectMsg{err: fmt.Errorf("docker inspect: %w", err)} } var pretty bytes.Buffer if err := json.Indent(&pretty, out, "", " "); err != nil { return dvInspectMsg{content: string(out)} // fallback — как есть } return dvInspectMsg{content: pretty.String()} } } // dvExecLogs передаёт терминал `docker logs -f` — TUI приостанавливается на время // просмотра, возврат в TUI происходит по Ctrl+C (завершает logs) или выходу. func dvExecLogs(name string) tea.Cmd { c := exec.Command("docker", "logs", "-f", "--tail", "200", name) return tea.ExecProcess(c, func(err error) tea.Msg { return dvExecFinishedMsg{action: "logs", err: err} }) } // dvExecShell аналогично передаёт терминал интерактивному шеллу внутри контейнера. // Пробует bash, если недоступен — падает обратно на sh (стандартный подход // для минимальных образов вроде alpine). func dvExecShell(name string) tea.Cmd { shellProbe := `if command -v bash >/dev/null 2>&1; then exec bash; else exec sh; fi` c := exec.Command("docker", "exec", "-it", name, "sh", "-c", shellProbe) return tea.ExecProcess(c, func(err error) tea.Msg { return dvExecFinishedMsg{action: "exec", err: err} }) }