Version Inicial
This commit is contained in:
5
internal/apperrors/apperrors.go
Normal file
5
internal/apperrors/apperrors.go
Normal file
@@ -0,0 +1,5 @@
|
||||
package apperrors
|
||||
|
||||
import "errors"
|
||||
|
||||
var ErrContainerNotFound = errors.New("container_not_found")
|
||||
87
internal/auth/auth.go
Normal file
87
internal/auth/auth.go
Normal file
@@ -0,0 +1,87 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io/fs"
|
||||
"os"
|
||||
"sync"
|
||||
)
|
||||
|
||||
type Store struct {
|
||||
file string
|
||||
users map[int64]bool
|
||||
mu sync.RWMutex
|
||||
}
|
||||
|
||||
func New(file string) (*Store, error) {
|
||||
users, err := loadUsers(file)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &Store{
|
||||
file: file,
|
||||
users: users,
|
||||
}, nil
|
||||
}
|
||||
func (s *Store) IsAllowed(userID int64) bool {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
return s.users[userID]
|
||||
}
|
||||
func (s *Store) IsEmpty() bool {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
return len(s.users) == 0
|
||||
}
|
||||
func (s *Store) FirstUser(userID int64) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.users[userID] = true
|
||||
return s.save()
|
||||
}
|
||||
func (s *Store) Add(userID int64) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.users[userID] = true
|
||||
return s.save()
|
||||
}
|
||||
func (s *Store) Remove(userID int64) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
delete(s.users, userID)
|
||||
return s.save()
|
||||
}
|
||||
func (s *Store) save() error {
|
||||
data, err := json.MarshalIndent(s.users, "", " ")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
tmpFile := s.file + ".tmp"
|
||||
if err := os.WriteFile(tmpFile, data, 0644); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return os.Rename(tmpFile, s.file)
|
||||
}
|
||||
func loadUsers(file string) (map[int64]bool, error) {
|
||||
data, err := os.ReadFile(file)
|
||||
if err != nil {
|
||||
if errors.Is(err, fs.ErrNotExist) {
|
||||
return make(map[int64]bool), nil
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
|
||||
users := make(map[int64]bool)
|
||||
if len(data) == 0 {
|
||||
return users, nil
|
||||
}
|
||||
|
||||
if err := json.Unmarshal(data, &users); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return users, nil
|
||||
}
|
||||
83
internal/cache/cache.go
vendored
Normal file
83
internal/cache/cache.go
vendored
Normal file
@@ -0,0 +1,83 @@
|
||||
package cache
|
||||
|
||||
import (
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"fergalla.com/dockerbot/internal/docker"
|
||||
)
|
||||
|
||||
type ContainerCache struct {
|
||||
mu sync.RWMutex
|
||||
|
||||
data []docker.ContainerBasicInfo
|
||||
expiresAt time.Time
|
||||
ttl time.Duration
|
||||
|
||||
// función para obtener datos frescos
|
||||
fetchFunc func() ([]docker.ContainerBasicInfo, error)
|
||||
}
|
||||
|
||||
// Constructor
|
||||
func NewContainerCache(ttl time.Duration, fetch func() ([]docker.ContainerBasicInfo, error)) *ContainerCache {
|
||||
return &ContainerCache{
|
||||
ttl: ttl,
|
||||
fetchFunc: fetch,
|
||||
}
|
||||
}
|
||||
|
||||
// Get devuelve datos cacheados o refresca si ha expirado
|
||||
func (c *ContainerCache) Get() ([]docker.ContainerBasicInfo, error) {
|
||||
// 🔍 lectura rápida
|
||||
c.mu.RLock()
|
||||
if time.Now().Before(c.expiresAt) && c.data != nil {
|
||||
data := c.data
|
||||
c.mu.RUnlock()
|
||||
return data, nil
|
||||
}
|
||||
c.mu.RUnlock()
|
||||
|
||||
// 🔒 lock de escritura (refresco)
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
|
||||
// ⚠️ doble check (evita refrescos duplicados)
|
||||
if time.Now().Before(c.expiresAt) && c.data != nil {
|
||||
return c.data, nil
|
||||
}
|
||||
|
||||
data, err := c.fetchFunc()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
c.data = data
|
||||
c.expiresAt = time.Now().Add(c.ttl)
|
||||
|
||||
return c.data, nil
|
||||
}
|
||||
|
||||
// Invalidate fuerza limpieza de cache
|
||||
func (c *ContainerCache) Invalidate() {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
|
||||
c.data = nil
|
||||
c.expiresAt = time.Time{}
|
||||
}
|
||||
|
||||
// ForceRefresh fuerza actualización inmediata
|
||||
func (c *ContainerCache) ForceRefresh() ([]docker.ContainerBasicInfo, error) {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
|
||||
data, err := c.fetchFunc()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
c.data = data
|
||||
c.expiresAt = time.Now().Add(c.ttl)
|
||||
|
||||
return c.data, nil
|
||||
}
|
||||
65
internal/config/config.go
Normal file
65
internal/config/config.go
Normal file
@@ -0,0 +1,65 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"os"
|
||||
"strconv"
|
||||
)
|
||||
|
||||
type Config struct {
|
||||
Locale string
|
||||
Token string
|
||||
Webhookurl string
|
||||
Webhooktoken string
|
||||
PageSize int
|
||||
CacheTTL int
|
||||
}
|
||||
|
||||
func Load() *Config {
|
||||
return &Config{
|
||||
Locale: getEnv("DOCKERBOT_LOCALE", "en"),
|
||||
Token: getEnv("DOCKERBOT_TOKEN", ""),
|
||||
Webhookurl: getEnv("DOCKERBOT_WEBHOOKURL", ""),
|
||||
Webhooktoken: getEnv("DOCKERBOT_WEBHOOK_TOKEN", ""),
|
||||
PageSize: getEnvInt("DOCKERBOT_PAGE_SIZE", 4),
|
||||
CacheTTL: getEnvInt("DOCKERBOT_CACHE_TTL", 10),
|
||||
}
|
||||
}
|
||||
|
||||
func getEnv(key, defaultValue string) string {
|
||||
val := os.Getenv(key)
|
||||
if val == "" {
|
||||
return defaultValue
|
||||
}
|
||||
return val
|
||||
}
|
||||
|
||||
func getEnvInt(key string, defaultValue int) int {
|
||||
valStr := os.Getenv(key)
|
||||
if valStr == "" {
|
||||
return defaultValue
|
||||
}
|
||||
|
||||
val, err := strconv.Atoi(valStr)
|
||||
if err != nil {
|
||||
return defaultValue
|
||||
}
|
||||
|
||||
if val <= 0 {
|
||||
return defaultValue
|
||||
}
|
||||
|
||||
return val
|
||||
}
|
||||
|
||||
func getEnvBool(key string, defaultValue bool) bool {
|
||||
val := os.Getenv(key)
|
||||
if val == "" {
|
||||
return defaultValue
|
||||
}
|
||||
|
||||
v, err := strconv.ParseBool(val)
|
||||
if err != nil {
|
||||
return defaultValue
|
||||
}
|
||||
return v
|
||||
}
|
||||
238
internal/docker/docker.go
Normal file
238
internal/docker/docker.go
Normal file
@@ -0,0 +1,238 @@
|
||||
package docker
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"fergalla.com/dockerbot/internal/apperrors"
|
||||
"github.com/containerd/errdefs"
|
||||
"github.com/moby/moby/api/pkg/stdcopy"
|
||||
"github.com/moby/moby/client"
|
||||
)
|
||||
|
||||
type DockerInfo struct {
|
||||
ctxDocker context.Context
|
||||
dockerClient *client.Client
|
||||
}
|
||||
|
||||
/* type ContainerStats struct {
|
||||
CPUPercent float64
|
||||
MemUsage uint64
|
||||
MemLimit uint64
|
||||
MemPercent float64
|
||||
NetInput uint64
|
||||
NetOutput uint64
|
||||
}
|
||||
|
||||
type statsResponse struct {
|
||||
CPUStats struct {
|
||||
CPUUsage struct {
|
||||
TotalUsage uint64 `json:"total_usage"`
|
||||
PercpuUsage []uint64 `json:"percpu_usage"`
|
||||
} `json:"cpu_usage"`
|
||||
SystemUsage uint64 `json:"system_cpu_usage"`
|
||||
} `json:"cpu_stats"`
|
||||
|
||||
PreCPUStats struct {
|
||||
CPUUsage struct {
|
||||
TotalUsage uint64 `json:"total_usage"`
|
||||
} `json:"cpu_usage"`
|
||||
SystemUsage uint64 `json:"system_cpu_usage"`
|
||||
} `json:"precpu_stats"`
|
||||
|
||||
MemoryStats struct {
|
||||
Usage uint64 `json:"usage"`
|
||||
Limit uint64 `json:"limit"`
|
||||
} `json:"memory_stats"`
|
||||
|
||||
Networks map[string]struct {
|
||||
RxBytes uint64 `json:"rx_bytes"`
|
||||
TxBytes uint64 `json:"tx_bytes"`
|
||||
} `json:"networks"`
|
||||
} */
|
||||
|
||||
type ContainerInspectInfo struct {
|
||||
ID string
|
||||
Name string
|
||||
Image string
|
||||
Created time.Time
|
||||
Path string
|
||||
Command string
|
||||
State string
|
||||
Health string
|
||||
RestartCount int
|
||||
StartedAt time.Time
|
||||
FinishedAt time.Time
|
||||
Ports map[string]string
|
||||
Mounts []string
|
||||
Networks []string
|
||||
}
|
||||
|
||||
type ContainerBasicInfo struct {
|
||||
Name string
|
||||
Image string
|
||||
State string
|
||||
Health string
|
||||
}
|
||||
|
||||
func New() (*DockerInfo, error) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
apiClient, err := client.New(client.FromEnv)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &DockerInfo{
|
||||
dockerClient: apiClient,
|
||||
ctxDocker: ctx,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (d *DockerInfo) ListContainers() ([]ContainerBasicInfo, error) {
|
||||
containers, err := d.dockerClient.ContainerList(d.ctxDocker, client.ContainerListOptions{All: true})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var basicInfo []ContainerBasicInfo
|
||||
for _, c := range containers.Items {
|
||||
name := "unknown"
|
||||
if len(c.Names) > 0 {
|
||||
name = c.Names[0][1:]
|
||||
}
|
||||
image := c.Image
|
||||
state := c.State
|
||||
health := c.Health.Status
|
||||
basicInfo = append(basicInfo, ContainerBasicInfo{
|
||||
Name: name,
|
||||
Image: image,
|
||||
State: string(state),
|
||||
Health: string(health),
|
||||
})
|
||||
}
|
||||
return basicInfo, nil
|
||||
}
|
||||
|
||||
func (d *DockerInfo) GetContainerInfo(containerID string) (*ContainerInspectInfo, error) {
|
||||
containerJSON, err := d.dockerClient.ContainerInspect(d.ctxDocker, containerID, client.ContainerInspectOptions{})
|
||||
if err != nil {
|
||||
if errdefs.IsNotFound(err) {
|
||||
return nil, apperrors.ErrContainerNotFound
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
c := containerJSON.Container
|
||||
info := &ContainerInspectInfo{
|
||||
Name: strings.TrimPrefix(c.Name, "/"),
|
||||
Image: c.Config.Image,
|
||||
ID: c.ID,
|
||||
Path: c.Path,
|
||||
Command: c.Path + " " + strings.Join(c.Args, " "),
|
||||
State: string(c.State.Status),
|
||||
Health: "none",
|
||||
RestartCount: c.RestartCount,
|
||||
Created: strToTime(c.Created),
|
||||
StartedAt: strToTime(c.State.StartedAt),
|
||||
FinishedAt: strToTime(c.State.FinishedAt),
|
||||
}
|
||||
|
||||
if c.State.Health != nil {
|
||||
info.Health = string(c.State.Health.Status)
|
||||
}
|
||||
|
||||
info.Ports = make(map[string]string)
|
||||
for port, bindings := range c.NetworkSettings.Ports {
|
||||
if len(bindings) > 0 {
|
||||
info.Ports[port.String()] = bindings[0].HostPort
|
||||
}
|
||||
}
|
||||
|
||||
for _, m := range c.Mounts {
|
||||
info.Mounts = append(info.Mounts, m.Source+"->"+m.Destination)
|
||||
}
|
||||
|
||||
for name := range c.NetworkSettings.Networks {
|
||||
info.Networks = append(info.Networks, name)
|
||||
}
|
||||
return info, nil
|
||||
}
|
||||
|
||||
func (d *DockerInfo) ContainerAction(containerID string, action string) error {
|
||||
var err error
|
||||
switch action {
|
||||
|
||||
case "start":
|
||||
_, err = d.dockerClient.ContainerStart(d.ctxDocker, containerID, client.ContainerStartOptions{})
|
||||
|
||||
case "stop":
|
||||
_, err = d.dockerClient.ContainerStop(d.ctxDocker, containerID, client.ContainerStopOptions{})
|
||||
|
||||
case "restart":
|
||||
_, err = d.dockerClient.ContainerRestart(d.ctxDocker, containerID, client.ContainerRestartOptions{})
|
||||
|
||||
case "pause":
|
||||
_, err = d.dockerClient.ContainerPause(d.ctxDocker, containerID, client.ContainerPauseOptions{})
|
||||
|
||||
case "unpause":
|
||||
_, err = d.dockerClient.ContainerUnpause(d.ctxDocker, containerID, client.ContainerUnpauseOptions{})
|
||||
|
||||
default:
|
||||
return fmt.Errorf("unknown docker action: %s", action)
|
||||
}
|
||||
if errdefs.IsNotFound(err) {
|
||||
return apperrors.ErrContainerNotFound
|
||||
} else {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
func (d *DockerInfo) GetContainerLogs(containerID string, tail int) (string, error) {
|
||||
opts := client.ContainerLogsOptions{
|
||||
ShowStdout: true,
|
||||
ShowStderr: true,
|
||||
}
|
||||
if tail != 0 {
|
||||
opts.Tail = strconv.Itoa(tail)
|
||||
}
|
||||
|
||||
reader, err := d.dockerClient.ContainerLogs(d.ctxDocker, containerID, opts)
|
||||
if err != nil {
|
||||
if errdefs.IsNotFound(err) {
|
||||
return "", apperrors.ErrContainerNotFound
|
||||
} else {
|
||||
return "", err
|
||||
}
|
||||
}
|
||||
|
||||
defer reader.Close()
|
||||
|
||||
var stdoutBuf bytes.Buffer
|
||||
var stderrBuf bytes.Buffer
|
||||
|
||||
_, err = stdcopy.StdCopy(&stdoutBuf, &stderrBuf, reader)
|
||||
if err != nil && err != io.EOF {
|
||||
return "", err
|
||||
}
|
||||
|
||||
out := "\n\n⚠️ STDOUT:\n" + stdoutBuf.String()
|
||||
errOut := stderrBuf.String()
|
||||
|
||||
if errOut != "" {
|
||||
out += "\n\n⚠️ STDERR:\n" + errOut
|
||||
}
|
||||
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func strToTime(date string) time.Time {
|
||||
t, err := time.Parse(time.RFC3339Nano, date)
|
||||
if err != nil {
|
||||
t = time.Time{}
|
||||
}
|
||||
return t
|
||||
}
|
||||
160
internal/i18n/i18n.go
Normal file
160
internal/i18n/i18n.go
Normal file
@@ -0,0 +1,160 @@
|
||||
package i18n
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
)
|
||||
|
||||
// I18n es la estructura principal
|
||||
type I18n struct {
|
||||
current string
|
||||
fallback string
|
||||
locales map[string]map[string]string
|
||||
mu sync.RWMutex // protege acceso concurrente
|
||||
}
|
||||
|
||||
// New crea una instancia de i18n
|
||||
/* func New(defaultLocale, fallback string) *I18n {
|
||||
return &I18n{
|
||||
current: defaultLocale,
|
||||
fallback: fallback,
|
||||
locales: make(map[string]map[string]string),
|
||||
}
|
||||
}
|
||||
*/
|
||||
func New(path string, fallback string) (*I18n, error) {
|
||||
i := &I18n{
|
||||
current: fallback,
|
||||
fallback: fallback,
|
||||
locales: make(map[string]map[string]string),
|
||||
}
|
||||
|
||||
if err := i.loadDir(path); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if len(i.locales) == 0 {
|
||||
return nil, fmt.Errorf("no locales loaded from %s", path)
|
||||
}
|
||||
|
||||
return i, nil
|
||||
}
|
||||
|
||||
func (i *I18n) loadDir(path string) error {
|
||||
files, err := os.ReadDir(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
for _, file := range files {
|
||||
if file.IsDir() {
|
||||
continue
|
||||
}
|
||||
|
||||
name := file.Name()
|
||||
|
||||
if !strings.HasSuffix(name, ".json") {
|
||||
continue
|
||||
}
|
||||
|
||||
lang := strings.TrimSuffix(name, ".json")
|
||||
|
||||
if err := i.loadFile(filepath.Join(path, name), lang); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// LoadLocale carga un archivo JSON en memoria
|
||||
func (i *I18n) LoadLocale(locale, path string) error {
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
var parsed map[string]string
|
||||
if err := json.Unmarshal(data, &parsed); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
i.mu.Lock()
|
||||
i.locales[locale] = parsed
|
||||
i.mu.Unlock()
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (i *I18n) loadFile(path string, lang string) error {
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
var content map[string]string
|
||||
|
||||
if err := json.Unmarshal(data, &content); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
i.mu.Lock()
|
||||
defer i.mu.Unlock()
|
||||
|
||||
i.locales[lang] = content
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// SetLocale cambia el idioma activo
|
||||
func (i *I18n) SetLocale(locale string) {
|
||||
i.mu.Lock()
|
||||
i.current = locale
|
||||
i.mu.Unlock()
|
||||
}
|
||||
|
||||
// T traduce una clave, opcionalmente interpolando variables
|
||||
func (i *I18n) T(key string, vars map[string]string) string {
|
||||
val := i.translate(key)
|
||||
if vars == nil {
|
||||
return val
|
||||
}
|
||||
// Interpolación simple: reemplaza {var} por su valor
|
||||
for k, v := range vars {
|
||||
val = strings.ReplaceAll(val, "{"+k+"}", v)
|
||||
}
|
||||
|
||||
return val
|
||||
}
|
||||
|
||||
// translate busca la clave en el idioma actual y fallback
|
||||
func (i *I18n) translate(key string) string {
|
||||
i.mu.RLock()
|
||||
defer i.mu.RUnlock()
|
||||
|
||||
// 1. Buscar en idioma actual
|
||||
if localeData, ok := i.locales[i.current]; ok {
|
||||
if val, ok := localeData[key]; ok {
|
||||
return val
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Buscar en fallback
|
||||
if localeData, ok := i.locales[i.fallback]; ok {
|
||||
if val, ok := localeData[key]; ok {
|
||||
return val
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Si no existe → devolver clave marcada
|
||||
return fmt.Sprintf("??%s??", key)
|
||||
}
|
||||
|
||||
// Helper para construir claves con seguridad
|
||||
func Key(parts ...string) string {
|
||||
return strings.Join(parts, ".")
|
||||
}
|
||||
42
internal/logger/logger.go
Normal file
42
internal/logger/logger.go
Normal file
@@ -0,0 +1,42 @@
|
||||
package logger
|
||||
|
||||
import (
|
||||
"log/slog"
|
||||
"os"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Logger envuelve slog.Logger
|
||||
type Logger struct {
|
||||
*slog.Logger
|
||||
}
|
||||
|
||||
func (l *Logger) Fatalf(s string, err error) {
|
||||
panic("unimplemented")
|
||||
}
|
||||
|
||||
// parseLevel convierte string → slog.Level
|
||||
func parseLevel(level string) slog.Level {
|
||||
switch strings.ToUpper(level) {
|
||||
case "DEBUG":
|
||||
return slog.LevelDebug
|
||||
case "INFO":
|
||||
return slog.LevelInfo
|
||||
case "WARN", "WARNING":
|
||||
return slog.LevelWarn
|
||||
case "ERROR":
|
||||
return slog.LevelError
|
||||
default:
|
||||
return slog.LevelInfo
|
||||
}
|
||||
}
|
||||
|
||||
func New(level string, service string) *Logger {
|
||||
handler := slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{
|
||||
Level: parseLevel(level),
|
||||
})
|
||||
base := slog.New(handler).With(
|
||||
"service", service,
|
||||
)
|
||||
return &Logger{base}
|
||||
}
|
||||
450
internal/ui/ui.go
Normal file
450
internal/ui/ui.go
Normal file
@@ -0,0 +1,450 @@
|
||||
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,
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user