refactor: mover apps TUI de fn_operations/ a apps/
Separa aplicaciones ejecutables (docker_tui, pipeline_launcher) de la librería fn_operations. La carpeta apps/ contiene módulos Go independientes, fn_operations/ queda como librería pura de models/store/operations. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,201 @@
|
||||
package views
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
"github.com/lucasdataproyects/devfactory/tui"
|
||||
)
|
||||
|
||||
type composeState int
|
||||
|
||||
const (
|
||||
composeLoading composeState = iota
|
||||
composeList
|
||||
composeAction
|
||||
composeLogs
|
||||
)
|
||||
|
||||
type composeLoadedMsg []ComposeService
|
||||
type composeActionMsg struct{ output string; err error }
|
||||
type composeLogsMsg struct{ output string; err error }
|
||||
|
||||
type ComposeModel struct {
|
||||
state composeState
|
||||
list tui.ListModel
|
||||
spinner tui.SpinnerModel
|
||||
styles tui.Styles
|
||||
services []ComposeService
|
||||
output string
|
||||
scrollOff int
|
||||
err error
|
||||
}
|
||||
|
||||
func NewComposeModel(styles tui.Styles) ComposeModel {
|
||||
return ComposeModel{
|
||||
state: composeLoading,
|
||||
list: tui.NewList(nil),
|
||||
spinner: tui.NewSpinner("Loading compose services..."),
|
||||
styles: styles,
|
||||
}
|
||||
}
|
||||
|
||||
func (m ComposeModel) Init() tea.Cmd {
|
||||
return tea.Batch(m.spinner.Init(), loadCompose)
|
||||
}
|
||||
|
||||
func loadCompose() tea.Msg {
|
||||
services, err := ComposePS()
|
||||
if err != nil {
|
||||
return composeLoadedMsg(nil)
|
||||
}
|
||||
return composeLoadedMsg(services)
|
||||
}
|
||||
|
||||
func (m ComposeModel) Update(msg tea.Msg) (ComposeModel, tea.Cmd) {
|
||||
switch msg := msg.(type) {
|
||||
case composeLoadedMsg:
|
||||
m.services = []ComposeService(msg)
|
||||
items := make([]tui.ListItem, 0, len(m.services)+2)
|
||||
// Add action items at the top
|
||||
items = append(items,
|
||||
tui.ListItem{Title: "▶ Compose Up", Description: "docker compose up -d", Value: "up"},
|
||||
tui.ListItem{Title: "■ Compose Down", Description: "docker compose down", Value: "down"},
|
||||
)
|
||||
for _, s := range m.services {
|
||||
stateIcon := "●"
|
||||
if s.State == "running" {
|
||||
stateIcon = "▶"
|
||||
}
|
||||
items = append(items, tui.ListItem{
|
||||
Title: fmt.Sprintf("%s %s", stateIcon, s.Name),
|
||||
Description: fmt.Sprintf("Service: %s — %s", s.Service, s.Status),
|
||||
Value: s,
|
||||
})
|
||||
}
|
||||
m.list.SetItems(items)
|
||||
m.state = composeList
|
||||
return m, nil
|
||||
|
||||
case composeActionMsg:
|
||||
m.output = msg.output
|
||||
if msg.err != nil {
|
||||
m.output = fmt.Sprintf("Error: %v", msg.err)
|
||||
}
|
||||
m.state = composeList
|
||||
return m, loadCompose
|
||||
|
||||
case composeLogsMsg:
|
||||
m.output = msg.output
|
||||
if msg.err != nil {
|
||||
m.output = fmt.Sprintf("Error: %v", msg.err)
|
||||
}
|
||||
m.state = composeLogs
|
||||
m.scrollOff = 0
|
||||
return m, nil
|
||||
|
||||
case tea.KeyMsg:
|
||||
switch m.state {
|
||||
case composeList:
|
||||
switch msg.String() {
|
||||
case "r":
|
||||
m.state = composeLoading
|
||||
return m, tea.Batch(m.spinner.Init(), loadCompose)
|
||||
case "l":
|
||||
m.state = composeAction
|
||||
return m, func() tea.Msg {
|
||||
output, err := ComposeLogs(100)
|
||||
return composeLogsMsg{output: output, err: err}
|
||||
}
|
||||
case "enter":
|
||||
if item := m.list.SelectedItem(); item != nil {
|
||||
switch v := item.Value.(type) {
|
||||
case string:
|
||||
m.state = composeAction
|
||||
if v == "up" {
|
||||
return m, func() tea.Msg {
|
||||
output, err := ComposeUp()
|
||||
return composeActionMsg{output: output, err: err}
|
||||
}
|
||||
}
|
||||
return m, func() tea.Msg {
|
||||
output, err := ComposeDown()
|
||||
return composeActionMsg{output: output, err: err}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
case composeLogs:
|
||||
switch msg.String() {
|
||||
case "j", "down":
|
||||
m.scrollOff++
|
||||
case "k", "up":
|
||||
if m.scrollOff > 0 {
|
||||
m.scrollOff--
|
||||
}
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
}
|
||||
|
||||
var cmd tea.Cmd
|
||||
switch m.state {
|
||||
case composeLoading, composeAction:
|
||||
var model tea.Model
|
||||
model, cmd = m.spinner.Update(msg)
|
||||
m.spinner = model.(tui.SpinnerModel)
|
||||
case composeList:
|
||||
var model tea.Model
|
||||
model, cmd = m.list.Update(msg)
|
||||
m.list = model.(tui.ListModel)
|
||||
}
|
||||
return m, cmd
|
||||
}
|
||||
|
||||
// HandleBack retrocede un nivel. Retorna true si ya estaba en estado base.
|
||||
func (m *ComposeModel) HandleBack() bool {
|
||||
switch m.state {
|
||||
case composeLogs:
|
||||
m.state = composeList
|
||||
return false
|
||||
default:
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
func (m ComposeModel) View() string {
|
||||
switch m.state {
|
||||
case composeLoading, composeAction:
|
||||
return m.spinner.View()
|
||||
case composeList:
|
||||
if len(m.services) == 0 {
|
||||
help := m.styles.Muted.Render(" No compose services. Use Enter on 'Compose Up' or press 'r' to refresh.")
|
||||
return m.list.View() + "\n" + help
|
||||
}
|
||||
help := m.styles.Muted.Render(" Enter: up/down │ l: logs │ r: refresh")
|
||||
return m.list.View() + "\n" + help
|
||||
case composeLogs:
|
||||
return m.renderLogs()
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (m ComposeModel) renderLogs() string {
|
||||
lines := strings.Split(m.output, "\n")
|
||||
if len(lines) == 0 {
|
||||
lines = []string{"(empty)"}
|
||||
}
|
||||
maxLines := 20
|
||||
if m.scrollOff >= len(lines) {
|
||||
m.scrollOff = max(0, len(lines)-1)
|
||||
}
|
||||
end := min(m.scrollOff+maxLines, len(lines))
|
||||
visible := lines[m.scrollOff:end]
|
||||
|
||||
header := m.styles.Header.Render("Compose Logs")
|
||||
content := strings.Join(visible, "\n")
|
||||
help := m.styles.Muted.Render(" j/k: scroll │ Esc: back")
|
||||
|
||||
return header + "\n" + content + "\n" + help
|
||||
}
|
||||
@@ -0,0 +1,251 @@
|
||||
package views
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
"github.com/charmbracelet/lipgloss"
|
||||
"github.com/lucasdataproyects/devfactory/tui"
|
||||
)
|
||||
|
||||
type containersState int
|
||||
|
||||
const (
|
||||
containersLoading containersState = iota
|
||||
containersList
|
||||
containersAction
|
||||
containersLogs
|
||||
)
|
||||
|
||||
type containersLoadedMsg []DockerContainer
|
||||
type containersActionMsg struct{ output string; err error }
|
||||
type containersLogsMsg struct{ output string; err error }
|
||||
|
||||
type ContainersModel struct {
|
||||
state containersState
|
||||
list tui.FilteredListModel
|
||||
spinner tui.SpinnerModel
|
||||
styles tui.Styles
|
||||
containers []DockerContainer
|
||||
output string
|
||||
scrollOff int
|
||||
err error
|
||||
}
|
||||
|
||||
func NewContainersModel(styles tui.Styles) ContainersModel {
|
||||
return ContainersModel{
|
||||
state: containersLoading,
|
||||
list: tui.NewFilteredList(nil, "Filter containers..."),
|
||||
spinner: tui.NewSpinner("Loading containers..."),
|
||||
styles: styles,
|
||||
}
|
||||
}
|
||||
|
||||
func (m ContainersModel) Init() tea.Cmd {
|
||||
return tea.Batch(
|
||||
m.spinner.Init(),
|
||||
loadContainers,
|
||||
)
|
||||
}
|
||||
|
||||
func loadContainers() tea.Msg {
|
||||
containers, err := ListContainers()
|
||||
if err != nil {
|
||||
return containersLoadedMsg(nil)
|
||||
}
|
||||
return containersLoadedMsg(containers)
|
||||
}
|
||||
|
||||
func (m ContainersModel) Update(msg tea.Msg) (ContainersModel, tea.Cmd) {
|
||||
switch msg := msg.(type) {
|
||||
case containersLoadedMsg:
|
||||
m.containers = []DockerContainer(msg)
|
||||
items := make([]tui.ListItem, len(m.containers))
|
||||
for i, c := range m.containers {
|
||||
stateIcon := "●"
|
||||
if c.State == "running" {
|
||||
stateIcon = "▶"
|
||||
} else if c.State == "exited" {
|
||||
stateIcon = "■"
|
||||
}
|
||||
items[i] = tui.ListItem{
|
||||
Title: fmt.Sprintf("%s %s", stateIcon, c.Names),
|
||||
Description: fmt.Sprintf("%s — %s", c.Image, c.Status),
|
||||
Value: c,
|
||||
}
|
||||
}
|
||||
m.list.SetItems(items)
|
||||
m.state = containersList
|
||||
return m, nil
|
||||
|
||||
case containersActionMsg:
|
||||
m.output = msg.output
|
||||
if msg.err != nil {
|
||||
m.output = fmt.Sprintf("Error: %v", msg.err)
|
||||
}
|
||||
m.state = containersList
|
||||
// Refresh after action
|
||||
return m, loadContainers
|
||||
|
||||
case containersLogsMsg:
|
||||
m.output = msg.output
|
||||
if msg.err != nil {
|
||||
m.output = fmt.Sprintf("Error: %v", msg.err)
|
||||
}
|
||||
m.state = containersLogs
|
||||
m.scrollOff = 0
|
||||
return m, nil
|
||||
|
||||
case tea.KeyMsg:
|
||||
switch m.state {
|
||||
case containersList:
|
||||
switch msg.String() {
|
||||
case "r":
|
||||
m.state = containersLoading
|
||||
return m, tea.Batch(m.spinner.Init(), loadContainers)
|
||||
case "enter":
|
||||
if item := m.list.SelectedItem(); item != nil {
|
||||
c := item.Value.(DockerContainer)
|
||||
if c.State == "running" {
|
||||
return m, stopContainerCmd(c.ID)
|
||||
}
|
||||
return m, startContainerCmd(c.ID)
|
||||
}
|
||||
case "l":
|
||||
if item := m.list.SelectedItem(); item != nil {
|
||||
c := item.Value.(DockerContainer)
|
||||
m.state = containersAction
|
||||
return m, logsContainerCmd(c.ID)
|
||||
}
|
||||
case "x":
|
||||
if item := m.list.SelectedItem(); item != nil {
|
||||
c := item.Value.(DockerContainer)
|
||||
return m, restartContainerCmd(c.ID)
|
||||
}
|
||||
}
|
||||
case containersLogs:
|
||||
switch msg.String() {
|
||||
case "j", "down":
|
||||
m.scrollOff++
|
||||
case "k", "up":
|
||||
if m.scrollOff > 0 {
|
||||
m.scrollOff--
|
||||
}
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
}
|
||||
|
||||
// Delegate to sub-components
|
||||
var cmd tea.Cmd
|
||||
switch m.state {
|
||||
case containersLoading:
|
||||
var spinnerModel tea.Model
|
||||
spinnerModel, cmd = m.spinner.Update(msg)
|
||||
m.spinner = spinnerModel.(tui.SpinnerModel)
|
||||
case containersList:
|
||||
var listModel tea.Model
|
||||
listModel, cmd = m.list.Update(msg)
|
||||
m.list = listModel.(tui.FilteredListModel)
|
||||
}
|
||||
return m, cmd
|
||||
}
|
||||
|
||||
// HandleBack retrocede un nivel. Retorna true si ya estaba en estado base (el caller debe salir).
|
||||
func (m *ContainersModel) HandleBack() bool {
|
||||
switch m.state {
|
||||
case containersLogs:
|
||||
m.state = containersList
|
||||
return false
|
||||
default:
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
func (m ContainersModel) View() string {
|
||||
switch m.state {
|
||||
case containersLoading:
|
||||
return m.spinner.View()
|
||||
case containersList:
|
||||
if len(m.containers) == 0 {
|
||||
return m.styles.Muted.Render("No containers found. Press 'r' to refresh.")
|
||||
}
|
||||
help := m.styles.Muted.Render(" Enter: start/stop │ l: logs │ x: restart │ r: refresh │ /: filter")
|
||||
return m.list.View() + "\n" + help
|
||||
case containersAction:
|
||||
return m.spinner.View()
|
||||
case containersLogs:
|
||||
return m.renderOutput()
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (m ContainersModel) renderOutput() string {
|
||||
lines := splitLines(m.output)
|
||||
maxLines := 20
|
||||
if m.scrollOff >= len(lines) {
|
||||
m.scrollOff = max(0, len(lines)-1)
|
||||
}
|
||||
end := min(m.scrollOff+maxLines, len(lines))
|
||||
visible := lines[m.scrollOff:end]
|
||||
|
||||
header := m.styles.Header.Render("Container Logs")
|
||||
content := lipgloss.JoinVertical(lipgloss.Left, visible...)
|
||||
help := m.styles.Muted.Render(" j/k: scroll │ Esc: back")
|
||||
|
||||
return header + "\n" + content + "\n" + help
|
||||
}
|
||||
|
||||
func startContainerCmd(id string) tea.Cmd {
|
||||
return func() tea.Msg {
|
||||
err := StartContainer(id)
|
||||
return containersActionMsg{output: "Started " + id, err: err}
|
||||
}
|
||||
}
|
||||
|
||||
func stopContainerCmd(id string) tea.Cmd {
|
||||
return func() tea.Msg {
|
||||
err := StopContainer(id)
|
||||
return containersActionMsg{output: "Stopped " + id, err: err}
|
||||
}
|
||||
}
|
||||
|
||||
func restartContainerCmd(id string) tea.Cmd {
|
||||
return func() tea.Msg {
|
||||
err := RestartContainer(id)
|
||||
return containersActionMsg{output: "Restarted " + id, err: err}
|
||||
}
|
||||
}
|
||||
|
||||
func logsContainerCmd(id string) tea.Cmd {
|
||||
return func() tea.Msg {
|
||||
output, err := ContainerLogs(id, 100)
|
||||
return containersLogsMsg{output: output, err: err}
|
||||
}
|
||||
}
|
||||
|
||||
func splitLines(s string) []string {
|
||||
if s == "" {
|
||||
return []string{"(empty)"}
|
||||
}
|
||||
lines := strings.Split(s, "\n")
|
||||
if len(lines) == 0 {
|
||||
return []string{"(empty)"}
|
||||
}
|
||||
return lines
|
||||
}
|
||||
|
||||
func max(a, b int) int {
|
||||
if a > b {
|
||||
return a
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
||||
func min(a, b int) int {
|
||||
if a < b {
|
||||
return a
|
||||
}
|
||||
return b
|
||||
}
|
||||
@@ -0,0 +1,192 @@
|
||||
package views
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/lucasdataproyects/devfactory/shell"
|
||||
)
|
||||
|
||||
const dockerTimeout = 15 * time.Second
|
||||
|
||||
// --- Containers ---
|
||||
|
||||
func ListContainers() ([]DockerContainer, error) {
|
||||
result := shell.RunWithTimeout("docker", dockerTimeout, "ps", "-a", "--format", "{{json .}}")
|
||||
stdout, err := result.Both()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return parseJSONLines[DockerContainer](stdout.Stdout)
|
||||
}
|
||||
|
||||
func StartContainer(id string) error {
|
||||
_, err := shell.RunWithTimeout("docker", dockerTimeout, "start", id).Both()
|
||||
return err
|
||||
}
|
||||
|
||||
func StopContainer(id string) error {
|
||||
_, err := shell.RunWithTimeout("docker", dockerTimeout, "stop", id).Both()
|
||||
return err
|
||||
}
|
||||
|
||||
func RestartContainer(id string) error {
|
||||
_, err := shell.RunWithTimeout("docker", dockerTimeout, "restart", id).Both()
|
||||
return err
|
||||
}
|
||||
|
||||
func ContainerLogs(id string, lines int) (string, error) {
|
||||
result := shell.RunWithTimeout("docker", dockerTimeout, "logs", "--tail", itoa(lines), id)
|
||||
out, err := result.Both()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
// docker logs writes to both stdout and stderr
|
||||
output := out.Stdout
|
||||
if out.Stderr != "" {
|
||||
if output != "" {
|
||||
output += "\n"
|
||||
}
|
||||
output += out.Stderr
|
||||
}
|
||||
return output, nil
|
||||
}
|
||||
|
||||
// --- Images ---
|
||||
|
||||
func ListImages() ([]DockerImage, error) {
|
||||
result := shell.RunWithTimeout("docker", dockerTimeout, "image", "ls", "--format", "{{json .}}")
|
||||
stdout, err := result.Both()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return parseJSONLines[DockerImage](stdout.Stdout)
|
||||
}
|
||||
|
||||
func PullImage(name string) (string, error) {
|
||||
result := shell.RunWithTimeout("docker", 120*time.Second, "pull", name)
|
||||
out, err := result.Both()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return out.Stdout, nil
|
||||
}
|
||||
|
||||
func RemoveImage(id string) error {
|
||||
_, err := shell.RunWithTimeout("docker", dockerTimeout, "rmi", id).Both()
|
||||
return err
|
||||
}
|
||||
|
||||
// --- Volumes ---
|
||||
|
||||
func ListVolumes() ([]DockerVolume, error) {
|
||||
result := shell.RunWithTimeout("docker", dockerTimeout, "volume", "ls", "--format", "{{json .}}")
|
||||
stdout, err := result.Both()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return parseJSONLines[DockerVolume](stdout.Stdout)
|
||||
}
|
||||
|
||||
func CreateVolume(name string) error {
|
||||
args := []string{"volume", "create"}
|
||||
if name != "" {
|
||||
args = append(args, name)
|
||||
}
|
||||
_, err := shell.RunWithTimeout("docker", dockerTimeout, args...).Both()
|
||||
return err
|
||||
}
|
||||
|
||||
func RemoveVolume(name string) error {
|
||||
_, err := shell.RunWithTimeout("docker", dockerTimeout, "volume", "rm", name).Both()
|
||||
return err
|
||||
}
|
||||
|
||||
// --- Networks ---
|
||||
|
||||
func ListNetworks() ([]DockerNetwork, error) {
|
||||
result := shell.RunWithTimeout("docker", dockerTimeout, "network", "ls", "--format", "{{json .}}")
|
||||
stdout, err := result.Both()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return parseJSONLines[DockerNetwork](stdout.Stdout)
|
||||
}
|
||||
|
||||
func CreateNetwork(name string) error {
|
||||
_, err := shell.RunWithTimeout("docker", dockerTimeout, "network", "create", name).Both()
|
||||
return err
|
||||
}
|
||||
|
||||
func RemoveNetwork(name string) error {
|
||||
_, err := shell.RunWithTimeout("docker", dockerTimeout, "network", "rm", name).Both()
|
||||
return err
|
||||
}
|
||||
|
||||
// --- Compose ---
|
||||
|
||||
func ComposePS() ([]ComposeService, error) {
|
||||
result := shell.RunWithTimeout("docker", dockerTimeout, "compose", "ps", "--format", "json")
|
||||
stdout, err := result.Both()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// docker compose ps --format json returns a JSON array
|
||||
var services []ComposeService
|
||||
if err := json.Unmarshal([]byte(stdout.Stdout), &services); err != nil {
|
||||
// Try line-by-line as fallback
|
||||
return parseJSONLines[ComposeService](stdout.Stdout)
|
||||
}
|
||||
return services, nil
|
||||
}
|
||||
|
||||
func ComposeUp() (string, error) {
|
||||
result := shell.RunWithTimeout("docker", 120*time.Second, "compose", "up", "-d")
|
||||
out, err := result.Both()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return out.Stdout + out.Stderr, nil
|
||||
}
|
||||
|
||||
func ComposeDown() (string, error) {
|
||||
result := shell.RunWithTimeout("docker", 60*time.Second, "compose", "down")
|
||||
out, err := result.Both()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return out.Stdout + out.Stderr, nil
|
||||
}
|
||||
|
||||
func ComposeLogs(lines int) (string, error) {
|
||||
result := shell.RunWithTimeout("docker", dockerTimeout, "compose", "logs", "--tail", itoa(lines))
|
||||
out, err := result.Both()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return out.Stdout + out.Stderr, nil
|
||||
}
|
||||
|
||||
// --- Helpers ---
|
||||
|
||||
func parseJSONLines[T any](s string) ([]T, error) {
|
||||
var result []T
|
||||
for _, line := range strings.Split(strings.TrimSpace(s), "\n") {
|
||||
line = strings.TrimSpace(line)
|
||||
if line == "" {
|
||||
continue
|
||||
}
|
||||
var item T
|
||||
if err := json.Unmarshal([]byte(line), &item); err != nil {
|
||||
continue
|
||||
}
|
||||
result = append(result, item)
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func itoa(n int) string {
|
||||
return fmt.Sprintf("%d", n)
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
package views
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
"github.com/lucasdataproyects/devfactory/tui"
|
||||
)
|
||||
|
||||
type imagesState int
|
||||
|
||||
const (
|
||||
imagesLoading imagesState = iota
|
||||
imagesList
|
||||
imagesAction
|
||||
)
|
||||
|
||||
type imagesLoadedMsg []DockerImage
|
||||
type imagesActionMsg struct{ output string; err error }
|
||||
|
||||
type ImagesModel struct {
|
||||
state imagesState
|
||||
list tui.FilteredListModel
|
||||
spinner tui.SpinnerModel
|
||||
styles tui.Styles
|
||||
images []DockerImage
|
||||
err error
|
||||
}
|
||||
|
||||
func NewImagesModel(styles tui.Styles) ImagesModel {
|
||||
return ImagesModel{
|
||||
state: imagesLoading,
|
||||
list: tui.NewFilteredList(nil, "Filter images..."),
|
||||
spinner: tui.NewSpinner("Loading images..."),
|
||||
styles: styles,
|
||||
}
|
||||
}
|
||||
|
||||
func (m ImagesModel) Init() tea.Cmd {
|
||||
return tea.Batch(m.spinner.Init(), loadImages)
|
||||
}
|
||||
|
||||
func loadImages() tea.Msg {
|
||||
images, err := ListImages()
|
||||
if err != nil {
|
||||
return imagesLoadedMsg(nil)
|
||||
}
|
||||
return imagesLoadedMsg(images)
|
||||
}
|
||||
|
||||
func (m ImagesModel) Update(msg tea.Msg) (ImagesModel, tea.Cmd) {
|
||||
switch msg := msg.(type) {
|
||||
case imagesLoadedMsg:
|
||||
m.images = []DockerImage(msg)
|
||||
items := make([]tui.ListItem, len(m.images))
|
||||
for i, img := range m.images {
|
||||
tag := img.Tag
|
||||
if tag == "" {
|
||||
tag = "latest"
|
||||
}
|
||||
items[i] = tui.ListItem{
|
||||
Title: fmt.Sprintf("%s:%s", img.Repository, tag),
|
||||
Description: fmt.Sprintf("Size: %s — %s", img.Size, img.ID[:12]),
|
||||
Value: img,
|
||||
}
|
||||
}
|
||||
m.list.SetItems(items)
|
||||
m.state = imagesList
|
||||
return m, nil
|
||||
|
||||
case imagesActionMsg:
|
||||
if msg.err != nil {
|
||||
m.err = msg.err
|
||||
}
|
||||
m.state = imagesList
|
||||
return m, loadImages
|
||||
|
||||
case tea.KeyMsg:
|
||||
if m.state == imagesList {
|
||||
switch msg.String() {
|
||||
case "r":
|
||||
m.state = imagesLoading
|
||||
return m, tea.Batch(m.spinner.Init(), loadImages)
|
||||
case "d", "delete":
|
||||
if item := m.list.SelectedItem(); item != nil {
|
||||
img := item.Value.(DockerImage)
|
||||
m.state = imagesAction
|
||||
return m, func() tea.Msg {
|
||||
err := RemoveImage(img.ID)
|
||||
return imagesActionMsg{output: "Removed", err: err}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var cmd tea.Cmd
|
||||
switch m.state {
|
||||
case imagesLoading, imagesAction:
|
||||
var model tea.Model
|
||||
model, cmd = m.spinner.Update(msg)
|
||||
m.spinner = model.(tui.SpinnerModel)
|
||||
case imagesList:
|
||||
var model tea.Model
|
||||
model, cmd = m.list.Update(msg)
|
||||
m.list = model.(tui.FilteredListModel)
|
||||
}
|
||||
return m, cmd
|
||||
}
|
||||
|
||||
// HandleBack retrocede un nivel. Retorna true si ya estaba en estado base.
|
||||
func (m *ImagesModel) HandleBack() bool {
|
||||
return true
|
||||
}
|
||||
|
||||
func (m ImagesModel) View() string {
|
||||
switch m.state {
|
||||
case imagesLoading, imagesAction:
|
||||
return m.spinner.View()
|
||||
case imagesList:
|
||||
if len(m.images) == 0 {
|
||||
return m.styles.Muted.Render("No images found. Press 'r' to refresh.")
|
||||
}
|
||||
help := m.styles.Muted.Render(" d: remove │ r: refresh │ /: filter")
|
||||
view := m.list.View() + "\n" + help
|
||||
if m.err != nil {
|
||||
view += "\n" + m.styles.Error.Render(fmt.Sprintf(" Error: %v", m.err))
|
||||
}
|
||||
return view
|
||||
}
|
||||
return ""
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
package views
|
||||
|
||||
// Navigation key constants.
|
||||
const (
|
||||
KeyQuit = "ctrl+c"
|
||||
KeyEsc = "esc"
|
||||
KeyBack = "0"
|
||||
KeyTab = "tab"
|
||||
)
|
||||
|
||||
// IsBack returns true if the key should trigger back navigation.
|
||||
func IsBack(key string) bool {
|
||||
return key == KeyEsc || key == KeyBack
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
package views
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
"github.com/lucasdataproyects/devfactory/tui"
|
||||
)
|
||||
|
||||
type networksState int
|
||||
|
||||
const (
|
||||
networksLoading networksState = iota
|
||||
networksList
|
||||
networksAction
|
||||
)
|
||||
|
||||
type networksLoadedMsg []DockerNetwork
|
||||
type networksActionMsg struct{ output string; err error }
|
||||
|
||||
type NetworksModel struct {
|
||||
state networksState
|
||||
list tui.ListModel
|
||||
spinner tui.SpinnerModel
|
||||
styles tui.Styles
|
||||
networks []DockerNetwork
|
||||
err error
|
||||
}
|
||||
|
||||
func NewNetworksModel(styles tui.Styles) NetworksModel {
|
||||
return NetworksModel{
|
||||
state: networksLoading,
|
||||
list: tui.NewList(nil),
|
||||
spinner: tui.NewSpinner("Loading networks..."),
|
||||
styles: styles,
|
||||
}
|
||||
}
|
||||
|
||||
func (m NetworksModel) Init() tea.Cmd {
|
||||
return tea.Batch(m.spinner.Init(), loadNetworks)
|
||||
}
|
||||
|
||||
func loadNetworks() tea.Msg {
|
||||
networks, err := ListNetworks()
|
||||
if err != nil {
|
||||
return networksLoadedMsg(nil)
|
||||
}
|
||||
return networksLoadedMsg(networks)
|
||||
}
|
||||
|
||||
func (m NetworksModel) Update(msg tea.Msg) (NetworksModel, tea.Cmd) {
|
||||
switch msg := msg.(type) {
|
||||
case networksLoadedMsg:
|
||||
m.networks = []DockerNetwork(msg)
|
||||
items := make([]tui.ListItem, len(m.networks))
|
||||
for i, n := range m.networks {
|
||||
items[i] = tui.ListItem{
|
||||
Title: n.Name,
|
||||
Description: fmt.Sprintf("Driver: %s — Scope: %s", n.Driver, n.Scope),
|
||||
Value: n,
|
||||
}
|
||||
}
|
||||
m.list.SetItems(items)
|
||||
m.state = networksList
|
||||
return m, nil
|
||||
|
||||
case networksActionMsg:
|
||||
if msg.err != nil {
|
||||
m.err = msg.err
|
||||
}
|
||||
m.state = networksList
|
||||
return m, loadNetworks
|
||||
|
||||
case tea.KeyMsg:
|
||||
if m.state == networksList {
|
||||
switch msg.String() {
|
||||
case "r":
|
||||
m.state = networksLoading
|
||||
return m, tea.Batch(m.spinner.Init(), loadNetworks)
|
||||
case "d", "delete":
|
||||
if item := m.list.SelectedItem(); item != nil {
|
||||
net := item.Value.(DockerNetwork)
|
||||
m.state = networksAction
|
||||
return m, func() tea.Msg {
|
||||
err := RemoveNetwork(net.Name)
|
||||
return networksActionMsg{output: "Removed", err: err}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var cmd tea.Cmd
|
||||
switch m.state {
|
||||
case networksLoading, networksAction:
|
||||
var model tea.Model
|
||||
model, cmd = m.spinner.Update(msg)
|
||||
m.spinner = model.(tui.SpinnerModel)
|
||||
case networksList:
|
||||
var model tea.Model
|
||||
model, cmd = m.list.Update(msg)
|
||||
m.list = model.(tui.ListModel)
|
||||
}
|
||||
return m, cmd
|
||||
}
|
||||
|
||||
// HandleBack retrocede un nivel. Retorna true si ya estaba en estado base.
|
||||
func (m *NetworksModel) HandleBack() bool {
|
||||
return true
|
||||
}
|
||||
|
||||
func (m NetworksModel) View() string {
|
||||
switch m.state {
|
||||
case networksLoading, networksAction:
|
||||
return m.spinner.View()
|
||||
case networksList:
|
||||
if len(m.networks) == 0 {
|
||||
return m.styles.Muted.Render("No networks found. Press 'r' to refresh.")
|
||||
}
|
||||
help := m.styles.Muted.Render(" d: remove │ r: refresh")
|
||||
view := m.list.View() + "\n" + help
|
||||
if m.err != nil {
|
||||
view += "\n" + m.styles.Error.Render(fmt.Sprintf(" Error: %v", m.err))
|
||||
}
|
||||
return view
|
||||
}
|
||||
return ""
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
package views
|
||||
|
||||
// DockerContainer represents a container from docker ps --format json.
|
||||
type DockerContainer struct {
|
||||
ID string `json:"ID"`
|
||||
Names string `json:"Names"`
|
||||
Image string `json:"Image"`
|
||||
Status string `json:"Status"`
|
||||
State string `json:"State"`
|
||||
Ports string `json:"Ports"`
|
||||
}
|
||||
|
||||
// DockerImage represents an image from docker image ls --format json.
|
||||
type DockerImage struct {
|
||||
ID string `json:"ID"`
|
||||
Repository string `json:"Repository"`
|
||||
Tag string `json:"Tag"`
|
||||
Size string `json:"Size"`
|
||||
CreatedAt string `json:"CreatedAt"`
|
||||
}
|
||||
|
||||
// DockerVolume represents a volume from docker volume ls --format json.
|
||||
type DockerVolume struct {
|
||||
Name string `json:"Name"`
|
||||
Driver string `json:"Driver"`
|
||||
Mountpoint string `json:"Mountpoint"`
|
||||
}
|
||||
|
||||
// DockerNetwork represents a network from docker network ls --format json.
|
||||
type DockerNetwork struct {
|
||||
ID string `json:"ID"`
|
||||
Name string `json:"Name"`
|
||||
Driver string `json:"Driver"`
|
||||
Scope string `json:"Scope"`
|
||||
}
|
||||
|
||||
// ComposeService represents a compose service from docker compose ps --format json.
|
||||
type ComposeService struct {
|
||||
ID string `json:"ID"`
|
||||
Name string `json:"Name"`
|
||||
Service string `json:"Service"`
|
||||
State string `json:"State"`
|
||||
Status string `json:"Status"`
|
||||
Ports string `json:"Ports"`
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
package views
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
"github.com/lucasdataproyects/devfactory/tui"
|
||||
)
|
||||
|
||||
type volumesState int
|
||||
|
||||
const (
|
||||
volumesLoading volumesState = iota
|
||||
volumesList
|
||||
volumesAction
|
||||
)
|
||||
|
||||
type volumesLoadedMsg []DockerVolume
|
||||
type volumesActionMsg struct{ output string; err error }
|
||||
|
||||
type VolumesModel struct {
|
||||
state volumesState
|
||||
list tui.ListModel
|
||||
spinner tui.SpinnerModel
|
||||
styles tui.Styles
|
||||
volumes []DockerVolume
|
||||
err error
|
||||
}
|
||||
|
||||
func NewVolumesModel(styles tui.Styles) VolumesModel {
|
||||
return VolumesModel{
|
||||
state: volumesLoading,
|
||||
list: tui.NewList(nil),
|
||||
spinner: tui.NewSpinner("Loading volumes..."),
|
||||
styles: styles,
|
||||
}
|
||||
}
|
||||
|
||||
func (m VolumesModel) Init() tea.Cmd {
|
||||
return tea.Batch(m.spinner.Init(), loadVolumes)
|
||||
}
|
||||
|
||||
func loadVolumes() tea.Msg {
|
||||
volumes, err := ListVolumes()
|
||||
if err != nil {
|
||||
return volumesLoadedMsg(nil)
|
||||
}
|
||||
return volumesLoadedMsg(volumes)
|
||||
}
|
||||
|
||||
func (m VolumesModel) Update(msg tea.Msg) (VolumesModel, tea.Cmd) {
|
||||
switch msg := msg.(type) {
|
||||
case volumesLoadedMsg:
|
||||
m.volumes = []DockerVolume(msg)
|
||||
items := make([]tui.ListItem, len(m.volumes))
|
||||
for i, v := range m.volumes {
|
||||
items[i] = tui.ListItem{
|
||||
Title: v.Name,
|
||||
Description: fmt.Sprintf("Driver: %s", v.Driver),
|
||||
Value: v,
|
||||
}
|
||||
}
|
||||
m.list.SetItems(items)
|
||||
m.state = volumesList
|
||||
return m, nil
|
||||
|
||||
case volumesActionMsg:
|
||||
if msg.err != nil {
|
||||
m.err = msg.err
|
||||
}
|
||||
m.state = volumesList
|
||||
return m, loadVolumes
|
||||
|
||||
case tea.KeyMsg:
|
||||
if m.state == volumesList {
|
||||
switch msg.String() {
|
||||
case "r":
|
||||
m.state = volumesLoading
|
||||
return m, tea.Batch(m.spinner.Init(), loadVolumes)
|
||||
case "d", "delete":
|
||||
if item := m.list.SelectedItem(); item != nil {
|
||||
vol := item.Value.(DockerVolume)
|
||||
m.state = volumesAction
|
||||
return m, func() tea.Msg {
|
||||
err := RemoveVolume(vol.Name)
|
||||
return volumesActionMsg{output: "Removed", err: err}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var cmd tea.Cmd
|
||||
switch m.state {
|
||||
case volumesLoading, volumesAction:
|
||||
var model tea.Model
|
||||
model, cmd = m.spinner.Update(msg)
|
||||
m.spinner = model.(tui.SpinnerModel)
|
||||
case volumesList:
|
||||
var model tea.Model
|
||||
model, cmd = m.list.Update(msg)
|
||||
m.list = model.(tui.ListModel)
|
||||
}
|
||||
return m, cmd
|
||||
}
|
||||
|
||||
// HandleBack retrocede un nivel. Retorna true si ya estaba en estado base.
|
||||
func (m *VolumesModel) HandleBack() bool {
|
||||
return true
|
||||
}
|
||||
|
||||
func (m VolumesModel) View() string {
|
||||
switch m.state {
|
||||
case volumesLoading, volumesAction:
|
||||
return m.spinner.View()
|
||||
case volumesList:
|
||||
if len(m.volumes) == 0 {
|
||||
return m.styles.Muted.Render("No volumes found. Press 'r' to refresh.")
|
||||
}
|
||||
help := m.styles.Muted.Render(" d: remove │ r: refresh")
|
||||
view := m.list.View() + "\n" + help
|
||||
if m.err != nil {
|
||||
view += "\n" + m.styles.Error.Render(fmt.Sprintf(" Error: %v", m.err))
|
||||
}
|
||||
return view
|
||||
}
|
||||
return ""
|
||||
}
|
||||
Reference in New Issue
Block a user