451 lines
11 KiB
Go
451 lines
11 KiB
Go
package ui
|
|
|
|
import (
|
|
"fmt"
|
|
"strings"
|
|
|
|
"fergalla.com/dockerbot/internal/docker"
|
|
"fergalla.com/dockerbot/internal/i18n"
|
|
"github.com/go-telegram/bot/models"
|
|
)
|
|
|
|
// Renderer encapsula toda la lógica de renderizado UI
|
|
|
|
type Renderer struct {
|
|
T func(string, ...map[string]string) string
|
|
}
|
|
|
|
// =========================
|
|
// 📦 LISTADO DE CONTENEDORES
|
|
// =========================
|
|
|
|
type Page struct {
|
|
Items []docker.ContainerBasicInfo
|
|
Page int
|
|
PageSize int
|
|
TotalItems int
|
|
}
|
|
|
|
func (r *Renderer) RenderContainerList(p Page) (string, *models.InlineKeyboardMarkup) {
|
|
var sb strings.Builder
|
|
|
|
// Header
|
|
sb.WriteString(r.T("app.headerList"))
|
|
sb.WriteString("\n")
|
|
sb.WriteString(r.separator())
|
|
sb.WriteString("\n")
|
|
|
|
// Summary
|
|
sb.WriteString(r.renderStateSummary(p.Items))
|
|
|
|
// Slice paginado
|
|
start := p.Page * p.PageSize
|
|
end := start + p.PageSize
|
|
|
|
if start > len(p.Items) {
|
|
return r.T("e.container.list.not_found"), nil
|
|
}
|
|
if end > len(p.Items) {
|
|
end = len(p.Items)
|
|
}
|
|
|
|
visibleItems := p.Items[start:end]
|
|
|
|
// Items (texto informativo)
|
|
for _, c := range visibleItems {
|
|
sb.WriteString(r.renderContainerItem(c))
|
|
}
|
|
|
|
// Footer
|
|
totalPages := (p.TotalItems + p.PageSize - 1) / p.PageSize
|
|
sb.WriteString("\n")
|
|
sb.WriteString(r.T("ui.page", map[string]string{
|
|
"current": fmt.Sprintf("%d", p.Page+1),
|
|
"total": fmt.Sprintf("%d", totalPages),
|
|
}))
|
|
|
|
// Keyboard combinando contenedores + navegación
|
|
keyboard := r.buildContainerListKeyboard(visibleItems, p.Page, totalPages)
|
|
|
|
return sb.String(), keyboard
|
|
}
|
|
|
|
func (r *Renderer) renderContainerItem(c docker.ContainerBasicInfo) string {
|
|
var sb strings.Builder
|
|
|
|
sb.WriteString(r.T("list.container.header", map[string]string{
|
|
"name": c.Name,
|
|
}))
|
|
|
|
sb.WriteString("\n")
|
|
|
|
sb.WriteString(r.T("list.container.image", map[string]string{
|
|
"image": c.Image,
|
|
}))
|
|
|
|
sb.WriteString("\n")
|
|
|
|
state := r.T(i18n.Key("d", "state", c.State))
|
|
|
|
line := r.T("list.container.state", map[string]string{
|
|
"emoji": stateEmoji(c.State),
|
|
"state": state,
|
|
})
|
|
|
|
if c.Health != "" {
|
|
health := r.T(i18n.Key("d", "health", c.Health))
|
|
line += r.T("list.container.health_suffix", map[string]string{
|
|
"health": health,
|
|
})
|
|
}
|
|
|
|
sb.WriteString(line)
|
|
sb.WriteString("\n\n")
|
|
|
|
return sb.String()
|
|
}
|
|
|
|
// =========================
|
|
// 📊 HELPERS
|
|
// =========================
|
|
|
|
func (r *Renderer) renderStateSummary(data []docker.ContainerBasicInfo) string {
|
|
running, stopped, restarting, paused, other := 0, 0, 0, 0, 0
|
|
|
|
for _, c := range data {
|
|
switch c.State {
|
|
case "running":
|
|
running++
|
|
case "exited", "dead":
|
|
stopped++
|
|
case "restarting":
|
|
restarting++
|
|
case "paused":
|
|
paused++
|
|
default:
|
|
other++
|
|
}
|
|
}
|
|
|
|
return r.T("list.summary", map[string]string{
|
|
"running": fmt.Sprintf("%d", running),
|
|
"stopped": fmt.Sprintf("%d", stopped),
|
|
"restarting": fmt.Sprintf("%d", restarting),
|
|
"paused": fmt.Sprintf("%d", paused),
|
|
"other": fmt.Sprintf("%d", other),
|
|
}) + "\n\n"
|
|
}
|
|
|
|
func (r *Renderer) separator() string {
|
|
return r.T("ui.separator")
|
|
}
|
|
|
|
// =========================
|
|
// ⬅️➡️ PAGINACIÓN + BOTONES
|
|
// =========================
|
|
|
|
func (r *Renderer) buildContainerListKeyboard(items []docker.ContainerBasicInfo, page, totalPages int) *models.InlineKeyboardMarkup {
|
|
var rows [][]models.InlineKeyboardButton
|
|
|
|
// 🔘 botones de contenedores (2 por fila)
|
|
var row []models.InlineKeyboardButton
|
|
|
|
for i, c := range items {
|
|
btn := models.InlineKeyboardButton{
|
|
Text: stateEmoji(c.State) + " " + c.Name,
|
|
CallbackData: fmt.Sprintf("dockerList_infoDocker:%s:%d", c.Name, page),
|
|
}
|
|
|
|
row = append(row, btn)
|
|
|
|
if len(row) == 2 {
|
|
rows = append(rows, row)
|
|
row = []models.InlineKeyboardButton{}
|
|
}
|
|
|
|
if i == len(items)-1 && len(row) > 0 {
|
|
rows = append(rows, row)
|
|
}
|
|
}
|
|
|
|
// ⬅️➡️ navegación
|
|
var navRow []models.InlineKeyboardButton
|
|
|
|
if page > 0 {
|
|
navRow = append(navRow, models.InlineKeyboardButton{
|
|
Text: r.T("ui.prev"),
|
|
CallbackData: fmt.Sprintf("dockerList_page:%d", page-1),
|
|
})
|
|
}
|
|
|
|
if page < totalPages-1 {
|
|
navRow = append(navRow, models.InlineKeyboardButton{
|
|
Text: r.T("ui.next"),
|
|
CallbackData: fmt.Sprintf("dockerList_page:%d", page+1),
|
|
})
|
|
}
|
|
|
|
// 🔄 refresh siempre visible
|
|
navRow = append(navRow, models.InlineKeyboardButton{
|
|
Text: r.T("btn.refresh"),
|
|
CallbackData: fmt.Sprintf("dockerList_refresh:%d", page),
|
|
})
|
|
|
|
rows = append(rows, navRow)
|
|
|
|
return &models.InlineKeyboardMarkup{
|
|
InlineKeyboard: rows,
|
|
}
|
|
}
|
|
|
|
// =========================
|
|
// 🎨 EMOJIS
|
|
// =========================
|
|
|
|
func stateEmoji(state string) string {
|
|
switch state {
|
|
case "running":
|
|
return "🟢"
|
|
case "exited":
|
|
return "🔴"
|
|
case "dead":
|
|
return "🪦"
|
|
case "restarting":
|
|
return "🟡"
|
|
case "paused":
|
|
return "⏸️"
|
|
default:
|
|
return "⚪"
|
|
}
|
|
}
|
|
|
|
/**************************************************************************************************************/
|
|
/**************************************************************************************************************/
|
|
/**************************************************************************************************************/
|
|
type ContainerDetailView struct {
|
|
Container *docker.ContainerInspectInfo
|
|
Page int
|
|
}
|
|
|
|
func (r *Renderer) RenderContainerDetail(v ContainerDetailView) (string, *models.InlineKeyboardMarkup) {
|
|
c := v.Container
|
|
|
|
var sb strings.Builder
|
|
|
|
// Header
|
|
sb.WriteString(r.T("c.header", map[string]string{"name": capitalize(c.Name)}))
|
|
sb.WriteString("\n")
|
|
sb.WriteString(r.T("c.separator"))
|
|
sb.WriteString("\n\n")
|
|
|
|
// Basic info
|
|
sb.WriteString(r.line("c.id", map[string]string{"id": c.ID}))
|
|
sb.WriteString(r.line("c.image", map[string]string{"image": c.Image}))
|
|
sb.WriteString(r.line("c.path", map[string]string{"path": c.Path}))
|
|
sb.WriteString(r.line("c.command", map[string]string{"command": c.Command}))
|
|
sb.WriteString(r.line("c.created", map[string]string{"created": formatTime(c.Created)}))
|
|
|
|
// State
|
|
sb.WriteString(r.line("c.state", map[string]string{
|
|
"emoji": stateEmoji(c.State),
|
|
"state": r.T(i18n.Key("d", "state", c.State)),
|
|
}))
|
|
|
|
// Health
|
|
sb.WriteString(r.line("c.health", map[string]string{
|
|
"emoji": healthEmoji(c.Health),
|
|
"health": r.T(i18n.Key("d", "health", c.Health)),
|
|
}))
|
|
|
|
// Stats
|
|
sb.WriteString(r.line("c.restart", map[string]string{"count": fmt.Sprintf("%d", c.RestartCount)}))
|
|
sb.WriteString(r.line("c.started", map[string]string{"started": formatTime(c.StartedAt)}))
|
|
sb.WriteString(r.line("c.finished", map[string]string{"finished": formatTime(c.FinishedAt)}))
|
|
|
|
// Ports
|
|
if len(c.Ports) > 0 {
|
|
sb.WriteString("\n" + r.T("c.ports") + "\n")
|
|
for port, mapping := range c.Ports {
|
|
sb.WriteString(fmt.Sprintf(" - %s → %s\n", port, mapping))
|
|
}
|
|
}
|
|
|
|
// Mounts
|
|
if len(c.Mounts) > 0 {
|
|
sb.WriteString("\n" + r.T("c.mounts") + "\n")
|
|
for _, m := range c.Mounts {
|
|
sb.WriteString(fmt.Sprintf(" - %s\n", m))
|
|
}
|
|
}
|
|
|
|
// Networks
|
|
if len(c.Networks) > 0 {
|
|
sb.WriteString("\n" + r.T("c.networks") + "\n")
|
|
for _, n := range c.Networks {
|
|
sb.WriteString(fmt.Sprintf(" - %s\n", n))
|
|
}
|
|
}
|
|
|
|
keyboard := r.buildContainerDetailKeyboard(v)
|
|
|
|
return sb.String(), keyboard
|
|
}
|
|
|
|
// =========================
|
|
// 🔘 KEYBOARD
|
|
// =========================
|
|
|
|
func (r *Renderer) buildContainerDetailKeyboard(v ContainerDetailView) *models.InlineKeyboardMarkup {
|
|
c := v.Container
|
|
page := v.Page
|
|
var buttons [][]models.InlineKeyboardButton
|
|
|
|
switch c.State {
|
|
|
|
case "running":
|
|
buttons = [][]models.InlineKeyboardButton{
|
|
{
|
|
{Text: r.T("btn.stop"), CallbackData: fmt.Sprintf("docker_action:stop:%s:%d", c.Name, page)},
|
|
{Text: r.T("btn.restart"), CallbackData: fmt.Sprintf("docker_action:restart:%s:%d", c.Name, page)},
|
|
},
|
|
{
|
|
{Text: r.T("btn.pause"), CallbackData: fmt.Sprintf("docker_action:pause:%s:%d", c.Name, page)},
|
|
},
|
|
}
|
|
|
|
case "exited", "created":
|
|
buttons = [][]models.InlineKeyboardButton{
|
|
{
|
|
{Text: r.T("btn.start"), CallbackData: fmt.Sprintf("docker_action:start:%s:%d", c.Name, page)},
|
|
},
|
|
}
|
|
|
|
case "paused":
|
|
buttons = [][]models.InlineKeyboardButton{
|
|
{
|
|
{Text: r.T("btn.resume"), CallbackData: fmt.Sprintf("docker_action:unpause:%s:%d", c.Name, page)},
|
|
{Text: r.T("btn.stop"), CallbackData: fmt.Sprintf("docker_action:stop:%s:%d", c.Name, page)},
|
|
},
|
|
}
|
|
|
|
default:
|
|
buttons = [][]models.InlineKeyboardButton{
|
|
{
|
|
{Text: r.T("btn.restart"), CallbackData: fmt.Sprintf("docker_action:restart:%s:%d", c.Name, page)},
|
|
},
|
|
}
|
|
}
|
|
|
|
buttons = append(buttons, []models.InlineKeyboardButton{
|
|
{Text: r.T("ui.back"), CallbackData: fmt.Sprintf("docker_back_list:%d", page)},
|
|
})
|
|
|
|
buttons = append(buttons, []models.InlineKeyboardButton{
|
|
{Text: r.T("btn.logs"), CallbackData: fmt.Sprintf("logs:%s:%d", c.Name, page)},
|
|
{Text: r.T("btn.refresh"), CallbackData: fmt.Sprintf("docker_action:refresh:%s:%d", c.Name, page)},
|
|
})
|
|
|
|
return &models.InlineKeyboardMarkup{
|
|
InlineKeyboard: buttons,
|
|
}
|
|
}
|
|
|
|
// =========================
|
|
// 🧩 HELPERS
|
|
// =========================
|
|
|
|
func (r *Renderer) line(key string, vars map[string]string) string {
|
|
return r.T(key, vars) + "\n"
|
|
}
|
|
|
|
func formatTime(t any) string {
|
|
if v, ok := t.(interface{ Format(string) string }); ok {
|
|
return v.Format("2006-01-02 15:04:05")
|
|
}
|
|
return "-"
|
|
}
|
|
|
|
func capitalize(s string) string {
|
|
if len(s) == 0 {
|
|
return s
|
|
}
|
|
return strings.ToUpper(string(s[0])) + s[1:]
|
|
}
|
|
|
|
func healthEmoji(h string) string {
|
|
switch h {
|
|
case "healthy":
|
|
return "✅"
|
|
case "unhealthy":
|
|
return "⚠️"
|
|
case "starting":
|
|
return "⏳"
|
|
default:
|
|
return "❓"
|
|
}
|
|
}
|
|
|
|
/**************************************************************************************************************/
|
|
/**************************************************************************************************************/
|
|
/**************************************************************************************************************/
|
|
|
|
type ContainerLogsView struct {
|
|
Container string
|
|
Logs string
|
|
Page int
|
|
Tail int
|
|
}
|
|
|
|
func (r *Renderer) RenderContainerLogs(v ContainerLogsView) (string, *models.InlineKeyboardMarkup) {
|
|
const maxLen = 3500
|
|
var sb strings.Builder
|
|
|
|
if len(v.Logs) > maxLen {
|
|
v.Logs = v.Logs[len(v.Logs)-maxLen:]
|
|
|
|
sb.WriteString(r.T("logs.truncated"))
|
|
sb.WriteString("\n\n")
|
|
}
|
|
|
|
sb.WriteString(r.T("logs.header", map[string]string{
|
|
"name": v.Container,
|
|
}))
|
|
sb.WriteString("\n")
|
|
sb.WriteString(r.T("ui.separator"))
|
|
sb.WriteString("\n\n")
|
|
|
|
if v.Logs == "" {
|
|
sb.WriteString(r.T("logs.empty"))
|
|
} else {
|
|
sb.WriteString("<pre>")
|
|
sb.WriteString(v.Logs)
|
|
sb.WriteString("</pre>")
|
|
}
|
|
|
|
keyboard := r.buildLogsKeyboard(v)
|
|
|
|
return sb.String(), keyboard
|
|
}
|
|
|
|
func (r *Renderer) buildLogsKeyboard(v ContainerLogsView) *models.InlineKeyboardMarkup {
|
|
|
|
rows := [][]models.InlineKeyboardButton{
|
|
{
|
|
{
|
|
Text: r.T("btn.refresh"),
|
|
CallbackData: fmt.Sprintf("logs_refresh:%s:%d:%d", v.Container, v.Page, v.Tail),
|
|
},
|
|
},
|
|
{
|
|
{
|
|
Text: r.T("ui.back"),
|
|
CallbackData: fmt.Sprintf("logs_back_detail:%s:%d", v.Container, v.Page),
|
|
},
|
|
},
|
|
}
|
|
|
|
return &models.InlineKeyboardMarkup{
|
|
InlineKeyboard: rows,
|
|
}
|
|
}
|