auspex/dockerview.go
2026-07-27 13:35:23 +03:00

632 lines
18 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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