Version final
This commit is contained in:
1 parent
2d4b09b9ea
commit
74b7a0c448
13 files changed
+1103
-7
No files matched your search
+179
@@ -0,0 +1,179 @@
|
||||
package ui
|
||||
|
||||
import (
|
||||
"registry-tui/client"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"github.com/charmbracelet/bubbles/textinput"
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
)
|
||||
|
||||
type ViewState int
|
||||
|
||||
const (
|
||||
StateLoading ViewState = iota
|
||||
StateMainTable
|
||||
StateConfirmDelete
|
||||
StateNewTag
|
||||
StateError
|
||||
StateRegistryDown
|
||||
)
|
||||
|
||||
type AppMode int
|
||||
|
||||
const (
|
||||
ModeRegistry AppMode = iota
|
||||
ModeLocal
|
||||
)
|
||||
|
||||
type ImageRow = client.ImageRowData
|
||||
|
||||
type Model struct {
|
||||
state ViewState
|
||||
mode AppMode
|
||||
client *client.RegistryClient
|
||||
rows []ImageRow
|
||||
localRows []ImageRow
|
||||
registryRows []ImageRow
|
||||
cursor int
|
||||
err error
|
||||
statusMsg string
|
||||
textInput textinput.Model
|
||||
version string
|
||||
}
|
||||
|
||||
func NewModel(registryURL string, version string) Model {
|
||||
ti := textinput.New()
|
||||
ti.Placeholder = "ej. proyecto-web/alpine:v1.0"
|
||||
ti.CharLimit = 128
|
||||
ti.Width = 35
|
||||
|
||||
return Model{
|
||||
state: StateLoading,
|
||||
mode: ModeRegistry,
|
||||
client: client.NewRegistryClient(registryURL),
|
||||
textInput: ti,
|
||||
version: version,
|
||||
}
|
||||
}
|
||||
|
||||
func (m Model) Init() tea.Cmd {
|
||||
return fetchRegistryImagesCmd(m.client)
|
||||
}
|
||||
|
||||
type imagesMsg struct {
|
||||
rows []ImageRow
|
||||
mode AppMode
|
||||
}
|
||||
type errorMsg error
|
||||
type successMsg string
|
||||
|
||||
func fetchRegistryImagesCmd(c *client.RegistryClient) tea.Cmd {
|
||||
return func() tea.Msg {
|
||||
repos, err := c.GetCatalog()
|
||||
if err != nil {
|
||||
return errorMsg(err)
|
||||
}
|
||||
|
||||
var rows []ImageRow
|
||||
for _, repo := range repos {
|
||||
tags, err := c.GetTags(repo)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
var project, imageName string
|
||||
parts := strings.Split(repo, "/")
|
||||
if len(parts) > 1 {
|
||||
project = parts[0]
|
||||
imageName = strings.Join(parts[1:], "/")
|
||||
} else {
|
||||
project = ""
|
||||
imageName = repo
|
||||
}
|
||||
|
||||
for _, tag := range tags {
|
||||
rows = append(rows, ImageRow{
|
||||
Project: project,
|
||||
RepoName: repo,
|
||||
ImageName: imageName,
|
||||
Tag: tag,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
sortRows(rows)
|
||||
return imagesMsg{rows: rows, mode: ModeRegistry}
|
||||
}
|
||||
}
|
||||
|
||||
func fetchLocalImagesCmd(c *client.RegistryClient) tea.Cmd {
|
||||
return func() tea.Msg {
|
||||
rows, err := c.GetLocalImages()
|
||||
if err != nil {
|
||||
return errorMsg(err)
|
||||
}
|
||||
sortRows(rows)
|
||||
return imagesMsg{rows: rows, mode: ModeLocal}
|
||||
}
|
||||
}
|
||||
|
||||
func sortRows(rows []ImageRow) {
|
||||
sort.Slice(rows, func(i, j int) bool {
|
||||
if rows[i].Project == rows[j].Project {
|
||||
return rows[i].RepoName < rows[j].RepoName
|
||||
}
|
||||
return rows[i].Project < rows[j].Project
|
||||
})
|
||||
}
|
||||
|
||||
func deleteTagCmd(c *client.RegistryClient, repo, tag string) tea.Cmd {
|
||||
return func() tea.Msg {
|
||||
err := c.DeleteManifest(repo, tag)
|
||||
if err != nil {
|
||||
return errorMsg(err)
|
||||
}
|
||||
return successMsg("Tag borrado con éxito del registro")
|
||||
}
|
||||
}
|
||||
|
||||
func deleteLocalImageCmd(c *client.RegistryClient, repo, tag string) tea.Cmd {
|
||||
return func() tea.Msg {
|
||||
err := c.DeleteLocalImage(repo, tag)
|
||||
if err != nil {
|
||||
return errorMsg(err)
|
||||
}
|
||||
return successMsg("Imagen local borrada con éxito")
|
||||
}
|
||||
}
|
||||
|
||||
func copyImageCmd(c *client.RegistryClient, sourceRepo, sourceTag, destRepo, destTag string) tea.Cmd {
|
||||
return func() tea.Msg {
|
||||
err := c.CopyManifest(sourceRepo, sourceTag, destRepo, destTag)
|
||||
if err != nil {
|
||||
return errorMsg(err)
|
||||
}
|
||||
return successMsg("Imagen reestructurada con éxito en el registro")
|
||||
}
|
||||
}
|
||||
|
||||
func tagLocalImageCmd(c *client.RegistryClient, sourceRepo, sourceTag, destRepo, destTag string) tea.Cmd {
|
||||
return func() tea.Msg {
|
||||
err := c.TagLocalImage(sourceRepo, sourceTag, destRepo, destTag)
|
||||
if err != nil {
|
||||
return errorMsg(err)
|
||||
}
|
||||
return successMsg("Imagen local etiquetada con éxito")
|
||||
}
|
||||
}
|
||||
|
||||
func pushImageCmd(c *client.RegistryClient, repo, tag string) tea.Cmd {
|
||||
return func() tea.Msg {
|
||||
err := c.PushLocalImage(repo, tag)
|
||||
if err != nil {
|
||||
return errorMsg(err)
|
||||
}
|
||||
return successMsg("Imagen subida (Push) al registro con éxito")
|
||||
}
|
||||
}
|
||||
+175
@@ -0,0 +1,175 @@
|
||||
package ui
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"github.com/charmbracelet/bubbles/textinput"
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
)
|
||||
|
||||
func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
// 1. Manejo especial cuando el Registry está caído (StateRegistryDown)
|
||||
if m.state == StateRegistryDown {
|
||||
if keyMsg, ok := msg.(tea.KeyMsg); ok {
|
||||
switch keyMsg.String() {
|
||||
case "enter", "L", "l":
|
||||
m.state = StateLoading
|
||||
m.mode = ModeLocal
|
||||
m.statusMsg = ""
|
||||
return m, fetchLocalImagesCmd(m.client)
|
||||
case "q", "ctrl+c", "CTRL+C":
|
||||
return m, tea.Quit
|
||||
}
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
|
||||
switch msg := msg.(type) {
|
||||
case tea.KeyMsg:
|
||||
if m.state == StateNewTag {
|
||||
switch msg.String() {
|
||||
case "esc":
|
||||
m.state = StateMainTable
|
||||
m.textInput.Reset()
|
||||
return m, nil
|
||||
case "enter":
|
||||
inputVal := m.textInput.Value()
|
||||
if inputVal != "" {
|
||||
lastColon := strings.LastIndex(inputVal, ":")
|
||||
var destRepo, destTag string
|
||||
if lastColon != -1 {
|
||||
destRepo = inputVal[:lastColon]
|
||||
destTag = inputVal[lastColon+1:]
|
||||
} else {
|
||||
destRepo = inputVal
|
||||
destTag = "latest"
|
||||
}
|
||||
|
||||
if destRepo != "" && destTag != "" {
|
||||
selectedRow := m.rows[m.cursor]
|
||||
m.state = StateLoading
|
||||
m.textInput.Reset()
|
||||
if m.mode == ModeLocal {
|
||||
return m, tagLocalImageCmd(m.client, selectedRow.RepoName, selectedRow.Tag, destRepo, destTag)
|
||||
}
|
||||
return m, copyImageCmd(m.client, selectedRow.RepoName, selectedRow.Tag, destRepo, destTag)
|
||||
}
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
var cmd tea.Cmd
|
||||
m.textInput, cmd = m.textInput.Update(msg)
|
||||
return m, cmd
|
||||
}
|
||||
|
||||
switch msg.String() {
|
||||
case "ctrl+c", "q":
|
||||
if m.state == StateConfirmDelete {
|
||||
m.state = StateMainTable
|
||||
return m, nil
|
||||
}
|
||||
return m, tea.Quit
|
||||
|
||||
case "up", "k":
|
||||
if m.cursor > 0 {
|
||||
m.cursor--
|
||||
}
|
||||
|
||||
case "down", "j":
|
||||
if m.cursor < len(m.rows)-1 {
|
||||
m.cursor++
|
||||
}
|
||||
|
||||
case "r":
|
||||
if m.state == StateMainTable && m.mode != ModeRegistry {
|
||||
m.state = StateLoading
|
||||
m.cursor = 0
|
||||
m.statusMsg = ""
|
||||
m.mode = ModeRegistry
|
||||
return m, fetchRegistryImagesCmd(m.client)
|
||||
}
|
||||
|
||||
case "l":
|
||||
if m.state == StateMainTable && m.mode != ModeLocal {
|
||||
m.state = StateLoading
|
||||
m.cursor = 0
|
||||
m.statusMsg = ""
|
||||
m.mode = ModeLocal
|
||||
return m, fetchLocalImagesCmd(m.client)
|
||||
}
|
||||
|
||||
case "d":
|
||||
if m.state == StateMainTable && len(m.rows) > 0 {
|
||||
m.state = StateConfirmDelete
|
||||
}
|
||||
|
||||
case "t":
|
||||
if m.state == StateMainTable && len(m.rows) > 0 {
|
||||
m.state = StateNewTag
|
||||
m.textInput.Focus()
|
||||
return m, textinput.Blink
|
||||
}
|
||||
|
||||
case "p":
|
||||
if m.state == StateMainTable && m.mode == ModeLocal && len(m.rows) > 0 {
|
||||
selectedRow := m.rows[m.cursor]
|
||||
m.state = StateLoading
|
||||
return m, pushImageCmd(m.client, selectedRow.RepoName, selectedRow.Tag)
|
||||
}
|
||||
|
||||
case "y":
|
||||
if m.state == StateConfirmDelete {
|
||||
selectedRow := m.rows[m.cursor]
|
||||
m.state = StateLoading
|
||||
if m.mode == ModeLocal {
|
||||
return m, deleteLocalImageCmd(m.client, selectedRow.RepoName, selectedRow.Tag)
|
||||
}
|
||||
return m, deleteTagCmd(m.client, selectedRow.RepoName, selectedRow.Tag)
|
||||
}
|
||||
|
||||
case "n":
|
||||
if m.state == StateConfirmDelete {
|
||||
m.state = StateMainTable
|
||||
}
|
||||
}
|
||||
|
||||
case imagesMsg:
|
||||
m.rows = msg.rows
|
||||
if msg.mode == ModeRegistry {
|
||||
m.registryRows = msg.rows
|
||||
} else {
|
||||
m.localRows = msg.rows
|
||||
}
|
||||
m.state = StateMainTable
|
||||
m.cursor = 0
|
||||
|
||||
case successMsg:
|
||||
m.statusMsg = string(msg)
|
||||
m.state = StateLoading
|
||||
if m.mode == ModeRegistry {
|
||||
return m, fetchRegistryImagesCmd(m.client)
|
||||
} else {
|
||||
return m, fetchLocalImagesCmd(m.client)
|
||||
}
|
||||
|
||||
case errorMsg:
|
||||
errVal := error(msg)
|
||||
errMsgStr := errVal.Error()
|
||||
|
||||
// Comprobamos si el error es de conexión con el registro
|
||||
if strings.Contains(errMsgStr, "connection refused") ||
|
||||
strings.Contains(errMsgStr, "dial tcp") ||
|
||||
strings.Contains(errMsgStr, "Get \"http://localhost:5000") {
|
||||
m.state = StateRegistryDown
|
||||
m.statusMsg = errMsgStr
|
||||
return m, nil
|
||||
}
|
||||
|
||||
// Error estándar para otros casos
|
||||
m.err = errVal
|
||||
m.state = StateError
|
||||
return m, nil
|
||||
}
|
||||
|
||||
return m, nil
|
||||
}
|
||||
+198
@@ -0,0 +1,198 @@
|
||||
package ui
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/charmbracelet/lipgloss"
|
||||
)
|
||||
|
||||
var (
|
||||
titleStyle = lipgloss.NewStyle().
|
||||
Bold(true).
|
||||
Foreground(lipgloss.Color("#FFFFFF")).
|
||||
Background(lipgloss.Color("#900C3F")).
|
||||
Padding(0, 1)
|
||||
|
||||
headerBgStyle = lipgloss.NewStyle().
|
||||
Bold(true).
|
||||
Foreground(lipgloss.Color("#FFFFFF")).
|
||||
Background(lipgloss.Color("#1F618D"))
|
||||
|
||||
localHeaderBgStyle = lipgloss.NewStyle().
|
||||
Bold(true).
|
||||
Foreground(lipgloss.Color("#FFFFFF")).
|
||||
Background(lipgloss.Color("#B7950B"))
|
||||
|
||||
selectedRowStyle = lipgloss.NewStyle().
|
||||
Foreground(lipgloss.Color("#FFFFFF")).
|
||||
Background(lipgloss.Color("#2980B9")).
|
||||
Bold(true)
|
||||
|
||||
normalProjectStyle = lipgloss.NewStyle().
|
||||
Foreground(lipgloss.Color("#F4D03F")).
|
||||
Bold(true)
|
||||
|
||||
normalImageStyle = lipgloss.NewStyle().
|
||||
Foreground(lipgloss.Color("#D5D8DC"))
|
||||
|
||||
fullNameStyle = lipgloss.NewStyle().
|
||||
Foreground(lipgloss.Color("#00FFFF")).
|
||||
Bold(true)
|
||||
|
||||
footerKeyStyle = lipgloss.NewStyle().
|
||||
Foreground(lipgloss.Color("#000000")).
|
||||
Background(lipgloss.Color("#F4D03F")).
|
||||
Bold(true)
|
||||
|
||||
footerDescStyle = lipgloss.NewStyle().
|
||||
Foreground(lipgloss.Color("#FFFFFF")).
|
||||
Background(lipgloss.Color("#2C3E50"))
|
||||
|
||||
errorBoxStyle = lipgloss.NewStyle().
|
||||
Foreground(lipgloss.Color("#FF5F56")).
|
||||
Bold(true)
|
||||
|
||||
successStyle = lipgloss.NewStyle().
|
||||
Foreground(lipgloss.Color("#2ECC71")).
|
||||
Bold(true)
|
||||
)
|
||||
|
||||
func (m Model) View() string {
|
||||
if m.state == StateRegistryDown {
|
||||
return m.viewRegistryDown()
|
||||
}
|
||||
|
||||
switch m.state {
|
||||
case StateLoading:
|
||||
modeText := "del Registro Docker"
|
||||
if m.mode == ModeLocal {
|
||||
modeText = "Locales de Docker"
|
||||
}
|
||||
return fmt.Sprintf("\n Cargando imágenes %s...\n", modeText)
|
||||
|
||||
case StateError:
|
||||
return fmt.Sprintf("\n %s\n\n [Presiona 'q' para salir / 'r' o 'l' para cambiar de modo]\n", errorBoxStyle.Render("Error: "+m.err.Error()))
|
||||
|
||||
case StateMainTable:
|
||||
s := ""
|
||||
modeStr := "REGISTRY"
|
||||
styleHeader := headerBgStyle
|
||||
if m.mode == ModeLocal {
|
||||
modeStr = "LOCAL"
|
||||
styleHeader = localHeaderBgStyle
|
||||
}
|
||||
|
||||
titleBar := fmt.Sprintf(" REGISTRY-TUI v%s [%s] ", m.version, modeStr)
|
||||
s += styleHeader.Width(70).Render(titleBar) + "\n"
|
||||
|
||||
s += "┌───────────────────────┬──────────────────────────────────────────┐\n"
|
||||
s += fmt.Sprintf("│ %-21s │ %-40s │\n", "PROYECTO / RUTA", "IMAGEN : VERSION")
|
||||
s += "├───────────────────────┼──────────────────────────────────────────┤\n"
|
||||
|
||||
if len(m.rows) == 0 {
|
||||
msgEmpty := "No hay imágenes en el registro"
|
||||
if m.mode == ModeLocal {
|
||||
msgEmpty = "No se encontraron imágenes locales"
|
||||
}
|
||||
s += fmt.Sprintf("│ %-21s │ %-40s │\n", "", msgEmpty)
|
||||
s += "└───────────────────────┴──────────────────────────────────────────┘\n"
|
||||
} else {
|
||||
for i, row := range m.rows {
|
||||
displayProj := row.Project
|
||||
if displayProj == "(raíz)" {
|
||||
displayProj = ""
|
||||
}
|
||||
|
||||
imageVersion := fmt.Sprintf("%s:%s", row.ImageName, row.Tag)
|
||||
isSelected := (m.cursor == i)
|
||||
|
||||
var lineContent string
|
||||
if isSelected {
|
||||
projFormatted := fmt.Sprintf(" %-21s ", displayProj)
|
||||
imgFormatted := fmt.Sprintf(" %-40s ", imageVersion)
|
||||
|
||||
renderedProj := selectedRowStyle.Render(projFormatted)
|
||||
renderedImg := selectedRowStyle.Render(imgFormatted)
|
||||
|
||||
lineContent = fmt.Sprintf("│%s│%s│", renderedProj, renderedImg)
|
||||
} else {
|
||||
projFormatted := normalProjectStyle.Render(fmt.Sprintf("%-21s", displayProj))
|
||||
imgFormatted := normalImageStyle.Render(fmt.Sprintf("%-40s", imageVersion))
|
||||
|
||||
lineContent = fmt.Sprintf("│ %s │ %s │", projFormatted, imgFormatted)
|
||||
}
|
||||
|
||||
s += lineContent + "\n"
|
||||
|
||||
if i < len(m.rows)-1 {
|
||||
s += "├───────────────────────┼──────────────────────────────────────────┤\n"
|
||||
}
|
||||
}
|
||||
s += "└───────────────────────┴──────────────────────────────────────────┘\n"
|
||||
}
|
||||
|
||||
// Ruta completa seleccionada
|
||||
if len(m.rows) > 0 {
|
||||
selected := m.rows[m.cursor]
|
||||
fullRef := fmt.Sprintf("%s:%s", selected.RepoName, selected.Tag)
|
||||
s += fmt.Sprintf(" Ruta completa: %s\n", fullNameStyle.Render(fullRef))
|
||||
} else {
|
||||
s += "\n"
|
||||
}
|
||||
|
||||
total := len(m.rows)
|
||||
statusInfo := fmt.Sprintf(" Total: %d imágenes (%s)", total, modeStr)
|
||||
if m.statusMsg != "" {
|
||||
statusInfo = " " + successStyle.Render("✔ "+m.statusMsg)
|
||||
}
|
||||
s += fmt.Sprintf("%-70s\n\n", statusInfo)
|
||||
|
||||
// Barra de botones inferior estilo MC uniforme para ambos modos
|
||||
btnR := footerKeyStyle.Render(" R ") + footerDescStyle.Render(" Registry ")
|
||||
btnL := footerKeyStyle.Render(" L ") + footerDescStyle.Render(" Local ")
|
||||
btnT := footerKeyStyle.Render(" t ") + footerDescStyle.Render(" Tagear ")
|
||||
btnD := footerKeyStyle.Render(" d ") + footerDescStyle.Render(" Borrar ")
|
||||
btnP := footerKeyStyle.Render(" p ") + footerDescStyle.Render(" Push ")
|
||||
btnQ := footerKeyStyle.Render(" q ") + footerDescStyle.Render(" Salir ")
|
||||
|
||||
if m.mode == ModeLocal {
|
||||
s += fmt.Sprintf(" %s %s %s %s %s %s\n", btnR, btnL, btnP, btnT, btnD, btnQ)
|
||||
} else {
|
||||
s += fmt.Sprintf(" %s %s %s %s %s\n", btnR, btnL, btnT, btnD, btnQ)
|
||||
}
|
||||
|
||||
return s
|
||||
|
||||
case StateConfirmDelete:
|
||||
selectedRow := m.rows[m.cursor]
|
||||
s := headerBgStyle.Width(50).Render(" Confirmar Borrado ") + "\n\n"
|
||||
s += fmt.Sprintf(" ¿Estás seguro de que deseas borrar la imagen:\n -> %s:%s?\n\n", selectedRow.RepoName, selectedRow.Tag)
|
||||
if m.mode == ModeLocal {
|
||||
s += errorBoxStyle.Render(" ¡Esta acción eliminará la imagen de tu sistema local!\n\n")
|
||||
} else {
|
||||
s += errorBoxStyle.Render(" ¡Esta acción eliminará el manifest permanentemente del registro!\n\n")
|
||||
}
|
||||
s += footerKeyStyle.Render(" y ") + footerDescStyle.Render(" Sí, borrar ") + " " + footerKeyStyle.Render(" n ") + footerDescStyle.Render(" Cancelar ")
|
||||
return s
|
||||
|
||||
case StateNewTag:
|
||||
selectedRow := m.rows[m.cursor]
|
||||
s := headerBgStyle.Width(50).Render(" Reestructurar / Tagear Imagen ") + "\n\n"
|
||||
s += fmt.Sprintf(" Origen: %s:%s\n\n", selectedRow.RepoName, selectedRow.Tag)
|
||||
s += " Introduce la ruta completa de destino\n (ej: proyecto-web/alpine:v1.0):\n\n"
|
||||
s += " " + m.textInput.View() + "\n\n"
|
||||
s += footerKeyStyle.Render(" Enter ") + footerDescStyle.Render(" Confirmar ") + " " + footerKeyStyle.Render(" Esc ") + footerDescStyle.Render(" Cancelar ")
|
||||
return s
|
||||
}
|
||||
|
||||
return ""
|
||||
}
|
||||
|
||||
func (m Model) viewRegistryDown() string {
|
||||
s := titleStyle.Render(" ⚠️ ADVERTENCIA DE CONEXIÓN - REGISTRY-TUI ") + "\n\n"
|
||||
s += fmt.Sprintf(" No se puede conectar al Registry local:\n %s\n\n", m.statusMsg)
|
||||
s += " ─────────────────────────────────────────────────────────\n"
|
||||
s += " [ENTER / L] Cambiar directamente al modo LOCAL\n"
|
||||
s += " [ q ] Salir de la aplicación\n"
|
||||
return lipgloss.NewStyle().Margin(2, 4).Render(s)
|
||||
}
|
||||
Reference in new issue
Block a user