632 lines
18 KiB
Go
632 lines
18 KiB
Go
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}
|
||
})
|
||
}
|