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("
")
sb.WriteString(v.Logs)
sb.WriteString("")
}
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,
}
}