From 3716c8fc6d04585be60dbed6177508f08b881de3 Mon Sep 17 00:00:00 2001 From: Egutierrez Date: Sat, 28 Mar 2026 04:38:04 +0100 Subject: [PATCH] =?UTF-8?q?feat:=20Docker=20TUI=20=E2=80=94=20aplicacion?= =?UTF-8?q?=20Bubble=20Tea=20para=20gestionar=20Docker?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit TUI fullscreen con 5 vistas: Containers (start/stop/restart/logs), Images (list/remove), Volumes (list/remove), Networks (list/remove), Compose (up/down/logs). Usa DevFactory (tui, shell, core). Navegacion por tabs, filtrado en containers/images, scroll en logs. Incluye operations.db con entities y relations del proyecto. --- fn_operations/docker_tui/.gitignore | 5 + fn_operations/docker_tui/Makefile | 19 ++ fn_operations/docker_tui/app/model.go | 146 +++++++++++ fn_operations/docker_tui/config/config.go | 15 ++ fn_operations/docker_tui/go.mod | 43 ++++ fn_operations/docker_tui/go.sum | 84 +++++++ fn_operations/docker_tui/go.work | 6 + fn_operations/docker_tui/go.work.sum | 40 +++ fn_operations/docker_tui/main.go | 20 ++ fn_operations/docker_tui/operations.db | Bin 0 -> 53248 bytes fn_operations/docker_tui/views/compose.go | 193 +++++++++++++++ fn_operations/docker_tui/views/containers.go | 243 +++++++++++++++++++ fn_operations/docker_tui/views/docker.go | 192 +++++++++++++++ fn_operations/docker_tui/views/images.go | 127 ++++++++++ fn_operations/docker_tui/views/keys.go | 14 ++ fn_operations/docker_tui/views/networks.go | 123 ++++++++++ fn_operations/docker_tui/views/types.go | 45 ++++ fn_operations/docker_tui/views/volumes.go | 123 ++++++++++ 18 files changed, 1438 insertions(+) create mode 100644 fn_operations/docker_tui/.gitignore create mode 100644 fn_operations/docker_tui/Makefile create mode 100644 fn_operations/docker_tui/app/model.go create mode 100644 fn_operations/docker_tui/config/config.go create mode 100644 fn_operations/docker_tui/go.mod create mode 100644 fn_operations/docker_tui/go.sum create mode 100644 fn_operations/docker_tui/go.work create mode 100644 fn_operations/docker_tui/go.work.sum create mode 100644 fn_operations/docker_tui/main.go create mode 100644 fn_operations/docker_tui/operations.db create mode 100644 fn_operations/docker_tui/views/compose.go create mode 100644 fn_operations/docker_tui/views/containers.go create mode 100644 fn_operations/docker_tui/views/docker.go create mode 100644 fn_operations/docker_tui/views/images.go create mode 100644 fn_operations/docker_tui/views/keys.go create mode 100644 fn_operations/docker_tui/views/networks.go create mode 100644 fn_operations/docker_tui/views/types.go create mode 100644 fn_operations/docker_tui/views/volumes.go diff --git a/fn_operations/docker_tui/.gitignore b/fn_operations/docker_tui/.gitignore new file mode 100644 index 00000000..c8427d40 --- /dev/null +++ b/fn_operations/docker_tui/.gitignore @@ -0,0 +1,5 @@ +build/ +*.exe +*.dll +*.so +*.dylib diff --git a/fn_operations/docker_tui/Makefile b/fn_operations/docker_tui/Makefile new file mode 100644 index 00000000..caed4a3e --- /dev/null +++ b/fn_operations/docker_tui/Makefile @@ -0,0 +1,19 @@ +.PHONY: run build clean install tidy help + +run: ## Ejecuta la TUI + go run . + +build: ## Compila el binario + go build -trimpath -ldflags='-s -w' -o build/docker-tui . + +clean: ## Limpia artefactos + rm -rf build/ + +install: build ## Instala en ~/.local/bin + cp build/docker-tui ~/.local/bin/docker-tui + +tidy: ## go mod tidy + go mod tidy + +help: ## Muestra esta ayuda + @grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | awk 'BEGIN {FS = ":.*?## "}; {printf " \033[36m%-12s\033[0m %s\n", $$1, $$2}' diff --git a/fn_operations/docker_tui/app/model.go b/fn_operations/docker_tui/app/model.go new file mode 100644 index 00000000..c0d83f08 --- /dev/null +++ b/fn_operations/docker_tui/app/model.go @@ -0,0 +1,146 @@ +package app + +import ( + "docker-tui/views" + + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" + "github.com/lucasdataproyects/devfactory/tui" +) + +type View int + +const ( + ViewContainers View = iota + ViewImages + ViewVolumes + ViewNetworks + ViewCompose +) + +var tabNames = []string{"Containers", "Images", "Volumes", "Networks", "Compose"} + +type Model struct { + tui.BaseModel + activeTab int + containers views.ContainersModel + images views.ImagesModel + volumes views.VolumesModel + networks views.NetworksModel + compose views.ComposeModel + ready bool +} + +func New() Model { + styles := tui.DefaultStyles() + return Model{ + BaseModel: tui.NewBaseModel().WithStyles(styles), + containers: views.NewContainersModel(styles), + images: views.NewImagesModel(styles), + volumes: views.NewVolumesModel(styles), + networks: views.NewNetworksModel(styles), + compose: views.NewComposeModel(styles), + } +} + +func (m Model) Init() tea.Cmd { + return m.containers.Init() +} + +func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + switch msg := msg.(type) { + case tea.KeyMsg: + switch msg.String() { + case views.KeyQuit: + return m, tea.Quit + case views.KeyTab: + m.activeTab = (m.activeTab + 1) % len(tabNames) + return m, m.initActiveView() + case "shift+tab": + m.activeTab = (m.activeTab - 1 + len(tabNames)) % len(tabNames) + return m, m.initActiveView() + } + case tea.WindowSizeMsg: + m.HandleWindowSize(msg) + m.ready = true + } + + var cmd tea.Cmd + switch View(m.activeTab) { + case ViewContainers: + m.containers, cmd = m.containers.Update(msg) + case ViewImages: + m.images, cmd = m.images.Update(msg) + case ViewVolumes: + m.volumes, cmd = m.volumes.Update(msg) + case ViewNetworks: + m.networks, cmd = m.networks.Update(msg) + case ViewCompose: + m.compose, cmd = m.compose.Update(msg) + } + return m, cmd +} + +func (m Model) View() string { + if !m.ready { + return "Loading..." + } + + // Tab bar + tabs := m.renderTabs() + + // Active view content + var content string + switch View(m.activeTab) { + case ViewContainers: + content = m.containers.View() + case ViewImages: + content = m.images.View() + case ViewVolumes: + content = m.volumes.View() + case ViewNetworks: + content = m.networks.View() + case ViewCompose: + content = m.compose.View() + } + + // Status bar + status := m.Styles.StatusBar.Render(" Tab: switch view │ Ctrl+C: quit │ Enter: action │ r: refresh") + + return lipgloss.JoinVertical(lipgloss.Left, + tabs, + "", + content, + "", + status, + ) +} + +func (m Model) renderTabs() string { + var tabs []string + for i, name := range tabNames { + if i == m.activeTab { + tabs = append(tabs, m.Styles.Selected.Render(" "+name+" ")) + } else { + tabs = append(tabs, m.Styles.Muted.Render(" "+name+" ")) + } + } + row := lipgloss.JoinHorizontal(lipgloss.Top, tabs...) + return m.Styles.Header.Render("Docker TUI") + " " + row +} + +func (m Model) initActiveView() tea.Cmd { + switch View(m.activeTab) { + case ViewContainers: + return m.containers.Init() + case ViewImages: + return m.images.Init() + case ViewVolumes: + return m.volumes.Init() + case ViewNetworks: + return m.networks.Init() + case ViewCompose: + return m.compose.Init() + } + return nil +} diff --git a/fn_operations/docker_tui/config/config.go b/fn_operations/docker_tui/config/config.go new file mode 100644 index 00000000..a68e66a6 --- /dev/null +++ b/fn_operations/docker_tui/config/config.go @@ -0,0 +1,15 @@ +package config + +// Config holds Docker TUI configuration. +type Config struct { + ComposeFile string + RefreshInterval int // seconds, 0 = manual +} + +// Default returns sensible defaults. +func Default() Config { + return Config{ + ComposeFile: "docker-compose.yml", + RefreshInterval: 0, + } +} diff --git a/fn_operations/docker_tui/go.mod b/fn_operations/docker_tui/go.mod new file mode 100644 index 00000000..42dc1f38 --- /dev/null +++ b/fn_operations/docker_tui/go.mod @@ -0,0 +1,43 @@ +module docker-tui + +go 1.22.2 + +require ( + github.com/charmbracelet/bubbletea v0.25.0 + github.com/charmbracelet/lipgloss v0.9.1 + github.com/lucasdataproyects/devfactory v0.0.0 +) + +require ( + github.com/apache/arrow/go/v14 v14.0.2 // indirect + github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect + github.com/charmbracelet/bubbles v0.18.0 // indirect + github.com/charmbracelet/harmonica v0.2.0 // indirect + github.com/containerd/console v1.0.4-0.20230313162750-1ae8d489ac81 // indirect + github.com/goccy/go-json v0.10.2 // indirect + github.com/google/flatbuffers v23.5.26+incompatible // indirect + github.com/klauspost/compress v1.16.7 // indirect + github.com/klauspost/cpuid/v2 v2.2.5 // indirect + github.com/lucasb-eyer/go-colorful v1.2.0 // indirect + github.com/marcboeker/go-duckdb v1.6.5 // indirect + github.com/mattn/go-isatty v0.0.19 // indirect + github.com/mattn/go-localereader v0.0.1 // indirect + github.com/mattn/go-runewidth v0.0.15 // indirect + github.com/mitchellh/mapstructure v1.5.0 // indirect + github.com/muesli/ansi v0.0.0-20211018074035-2e021307bc4b // indirect + github.com/muesli/cancelreader v0.2.2 // indirect + github.com/muesli/reflow v0.3.0 // indirect + github.com/muesli/termenv v0.15.2 // indirect + github.com/pierrec/lz4/v4 v4.1.18 // indirect + github.com/rivo/uniseg v0.4.6 // indirect + github.com/zeebo/xxh3 v1.0.2 // indirect + golang.org/x/mod v0.13.0 // indirect + golang.org/x/sync v0.4.0 // indirect + golang.org/x/sys v0.13.0 // indirect + golang.org/x/term v0.6.0 // indirect + golang.org/x/text v0.13.0 // indirect + golang.org/x/tools v0.14.0 // indirect + golang.org/x/xerrors v0.0.0-20220907171357-04be3eba64a2 // indirect +) + +replace github.com/lucasdataproyects/devfactory => /home/lucas/.local_agentes/backend diff --git a/fn_operations/docker_tui/go.sum b/fn_operations/docker_tui/go.sum new file mode 100644 index 00000000..e0ff28d7 --- /dev/null +++ b/fn_operations/docker_tui/go.sum @@ -0,0 +1,84 @@ +github.com/apache/arrow/go/v14 v14.0.2 h1:N8OkaJEOfI3mEZt07BIkvo4sC6XDbL+48MBPWO5IONw= +github.com/apache/arrow/go/v14 v14.0.2/go.mod h1:u3fgh3EdgN/YQ8cVQRguVW3R+seMybFg8QBQ5LU+eBY= +github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= +github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= +github.com/charmbracelet/bubbles v0.18.0 h1:PYv1A036luoBGroX6VWjQIE9Syf2Wby2oOl/39KLfy0= +github.com/charmbracelet/bubbles v0.18.0/go.mod h1:08qhZhtIwzgrtBjAcJnij1t1H0ZRjwHyGsy6AL11PSw= +github.com/charmbracelet/bubbletea v0.25.0 h1:bAfwk7jRz7FKFl9RzlIULPkStffg5k6pNt5dywy4TcM= +github.com/charmbracelet/bubbletea v0.25.0/go.mod h1:EN3QDR1T5ZdWmdfDzYcqOCAps45+QIJbLOBxmVNWNNg= +github.com/charmbracelet/harmonica v0.2.0 h1:8NxJWRWg/bzKqqEaaeFNipOu77YR5t8aSwG4pgaUBiQ= +github.com/charmbracelet/harmonica v0.2.0/go.mod h1:KSri/1RMQOZLbw7AHqgcBycp8pgJnQMYYT8QZRqZ1Ao= +github.com/charmbracelet/lipgloss v0.9.1 h1:PNyd3jvaJbg4jRHKWXnCj1akQm4rh8dbEzN1p/u1KWg= +github.com/charmbracelet/lipgloss v0.9.1/go.mod h1:1mPmG4cxScwUQALAAnacHaigiiHB9Pmr+v1VEawJl6I= +github.com/containerd/console v1.0.4-0.20230313162750-1ae8d489ac81 h1:q2hJAaP1k2wIvVRd/hEHD7lacgqrCPS+k8g1MndzfWY= +github.com/containerd/console v1.0.4-0.20230313162750-1ae8d489ac81/go.mod h1:YynlIjWYF8myEu6sdkwKIvGQq+cOckRm6So2avqoYAk= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU= +github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I= +github.com/google/flatbuffers v23.5.26+incompatible h1:M9dgRyhJemaM4Sw8+66GHBu8ioaQmyPLg1b8VwK5WJg= +github.com/google/flatbuffers v23.5.26+incompatible/go.mod h1:1AeVuKshWv4vARoZatz6mlQ0JxURH0Kv5+zNeJKJCa8= +github.com/google/uuid v1.3.1 h1:KjJaJ9iWZ3jOFZIf1Lqf4laDRCasjl0BCmnEGxkdLb4= +github.com/google/uuid v1.3.1/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/klauspost/compress v1.16.7 h1:2mk3MPGNzKyxErAw8YaohYh69+pa4sIQSC0fPGCFR9I= +github.com/klauspost/compress v1.16.7/go.mod h1:ntbaceVETuRiXiv4DpjP66DpAtAGkEQskQzEyD//IeE= +github.com/klauspost/cpuid/v2 v2.2.5 h1:0E5MSMDEoAulmXNFquVs//DdoomxaoTY1kUhbc/qbZg= +github.com/klauspost/cpuid/v2 v2.2.5/go.mod h1:Lcz8mBdAVJIBVzewtcLocK12l3Y+JytZYpaMropDUws= +github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY= +github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= +github.com/marcboeker/go-duckdb v1.6.5 h1:XCfR1JVZxsemcSPxRQKK0R0ESfgRMHTEqh3Y+dv40SI= +github.com/marcboeker/go-duckdb v1.6.5/go.mod h1:WtWeqqhZoTke/Nbd7V9lnBx7I2/A/q0SAq/urGzPCMs= +github.com/mattn/go-isatty v0.0.19 h1:JITubQf0MOLdlGRuRq+jtsDlekdYPia9ZFsB8h/APPA= +github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4= +github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88= +github.com/mattn/go-runewidth v0.0.12/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk= +github.com/mattn/go-runewidth v0.0.15 h1:UNAjwbU9l54TA3KzvqLGxwWjHmMgBUVhBiTjelZgg3U= +github.com/mattn/go-runewidth v0.0.15/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= +github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY= +github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= +github.com/muesli/ansi v0.0.0-20211018074035-2e021307bc4b h1:1XF24mVaiu7u+CFywTdcDo2ie1pzzhwjt6RHqzpMU34= +github.com/muesli/ansi v0.0.0-20211018074035-2e021307bc4b/go.mod h1:fQuZ0gauxyBcmsdE3ZT4NasjaRdxmbCS0jRHsrWu3Ho= +github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA= +github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo= +github.com/muesli/reflow v0.3.0 h1:IFsN6K9NfGtjeggFP+68I4chLZV2yIKsXJFNZ+eWh6s= +github.com/muesli/reflow v0.3.0/go.mod h1:pbwTDkVPibjO2kyvBQRBxTWEEGDGq0FlB1BIKtnHY/8= +github.com/muesli/termenv v0.15.2 h1:GohcuySI0QmI3wN8Ok9PtKGkgkFIk7y6Vpb5PvrY+Wo= +github.com/muesli/termenv v0.15.2/go.mod h1:Epx+iuz8sNs7mNKhxzH4fWXGNpZwUaJKRS1noLXviQ8= +github.com/pierrec/lz4/v4 v4.1.18 h1:xaKrnTkyoqfh1YItXl56+6KJNVYWlEEPuAQW9xsplYQ= +github.com/pierrec/lz4/v4 v4.1.18/go.mod h1:gZWDp/Ze/IJXGXf23ltt2EXimqmTUXEy0GFuRQyBid4= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/rivo/uniseg v0.1.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= +github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= +github.com/rivo/uniseg v0.4.6 h1:Sovz9sDSwbOz9tgUy8JpT+KgCkPYJEN/oYzlJiYTNLg= +github.com/rivo/uniseg v0.4.6/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= +github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk= +github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= +github.com/zeebo/assert v1.3.0 h1:g7C04CbJuIDKNPFHmsk4hwZDO5O+kntRxzaUoNXj+IQ= +github.com/zeebo/assert v1.3.0/go.mod h1:Pq9JiuJQpG8JLJdtkwrJESF0Foym2/D9XMU5ciN/wJ0= +github.com/zeebo/xxh3 v1.0.2 h1:xZmwmqxHZA8AI603jOQ0tMqmBr9lPeFwGg6d+xy9DC0= +github.com/zeebo/xxh3 v1.0.2/go.mod h1:5NWz9Sef7zIDm2JHfFlcQvNekmcEl9ekUZQQKCYaDcA= +golang.org/x/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM8rJBtfilJ2qTU199MI= +golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo= +golang.org/x/mod v0.13.0 h1:I/DsJXRlw/8l/0c24sM9yb0T4z9liZTduXvdAWYiysY= +golang.org/x/mod v0.13.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= +golang.org/x/sync v0.4.0 h1:zxkM55ReGkDlKSM+Fu41A+zmbZuaPVbGMzvvdUPznYQ= +golang.org/x/sync v0.4.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y= +golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.13.0 h1:Af8nKPmuFypiUBjVoU9V20FiaFXOcuZI21p0ycVYYGE= +golang.org/x/sys v0.13.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/term v0.6.0 h1:clScbb1cHjoCkyRbWwBEUZ5H/tIFu5TAXIqaZD0Gcjw= +golang.org/x/term v0.6.0/go.mod h1:m6U89DPEgQRMq3DNkDClhWw02AUbt2daBVO4cn4Hv9U= +golang.org/x/text v0.13.0 h1:ablQoSUd0tRdKxZewP80B+BaqeKJuVhuRxj/dkrun3k= +golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE= +golang.org/x/tools v0.14.0 h1:jvNa2pY0M4r62jkRQ6RwEZZyPcymeL9XZMLBbV7U2nc= +golang.org/x/tools v0.14.0/go.mod h1:uYBEerGOWcJyEORxN+Ek8+TT266gXkNlHdJBwexUsBg= +golang.org/x/xerrors v0.0.0-20220907171357-04be3eba64a2 h1:H2TDz8ibqkAF6YGhCdN3jS9O0/s90v0rJh3X/OLHEUk= +golang.org/x/xerrors v0.0.0-20220907171357-04be3eba64a2/go.mod h1:K8+ghG5WaK9qNqU5K3HdILfMLy1f3aNYFI/wnl100a8= +gonum.org/v1/gonum v0.12.0 h1:xKuo6hzt+gMav00meVPUlXwSdoEJP46BR+wdxQEFK2o= +gonum.org/v1/gonum v0.12.0/go.mod h1:73TDxJfAAHeA8Mk9mf8NlIppyhQNo5GLTcYeqgo2lvY= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/fn_operations/docker_tui/go.work b/fn_operations/docker_tui/go.work new file mode 100644 index 00000000..c00c9dbe --- /dev/null +++ b/fn_operations/docker_tui/go.work @@ -0,0 +1,6 @@ +go 1.22.2 + +use ( + . + /home/lucas/.local_agentes/backend +) diff --git a/fn_operations/docker_tui/go.work.sum b/fn_operations/docker_tui/go.work.sum new file mode 100644 index 00000000..e0b4994d --- /dev/null +++ b/fn_operations/docker_tui/go.work.sum @@ -0,0 +1,40 @@ +github.com/JohnCGriffin/overflow v0.0.0-20211019200055-46fa312c352c/go.mod h1:X0CRv0ky0k6m906ixxpzmDRLvX58TFUKS2eePweuyxk= +github.com/alecthomas/participle/v2 v2.1.0/go.mod h1:Y1+hAs8DHPmc3YUFzqllV+eSQ9ljPTk0ZkPMtEdAx2c= +github.com/andybalholm/brotli v1.0.5/go.mod h1:fO7iG3H7G2nSZ7m0zPUDn85XEX2GTukHGRSepvi9Eig= +github.com/apache/thrift v0.17.0/go.mod h1:OLxhMRJxomX+1I/KUw03qoV3mMz16BwaKI+d4fPBx7Q= +github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI= +github.com/docopt/docopt-go v0.0.0-20180111231733-ee0de3bc6815/go.mod h1:WwZ+bS3ebgob9U8Nd0kOddGdZWjyMGR8Wziv+TBNwSE= +github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= +github.com/fatih/color v1.15.0/go.mod h1:0h5ZqXfHYED7Bhv2ZJamyIOUej9KtShiJESRwBDUSsw= +github.com/goccy/go-yaml v1.11.0/go.mod h1:H+mJrWtjPTJAHvRbV09MCK9xYwODM+wRTVFFTWckfng= +github.com/golang/protobuf v1.5.3/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY= +github.com/golang/snappy v0.0.4/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q= +github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51/go.mod h1:CzGEWj7cYgsdH8dAjBGEr58BoE7ScuLd+fwFZ44+/x8= +github.com/klauspost/asmfmt v1.3.2/go.mod h1:AG8TuvYojzulgDAMCnYn50l/5QV3Bs/tp6j0HLHbNSE= +github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= +github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= +github.com/minio/asm2plan9s v0.0.0-20200509001527-cdd76441f9d8/go.mod h1:mC1jAcsrzbxHt8iiaC+zU4b1ylILSosueou12R++wfY= +github.com/minio/c2goasm v0.0.0-20190812172519-36a3d3bbc4f3/go.mod h1:RagcQ7I8IeTMnF8JTXieKnO4Z6JCsikNEzj0DwauVzE= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= +github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs= +github.com/sahilm/fuzzy v0.1.1-0.20230530133925-c48e322e2a8f/go.mod h1:VFvziUEIMCrT6A6tw2RFIXPXXmzXbOsSHF0DOI8ZK9Y= +github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= +github.com/substrait-io/substrait-go v0.4.2/go.mod h1:qhpnLmrcvAnlZsUyPXZRqldiHapPTXC3t7xFgDi3aQg= +github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= +golang.org/x/net v0.17.0/go.mod h1:NxSsAGuq816PNPmqtQdLE42eU2Fs7NoRIZrHJAlaCOE= +google.golang.org/genproto/googleapis/rpc v0.0.0-20231002182017-d307bd883b97/go.mod h1:v7nGkzlmW8P3n/bKmWBn2WpBjpOEx8Q6gMueudAmKfY= +google.golang.org/grpc v1.58.2/go.mod h1:tgX3ZQDlNJGU96V6yHh1T/JeoBQ2TXdr43YbYSsCJk0= +google.golang.org/protobuf v1.31.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I= +gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +lukechampine.com/uint128 v1.3.0/go.mod h1:c4eWIwlEGaxC/+H1VguhU4PHXNWDCDMUlWdIWl2j1gk= +modernc.org/cc/v3 v3.40.0/go.mod h1:/bTg4dnWkSXowUO6ssQKnOV0yMVxDYNIsIrzqTFDGH0= +modernc.org/ccgo/v3 v3.16.13/go.mod h1:2Quk+5YgpImhPjv2Qsob1DnZ/4som1lJTodubIcoUkY= +modernc.org/libc v1.22.4/go.mod h1:jj+Z7dTNX8fBScMVNRAYZ/jF91K8fdT2hYMThc3YjBY= +modernc.org/mathutil v1.5.0/go.mod h1:mZW8CKdRPY1v87qxC/wUdX5O1qDzXMP5TH3wjfpga6E= +modernc.org/memory v1.5.0/go.mod h1:PkUhL0Mugw21sHPeskwZW4D6VscE/GQJOnIpCnW6pSU= +modernc.org/opt v0.1.3/go.mod h1:WdSiB5evDcignE70guQKxYUl14mgWtbClRi5wmkkTX0= +modernc.org/sqlite v1.21.2/go.mod h1:cxbLkB5WS32DnQqeH4h4o1B0eMr8W/y8/RGuxQ3JsC0= +modernc.org/strutil v1.1.3/go.mod h1:MEHNA7PdEnEwLvspRMtWTNnp2nnyvMfkimT1NKNAGbw= +modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM= diff --git a/fn_operations/docker_tui/main.go b/fn_operations/docker_tui/main.go new file mode 100644 index 00000000..72d81fca --- /dev/null +++ b/fn_operations/docker_tui/main.go @@ -0,0 +1,20 @@ +package main + +import ( + "fmt" + "os" + + "docker-tui/app" + + "github.com/lucasdataproyects/devfactory/tui" +) + +func main() { + model := app.New() + result := tui.RunFullscreen(model) + + if result.IsErr() { + fmt.Fprintf(os.Stderr, "error: %v\n", result.Error()) + os.Exit(1) + } +} diff --git a/fn_operations/docker_tui/operations.db b/fn_operations/docker_tui/operations.db new file mode 100644 index 0000000000000000000000000000000000000000..5196b536e044a485e480b6b6c2b3b26731416b9e GIT binary patch literal 53248 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 0 { + m.scrollOff-- + } + case "esc", "q", "0": + m.state = composeList + return m, nil + } + 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 +} + +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 +} diff --git a/fn_operations/docker_tui/views/containers.go b/fn_operations/docker_tui/views/containers.go new file mode 100644 index 00000000..1dcf803c --- /dev/null +++ b/fn_operations/docker_tui/views/containers.go @@ -0,0 +1,243 @@ +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-- + } + case "esc", "q", "0": + m.state = containersList + return m, nil + } + 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 +} + +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 +} diff --git a/fn_operations/docker_tui/views/docker.go b/fn_operations/docker_tui/views/docker.go new file mode 100644 index 00000000..1d8650c2 --- /dev/null +++ b/fn_operations/docker_tui/views/docker.go @@ -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) +} diff --git a/fn_operations/docker_tui/views/images.go b/fn_operations/docker_tui/views/images.go new file mode 100644 index 00000000..ec989592 --- /dev/null +++ b/fn_operations/docker_tui/views/images.go @@ -0,0 +1,127 @@ +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 +} + +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 "" +} diff --git a/fn_operations/docker_tui/views/keys.go b/fn_operations/docker_tui/views/keys.go new file mode 100644 index 00000000..2ed80d08 --- /dev/null +++ b/fn_operations/docker_tui/views/keys.go @@ -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 +} diff --git a/fn_operations/docker_tui/views/networks.go b/fn_operations/docker_tui/views/networks.go new file mode 100644 index 00000000..5c3aefac --- /dev/null +++ b/fn_operations/docker_tui/views/networks.go @@ -0,0 +1,123 @@ +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 +} + +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 "" +} diff --git a/fn_operations/docker_tui/views/types.go b/fn_operations/docker_tui/views/types.go new file mode 100644 index 00000000..c319c93e --- /dev/null +++ b/fn_operations/docker_tui/views/types.go @@ -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"` +} diff --git a/fn_operations/docker_tui/views/volumes.go b/fn_operations/docker_tui/views/volumes.go new file mode 100644 index 00000000..b55d4ec7 --- /dev/null +++ b/fn_operations/docker_tui/views/volumes.go @@ -0,0 +1,123 @@ +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 +} + +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 "" +}