├── assets ├── demo.gif └── screenshot.png ├── pkg └── version │ └── version.go ├── .gitignore ├── .github ├── ISSUE_TEMPLATE │ ├── feature_request.md │ └── bug_report.md └── workflows │ ├── release.yml │ └── test.yml ├── LICENSE ├── go.mod ├── .goreleaser.yml ├── main.go ├── internal ├── docker │ ├── types.go │ └── client.go ├── tui │ ├── keymap.go │ └── model.go ├── update │ └── update.go └── check │ └── precheck.go ├── CONTRIBUTING.md ├── go.sum ├── README.md └── install.sh /assets/demo.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/shubh-io/DockMate/HEAD/assets/demo.gif -------------------------------------------------------------------------------- /assets/screenshot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/shubh-io/DockMate/HEAD/assets/screenshot.png -------------------------------------------------------------------------------- /pkg/version/version.go: -------------------------------------------------------------------------------- 1 | package version 2 | 3 | // Current application version, would update this when releasing a new version. 4 | const Dockmate_Version = "0.0.11" 5 | 6 | // Repository to check for releases (owner/repo) 7 | const Repo = "shubh-io/dockmate" 8 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Binaries 2 | dockmate 3 | *.exe 4 | *.out 5 | *.test 6 | 7 | # Go 8 | vendor/ 9 | *.so 10 | *.dylib 11 | 12 | # IDE 13 | .vscode/ 14 | .idea/ 15 | *.swp 16 | *.swo 17 | *~ 18 | 19 | # OS 20 | .DS_Store 21 | Thumbs.db 22 | 23 | # Build artifacts 24 | dockmate 25 | dockmate-* 26 | *.sha256 27 | checksums.txt 28 | 29 | # Test releases 30 | .goreleaser.snapshot.yml 31 | 32 | # GoReleaser 33 | dist/ 34 | *.deb 35 | *.rpm 36 | 37 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an feature for this project 4 | title: '' 5 | labels: enhancement 6 | assignees: '' 7 | 8 | --- 9 | 10 | ## Feature Description 11 | A clear description of the feature you'd like. 12 | 13 | ## Use Case 14 | Why would this feature be useful? What problem does it solve? 15 | 16 | ## Proposed Solution 17 | How you imagine this feature working. 18 | 19 | ## Alternatives Considered 20 | Any alternative solutions you've thought about. 21 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release 2 | 3 | on: 4 | push: 5 | tags: 6 | - 'v*' 7 | 8 | permissions: 9 | contents: write 10 | 11 | jobs: 12 | goreleaser: 13 | runs-on: ubuntu-latest 14 | steps: 15 | - name: Checkout 16 | uses: actions/checkout@v4 17 | with: 18 | fetch-depth: 0 19 | 20 | - name: Set up Go 21 | uses: actions/setup-go@v5 22 | with: 23 | go-version: '1.24' 24 | 25 | - name: Run GoReleaser 26 | uses: goreleaser/goreleaser-action@v6 27 | with: 28 | distribution: goreleaser 29 | version: '~> v2' 30 | args: release --clean 31 | env: 32 | GITHUB_TOKEN: ${{ secrets.HOMEBREW_TAP_TOKEN }} 33 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | title: '' 5 | labels: bug 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Describe the bug** 11 | A clear and concise description of what the bug is. 12 | 13 | **To Reproduce** 14 | Steps to reproduce the behavior: 15 | 1. Go to '...' 16 | 2. Click on '....' 17 | 3. Scroll down to '....' 18 | 4. See error 19 | 20 | **Expected behavior** 21 | A clear and concise description of what you expected to happen. 22 | 23 | **Screenshots** 24 | If applicable, add screenshots to help explain your problem. 25 | 26 | **Desktop (please complete the following information):** 27 | - OS: [e.g. iOS] 28 | - Browser [e.g. chrome, safari] 29 | - Version [e.g. 22] 30 | 31 | **Smartphone (please complete the following information):** 32 | - Device: [e.g. iPhone6] 33 | - OS: [e.g. iOS8.1] 34 | - Browser [e.g. stock browser, safari] 35 | - Version [e.g. 22] 36 | 37 | **Additional context** 38 | Add any other context about the problem here. 39 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2025 Shubhransh Nath 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/shubh-io/dockmate 2 | 3 | go 1.24.0 4 | 5 | require ( 6 | github.com/charmbracelet/bubbles v0.16.1 7 | github.com/charmbracelet/bubbletea v0.24.2 8 | github.com/charmbracelet/lipgloss v0.7.1 9 | ) 10 | 11 | require ( 12 | github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect 13 | github.com/containerd/console v1.0.4-0.20230313162750-1ae8d489ac81 // indirect 14 | github.com/lucasb-eyer/go-colorful v1.2.0 // indirect 15 | github.com/mattn/go-isatty v0.0.20 // indirect 16 | github.com/mattn/go-localereader v0.0.1 // indirect 17 | github.com/mattn/go-runewidth v0.0.16 // indirect 18 | github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect 19 | github.com/muesli/cancelreader v0.2.2 // indirect 20 | github.com/muesli/reflow v0.3.0 // indirect 21 | github.com/muesli/termenv v0.15.2 // indirect 22 | github.com/rivo/uniseg v0.4.7 // indirect 23 | github.com/savannahostrowski/tree-bubble v0.0.0-20230724043728-d7bb06a8a67e // indirect 24 | golang.org/x/sync v0.1.0 // indirect 25 | golang.org/x/sys v0.36.0 // indirect 26 | golang.org/x/term v0.6.0 // indirect 27 | golang.org/x/text v0.3.8 // indirect 28 | ) 29 | -------------------------------------------------------------------------------- /.goreleaser.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | 3 | project_name: dockmate 4 | 5 | before: 6 | hooks: 7 | - go mod tidy 8 | 9 | builds: 10 | - binary: dockmate 11 | main: . 12 | goos: 13 | - linux 14 | - darwin # ADD THIS (for macOS) 15 | goarch: 16 | - amd64 17 | - arm64 18 | env: 19 | - CGO_ENABLED=0 20 | ldflags: 21 | - -s -w -X main.version={{.Version}} 22 | 23 | archives: 24 | - format: binary 25 | name_template: "{{ .Binary }}-{{ .Os }}-{{ .Arch }}" 26 | 27 | checksum: 28 | name_template: 'checksums.txt' 29 | algorithm: sha256 30 | 31 | # ADD THIS ENTIRE SECTION 32 | brews: 33 | - repository: 34 | owner: shubh-io 35 | name: homebrew-tap 36 | token: "{{ .Env.GITHUB_TOKEN }}" 37 | directory: Formula 38 | homepage: "https://github.com/shubh-io/DockMate" 39 | description: "Docker container monitoring TUI" 40 | license: "MIT" 41 | commit_author: 42 | name: goreleaserbot 43 | email: bot@goreleaser.com 44 | test: | 45 | system "#{bin}/dockmate --version" 46 | 47 | changelog: 48 | sort: asc 49 | filters: 50 | exclude: 51 | - '^docs:' 52 | - '^test:' 53 | - '^chore:' 54 | 55 | release: 56 | github: 57 | owner: shubh-io 58 | name: dockmate 59 | -------------------------------------------------------------------------------- /main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | 7 | tea "github.com/charmbracelet/bubbletea" 8 | "github.com/shubh-io/dockmate/internal/check" 9 | "github.com/shubh-io/dockmate/internal/tui" 10 | "github.com/shubh-io/dockmate/internal/update" 11 | "github.com/shubh-io/dockmate/pkg/version" 12 | ) 13 | 14 | // ============================================================================ 15 | // Main 16 | // ============================================================================ 17 | 18 | func main() { 19 | 20 | if len(os.Args) > 1 { 21 | switch os.Args[1] { 22 | case "version", "--version", "-v": 23 | fmt.Printf("DockMate version: %s\n", version.Dockmate_Version) 24 | return 25 | case "update": 26 | update.UpdateCommand() 27 | return 28 | } 29 | } 30 | 31 | result := check.RunPreChecks() 32 | 33 | if !result.Passed { 34 | fmt.Fprintf(os.Stderr, "%s\n\n%s\n", result.ErrorMessage, result.SuggestedAction) 35 | os.Stderr.Sync() 36 | os.Exit(1) 37 | } 38 | 39 | // start the TUI with alternate screen mode 40 | // (alternate screen = your terminal history stays clean) 41 | 42 | p := tea.NewProgram(tui.InitialModel(), tea.WithAltScreen()) 43 | if _, err := p.Run(); err != nil { 44 | fmt.Fprintf(os.Stderr, "Error running TUI: %v\n", err) 45 | os.Exit(1) 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /internal/docker/types.go: -------------------------------------------------------------------------------- 1 | package docker 2 | 3 | type ProjectStatus int 4 | 5 | const ( 6 | AllRunning ProjectStatus = iota 7 | SomeStopped 8 | AllStopped 9 | Unknown 10 | ) 11 | 12 | type ComposeProject struct { 13 | Name string 14 | Containers []Container 15 | ConfigFile string // from label 16 | WorkingDir string // from label 17 | Status ProjectStatus // all running, some stopped, etc 18 | } 19 | 20 | // Container holds all the data we show in the TUI 21 | type Container struct { 22 | ID string // short container id 23 | Names []string // can have multiple names 24 | Image string // image name like "nginx:latest" 25 | Status string // human readable status 26 | State string // running/exited/etc 27 | Memory string // mem usage % 28 | CPU string // cpu usage % 29 | //PIDs string // process count 30 | Ports string // ports 31 | NetIO string // network I/O 32 | BlockIO string // block I/O 33 | ComposeProject string // compose project name (empty if standalone) 34 | ComposeService string // compose service name 35 | ComposeNumber string // compose container number 36 | } 37 | type ComposeInfo struct { 38 | Project string 39 | Service string 40 | Number int 41 | } 42 | 43 | // ContainerStats holds stats for a single container 44 | type ContainerStats struct { 45 | CPU string 46 | Memory string 47 | // PIDs string 48 | NetIO string 49 | BlockIO string 50 | } 51 | 52 | // sent when we finish fetching the container list 53 | type ContainersMsg struct { 54 | Containers []Container 55 | Err error 56 | } 57 | 58 | // sent when logs are ready 59 | type LogsMsg struct { 60 | ID string 61 | Lines []string 62 | Err error 63 | } 64 | -------------------------------------------------------------------------------- /internal/tui/keymap.go: -------------------------------------------------------------------------------- 1 | package tui 2 | 3 | import ( 4 | "github.com/charmbracelet/bubbles/key" 5 | ) 6 | 7 | // ============================================================================ 8 | // Keyboard shortcuts 9 | // ============================================================================ 10 | 11 | // all the keybindings 12 | type keyMap struct { 13 | Up key.Binding 14 | Down key.Binding 15 | Start key.Binding 16 | Stop key.Binding 17 | Restart key.Binding 18 | Logs key.Binding 19 | Exec key.Binding 20 | Remove key.Binding 21 | Refresh key.Binding 22 | PageUp key.Binding 23 | NextPage key.Binding 24 | PrevPage key.Binding 25 | Terminal key.Binding 26 | PageDown key.Binding 27 | Quit key.Binding 28 | Help key.Binding 29 | } 30 | 31 | // global keymap 32 | // supports vim keys (hjkl) and arrows 33 | var Keys = keyMap{ 34 | Up: key.NewBinding(key.WithKeys("up", "k")), 35 | Down: key.NewBinding(key.WithKeys("down", "j")), 36 | Start: key.NewBinding(key.WithKeys("s")), 37 | Stop: key.NewBinding(key.WithKeys("x")), 38 | Logs: key.NewBinding(key.WithKeys("l")), 39 | Exec: key.NewBinding(key.WithKeys("e")), 40 | Restart: key.NewBinding(key.WithKeys("r")), 41 | Remove: key.NewBinding(key.WithKeys("d")), 42 | Refresh: key.NewBinding(key.WithKeys("f5", "R")), 43 | PageUp: key.NewBinding(key.WithKeys("pgup", "left")), 44 | NextPage: key.NewBinding(key.WithKeys("n", "pagedown")), 45 | PrevPage: key.NewBinding(key.WithKeys("p", "pageup")), 46 | Terminal: key.NewBinding(key.WithKeys("t")), 47 | PageDown: key.NewBinding(key.WithKeys("pgdown", "right")), 48 | Quit: key.NewBinding(key.WithKeys("q", "ctrl+c", "f10")), 49 | Help: key.NewBinding(key.WithKeys("f1", "?")), 50 | } 51 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | 2 | name: Test Installation 3 | 4 | on: 5 | workflow_dispatch: 6 | push: 7 | tags: 8 | - 'v*' 9 | pull_request: 10 | 11 | jobs: 12 | # Test Ubuntu with user install (most common case) 13 | test-ubuntu: 14 | runs-on: ubuntu-latest 15 | steps: 16 | - uses: actions/checkout@v4 17 | - name: Test user-local install 18 | run: | 19 | chmod +x install.sh 20 | INSTALL_DIR=$HOME/.local/bin ./install.sh 21 | - name: Add to PATH 22 | run: echo "$HOME/.local/bin" >> $GITHUB_PATH 23 | - name: Verify 24 | run: | 25 | which dockmate 26 | file $(which dockmate) 27 | dockmate --version || echo "No version flag" 28 | 29 | # Test macOS ARM (M1/M2) - most common Mac now 30 | test-macos-arm: 31 | runs-on: macos-14 32 | steps: 33 | - uses: actions/checkout@v4 34 | - name: System info 35 | run: uname -a 36 | - name: Test install 37 | run: | 38 | chmod +x install.sh 39 | INSTALL_DIR=$HOME/.local/bin ./install.sh 40 | - name: Add to PATH 41 | run: echo "$HOME/.local/bin" >> $GITHUB_PATH 42 | - name: Verify architecture 43 | run: | 44 | file $(which dockmate) 45 | dockmate --version || echo "No version flag" 46 | 47 | 48 | # Test system-wide install with sudo 49 | test-system-install: 50 | runs-on: ubuntu-latest 51 | steps: 52 | - uses: actions/checkout@v4 53 | - name: Test sudo install to /usr/local/bin 54 | run: | 55 | chmod +x install.sh 56 | sudo ./install.sh 57 | - name: Verify system install 58 | run: | 59 | which dockmate 60 | dockmate --version || echo "No version flag" 61 | 62 | # Test Docker container (no sudo, minimal env) 63 | test-container: 64 | runs-on: ubuntu-latest 65 | container: 66 | image: alpine:latest 67 | steps: 68 | - name: Install deps 69 | run: apk add --no-cache bash curl git 70 | - uses: actions/checkout@v4 71 | - name: Test as root in container 72 | run: | 73 | chmod +x install.sh 74 | INSTALL_DIR=/usr/local/bin ./install.sh 75 | - name: Verify 76 | run: dockmate --version || echo "No version flag" 77 | 78 | # Test checksum verification 79 | test-checksum: 80 | runs-on: ubuntu-latest 81 | steps: 82 | - uses: actions/checkout@v4 83 | - name: Verify checksum works 84 | run: | 85 | chmod +x install.sh 86 | INSTALL_DIR=$HOME/.local/bin ./install.sh | tee install.log 87 | grep -q "Checksum verified" install.log 88 | - name: Test binary works 89 | run: | 90 | export PATH="$HOME/.local/bin:$PATH" 91 | dockmate --version || echo "No version flag" -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | 2 |

Contributing To DockMate 🐳

3 |

Thanks for your interest in contributing to DockMate

4 | 5 | ## Table of Contents 6 | 7 | - [Ways to Contribute](#ways-to-contribute) 8 | - [Getting Started](#getting-started) 9 | - [Prerequisites](#prerequisites) 10 | - [Setup](#setup) 11 | - [Running Locally](#running-locally) 12 | - [Building](#building) 13 | - [Making Changes](#making-changes) 14 | - [1. Create a Branch](#1-create-a-branch) 15 | - [2. Make Your Changes](#2-make-your-changes) 16 | - [3. Test](#3-test) 17 | - [Submitting Changes](#submitting-changes) 18 | - [Code Guidelines](#code-guidelines) 19 | - [Commit Messages](#commit-messages) 20 | - [Getting Help](#getting-help) 21 | 22 | ## Ways to Contribute 23 | - 🐞 Report bugs 24 | - ⭐ Suggest features 25 | - 📝 Improve documentation 26 | - ❗ Submit bug fixes 27 | - ✨ Add new features 28 | 29 | ## Getting Started 30 | 31 | ### Prerequisites 32 | - Go 1.24 or higher 33 | - Docker installed and running 34 | - Git 35 | 36 | ### Setup 37 | 1. Fork the repository 38 | 2. Clone your fork: 39 | ``` 40 | git clone https://github.com/YOUR_USERNAME/dockmate.git 41 | cd dockmate 42 | ``` 43 | 3. Add upstream remote: 44 | ``` 45 | git remote add upstream https://github.com/shubh-io/dockmate.git 46 | ``` 47 | 4. Install dependencies: 48 | ``` 49 | go mod download 50 | ``` 51 | 52 | ### Running Locally 53 | ``` 54 | go run . 55 | ``` 56 | 57 | ### Building 58 | ``` 59 | go build -o dockmate 60 | ./dockmate 61 | ``` 62 | 63 | ## Making Changes 64 | 65 | ### 1. Create a Branch 66 | 67 | First, sync with the latest changes: 68 | ``` 69 | git checkout main 70 | git pull upstream main 71 | ``` 72 | 73 | Then create your feature branch: 74 | ``` 75 | git checkout -b feature/your-feature-name 76 | ``` 77 | 78 | Use descriptive branch names: 79 | - `feature/add-compose-support` 80 | - `fix/memory-leak` 81 | - `docs/update-readme` 82 | 83 | ### 2. Make Your Changes 84 | - Write clean, readable code 85 | - Follow Go conventions 86 | - Add comments where needed 87 | - Test your changes 88 | 89 | ### 3. Test 90 | Make sure everything still works: 91 | ``` 92 | go test ./... 93 | go run . # Manual testing 94 | ``` 95 | 96 | ## Submitting Changes 97 | 98 | 1. Commit your changes: 99 | ``` 100 | git add . 101 | git commit -m "Add: brief description of changes" 102 | ``` 103 | 2. Push to your fork: 104 | ``` 105 | git push origin feature/your-feature-name 106 | ``` 107 | 3. Open a Pull Request on GitHub 108 | 4. Describe what you changed and why 109 | 5. Link any related issues 110 | 111 | ## Code Guidelines 112 | 113 | - Run `go fmt` before committing 114 | - Keep functions small and focused 115 | - Use meaningful variable names 116 | - Add comments for complex logic 117 | - Update documentation for new features 118 | 119 | ## Commit Messages 120 | 121 | Use clear, descriptive commit messages: 122 | - `Add: feature name` for new features 123 | - `Fix: bug description` for bug fixes 124 | - `Docs: what you updated` for documentation 125 | - `Refactor: what you improved` for code improvements 126 | 127 | ## Getting Help 128 | 129 | - 💬 Open an issue for questions 130 | - 🐞 Report bugs via GitHub Issues 131 | - 🎯 Suggest features in Discussions 132 | 133 | **Thanks for contributing :)** 134 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= 2 | github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= 3 | github.com/charmbracelet/bubbles v0.16.1 h1:6uzpAAaT9ZqKssntbvZMlksWHruQLNxg49H5WdeuYSY= 4 | github.com/charmbracelet/bubbles v0.16.1/go.mod h1:2QCp9LFlEsBQMvIYERr7Ww2H2bA7xen1idUDIzm/+Xc= 5 | github.com/charmbracelet/bubbles v0.21.0 h1:9TdC97SdRVg/1aaXNVWfFH3nnLAwOXr8Fn6u6mfQdFs= 6 | github.com/charmbracelet/bubbles v0.21.0/go.mod h1:HF+v6QUR4HkEpz62dx7ym2xc71/KBHg+zKwJtMw+qtg= 7 | github.com/charmbracelet/bubbletea v0.24.2 h1:uaQIKx9Ai6Gdh5zpTbGiWpytMU+CfsPp06RaW2cx/SY= 8 | github.com/charmbracelet/bubbletea v0.24.2/go.mod h1:XdrNrV4J8GiyshTtx3DNuYkR1FDaJmO3l2nejekbsgg= 9 | github.com/charmbracelet/bubbletea v1.3.10 h1:otUDHWMMzQSB0Pkc87rm691KZ3SWa4KUlvF9nRvCICw= 10 | github.com/charmbracelet/bubbletea v1.3.10/go.mod h1:ORQfo0fk8U+po9VaNvnV95UPWA1BitP1E0N6xJPlHr4= 11 | github.com/charmbracelet/colorprofile v0.3.3 h1:DjJzJtLP6/NZ8p7Cgjno0CKGr7wwRJGxWUwh2IyhfAI= 12 | github.com/charmbracelet/colorprofile v0.3.3/go.mod h1:nB1FugsAbzq284eJcjfah2nhdSLppN2NqvfotkfRYP4= 13 | github.com/charmbracelet/lipgloss v0.7.1 h1:17WMwi7N1b1rVWOjMT+rCh7sQkvDU75B2hbZpc5Kc1E= 14 | github.com/charmbracelet/lipgloss v0.7.1/go.mod h1:yG0k3giv8Qj8edTCbbg6AlQ5e8KNWpFujkNawKNhE2c= 15 | github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY= 16 | github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30= 17 | github.com/charmbracelet/x/ansi v0.11.2 h1:XAG3FSjiVtFvgEgGrNBkCNNYrsucAt8c6bfxHyROLLs= 18 | github.com/charmbracelet/x/ansi v0.11.2/go.mod h1:9tY2bzX5SiJCU0iWyskjBeI2BRQfvPqI+J760Mjf+Rg= 19 | github.com/charmbracelet/x/cellbuf v0.0.14 h1:iUEMryGyFTelKW3THW4+FfPgi4fkmKnnaLOXuc+/Kj4= 20 | github.com/charmbracelet/x/cellbuf v0.0.14/go.mod h1:P447lJl49ywBbil/KjCk2HexGh4tEY9LH0/1QrZZ9rA= 21 | github.com/charmbracelet/x/term v0.2.2 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSgfgZRk= 22 | github.com/charmbracelet/x/term v0.2.2/go.mod h1:kF8CY5RddLWrsgVwpw4kAa6TESp6EB5y3uxGLeCqzAI= 23 | github.com/clipperhouse/displaywidth v0.6.1 h1:/zMlAezfDzT2xy6acHBzwIfyu2ic0hgkT83UX5EY2gY= 24 | github.com/clipperhouse/displaywidth v0.6.1/go.mod h1:R+kHuzaYWFkTm7xoMmK1lFydbci4X2CicfbGstSGg0o= 25 | github.com/clipperhouse/stringish v0.1.1 h1:+NSqMOr3GR6k1FdRhhnXrLfztGzuG+VuFDfatpWHKCs= 26 | github.com/clipperhouse/stringish v0.1.1/go.mod h1:v/WhFtE1q0ovMta2+m+UbpZ+2/HEXNWYXQgCt4hdOzA= 27 | github.com/clipperhouse/uax29/v2 v2.3.0 h1:SNdx9DVUqMoBuBoW3iLOj4FQv3dN5mDtuqwuhIGpJy4= 28 | github.com/clipperhouse/uax29/v2 v2.3.0/go.mod h1:Wn1g7MK6OoeDT0vL+Q0SQLDz/KpfsVRgg6W7ihQeh4g= 29 | github.com/containerd/console v1.0.4-0.20230313162750-1ae8d489ac81 h1:q2hJAaP1k2wIvVRd/hEHD7lacgqrCPS+k8g1MndzfWY= 30 | github.com/containerd/console v1.0.4-0.20230313162750-1ae8d489ac81/go.mod h1:YynlIjWYF8myEu6sdkwKIvGQq+cOckRm6So2avqoYAk= 31 | github.com/containerd/console v1.0.5 h1:R0ymNeydRqH2DmakFNdmjR2k0t7UPuiOV/N/27/qqsc= 32 | github.com/containerd/console v1.0.5/go.mod h1:YynlIjWYF8myEu6sdkwKIvGQq+cOckRm6So2avqoYAk= 33 | github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4= 34 | github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM= 35 | github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY= 36 | github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= 37 | github.com/lucasb-eyer/go-colorful v1.3.0 h1:2/yBRLdWBZKrf7gB40FoiKfAWYQ0lqNcbuQwVHXptag= 38 | github.com/lucasb-eyer/go-colorful v1.3.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= 39 | github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= 40 | github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= 41 | github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4= 42 | github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88= 43 | github.com/mattn/go-runewidth v0.0.12/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk= 44 | github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc= 45 | github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= 46 | github.com/mattn/go-runewidth v0.0.19 h1:v++JhqYnZuu5jSKrk9RbgF5v4CGUjqRfBm05byFGLdw= 47 | github.com/mattn/go-runewidth v0.0.19/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs= 48 | github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI= 49 | github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo= 50 | github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA= 51 | github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo= 52 | github.com/muesli/reflow v0.3.0 h1:IFsN6K9NfGtjeggFP+68I4chLZV2yIKsXJFNZ+eWh6s= 53 | github.com/muesli/reflow v0.3.0/go.mod h1:pbwTDkVPibjO2kyvBQRBxTWEEGDGq0FlB1BIKtnHY/8= 54 | github.com/muesli/termenv v0.15.2 h1:GohcuySI0QmI3wN8Ok9PtKGkgkFIk7y6Vpb5PvrY+Wo= 55 | github.com/muesli/termenv v0.15.2/go.mod h1:Epx+iuz8sNs7mNKhxzH4fWXGNpZwUaJKRS1noLXviQ8= 56 | github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc= 57 | github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk= 58 | github.com/rivo/uniseg v0.1.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= 59 | github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= 60 | github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= 61 | github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= 62 | github.com/savannahostrowski/tree-bubble v0.0.0-20230724043728-d7bb06a8a67e h1:nktxNPbFDw48XE8CImkdkjFu68+kmMf0sC+W+v46uKI= 63 | github.com/savannahostrowski/tree-bubble v0.0.0-20230724043728-d7bb06a8a67e/go.mod h1:inJ/hStD+7ynR44VY0B/Ebp9U2TK1QFD4NavPTm8BsY= 64 | github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= 65 | github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= 66 | golang.org/x/sync v0.1.0 h1:wsuoTGHzEhffawBOhz5CYhcrV4IdKZbEyZjBMuTp12o= 67 | golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 68 | golang.org/x/sync v0.18.0 h1:kr88TuHDroi+UVf+0hZnirlk8o8T+4MrK6mr60WkH/I= 69 | golang.org/x/sync v0.18.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= 70 | golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 71 | golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 72 | golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 73 | golang.org/x/sys v0.36.0 h1:KVRy2GtZBrk1cBYA7MKu5bEZFxQk4NIDV6RLVcC8o0k= 74 | golang.org/x/sys v0.36.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= 75 | golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc= 76 | golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= 77 | golang.org/x/term v0.6.0 h1:clScbb1cHjoCkyRbWwBEUZ5H/tIFu5TAXIqaZD0Gcjw= 78 | golang.org/x/term v0.6.0/go.mod h1:m6U89DPEgQRMq3DNkDClhWw02AUbt2daBVO4cn4Hv9U= 79 | golang.org/x/term v0.37.0 h1:8EGAD0qCmHYZg6J17DvsMy9/wJ7/D/4pV/wfnld5lTU= 80 | golang.org/x/term v0.37.0/go.mod h1:5pB4lxRNYYVZuTLmy8oR2BH8dflOR+IbTYFD8fi3254= 81 | golang.org/x/text v0.3.8 h1:nAL+RVCQ9uMn3vJZbV+MRnydTJFPf8qqY42YiA6MrqY= 82 | golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ= 83 | golang.org/x/text v0.31.0 h1:aC8ghyu4JhP8VojJ2lEHBnochRno1sgL6nEi9WGFGMM= 84 | golang.org/x/text v0.31.0/go.mod h1:tKRAlv61yKIjGGHX/4tP1LTbc13YSec1pxVEWXzfoeM= 85 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | 2 |

DockMate 🐳

3 |

A terminal-based Docker container manager that actually works.

4 | 5 |

6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 |

14 | 15 | > **Note**: Previously named **DockWatch** (renamed to avoid confusion with another project). 16 | 17 | ![DockMate demo gif](assets/demo.gif) 18 | 19 | --- 20 | 21 | ## Overview 22 | 23 | DockMate is a **TUI (Text User Interface)** for **managing Docker containers** directly from your terminal. 24 | Think of `htop`, but for Docker. 25 | 26 | - See live container stats at a glance 27 | - Start, stop, restart, and remove containers with single keypresses 28 | - Jump into logs or an interactive shell instantly 29 | 30 | --- 31 | 32 | ## Comparison 33 |
34 | 35 | ### DockMate vs LazyDocker 36 | 37 | 38 | | Feature | DockMate | LazyDocker | 39 | |---------|----------|------------| 40 | | **Installation** | One-command + Homebrew | Homebrew + Multiple package managers | 41 | | **Auto-update** | ✅ Built-in (`dockmate update`) | ❌ Manual updates required | 42 | | **Container loading** | ✅ **Fast (2 seconds)** | Slower (variable) | 43 | | **UI Framework** | ✅ **Bubble Tea (new)** | gocui (older library) | 44 | | **Dependencies** | ✅ **Minimal** (bash, curl) | Multiple system dependencies | 45 | | **Container stats** | ✅ Real-time (CPU, memory, network, disk I/O) | Real-time + ASCII graphs | 46 | | **Interactive logs** | ✅ | ✅ | 47 | | **Shell access** | ✅ One keypress | ✅ | 48 | | **Docker Compose** | ✅ | ✅ | 49 | | **Image management** | ⏳ Planned | ✅ Layer inspection & pruning | 50 | | **Metrics graphs** | ❌ Text-based (lighter) | ✅ Customizable ASCII graphs | 51 | | **Mouse support** | ❌ Keyboard-focused | ✅ | 52 | | **Resource usage** | ✅ **Lightweight** | Heavier footprint | 53 | | **Best for** | Speed, simplicity, modern workflows | Feature-rich power users | 54 | 55 | 56 | 57 |
58 | 59 | ### When to use DockMate? 60 | 61 | - ✅ You want a modern, lightweight, and fast TUI 62 | - ✅ You prefer keyboard-driven workflows 63 | - ✅ You need quick container monitoring over SSH 64 | - ✅ You want one-command install with auto-updates 65 | - ✅ You value simplicity over features 66 | 67 | ### When to use LazyDocker? 68 | 69 | - ✅ You need Docker Compose management 70 | - ✅ You want metrics graphs and visualizations 71 | - ✅ You need image layer inspection 72 | - ✅ You prefer mouse support 73 | - ✅ You want a mature tool 74 | 75 | 76 | **Both are great tools - choose based on your workflow!** 🐳 77 | 78 | 79 | --- 80 | 81 | ## Features 82 | 83 | - Live container metrics: CPU, memory, PIDs, network I/O, block I/O 84 | - Start / stop / restart containers 85 | - View recent logs 86 | - Open an interactive shell inside a container 87 | - Sort by any column 88 | - Auto-refresh every 2 seconds 89 | - Fully keyboard-driven (no mouse) 90 | - Resizes cleanly with your terminal 91 | 92 | --- 93 | 94 | 95 | ## Requirements 96 | 97 | - Docker installed and running 98 | - Linux or macOS 99 | 100 | - Go 1.24+ **only if** building from source 101 | 102 | --- 103 | 104 | ## System Dependencies 105 | 106 | DockMate uses the following system tools: 107 | 108 | - **curl** - For one-command installation 109 | 110 | **macOS:** systemctl checks are automatically skipped. 111 | 112 | 113 | --- 114 | 115 | ## Installation 116 | 117 | ### 🍺 Homebrew (Recommended) 118 | 119 | ``` 120 | brew install shubh-io/tap/dockmate 121 | ``` 122 | 123 | Works on both **Linux** and **macOS**. Easiest way to install and update. 124 | 125 | ### 📦 Quick Install Script 126 | 127 | ``` 128 | curl -fsSL https://raw.githubusercontent.com/shubh-io/DockMate/main/install.sh | sh 129 | ``` 130 | 131 | If that ever fails on your setup, use the two-step variant: 132 | 133 | ``` 134 | curl -fsSL https://raw.githubusercontent.com/shubh-io/DockMate/main/install.sh -o install.sh 135 | sh install.sh 136 | ``` 137 | 138 | ### Alternative: User-local installation 139 | 140 | If you encounter permission issues with `/usr/local/bin`, install to your user directory instead: 141 | 142 | ``` 143 | curl -fsSL https://raw.githubusercontent.com/shubh-io/dockmate/main/install.sh | INSTALL_DIR=$HOME/.local/bin sh 144 | ``` 145 | 146 | Then add to your PATH. Choose based on your shell: 147 | 148 | **For Bash** (most Linux): 149 | ``` 150 | echo 'export PATH="$HOME/.local/bin:$PATH"' >> ~/.bashrc 151 | source ~/.bashrc 152 | ``` 153 | 154 | **For Zsh** (macOS default): 155 | ``` 156 | echo 'export PATH="$HOME/.local/bin:$PATH"' >> ~/.zshrc 157 | source ~/.zshrc 158 | ``` 159 | 160 | **Not sure which shell?** Run `echo $SHELL` to check. 161 | 162 | 163 | 164 | --- 165 | 166 | **Note:** Some shells cache executable locations. If `dockmate` isn't found immediately after 167 | installation, refresh your shell's command cache with: 168 | 169 | ``` 170 | hash -r 171 | ``` 172 | 173 | Or open a new terminal session. 174 | 175 | ### 🔨 Build from Source 176 | 177 | If you want to tweak or contribute: 178 | 179 | ``` 180 | git clone https://github.com/shubh-io/DockMate 181 | cd DockMate 182 | go build -o dockmate 183 | 184 | # Run locally 185 | ./dockmate 186 | 187 | # Optional: make it available system-wide 188 | sudo mv dockmate /usr/local/bin/ 189 | ``` 190 | 191 | ### 🔄 Updating 192 | 193 | **Homebrew:** 194 | ``` 195 | brew upgrade shubh-io/tap/dockmate 196 | ``` 197 | 198 | **Built-in updater:** 199 | ``` 200 | dockmate update 201 | ``` 202 | 203 | **Or re-run the installer:** 204 | ``` 205 | curl -fsSL https://raw.githubusercontent.com/shubh-io/DockMate/main/install.sh | sh 206 | ``` 207 | 208 | --- 209 | 210 | ## Usage 211 | 212 | ``` 213 | dockmate 214 | ``` 215 | 216 | Use the keyboard to navigate and control containers. 217 | 218 | **Check installed version:** 219 | ``` 220 | dockmate version 221 | # or 222 | dockmate -v 223 | # or 224 | dockmate --version 225 | ``` 226 | 227 | --- 228 | 229 | ## Keyboard shortcuts 230 | 231 | | Key | Action | 232 | |-------------------|---------------------------------| 233 | | `↑ / ↓` or `j / k`| Navigate containers | 234 | | `Tab` | Switch to column selection mode | 235 | | `← / →` or `h / l`| Move between columns | 236 | | `Enter` | Sort by selected column | 237 | | `s` | Start container | 238 | | `x` | Stop container | 239 | | `r` | Restart container | 240 | | `l` | View logs | 241 | | `e` | Open interactive shell | 242 | | `c` | Compose view | 243 | | `d` | Remove container | 244 | | `q` or `Ctrl+C` | Quit | 245 | 246 | --- 247 | 248 | ## Verifying downloads (optional) 249 | 250 | Release binaries are published with matching SHA256 checksum files. 251 | 252 | Example for verifying a release: 253 | 254 | ``` 255 | # Download binary and checksum 256 | curl -fsSL -o dockmate-linux-amd64 \ 257 | https://github.com/shubh-io/DockMate/releases/download/v0.0.8/dockmate-linux-amd64 258 | 259 | curl -fsSL -o dockmate-linux-amd64.sha256 \ 260 | https://github.com/shubh-io/DockMate/releases/download/v0.0.8/dockmate-linux-amd64.sha256 261 | 262 | # Verify on Linux 263 | sha256sum -c dockmate-linux-amd64.sha256 264 | 265 | # Or on macOS 266 | shasum -a 256 -c dockmate-linux-amd64.sha256 267 | ``` 268 | 269 | The installer script will also try to fetch and verify the corresponding `.sha256` file automatically. 270 | If verification fails, installation is aborted. 271 | 272 | --- 273 | 274 | ## Why DockMate? 275 | 276 | Most Docker TUIs either try to do too much or require config and setup. 277 | DockMate aims to be: 278 | 279 | - Lightweight 280 | - Zero-config 281 | - "Install and go" for daily container management work 282 | 283 | --- 284 | 285 | ## Roadmap 286 | 287 | - [x] Docker Compose integration 288 | - [ ] Container search / filter 289 | - [ ] Resource monitoring alerts & notifications 290 | - [ ] Image management 291 | - [x] Homebrew distribution 292 | - [x] macOS support 293 | 294 | Have ideas? Open an issue. 295 | 296 | --- 297 | 298 | ## Contributing 299 | 300 | Bug reports, feature requests, and pull requests are all welcome. 301 | 302 | 1. Fork the repo 303 | 2. Create a feature branch 304 | 3. Open a PR with a clear description 305 | 306 | --- 307 | 308 | ## License 309 | 310 | MIT License – do pretty much whatever you want, just keep the license intact. 311 | 312 | --- 313 | 314 | ## Credits 315 | 316 | Built by [@shubh-io](https://github.com/shubh-io) while learning Go and Docker. 317 | If DockMate saves you some keystrokes, consider dropping a ⭐ on the repo. 318 | 319 | -------------------------------------------------------------------------------- /internal/update/update.go: -------------------------------------------------------------------------------- 1 | package update 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "io" 7 | "net/http" 8 | "os" 9 | "os/exec" 10 | 11 | "strconv" 12 | "strings" 13 | 14 | "github.com/shubh-io/dockmate/pkg/version" 15 | ) 16 | 17 | // commandExists checks if a command is available in PATH 18 | func commandExists(cmd string) bool { 19 | _, err := exec.LookPath(cmd) 20 | return err == nil 21 | } 22 | 23 | // downloadFile downloads a file from URL and saves it to the given path 24 | func downloadFile(url, filepath string) error { 25 | resp, err := http.Get(url) 26 | if err != nil { 27 | return fmt.Errorf("failed to download: %w", err) 28 | } 29 | defer resp.Body.Close() 30 | 31 | if resp.StatusCode != http.StatusOK { 32 | return fmt.Errorf("download failed with status: %d", resp.StatusCode) 33 | } 34 | 35 | out, err := os.Create(filepath) 36 | if err != nil { 37 | return fmt.Errorf("failed to create file: %w", err) 38 | } 39 | defer out.Close() 40 | 41 | _, err = io.Copy(out, resp.Body) 42 | if err != nil { 43 | return fmt.Errorf("failed to write file: %w", err) 44 | } 45 | 46 | return nil 47 | } 48 | 49 | // getShellCommand returns the appropriate shell command 50 | func getShellCommand() (string, bool) { 51 | if commandExists("bash") { 52 | return "bash", true 53 | } 54 | if commandExists("sh") { 55 | return "sh", true 56 | } 57 | 58 | return "", false 59 | } 60 | 61 | // Check if dockmate is installed via Homebrew 62 | func isHomebrewInstall() bool { 63 | // First, check if brew exists and whether dockmate is registered under brew 64 | if _, err := exec.LookPath("brew"); err == nil { 65 | // Prefer explicit tap name; fallback to plain formula name 66 | if err := exec.Command("brew", "list", "--versions", "shubh-io/tap/dockmate").Run(); err == nil { 67 | return true 68 | } 69 | if err := exec.Command("brew", "list", "--versions", "dockmate").Run(); err == nil { 70 | return true 71 | } 72 | // Fallback: compare executable path to brew prefix 73 | exe, err := os.Executable() 74 | if err == nil { 75 | prefixOut, pErr := exec.Command("brew", "--prefix").Output() 76 | if pErr == nil { 77 | prefix := strings.TrimSpace(string(prefixOut)) 78 | exeLower := strings.ToLower(exe) 79 | // Common brew locations 80 | if strings.HasPrefix(exeLower, strings.ToLower(prefix)) || 81 | strings.Contains(exeLower, "/cellar/dockmate") || 82 | strings.Contains(exeLower, "/opt/homebrew") || 83 | strings.Contains(exeLower, "/usr/local/cellar") || 84 | strings.Contains(exeLower, ".linuxbrew") || 85 | strings.Contains(exeLower, "/home/linuxbrew") { 86 | return true 87 | } 88 | } 89 | } 90 | } 91 | 92 | // As a last resort, path heuristics without brew available 93 | exe, err := os.Executable() 94 | if err != nil { 95 | return false 96 | } 97 | exePath := strings.ToLower(exe) 98 | homebrewHints := []string{ 99 | "/linuxbrew/", 100 | "/home/linuxbrew/", 101 | "/homebrew/", 102 | "/opt/homebrew/", 103 | "/usr/local/cellar/", 104 | "cellar/dockmate", 105 | ".linuxbrew", 106 | } 107 | for _, h := range homebrewHints { 108 | if strings.Contains(exePath, strings.ToLower(h)) { 109 | return true 110 | } 111 | } 112 | return false 113 | } 114 | 115 | // getLatestReleaseTag fetches the latest release tag name from GitHub for the given repo (owner/repo) 116 | // This uses a small shell pipeline to keep the implementation compact 117 | func getLatestReleaseTag(repo string) (string, error) { 118 | url := fmt.Sprintf("https://api.github.com/repos/%s/releases/latest", repo) 119 | 120 | resp, err := http.Get(url) 121 | if err != nil { 122 | return "", fmt.Errorf("failed to fetch release info: %w", err) 123 | } 124 | defer resp.Body.Close() 125 | 126 | if resp.StatusCode != http.StatusOK { 127 | return "", fmt.Errorf("GitHub API returned status %d", resp.StatusCode) 128 | } 129 | 130 | body, err := io.ReadAll(resp.Body) 131 | if err != nil { 132 | return "", fmt.Errorf("failed to read response: %w", err) 133 | } 134 | 135 | var release struct { 136 | TagName string `json:"tag_name"` 137 | } 138 | 139 | if err := json.Unmarshal(body, &release); err != nil { 140 | return "", fmt.Errorf("failed to parse JSON: %w", err) 141 | } 142 | 143 | if strings.TrimSpace(release.TagName) == "" { 144 | return "", fmt.Errorf("no tag name found in release") 145 | } 146 | 147 | return release.TagName, nil 148 | } 149 | 150 | // trims whitespace and leading 'v' or 'V' 151 | func normalizeTag(tag string) string { 152 | tag = strings.TrimSpace(tag) 153 | if strings.HasPrefix(tag, "v") || strings.HasPrefix(tag, "V") { 154 | return tag[1:] 155 | } 156 | return tag 157 | } 158 | 159 | // compareSemver compares two simple dot-separated semantic versions (major.minor.patch). 160 | // returns -1 if ab. 161 | // non-numeric parts are compared lexically! 162 | func compareSemver(a, b string) int { 163 | a = normalizeTag(a) 164 | b = normalizeTag(b) 165 | if a == b { 166 | return 0 167 | } 168 | // split a into parts - eg: "1.2.3" -> ["1","2","3"] 169 | a_splited := strings.Split(a, ".") 170 | // split b into parts - eg: "1.2.0" -> ["1","2","0"] 171 | b_splited := strings.Split(b, ".") 172 | 173 | // compare each part 174 | n := len(a_splited) 175 | // if length of b is greater than a, then we compare by b's length 176 | if len(b_splited) > n { 177 | n = len(b_splited) 178 | } 179 | 180 | for i := 0; i < n; i++ { 181 | var a_value, b_value string 182 | if i < len(a_splited) { 183 | a_value = a_splited[i] 184 | } 185 | if i < len(b_splited) { 186 | b_value = b_splited[i] 187 | } 188 | if a_value == b_value { 189 | continue 190 | } 191 | // attempting numeric compare for best accuracy 192 | ai, aErr := strconv.Atoi(a_value) 193 | bi, bErr := strconv.Atoi(b_value) 194 | if aErr == nil && bErr == nil { 195 | if ai < bi { 196 | return -1 197 | } 198 | if ai > bi { 199 | return 1 200 | } 201 | // equal numerically, continue 202 | continue 203 | } 204 | // fallback to lexical comparison if either segment isn't a plain integer 205 | if cmp := strings.Compare(a_value, b_value); cmp != 0 { 206 | return cmp 207 | } 208 | } 209 | return 0 210 | } 211 | 212 | func UpdateCommand() { 213 | fmt.Println("Checking for updates...") 214 | 215 | // Check if installed via Homebrew FIRST 216 | if isHomebrewInstall() { 217 | fmt.Println("⚠️ Detected: dockmate is installed via Homebrew") 218 | fmt.Println("") 219 | fmt.Println("To update, please run:") 220 | fmt.Println(" brew upgrade shubh-io/tap/dockmate") 221 | fmt.Println("") 222 | fmt.Println("Current version:", version.Dockmate_Version) 223 | return 224 | } 225 | 226 | // Ensure we have the current version constant available 227 | current := version.Dockmate_Version 228 | 229 | latestTag, err := getLatestReleaseTag(version.Repo) 230 | if err != nil { 231 | fmt.Fprintf(os.Stderr, "Could not check latest release: %v\n", err) 232 | return 233 | } 234 | 235 | // compare normalized tags (striped 'v') 236 | cmp := compareSemver(current, latestTag) 237 | if cmp >= 0 { 238 | fmt.Printf("Already up-to-date (current: %s, latest: %s)\n", current, latestTag) 239 | return 240 | } 241 | 242 | fmt.Printf("New release available! : %s → %s\n", current, latestTag) 243 | fmt.Println("Re-running installer to update...") 244 | 245 | // Check for required shell 246 | _, hasShell := getShellCommand() 247 | if !hasShell { 248 | fmt.Fprintln(os.Stderr, "Error: No compatible shell found (bash, sh)") 249 | fmt.Fprintln(os.Stderr, "Please install bash or sh to use auto-update") 250 | fmt.Printf("\nManual update: https://github.com/%s/releases/latest\n", version.Repo) 251 | return 252 | } 253 | 254 | // define install script URL and local filename 255 | installURL := "https://raw.githubusercontent.com/shubh-io/dockmate/main/install.sh" 256 | installScript := "install.sh" 257 | 258 | // Try piped install first using `sh` only for portability. 259 | // bash directly to prevent failures on systems without bash in PATH. 260 | if commandExists("sh") { 261 | if commandExists("curl") { 262 | cmd := exec.Command("sh", "-c", fmt.Sprintf("curl -fsSL %s | sh", installURL)) 263 | cmd.Stdout = os.Stdout 264 | cmd.Stderr = os.Stderr 265 | if err := cmd.Run(); err == nil { 266 | fmt.Println("") 267 | fmt.Println("Updated successfully!") 268 | return 269 | } 270 | fmt.Println("Piped install failed, trying fallback method...") 271 | } else if commandExists("wget") { 272 | cmd := exec.Command("sh", "-c", fmt.Sprintf("wget -qO- %s | sh", installURL)) 273 | cmd.Stdout = os.Stdout 274 | cmd.Stderr = os.Stderr 275 | if err := cmd.Run(); err == nil { 276 | fmt.Println("") 277 | fmt.Println("Updated successfully!") 278 | return 279 | } 280 | fmt.Println("Piped install failed, trying fallback method...") 281 | } 282 | } 283 | 284 | // Fallback- download script using Go's http client, then run it 285 | fmt.Println("Downloading installer...") 286 | if err := downloadFile(installURL, installScript); err != nil { 287 | fmt.Fprintf(os.Stderr, "Failed to download install script: %v\n", err) 288 | fmt.Printf("\nPlease update manually: https://github.com/%s/releases/latest\n", version.Repo) 289 | return 290 | } 291 | // run installer script 292 | fmt.Println("Running installer...") 293 | runCmd := exec.Command("sh", installScript) 294 | runCmd.Stdout = os.Stdout 295 | runCmd.Stderr = os.Stderr 296 | 297 | if err := runCmd.Run(); err != nil { 298 | fmt.Fprintf(os.Stderr, "Update failed: %v\n", err) 299 | fmt.Printf("\nPlease update manually: https://github.com/%s/releases/latest\n", version.Repo) 300 | // Still try to clean up 301 | os.Remove(installScript) 302 | return 303 | } 304 | 305 | // removes the script file 306 | if err := os.Remove(installScript); err != nil { 307 | fmt.Printf("Warning: could not remove %s: %v\n", installScript, err) 308 | } 309 | 310 | fmt.Println("") 311 | fmt.Println("Updated successfully!") 312 | } 313 | -------------------------------------------------------------------------------- /internal/check/precheck.go: -------------------------------------------------------------------------------- 1 | package check 2 | 3 | import ( 4 | "bytes" 5 | "fmt" 6 | "os" 7 | "os/exec" 8 | "os/user" 9 | "runtime" 10 | "strings" 11 | ) 12 | 13 | // ============================================================================ 14 | // PreCheck Types 15 | // ============================================================================ 16 | 17 | type PreCheckResult struct { 18 | Passed bool 19 | ErrorType PreCheckErrorType 20 | ErrorMessage string 21 | SuggestedAction string 22 | } 23 | 24 | type PreCheckErrorType int 25 | 26 | const ( 27 | NoError PreCheckErrorType = iota 28 | DockerNotInstalled 29 | DockerDaemonNotRunning 30 | DockerPermissionDenied 31 | DockerGroupNotRefreshed 32 | ) 33 | 34 | // ============================================================================ 35 | // PreCheck Functions 36 | // ============================================================================ 37 | 38 | // commandExists checks if a command is available in PATH 39 | func commandExists(cmd string) bool { 40 | _, err := exec.LookPath(cmd) 41 | return err == nil 42 | } 43 | 44 | // getDockerStartCommand detects the init system and returns the appropriate command 45 | func getDockerStartCommand() string { 46 | if runtime.GOOS == "darwin" { 47 | return "Start Docker Desktop application" 48 | } 49 | 50 | // Check for different init systems 51 | if commandExists("systemctl") { 52 | return "sudo systemctl start docker" 53 | } 54 | if commandExists("rc-service") { 55 | return "sudo rc-service docker start" 56 | } 57 | if commandExists("sv") { 58 | return "sudo sv up docker" 59 | } 60 | 61 | // Fallback to generic service command 62 | return "sudo service docker start" 63 | } 64 | 65 | // getDockerRestartCommand detects the init system and returns the restart command 66 | func getDockerRestartCommand() string { 67 | if runtime.GOOS == "darwin" { 68 | return "Restart Docker Desktop application" 69 | } 70 | 71 | // check for different init systems 72 | if commandExists("systemctl") { 73 | return "sudo systemctl restart docker" 74 | } 75 | if commandExists("rc-service") { 76 | return "sudo rc-service docker restart" 77 | } 78 | if commandExists("sv") { 79 | return "sudo sv restart docker" 80 | } 81 | 82 | // Fallback 83 | return "sudo service docker restart" 84 | } 85 | 86 | // checks if the 'docker' group exists on the system and anchor before docker to help find group that 'starts with' docker 87 | // On macOS, Docker Desktop doesn't use groups, so this always returns false 88 | func doesDockerGroupExist() bool { 89 | if runtime.GOOS == "darwin" { 90 | return false 91 | } 92 | 93 | // check /etc/group on Linux/Unix systems 94 | if !commandExists("grep") { 95 | // fallback - check if group file exists and contains docker 96 | data, err := os.ReadFile("/etc/group") 97 | if err != nil { 98 | return false 99 | } 100 | return strings.Contains(string(data), "\ndocker:") || strings.HasPrefix(string(data), "docker:") 101 | } 102 | 103 | cmd := exec.Command("grep", "^docker:", "/etc/group") 104 | err := cmd.Run() 105 | return err == nil 106 | } 107 | 108 | // checks if the current user is listed in the 'docker' group in /etc/group 109 | // On mac-os, Docker Desktop doesn't use groups, so this always returns false 110 | func isUserInDockerGroup() (bool, error) { 111 | if runtime.GOOS == "darwin" { 112 | return false, nil 113 | } 114 | 115 | // get current user in a cross-platform way 116 | currentUser, err := user.Current() 117 | if err != nil { 118 | return false, err 119 | } 120 | username := currentUser.Username 121 | 122 | //reading /etc/group directly if grep is not available 123 | var output []byte 124 | if commandExists("grep") { 125 | cmd := exec.Command("grep", "^docker:", "/etc/group") 126 | output, err = cmd.Output() 127 | if err != nil { 128 | return false, err 129 | } 130 | } else { 131 | // Fallback: read /etc/group and find docker line 132 | data, err := os.ReadFile("/etc/group") 133 | if err != nil { 134 | return false, err 135 | } 136 | // split into lines and find docker line 137 | lines := strings.Split(string(data), "\n") 138 | 139 | for _, line := range lines { 140 | // find the line that starts with 'docker:' 141 | if strings.HasPrefix(line, "docker:") { 142 | output = []byte(line) 143 | break 144 | } 145 | } 146 | if len(output) == 0 { 147 | return false, nil 148 | } 149 | } 150 | 151 | // output format: docker:x:999:user1,user2,.. 152 | line := string(output) 153 | parts := strings.Split(line, ":") 154 | if len(parts) < 4 { 155 | return false, nil 156 | } 157 | 158 | // removes whitespaces and 'docker:x:999:' to get only usersInGroup 159 | usersInGroup := strings.TrimSpace(parts[3]) 160 | if usersInGroup == "" { 161 | return false, nil 162 | } 163 | // split users by comma and check for current user 164 | users := strings.Split(usersInGroup, ",") 165 | for _, user := range users { 166 | if strings.TrimSpace(user) == username { 167 | return true, nil 168 | } 169 | } 170 | return false, nil 171 | } 172 | 173 | // checks if the 'docker' group is in the user's active groups (id -nG) 174 | // On macOS, Docker Desktop doesn't use groups, so this always returns false 175 | func isDockerInActiveGroups() (bool, error) { 176 | if runtime.GOOS == "darwin" { 177 | return false, nil 178 | } 179 | 180 | // Check if id command exists 181 | if !commandExists("id") { 182 | return false, nil 183 | } 184 | 185 | cmd := exec.Command("id", "-nG") 186 | output, err := cmd.Output() 187 | if err != nil { 188 | return false, err 189 | } 190 | 191 | groups := strings.Fields(string(output)) 192 | for _, group := range groups { 193 | if group == "docker" { 194 | return true, nil 195 | } 196 | } 197 | return false, nil 198 | } 199 | 200 | func checkDockerSocketPermissions() (hasAccess bool, errorMsg string) { 201 | if runtime.GOOS == "darwin" { 202 | // permissions are managed by Docker Desktop, so skip this check 203 | return true, "" 204 | } 205 | 206 | socketPath := "/var/run/docker.sock" 207 | 208 | // check if socket exists 209 | _, err := os.Stat(socketPath) 210 | if err != nil { 211 | return false, "Docker socket not found at /var/run/docker.sock" 212 | } 213 | 214 | // try to access the socket with read and write flags(os.O_RDWR) 215 | file, err := os.OpenFile(socketPath, os.O_RDWR, 0) 216 | if err != nil { 217 | if os.IsPermission(err) { 218 | return false, fmt.Sprintf("Socket exists but insufficient permissions: %v", err) 219 | } 220 | return false, fmt.Sprintf("Cannot access socket: %v", err) 221 | } 222 | //close the file 223 | file.Close() 224 | 225 | return true, "" 226 | } 227 | 228 | // check if docker is installed 229 | 230 | func checkDockerInstalled() PreCheckResult { 231 | _, err := exec.LookPath("docker") 232 | if err != nil { 233 | return PreCheckResult{ 234 | Passed: false, 235 | ErrorType: DockerNotInstalled, 236 | ErrorMessage: "Docker is not installed or not found in PATH", 237 | SuggestedAction: "Please install Docker to use this application.\n\n" + 238 | "Installation guide: https://docs.docker.com/engine/install/", 239 | } 240 | } 241 | return PreCheckResult{Passed: true} 242 | } 243 | 244 | func checkDockerDaemon() PreCheckResult { 245 | cmd := exec.Command("docker", "info") 246 | var stderr bytes.Buffer 247 | cmd.Stderr = &stderr 248 | 249 | err := cmd.Run() 250 | if err == nil { 251 | return PreCheckResult{Passed: true} 252 | } 253 | 254 | stderrOutput := stderr.String() 255 | 256 | // Check daemon status FIRST 257 | if strings.Contains(stderrOutput, "Is the docker daemon running") || 258 | strings.Contains(stderrOutput, "cannot connect to the Docker daemon") || 259 | !isDaemonRunning() { 260 | return PreCheckResult{ 261 | Passed: false, 262 | ErrorType: DockerDaemonNotRunning, 263 | ErrorMessage: fmt.Sprintf("Docker daemon is not running.\n\nDocker error:\n%s", stderrOutput), 264 | SuggestedAction: fmt.Sprintf("Start the Docker service:\n\n"+ 265 | " %s\n\n"+ 266 | "Troubleshooting: https://docs.docker.com/config/daemon/troubleshoot/", getDockerStartCommand()), 267 | } 268 | } 269 | 270 | // Check for permission/connection issues 271 | if strings.Contains(stderrOutput, "permission denied") || 272 | strings.Contains(stderrOutput, "dial unix") { 273 | 274 | // macOS Docker Desktop handles permissions differently 275 | if runtime.GOOS == "darwin" { 276 | return PreCheckResult{ 277 | Passed: false, 278 | ErrorType: DockerPermissionDenied, 279 | ErrorMessage: fmt.Sprintf("Cannot connect to Docker Desktop.\n\nDocker error:\n%s", stderrOutput), 280 | SuggestedAction: "Make sure Docker Desktop is running:\n\n" + 281 | "1. Open Docker Desktop application\n" + 282 | "2. Wait for it to start completely\n" + 283 | "3. Check that the Docker icon in the menu bar shows it's running\n\n" + 284 | "If issues persist, try restarting Docker Desktop.\n\n" + 285 | "Docker Desktop guide: https://docs.docker.com/desktop/install/mac-install/", 286 | } 287 | } 288 | 289 | // Linux/Unix permission handling 290 | inGroupFile, _ := isUserInDockerGroup() 291 | inActiveGroups, _ := isDockerInActiveGroups() 292 | 293 | // check socket permissions specifically 294 | hasSocketAccess, socketError := checkDockerSocketPermissions() 295 | 296 | // User is in group (both file and active) but still can't access socket 297 | if inGroupFile && inActiveGroups && !hasSocketAccess { 298 | return PreCheckResult{ 299 | Passed: false, 300 | ErrorType: DockerPermissionDenied, 301 | ErrorMessage: fmt.Sprintf("You're in the docker group, but the socket has incorrect permissions.\n\n"+ 302 | "Socket error: %s\n\n"+ 303 | "Docker error:\n%s", socketError, stderrOutput), 304 | SuggestedAction: fmt.Sprintf("Fix the Docker socket permissions:\n\n"+ 305 | " sudo chown root:docker /var/run/docker.sock\n"+ 306 | " sudo chmod 660 /var/run/docker.sock\n\n"+ 307 | "Or restart Docker to recreate the socket:\n\n"+ 308 | " %s\n\n"+ 309 | "Guide: https://docs.docker.com/engine/install/linux-postinstall/", getDockerRestartCommand()), 310 | } 311 | } 312 | 313 | if inGroupFile && !inActiveGroups { 314 | return PreCheckResult{ 315 | Passed: false, 316 | ErrorType: DockerGroupNotRefreshed, 317 | ErrorMessage: fmt.Sprintf("You're in the docker group but your session hasn't been refreshed.\n\nDocker error:\n%s", stderrOutput), 318 | SuggestedAction: "Log out and log back in to refresh your group membership.\n\n" + 319 | "More info: https://docs.docker.com/engine/install/linux-postinstall/#manage-docker-as-a-non-root-user", 320 | } 321 | } 322 | 323 | // Check if docker group exists 324 | if !doesDockerGroupExist() { 325 | return PreCheckResult{ 326 | Passed: false, 327 | ErrorType: DockerPermissionDenied, 328 | ErrorMessage: fmt.Sprintf("Cannot communicate with the Docker daemon.\n\nDocker error:\n%s", stderrOutput), 329 | SuggestedAction: "The 'docker' group doesn't exist. Create it and add your user:\n\n" + 330 | " sudo groupadd docker\n" + 331 | " sudo usermod -aG docker $USER\n\n" + 332 | "Then log out and back in.\n\n" + 333 | "Guide: https://docs.docker.com/engine/install/linux-postinstall/#manage-docker-as-a-non-root-user", 334 | } 335 | } 336 | 337 | // Docker group exists, just need to add user 338 | return PreCheckResult{ 339 | Passed: false, 340 | ErrorType: DockerPermissionDenied, 341 | ErrorMessage: fmt.Sprintf("Cannot communicate with the Docker daemon.\n\nDocker error:\n%s", stderrOutput), 342 | SuggestedAction: "Add your user to the 'docker' group:\n\n" + 343 | " sudo usermod -aG docker $USER\n\n" + 344 | "Then log out and back in.\n\n" + 345 | "Guide: https://docs.docker.com/engine/install/linux-postinstall/#manage-docker-as-a-non-root-user", 346 | } 347 | } 348 | 349 | // Fallback for other errors 350 | return PreCheckResult{ 351 | Passed: false, 352 | ErrorType: DockerDaemonNotRunning, 353 | ErrorMessage: fmt.Sprintf("Docker error:\n%s", stderrOutput), 354 | SuggestedAction: fmt.Sprintf("Check Docker installation and try:\n\n"+ 355 | " %s\n\n"+ 356 | "Docker docs: https://docs.docker.com/", getDockerStartCommand()), 357 | } 358 | } 359 | 360 | // Helper function to check if daemon is actually running 361 | func isDaemonRunning() bool { 362 | cmd := exec.Command("docker", "info") 363 | err := cmd.Run() 364 | return err == nil 365 | } 366 | 367 | func RunPreChecks() PreCheckResult { 368 | // Check 1: Is Docker even installed? 369 | result := checkDockerInstalled() 370 | if !result.Passed { 371 | return result 372 | } 373 | 374 | // Check 2: Can we connect to Docker daemon 375 | result = checkDockerDaemon() 376 | if !result.Passed { 377 | return result 378 | } 379 | 380 | return PreCheckResult{Passed: true} 381 | } 382 | -------------------------------------------------------------------------------- /internal/docker/client.go: -------------------------------------------------------------------------------- 1 | package docker 2 | 3 | import ( 4 | "bufio" 5 | "context" 6 | "encoding/json" 7 | "fmt" 8 | "os/exec" 9 | "strings" 10 | "time" 11 | ) 12 | 13 | // GetContainerStats grabs cpu/mem/pids for a container 14 | // returns empty strings on error so we don't block the UI 15 | func GetContainerStats(containerID string) (cpu string, mem string, pids string, netIO string, blockIO string, err error) { 16 | // 3 sec timeout because some containers are weird and hang 17 | ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) 18 | defer cancel() 19 | 20 | // --no-stream = instant snapshot, not continuous 21 | cmd := exec.CommandContext(ctx, "docker", "stats", "--no-stream", "--format", "{{json .}}", containerID) 22 | 23 | output, err := cmd.Output() 24 | if err != nil { 25 | // timeout or error, just bail 26 | return "", "", "", "", "", err 27 | } 28 | 29 | // docker stats returns json like this 30 | type statsEntry struct { 31 | CPUPerc string `json:"CPUPerc"` 32 | MemPerc string `json:"MemPerc"` 33 | PIDs string `json:"PIDs"` 34 | NetIO string `json:"NetIO"` 35 | BlockIO string `json:"BlockIO"` 36 | } 37 | 38 | // parse it 39 | var s statsEntry 40 | if err := json.Unmarshal(output, &s); err != nil { 41 | return "", "", "", "", "", err 42 | } 43 | 44 | return s.CPUPerc, s.MemPerc, s.PIDs, s.NetIO, s.BlockIO, nil 45 | } 46 | 47 | // GetLogs fetches logs from a container 48 | // skips empty lines and trims whitespace 49 | func GetLogs(containerID string) ([]string, error) { 50 | // 5 sec timeout 51 | ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) 52 | defer cancel() 53 | 54 | // run docker logs but only tail the last 100 lines to avoid huge output 55 | // using the CLI --tail is more efficient than fetching everything then truncating 56 | // saves resources and time 57 | cmd := exec.CommandContext(ctx, "docker", "logs", "--tail", "100", containerID) 58 | stdout, err := cmd.StdoutPipe() 59 | if err != nil { 60 | return nil, err 61 | } 62 | 63 | // read output line by line 64 | scanner := bufio.NewScanner(stdout) 65 | 66 | if err := cmd.Start(); err != nil { 67 | return nil, err 68 | } 69 | 70 | // grab all non-empty lines 71 | var out []string 72 | for scanner.Scan() { 73 | line := strings.TrimSpace(scanner.Text()) 74 | 75 | // skip blanks 76 | if line == "" { 77 | continue 78 | } 79 | out = append(out, line) 80 | } 81 | 82 | // Check for scanner errors 83 | if err := scanner.Err(); err != nil { 84 | cmd.Wait() 85 | return nil, err 86 | } 87 | 88 | // Wait for command to complete 89 | if err := cmd.Wait(); err != nil { 90 | return nil, err 91 | } 92 | 93 | return out, nil 94 | } 95 | 96 | // ListContainers gets all containers using docker CLI 97 | // grabs live stats for running ones 98 | func ListContainers() ([]Container, error) { 99 | // 30 sec timeout since we fetch stats for each running container 100 | ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) 101 | defer cancel() 102 | 103 | // docker ps with json output 104 | cmd := exec.CommandContext(ctx, "docker", "ps", "--format", "{{json .}}", "--all") 105 | 106 | stdout, err := cmd.StdoutPipe() 107 | if err != nil { 108 | return nil, err 109 | } 110 | 111 | if err := cmd.Start(); err != nil { 112 | return nil, err 113 | } 114 | 115 | // read json lines 116 | scanner := bufio.NewScanner(stdout) 117 | 118 | var out []Container 119 | var runningIDs []string // collect running container IDs 120 | 121 | // docker ps returns json like this 122 | type psEntry struct { 123 | ID string `json:"ID"` 124 | Names string `json:"Names"` 125 | Image string `json:"Image"` 126 | Status string `json:"Status"` 127 | // State string `json:"State"` 128 | Ports string `json:"Ports"` 129 | } 130 | 131 | // parse each line 132 | for scanner.Scan() { 133 | line := strings.TrimSpace(scanner.Text()) 134 | if line == "" { 135 | continue 136 | } 137 | 138 | var e psEntry 139 | if err := json.Unmarshal([]byte(line), &e); err != nil { 140 | cmd.Wait() 141 | return nil, fmt.Errorf("parsing docker output: %w", err) 142 | } 143 | 144 | // split comma separated names 145 | names := []string{} 146 | if e.Names != "" { 147 | for _, n := range strings.Split(e.Names, ",") { 148 | names = append(names, strings.TrimSpace(n)) 149 | } 150 | } 151 | 152 | // build container struct 153 | // derive a short state from Status text (ex- "Up 2 minutes" -> "running") 154 | st := strings.ToLower(strings.TrimSpace(e.Status)) 155 | state := "unknown" 156 | if strings.HasPrefix(st, "up") { 157 | state = "running" 158 | } else if strings.HasPrefix(st, "paused") || strings.Contains(st, "paused") { 159 | state = "paused" 160 | } else if strings.Contains(st, "restarting") { 161 | state = "restarting" 162 | } else if strings.HasPrefix(st, "exited") || strings.Contains(st, "exited") || strings.Contains(st, "dead") { 163 | state = "exited" 164 | } else if strings.HasPrefix(st, "created") { 165 | state = "created" 166 | } 167 | 168 | container := Container{ 169 | ID: e.ID, 170 | Names: names, 171 | Image: e.Image, 172 | Status: e.Status, 173 | State: state, 174 | Ports: e.Ports, 175 | } 176 | 177 | // collect running container Ids for batch stats fetch (based on derived State) 178 | if state == "running" { 179 | runningIDs = append(runningIDs, e.ID) 180 | } 181 | 182 | out = append(out, container) 183 | } 184 | 185 | // Check for scanner errors 186 | if err := scanner.Err(); err != nil { 187 | cmd.Wait() 188 | return nil, err 189 | } 190 | 191 | // Wait for command to complete 192 | if err := cmd.Wait(); err != nil { 193 | return nil, err 194 | } 195 | 196 | // Fetch stats for all running containers in ONE call 197 | if len(runningIDs) > 0 { 198 | statsMap, err := GetAllContainerStats(runningIDs) 199 | if err == nil { 200 | // Apply stats to containers 201 | for i := range out { 202 | if stats, ok := statsMap[out[i].ID]; ok { 203 | out[i].CPU = stats.CPU 204 | out[i].Memory = stats.Memory 205 | out[i].NetIO = stats.NetIO 206 | out[i].BlockIO = stats.BlockIO 207 | } 208 | } 209 | } 210 | } 211 | 212 | return out, nil 213 | } 214 | 215 | // GetAllContainerStats fetches stats for multiple containers in a single docker stats call 216 | // This is MUCH MUCH MUCH faster than previously calling docker stats separately for each container 217 | func GetAllContainerStats(containerIDs []string) (map[string]ContainerStats, error) { 218 | if len(containerIDs) == 0 { 219 | return nil, nil 220 | } 221 | 222 | // 5 sec timeout for batch stats (much faster than individual calls) 223 | ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) 224 | defer cancel() 225 | 226 | // Build command with all container IDs instead of one by one like old logic flow which resulted in more loading time 227 | args := []string{"stats", "--no-stream", "--format", "{{json .}}"} 228 | args = append(args, containerIDs...) 229 | 230 | cmd := exec.CommandContext(ctx, "docker", args...) 231 | stdout, err := cmd.StdoutPipe() 232 | if err != nil { 233 | return nil, err 234 | } 235 | 236 | if err := cmd.Start(); err != nil { 237 | return nil, err 238 | } 239 | 240 | // Read stats JSON lines 241 | scanner := bufio.NewScanner(stdout) 242 | statsMap := make(map[string]ContainerStats) 243 | 244 | type statsEntry struct { 245 | ID string `json:"ID"` 246 | CPUPerc string `json:"CPUPerc"` 247 | MemPerc string `json:"MemPerc"` 248 | NetIO string `json:"NetIO"` 249 | BlockIO string `json:"BlockIO"` 250 | } 251 | 252 | for scanner.Scan() { 253 | line := strings.TrimSpace(scanner.Text()) 254 | if line == "" { 255 | continue 256 | } 257 | 258 | var s statsEntry 259 | if err := json.Unmarshal([]byte(line), &s); err != nil { 260 | continue // skip malformed lines 261 | } 262 | 263 | statsMap[s.ID] = ContainerStats{ 264 | CPU: s.CPUPerc, 265 | Memory: s.MemPerc, 266 | // PIDs: s.PIDs, 267 | NetIO: s.NetIO, 268 | BlockIO: s.BlockIO, 269 | } 270 | } 271 | 272 | if err := scanner.Err(); err != nil { 273 | cmd.Wait() 274 | return nil, err 275 | } 276 | 277 | if err := cmd.Wait(); err != nil { 278 | return nil, err 279 | } 280 | 281 | return statsMap, nil 282 | } 283 | 284 | // DoAction runs a docker command on a container 285 | // works with start, stop, restart, rm, etc 286 | func DoAction(action, containerID string) error { 287 | // 30 sec timeout 288 | ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) 289 | defer cancel() 290 | 291 | cmd := exec.CommandContext(ctx, "docker", action, containerID) 292 | return cmd.Run() 293 | } 294 | 295 | // FetchComposeProjects fetches all Docker Compose projects with their containers 296 | // Groups containers by compose project and calculates running/total counts 297 | func FetchComposeProjects() (map[string]*ComposeProject, error) { 298 | // 30 sec timeout 299 | ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) 300 | defer cancel() 301 | 302 | // Get all containers with compose labels 303 | cmd := exec.CommandContext(ctx, "docker", "ps", "-a", 304 | "--filter", "label=com.docker.compose.project", 305 | "--format", "{{json .}}") 306 | 307 | stdout, err := cmd.StdoutPipe() 308 | if err != nil { 309 | return nil, err 310 | } 311 | 312 | if err := cmd.Start(); err != nil { 313 | return nil, err 314 | } 315 | 316 | // Structure for docker ps JSON output 317 | type psEntry struct { 318 | ID string `json:"ID"` 319 | Names string `json:"Names"` 320 | Image string `json:"Image"` 321 | Status string `json:"Status"` 322 | State string `json:"State"` 323 | Ports string `json:"Ports"` 324 | Labels string `json:"Labels"` 325 | CreatedAt string `json:"CreatedAt"` 326 | } 327 | 328 | scanner := bufio.NewScanner(stdout) 329 | projects := make(map[string]*ComposeProject) 330 | var runningIDs []string 331 | 332 | // Parse each container line 333 | for scanner.Scan() { 334 | line := strings.TrimSpace(scanner.Text()) 335 | if line == "" { 336 | continue 337 | } 338 | 339 | var e psEntry 340 | if err := json.Unmarshal([]byte(line), &e); err != nil { 341 | continue // Skip malformed entries 342 | } 343 | 344 | // Parse labels from comma-separated key=value pairs 345 | labels := parseLabels(e.Labels) 346 | 347 | // Extract compose metadata 348 | projectName := labels["com.docker.compose.project"] 349 | serviceName := labels["com.docker.compose.service"] 350 | containerNumber := labels["com.docker.compose.container-number"] 351 | 352 | // Skip if not a compose container 353 | if projectName == "" { 354 | continue 355 | } 356 | 357 | // Split comma separated names 358 | names := []string{} 359 | if e.Names != "" { 360 | for _, n := range strings.Split(e.Names, ",") { 361 | names = append(names, strings.TrimSpace(n)) 362 | } 363 | } 364 | 365 | // Derive state from Status 366 | st := strings.ToLower(strings.TrimSpace(e.Status)) 367 | state := "unknown" 368 | if strings.HasPrefix(st, "up") { 369 | state = "running" 370 | } else if strings.HasPrefix(st, "paused") || strings.Contains(st, "paused") { 371 | state = "paused" 372 | } else if strings.Contains(st, "restarting") { 373 | state = "restarting" 374 | } else if strings.HasPrefix(st, "exited") || strings.Contains(st, "exited") || strings.Contains(st, "dead") { 375 | state = "exited" 376 | } else if strings.HasPrefix(st, "created") { 377 | state = "created" 378 | } 379 | 380 | // Build container struct 381 | container := Container{ 382 | ID: e.ID, 383 | Names: names, 384 | Image: e.Image, 385 | Status: e.Status, 386 | State: state, 387 | Ports: e.Ports, 388 | ComposeProject: projectName, 389 | ComposeService: serviceName, 390 | ComposeNumber: containerNumber, 391 | } 392 | 393 | // Collect running IDs for stats 394 | if state == "running" { 395 | runningIDs = append(runningIDs, e.ID) 396 | } 397 | 398 | // Get or create project 399 | project, exists := projects[projectName] 400 | if !exists { 401 | project = &ComposeProject{ 402 | Name: projectName, 403 | Containers: []Container{}, 404 | ConfigFile: labels["com.docker.compose.project.config_files"], 405 | WorkingDir: labels["com.docker.compose.project.working_dir"], 406 | } 407 | projects[projectName] = project 408 | } 409 | 410 | // Add container to project 411 | project.Containers = append(project.Containers, container) 412 | } 413 | 414 | // Check scanner errors 415 | if err := scanner.Err(); err != nil { 416 | cmd.Wait() 417 | return nil, err 418 | } 419 | 420 | // Wait for command completion 421 | if err := cmd.Wait(); err != nil { 422 | return nil, err 423 | } 424 | 425 | // Fetch stats for running containers 426 | if len(runningIDs) > 0 { 427 | statsMap, err := GetAllContainerStats(runningIDs) 428 | if err == nil { 429 | // Apply stats to containers in projects 430 | for _, project := range projects { 431 | for i := range project.Containers { 432 | if stats, ok := statsMap[project.Containers[i].ID]; ok { 433 | project.Containers[i].CPU = stats.CPU 434 | project.Containers[i].Memory = stats.Memory 435 | project.Containers[i].NetIO = stats.NetIO 436 | project.Containers[i].BlockIO = stats.BlockIO 437 | } 438 | } 439 | } 440 | } 441 | } 442 | 443 | // Calculate project status 444 | for _, project := range projects { 445 | running := 0 446 | total := len(project.Containers) 447 | for _, c := range project.Containers { 448 | if strings.ToLower(c.State) == "running" { 449 | running++ 450 | } 451 | } 452 | 453 | if running == total { 454 | project.Status = AllRunning 455 | } else if running == 0 { 456 | project.Status = AllStopped 457 | } else { 458 | project.Status = SomeStopped 459 | } 460 | } 461 | 462 | return projects, nil 463 | } 464 | 465 | // parseLabels parses Docker's comma-separated label format into a map 466 | // Format: "key1=value1,key2=value2" 467 | // Handles edge cases like commas in values and empty strings 468 | func parseLabels(labelsStr string) map[string]string { 469 | labels := make(map[string]string) 470 | if labelsStr == "" { 471 | return labels 472 | } 473 | 474 | // Split by comma, but be careful of escaped commas 475 | parts := strings.Split(labelsStr, ",") 476 | for _, part := range parts { 477 | part = strings.TrimSpace(part) 478 | if part == "" { 479 | continue 480 | } 481 | 482 | // Split on first = only 483 | idx := strings.Index(part, "=") 484 | if idx == -1 { 485 | continue 486 | } 487 | 488 | key := strings.TrimSpace(part[:idx]) 489 | value := strings.TrimSpace(part[idx+1:]) 490 | labels[key] = value 491 | } 492 | 493 | return labels 494 | } 495 | -------------------------------------------------------------------------------- /install.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | set -eu 3 | 4 | REPO="shubh-io/dockmate" 5 | BINARY_NAME="dockmate" 6 | 7 | # ============================================================================== 8 | # Dockmate Installer 9 | # ============================================================================== 10 | # This script will: 11 | # 1. Check for existing Homebrew installation (won't overwrite) 12 | # 2. Detect your system architecture (amd64/arm64) 13 | # 3. Download the latest release from GitHub: https://github.com/shubh-io/dockmate/releases/latest 14 | # 4. Verify checksum if available (for security) 15 | # 5. Install binary to /usr/local/bin (or custom INSTALL_DIR) 16 | # 17 | # Source code: https://github.com/shubh-io/dockmate 18 | # This installer: https://github.com/shubh-io/dockmate/blob/main/install.sh 19 | # ============================================================================== 20 | 21 | echo "" 22 | echo "" 23 | echo "=====================================================================" 24 | echo "Dockmate Installer 🐳" 25 | echo "=====================================================================" 26 | echo "" 27 | echo "This installer will:" 28 | echo " • Check for existing installations and overwrite if needed" 29 | echo " • Download the latest release from GitHub" 30 | echo " • Verify the download with checksums" 31 | echo " • Install to /usr/local/bin (or \$INSTALL_DIR if set)" 32 | echo "" 33 | echo "Source: https://github.com/$REPO" 34 | echo "Installation script: https://github.com/$REPO/blob/main/install.sh" 35 | echo "" 36 | 37 | # Give users time to read the intro 38 | sleep 5 39 | 40 | # For Homebrew folks — robust detection (check early to avoid unnecessary work) 41 | # Check via brew metadata first, then path heuristics 42 | if command -v brew >/dev/null 2>&1; then 43 | # Prefer explicit tap formula; fallback to plain name 44 | if brew list --versions shubh-io/tap/dockmate >/dev/null 2>&1 || brew list --versions dockmate >/dev/null 2>&1; then 45 | echo "⚠️ Detected: dockmate is installed via Homebrew" 46 | echo "" 47 | echo "To update, please use:" 48 | echo " brew upgrade shubh-io/tap/dockmate" 49 | echo "" 50 | echo "If you want to switch to script-based installation:" 51 | echo " 1. brew uninstall dockmate" 52 | echo " 2. Re-run this installer" 53 | exit 0 54 | else 55 | # Fallback: compare executable path against Homebrew prefix 56 | if command -v dockmate >/dev/null 2>&1; then 57 | DOCKMATE_PATH=$(command -v dockmate) 58 | BREW_PREFIX=$(brew --prefix 2>/dev/null || true) 59 | if [ -n "$BREW_PREFIX" ]; then 60 | # Common brew locations to match (Intel/macOS, Apple Silicon, Linuxbrew) 61 | case "$DOCKMATE_PATH" in 62 | "$BREW_PREFIX"*|*"/Cellar/dockmate"*|*"/opt/homebrew"*|*"/usr/local/Cellar"*|*".linuxbrew"*|*"/home/linuxbrew"*) 63 | echo "⚠️ Detected: dockmate appears to be installed under Homebrew prefix ($BREW_PREFIX)" 64 | echo "" 65 | echo "To update, please use:" 66 | echo " brew upgrade shubh-io/tap/dockmate" 67 | echo "" 68 | echo "If you want to switch to script-based installation:" 69 | echo " 1. brew uninstall dockmate" 70 | echo " 2. Re-run this installer" 71 | exit 0 72 | ;; 73 | esac 74 | fi 75 | fi 76 | fi 77 | fi 78 | 79 | # Detect OS and architecture 80 | OS_NAME=$(uname -s) 81 | case "$OS_NAME" in 82 | Darwin) RELEASE_OS="darwin" ;; 83 | Linux) RELEASE_OS="linux" ;; 84 | *) echo "Unsupported OS: $OS_NAME"; exit 1 ;; 85 | esac 86 | 87 | ARCH=$(uname -m) 88 | case "$ARCH" in 89 | x86_64) RELEASE_ARCH="amd64" ;; 90 | aarch64|arm64) RELEASE_ARCH="arm64" ;; 91 | *) echo "Unsupported architecture: $ARCH"; exit 1 ;; 92 | esac 93 | 94 | ASSET_NAME="${BINARY_NAME}-${RELEASE_OS}-${RELEASE_ARCH}" 95 | 96 | echo "==> Preparing to install dockmate from GitHub releases..." 97 | echo "==> System: $OS_NAME / Architecture: $ARCH ($RELEASE_ARCH)" 98 | echo "" 99 | 100 | # Installation directory (default or from environment) 101 | 102 | # To change install dir, run: 103 | # export INSTALL_DIR=$HOME/.local/bin 104 | # or set it inline: 105 | # INSTALL_DIR=$HOME/.local/bin sh install.sh 106 | INSTALL_DIR="${INSTALL_DIR:-/usr/local/bin}" 107 | echo "Installation directory: $INSTALL_DIR" 108 | echo "" 109 | 110 | # Check if dockmate already exists on PATH. If found, install into the same directory 111 | # instead of prompting the user (keeps behavior simple and avoids accidental multi-installs). 112 | EXISTING_PATH="$(command -v dockmate 2>/dev/null || true)" 113 | if [ -n "$EXISTING_PATH" ]; then 114 | if [ "$EXISTING_PATH" != "$INSTALL_DIR/$BINARY_NAME" ]; then 115 | EXISTING_DIR="$(dirname "$EXISTING_PATH")" 116 | echo "⚠️ Found existing 'dockmate' executable at: $EXISTING_PATH" 117 | echo "Installing into existing location: $EXISTING_DIR/" 118 | INSTALL_DIR="$EXISTING_DIR" 119 | else 120 | echo "dockmate already installed at $INSTALL_DIR; installer will overwrite the existing file." 121 | fi 122 | fi 123 | # Determine whether sudo is needed 124 | # 1) If running as root (id -u == 0) -> no sudo 125 | # 2) If INSTALL_DIR exists and is writable by current user -> no sudo 126 | # 3) If parent directory is writable (we can create INSTALL_DIR) -> no sudo 127 | # Otherwise -> sudo is required 128 | UID_VALUE=$(id -u 2>/dev/null || echo "") 129 | if [ "$UID_VALUE" = "0" ]; then 130 | USE_SUDO=0 131 | else 132 | if [ -d "$INSTALL_DIR" ]; then 133 | if [ -w "$INSTALL_DIR" ]; then 134 | USE_SUDO=0 135 | else 136 | USE_SUDO=1 137 | fi 138 | else 139 | # Directory doesn't exist - check if we can create it 140 | PARENT_DIR=$(dirname "$INSTALL_DIR") 141 | if [ -w "$PARENT_DIR" ]; then 142 | USE_SUDO=0 143 | else 144 | USE_SUDO=1 145 | fi 146 | fi 147 | fi 148 | 149 | # Check if sudo is available when actually needed 150 | if [ "$USE_SUDO" -eq 1 ]; then 151 | echo "ℹ️ Note: $INSTALL_DIR requires elevated privileges" 152 | echo " This installer will use 'sudo' to:" 153 | echo " - Create the directory (if needed)" 154 | echo " - Copy the binary to $INSTALL_DIR" 155 | echo " - Set executable permissions (chmod 755)" 156 | echo "" 157 | if ! command -v sudo >/dev/null 2>&1; then 158 | echo "Error: sudo is not available on this system" 159 | echo "" 160 | echo "Options:" 161 | echo " 1. Run this script as root" 162 | echo " 2. Set a writable INSTALL_DIR: export INSTALL_DIR=\$HOME/.local/bin" 163 | echo "" 164 | exit 1 165 | fi 166 | echo "You may be prompted for your password..." 167 | echo "" 168 | fi 169 | 170 | # Create directory if needed 171 | if [ ! -d "$INSTALL_DIR" ]; then 172 | if [ "$USE_SUDO" -eq 1 ]; then 173 | echo "==> Creating directory: $INSTALL_DIR (requires sudo)" 174 | sudo mkdir -p "$INSTALL_DIR" || { 175 | echo "Error: Failed to create $INSTALL_DIR" 176 | exit 1 177 | } 178 | else 179 | echo "==> Creating directory: $INSTALL_DIR" 180 | mkdir -p "$INSTALL_DIR" || { 181 | echo "Error: Failed to create $INSTALL_DIR" 182 | exit 1 183 | } 184 | fi 185 | fi 186 | 187 | # Better JSON parsing - fetch entire response first 188 | API_URL="https://api.github.com/repos/$REPO/releases/latest" 189 | echo "==> Checking GitHub for the latest release..." 190 | 191 | # Detect available download tool 192 | if command -v curl >/dev/null 2>&1; then 193 | DOWNLOAD_TOOL="curl" 194 | elif command -v wget >/dev/null 2>&1; then 195 | DOWNLOAD_TOOL="wget" 196 | elif command -v fetch >/dev/null 2>&1; then 197 | DOWNLOAD_TOOL="fetch" 198 | else 199 | echo "Error: No download tool found (curl, wget, or fetch)" 200 | echo "Please install curl, wget, or fetch and re-run this installer" 201 | exit 1 202 | fi 203 | 204 | # Download the full API response to avoid pipe issues 205 | case "$DOWNLOAD_TOOL" in 206 | curl) 207 | API_RESPONSE=$(curl -fsSL "$API_URL" 2>&1) || { 208 | echo "Error: Failed to fetch release info from GitHub" 209 | echo "This might be due to rate limiting or network issues" 210 | exit 1 211 | } 212 | ;; 213 | wget) 214 | API_RESPONSE=$(wget -qO- "$API_URL" 2>&1) || { 215 | echo "Error: Failed to fetch release info from GitHub" 216 | echo "This might be due to rate limiting or network issues" 217 | exit 1 218 | } 219 | ;; 220 | fetch) 221 | API_RESPONSE=$(fetch -qo- "$API_URL" 2>&1) || { 222 | echo "Error: Failed to fetch release info from GitHub" 223 | echo "This might be due to rate limiting or network issues" 224 | exit 1 225 | } 226 | ;; 227 | esac 228 | 229 | # Parse tag name more reliably 230 | LATEST_TAG=$(echo "$API_RESPONSE" | grep -o '"tag_name": *"[^"]*"' | head -1 | sed 's/.*"\([^"]*\)".*/\1/') 231 | 232 | if [ -z "$LATEST_TAG" ]; then 233 | echo "Error: Could not determine latest release version" 234 | echo "GitHub API might be rate limited. Try again in a few minutes." 235 | exit 1 236 | fi 237 | 238 | echo "✔ Latest version found: $LATEST_TAG" 239 | echo "" 240 | ASSET_NAME="dockmate-linux-${RELEASE_ARCH}" 241 | DOWNLOAD_URL="https://github.com/$REPO/releases/download/$LATEST_TAG/$ASSET_NAME" 242 | 243 | echo "==> Downloading release binary..." 244 | echo "==> From: $DOWNLOAD_URL" 245 | 246 | TMP_BIN=$(mktemp /tmp/dockmate.XXXXXX) 247 | 248 | # Download with better error handling 249 | case "$DOWNLOAD_TOOL" in 250 | curl) 251 | if ! curl -fsSL "$DOWNLOAD_URL" -o "$TMP_BIN"; then 252 | echo "Error: Failed to download binary" 253 | rm -f "$TMP_BIN" 254 | exit 1 255 | fi 256 | ;; 257 | wget) 258 | if ! wget -qO "$TMP_BIN" "$DOWNLOAD_URL"; then 259 | echo "Error: Failed to download binary" 260 | rm -f "$TMP_BIN" 261 | exit 1 262 | fi 263 | ;; 264 | fetch) 265 | if ! fetch -qo "$TMP_BIN" "$DOWNLOAD_URL"; then 266 | echo "Error: Failed to download binary" 267 | rm -f "$TMP_BIN" 268 | exit 1 269 | fi 270 | ;; 271 | esac 272 | 273 | # Checksum verification (optional) — use checksums.txt from the release 274 | CHECKSUMS_URL="https://github.com/$REPO/releases/download/$LATEST_TAG/checksums.txt" 275 | CHECKSUM_DOWNLOADED=0 276 | 277 | # Try to download checksums.txt to a temp file next to TMP_BIN 278 | CHECKSUM_FILE_TMP="${TMP_BIN}.checksums" 279 | case "$DOWNLOAD_TOOL" in 280 | curl) 281 | if curl -fsSL -o "$CHECKSUM_FILE_TMP" "$CHECKSUMS_URL" 2>/dev/null; then 282 | CHECKSUM_FILE="$CHECKSUM_FILE_TMP" 283 | fi 284 | ;; 285 | wget) 286 | if wget -qO "$CHECKSUM_FILE_TMP" "$CHECKSUMS_URL" 2>/dev/null; then 287 | CHECKSUM_FILE="$CHECKSUM_FILE_TMP" 288 | fi 289 | ;; 290 | fetch) 291 | if fetch -qo "$CHECKSUM_FILE_TMP" "$CHECKSUMS_URL" 2>/dev/null; then 292 | CHECKSUM_FILE="$CHECKSUM_FILE_TMP" 293 | fi 294 | ;; 295 | esac 296 | 297 | if [ -n "${CHECKSUM_FILE:-}" ] && [ -f "$CHECKSUM_FILE" ]; then 298 | # File format expected: or 299 | CHECKSUM=$(awk -v name="$ASSET_NAME" '$2==name {print $1; exit}' "$CHECKSUM_FILE" 2>/dev/null || true) 300 | if [ -n "$CHECKSUM" ]; then 301 | echo "==> Verifying checksum for $ASSET_NAME..." 302 | VERIFY_FILE=$(mktemp /tmp/dockmate-check.XXXXXX) 303 | printf '%s %s\n' "$CHECKSUM" "$TMP_BIN" > "$VERIFY_FILE" 304 | 305 | if command -v sha256sum >/dev/null 2>&1; then 306 | if sha256sum -c "$VERIFY_FILE" >/dev/null 2>&1; then 307 | echo "✔ Checksum verified" 308 | CHECKSUM_DOWNLOADED=1 309 | else 310 | echo "Warning: Checksum mismatch for $ASSET_NAME" 311 | fi 312 | elif command -v shasum >/dev/null 2>&1; then 313 | if shasum -a 256 -c "$VERIFY_FILE" >/dev/null 2>&1; then 314 | echo "✔ Checksum verified (shasum)" 315 | CHECKSUM_DOWNLOADED=1 316 | else 317 | echo "Warning: Checksum mismatch for $ASSET_NAME (shasum)" 318 | fi 319 | else 320 | echo "Warning: no checksum verifier available (install sha256sum or shasum)" 321 | fi 322 | 323 | rm -f "$VERIFY_FILE" 324 | else 325 | echo "==> No checksum entry for $ASSET_NAME in checksums.txt; skipping verification." 326 | fi 327 | 328 | rm -f "$CHECKSUM_FILE" 329 | else 330 | echo "==> No checksums.txt found for this release; skipping verification." 331 | fi 332 | echo "" 333 | chmod +x "$TMP_BIN" 334 | 335 | # Check if install directory is in PATH 336 | PATH_CHECK=0 337 | IFS=: 338 | for dir in $PATH; do 339 | if [ "$dir" = "$INSTALL_DIR" ]; then 340 | PATH_CHECK=1 341 | break 342 | fi 343 | done 344 | unset IFS 345 | 346 | echo "==> Installing $BINARY_NAME to $INSTALL_DIR..." 347 | 348 | # Use sudo only if needed. Try to stop services/processes if target is busy. 349 | install_target="$INSTALL_DIR/$BINARY_NAME" 350 | do_install() { 351 | src="$1" 352 | dst="$2" 353 | if [ "$USE_SUDO" -eq 1 ]; then 354 | sudo cp "$src" "$dst" 355 | sudo chmod 755 "$dst" || true 356 | else 357 | cp "$src" "$dst" 358 | chmod 755 "$dst" || true 359 | fi 360 | } 361 | 362 | try_install() { 363 | do_install "$TMP_BIN" "$install_target" 2>/tmp/dockmate-install.err 364 | return $? 365 | } 366 | 367 | # First attempt 368 | if try_install; then 369 | rm -f "$TMP_BIN" 370 | else 371 | # If install failed, attempt to stop systemd service and kill running processes, then retry 372 | echo "Install failed; attempting to stop running dockmate processes and retry..." 373 | # kill any running processes by name 374 | sudo pkill -f dockmate >/dev/null 2>&1 || true 375 | sleep 1 376 | 377 | if try_install; then 378 | rm -f "$TMP_BIN" 379 | else 380 | # final fallback: save the downloaded binary as .new and instruct user to reboot or manually replace 381 | NEW_TARGET="$INSTALL_DIR/${BINARY_NAME}.new" 382 | echo "Could not overwrite $install_target (file busy). Saving new binary as: $NEW_TARGET" 383 | if [ "$USE_SUDO" -eq 1 ]; then 384 | sudo mv "$TMP_BIN" "$NEW_TARGET" || { 385 | echo "Error: Failed to move new binary to $NEW_TARGET" 386 | rm -f "$TMP_BIN" 387 | exit 1 388 | } 389 | sudo chmod 755 "$NEW_TARGET" || true 390 | else 391 | mv "$TMP_BIN" "$NEW_TARGET" || { 392 | echo "Error: Failed to move new binary to $NEW_TARGET" 393 | rm -f "$TMP_BIN" 394 | exit 1 395 | } 396 | chmod 755 "$NEW_TARGET" || true 397 | fi 398 | echo "The installer was unable to overwrite the running executable due to a 'Text file busy' error." 399 | echo "A copy of the new binary was saved as: $NEW_TARGET" 400 | echo "To complete the update, either reboot the machine or replace the file after stopping the process:" 401 | echo " sudo mv $NEW_TARGET $install_target && sudo chmod 755 $install_target" 402 | exit 0 403 | fi 404 | fi 405 | 406 | echo "" 407 | echo "=====================================================================" 408 | echo "✔ Installation Complete!" 409 | echo "=====================================================================" 410 | echo "" 411 | echo "Installed:" 412 | echo " Binary: $INSTALL_DIR/$BINARY_NAME" 413 | echo " Version: $LATEST_TAG" 414 | if [ "${CHECKSUM_DOWNLOADED:-0}" -eq 1 ]; then 415 | CHECKSUM_STATUS="Verified" 416 | else 417 | CHECKSUM_STATUS="Not available" 418 | fi 419 | 420 | echo " Checksum: $CHECKSUM_STATUS" 421 | echo "" 422 | echo "NOTE: You can check dockmate version by running:" 423 | echo " dockmate version" 424 | echo "" 425 | echo "It should show something like:" 426 | echo " DockMate version: $LATEST_TAG" 427 | echo "" 428 | echo "To run the application now, execute:" 429 | echo " dockmate" 430 | echo "" 431 | echo "To update later:" 432 | echo " dockmate update" 433 | echo "" 434 | echo " or" 435 | # echo " re-run the installer script manually:" 436 | echo "" 437 | echo " curl -fsSL https://raw.githubusercontent.com/$REPO/main/install.sh | sh" 438 | echo "" 439 | 440 | if [ "$PATH_CHECK" -eq 0 ]; then 441 | echo "⚠️ $INSTALL_DIR is not in your PATH" 442 | echo "" 443 | echo "Add this to your shell profile (~/.bashrc, ~/.zshrc, etc.):" 444 | echo " export PATH=\"$INSTALL_DIR:\$PATH\"" 445 | echo "" 446 | echo "Then reload your shell or run: source ~/.bashrc" 447 | echo "" 448 | fi 449 | 450 | echo "If the command isn't found immediately, refresh your shell:" 451 | echo " hash -r" 452 | echo "" 453 | echo "Thank you for using dockmate! 🐳" 454 | echo "=====================================================================" 455 | -------------------------------------------------------------------------------- /internal/tui/model.go: -------------------------------------------------------------------------------- 1 | package tui 2 | 3 | import ( 4 | "fmt" 5 | "io" 6 | "log" 7 | "os" 8 | "os/exec" 9 | "sort" 10 | "strconv" 11 | "strings" 12 | "time" 13 | 14 | "github.com/charmbracelet/bubbles/key" 15 | tea "github.com/charmbracelet/bubbletea" 16 | "github.com/charmbracelet/lipgloss" 17 | "github.com/shubh-io/dockmate/internal/docker" 18 | ) 19 | 20 | // ============================================================================ 21 | 22 | var ( 23 | // color palette 24 | // main accents 25 | accent = lipgloss.Color("#22D3EE") 26 | 27 | // text colors 28 | textPrimary = lipgloss.Color("#F8FAFC") // near white 29 | textSecondary = lipgloss.Color("#94A3B8") 30 | textMuted = lipgloss.Color("#475569") 31 | 32 | // backgrounds 33 | borderColor = lipgloss.Color("#334155") 34 | 35 | // status colors 36 | yellowColor = lipgloss.Color("#F59E0B") // warnings/actions 37 | cyanColor = lipgloss.Color("#06B6D4") // selected 38 | 39 | // others 40 | meterGreen = lipgloss.Color("#4ADE80") // bright green for bars 41 | meterRed = lipgloss.Color("#F87171") // bright red for bars 42 | 43 | // title style 44 | titleStyle = lipgloss.NewStyle(). 45 | Bold(true). 46 | Foreground(accent). 47 | Padding(0, 1) 48 | 49 | // app name 50 | appNameStyle = lipgloss.NewStyle(). 51 | Bold(true). 52 | Foreground(textPrimary) 53 | 54 | // meter styles 55 | meterLabelStyle = lipgloss.NewStyle(). 56 | Foreground(cyanColor). 57 | Bold(true) 58 | 59 | meterBracketStyle = lipgloss.NewStyle(). 60 | Foreground(textMuted) 61 | 62 | infoLabelStyle = lipgloss.NewStyle(). 63 | Foreground(textSecondary) 64 | 65 | infoValueStyle = lipgloss.NewStyle(). 66 | Foreground(textPrimary). 67 | Bold(true) 68 | 69 | // table header 70 | headerStyle = lipgloss.NewStyle(). 71 | Bold(true). 72 | Foreground(lipgloss.Color("#000000")). 73 | Background(meterGreen) 74 | 75 | // selected row 76 | selectedStyle = lipgloss.NewStyle(). 77 | Bold(true). 78 | Foreground(lipgloss.Color("#000000")). 79 | Background(cyanColor) 80 | 81 | // container states 82 | runningStyle = lipgloss.NewStyle(). 83 | Foreground(meterGreen). 84 | Bold(true) 85 | 86 | stoppedStyle = lipgloss.NewStyle(). 87 | Foreground(meterRed) 88 | 89 | pausedStyle = lipgloss.NewStyle(). 90 | Foreground(yellowColor) 91 | 92 | normalStyle = lipgloss.NewStyle(). 93 | Foreground(textSecondary) 94 | 95 | // footer 96 | footerKeyStyle = lipgloss.NewStyle(). 97 | Foreground(lipgloss.Color("#000000")). 98 | Background(cyanColor). 99 | Bold(true). 100 | Padding(0, 0) 101 | 102 | footerDescStyle = lipgloss.NewStyle(). 103 | Foreground(textPrimary). 104 | Padding(0, 0) 105 | 106 | footerArrowStyle = lipgloss.NewStyle(). 107 | Foreground(yellowColor). 108 | Bold(true) 109 | 110 | // message 111 | messageStyle = lipgloss.NewStyle(). 112 | Foreground(yellowColor). 113 | Bold(true) 114 | 115 | // divider 116 | dividerStyle = lipgloss.NewStyle(). 117 | Foreground(borderColor) 118 | ) 119 | 120 | // debug logger writes snapshots to a file (dockmate-debug.log) by default 121 | var ( 122 | debugLogger *log.Logger 123 | debugFile *os.File 124 | ) 125 | 126 | // init sets up file-backed debug logging. If the file can't be opened, 127 | // debugLogger falls back to discarding output. 128 | func init() { 129 | // default debug file in working directory 130 | _ = SetDebugFile("dockmate-debug.log") 131 | } 132 | 133 | // SetDebugFile opens (or creates) the given path and directs debug output there. 134 | // It returns an error if the file cannot be opened. 135 | func SetDebugFile(path string) error { 136 | if debugFile != nil { 137 | _ = debugFile.Close() 138 | debugFile = nil 139 | } 140 | f, err := os.OpenFile(path, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o644) 141 | if err != nil { 142 | // fallback to discard 143 | debugLogger = log.New(io.Discard, "DEBUG: ", log.LstdFlags) 144 | return err 145 | } 146 | debugFile = f 147 | debugLogger = log.New(debugFile, "DEBUG: ", log.LstdFlags) 148 | return nil 149 | } 150 | 151 | // CloseDebug closes the current debug file (if any) and disables logging. 152 | func CloseDebug() error { 153 | if debugFile == nil { 154 | return nil 155 | } 156 | err := debugFile.Close() 157 | debugFile = nil 158 | debugLogger = log.New(io.Discard, "DEBUG: ", log.LstdFlags) 159 | return err 160 | } 161 | 162 | // layout sizing constants 163 | const ( 164 | HEADER_HEIGHT = 8 165 | CONTAINER_ROW_HEIGHT = 1 166 | LOG_PANEL_HEIGHT = 15 167 | ) 168 | 169 | // ============================================================================ 170 | // App state (bubble tea model) 171 | // ============================================================================ 172 | 173 | // model holds everything for the TUI 174 | type model struct { 175 | containers []docker.Container // all containers (running + stopped) 176 | projects map[string]*docker.ComposeProject // compose projects 177 | expandedProjects map[string]bool // track which projects are expanded 178 | flatList []treeRow // flattened tree for rendering 179 | cursor int // selected container index 180 | page int // current page 181 | maxContainersPerPage int // containers per page (dynamic) 182 | terminalWidth int // terminal width 183 | terminalHeight int // terminal height 184 | err error // last error 185 | loading bool // fetching data? 186 | message string // page indicator (persistent) 187 | statusMessage string // transient status message 188 | startTime time.Time // when app started 189 | logsVisible bool // logs panel visible? 190 | logPanelHeight int // height of logs panel 191 | logsLines []string // log lines 192 | logsContainer string // container id for logs 193 | sortBy sortColumn // which column to sort by 194 | sortAsc bool // sort direction 195 | columnMode bool // column nav mode (vs row nav) 196 | selectedColumn int // selected column (0-8) 197 | currentMode appMode // current UI mode 198 | 199 | // settings 200 | settings Settings // user configurable settings 201 | composeViewMode bool // currently selected compose view row 202 | suspendRefresh bool // when true, suspend background refreshes of containers 203 | settingsSelected int // which settings row/column is selected in settting mdoe 204 | } 205 | 206 | // treeRow represents a row in the flattened tree 207 | type treeRow struct { 208 | isProject bool 209 | projectName string 210 | container *docker.Container 211 | indent int 212 | running int // for project rows 213 | total int // for project rows 214 | } 215 | 216 | // runtime 217 | // type ContainerRuntime string 218 | 219 | // const ( 220 | // RuntimeDocker ContainerRuntime = "docker" 221 | // RuntimePodman ContainerRuntime = "podman" 222 | // ) 223 | 224 | // app settings 225 | type Settings struct { 226 | ColumnPercents []int // percent allocation for each column aprx sum to 100 227 | RefreshInterval int // seconds between auto refresh ticks 228 | // Runtime ContainerRuntime // runtime 229 | } 230 | 231 | // which column to sort by 232 | type sortColumn int 233 | 234 | const ( 235 | sortByID sortColumn = iota 236 | sortByName 237 | sortByMemory 238 | sortByCPU 239 | sortByNetIO 240 | sortByBlockIO 241 | sortByImage 242 | sortByStatus 243 | sortByPorts 244 | ) 245 | 246 | // which mode the TUI is in 247 | type appMode int 248 | 249 | const ( 250 | modeNormal appMode = iota 251 | modeColumnSelect 252 | modeLogs 253 | modeSettings 254 | modeComposeView 255 | ) 256 | 257 | // ============================================================================ 258 | // Initialization 259 | // ============================================================================ 260 | 261 | // set up initial state 262 | func InitialModel() model { 263 | return model{ 264 | loading: true, // start loading 265 | startTime: time.Now(), // track uptime 266 | page: 0, // first page 267 | maxContainersPerPage: 12, // initial guess until resize event 268 | terminalWidth: 0, 269 | terminalHeight: 0, 270 | projects: make(map[string]*docker.ComposeProject), 271 | expandedProjects: make(map[string]bool), 272 | flatList: []treeRow{}, 273 | logsVisible: false, // logs hidden by default 274 | logPanelHeight: LOG_PANEL_HEIGHT, 275 | sortBy: sortByStatus, // sort by status as default 276 | sortAsc: false, // descending 277 | columnMode: false, // row nav mode 278 | selectedColumn: 7, // status column 279 | currentMode: modeNormal, // start in normal mode 280 | // sensible defaults for settings (sum to 100) 281 | settings: Settings{ 282 | ColumnPercents: []int{8, 14, 6, 6, 10, 12, 18, 13, 13}, 283 | RefreshInterval: 2, 284 | // Runtime: RuntimeDocker, // default to docker 285 | }, 286 | suspendRefresh: false, 287 | settingsSelected: 0, 288 | } 289 | } 290 | 291 | // called once at startup 292 | // kicks off container fetch and timer 293 | func (m model) Init() tea.Cmd { 294 | // start fetch and schedule tick based on settings 295 | return tea.Batch(fetchContainers(), tickCmd(time.Duration(m.settings.RefreshInterval)*time.Second)) 296 | } 297 | 298 | // ============================================================================ 299 | // Message types 300 | // ============================================================================ 301 | 302 | // sent when docker action finishes 303 | type actionDoneMsg struct { 304 | err error // nil if ok 305 | } 306 | 307 | // sent every 2 seconds for refresh 308 | type tickMsg time.Time 309 | 310 | // sent when compose projects are fetched 311 | type composeProjectsMsg struct { 312 | Projects map[string]*docker.ComposeProject 313 | Err error 314 | } 315 | 316 | // ============================================================================ 317 | // Async commands 318 | // ============================================================================ 319 | 320 | // grab container list in background 321 | func fetchContainers() tea.Cmd { 322 | return func() tea.Msg { 323 | containers, err := docker.ListContainers() 324 | return docker.ContainersMsg{Containers: containers, Err: err} 325 | } 326 | } 327 | 328 | // fetch compose projects asynchronously 329 | func fetchComposeProjects() tea.Cmd { 330 | return func() tea.Msg { 331 | projects, err := docker.FetchComposeProjects() 332 | return composeProjectsMsg{Projects: projects, Err: err} 333 | } 334 | } 335 | 336 | // fire every 2 seconds for auto-refresh 337 | func tickCmd(d time.Duration) tea.Cmd { 338 | if d < time.Second { 339 | d = 1 * time.Second 340 | } 341 | return tea.Tick(d, func(t time.Time) tea.Msg { 342 | return tickMsg(t) 343 | }) 344 | } 345 | 346 | // run docker action in background (start/stop/etc) 347 | func doAction(action, containerID string) tea.Cmd { 348 | return func() tea.Msg { 349 | err := docker.DoAction(action, containerID) 350 | return actionDoneMsg{err: err} 351 | } 352 | } 353 | 354 | // fetch logs for a container 355 | func fetchLogsCmd(id string) tea.Cmd { 356 | return func() tea.Msg { 357 | lines, err := docker.GetLogs(id) 358 | return docker.LogsMsg{ID: id, Lines: lines, Err: err} 359 | } 360 | } 361 | 362 | // ============================================================================ 363 | // Sorting 364 | // ============================================================================ 365 | 366 | // sort containers by current column and direction 367 | func (m *model) sortContainers() { 368 | // helper to compare two containers according to current sort settings 369 | lessContainer := func(a, b docker.Container) bool { 370 | 371 | switch m.sortBy { 372 | case sortByID: 373 | return a.ID < b.ID 374 | 375 | case sortByName: 376 | ai, aj := "", "" 377 | 378 | if len(a.Names) > 0 { 379 | ai = a.Names[0] 380 | } 381 | 382 | if len(b.Names) > 0 { 383 | aj = b.Names[0] 384 | } 385 | return strings.ToLower(ai) < strings.ToLower(aj) 386 | 387 | case sortByMemory: 388 | return parsePercent(a.Memory) < parsePercent(b.Memory) 389 | 390 | case sortByCPU: 391 | return parsePercent(a.CPU) < parsePercent(b.CPU) 392 | case sortByImage: 393 | return strings.ToLower(a.Image) < strings.ToLower(b.Image) 394 | 395 | case sortByStatus: 396 | return strings.ToLower(a.Status) < strings.ToLower(b.Status) 397 | 398 | case sortByPorts: 399 | return strings.ToLower(a.Ports) < strings.ToLower(b.Ports) 400 | 401 | case sortByNetIO: 402 | 403 | return parseNetIO(a.NetIO) < parseNetIO(b.NetIO) 404 | 405 | case sortByBlockIO: 406 | return parseNetIO(a.BlockIO) < parseNetIO(b.BlockIO) 407 | default: 408 | return a.ID < b.ID 409 | } 410 | } 411 | 412 | // sort main container slice 413 | sort.Slice(m.containers, func(i, j int) bool { 414 | if m.sortAsc { 415 | return lessContainer(m.containers[i], m.containers[j]) 416 | } 417 | return !lessContainer(m.containers[i], m.containers[j]) 418 | }) 419 | 420 | // also sort containers inside each compose project so compose view matches column sorting 421 | if len(m.projects) > 0 { 422 | for _, p := range m.projects { 423 | sort.Slice(p.Containers, func(i, j int) bool { 424 | if m.sortAsc { 425 | return lessContainer(p.Containers[i], p.Containers[j]) 426 | } 427 | return !lessContainer(p.Containers[i], p.Containers[j]) 428 | }) 429 | } 430 | if m.composeViewMode { 431 | m.buildFlatList() 432 | } 433 | } 434 | } 435 | 436 | // convert "0.48%" to 0.48 437 | func parsePercent(s string) float64 { 438 | s = strings.TrimSpace(s) 439 | s = strings.TrimSuffix(s, "%") 440 | val, _ := strconv.ParseFloat(s, 64) 441 | return val 442 | } 443 | 444 | // parseNetIO parses a string like "1.2kB / 3.4kB" and returns total bytes 445 | func parseNetIO(s string) float64 { 446 | s = strings.TrimSpace(s) 447 | if s == "" || s == "─" { 448 | return 0 449 | } 450 | parts := strings.Split(s, "/") 451 | if len(parts) == 0 { 452 | return 0 453 | } 454 | total := 0.0 455 | for _, p := range parts { 456 | p = strings.TrimSpace(p) 457 | if p == "" { 458 | continue 459 | } 460 | v := parseSize(p) 461 | total += v 462 | } 463 | return total 464 | } 465 | 466 | // parseSize parses a human-readable size like "1.2kB" or "3MiB" into bytes. 467 | func parseSize(s string) float64 { 468 | s = strings.TrimSpace(s) 469 | if s == "" { 470 | return 0 471 | } 472 | // remove possible commas 473 | s = strings.ReplaceAll(s, ",", "") 474 | // split number and unit 475 | num := "" 476 | unit := "" 477 | for i, r := range s { 478 | if (r >= '0' && r <= '9') || r == '.' || r == '-' { 479 | num += string(r) 480 | } else { 481 | unit = strings.TrimSpace(s[i:]) 482 | break 483 | } 484 | } 485 | if num == "" { 486 | return 0 487 | } 488 | val, err := strconv.ParseFloat(num, 64) 489 | if err != nil { 490 | return 0 491 | } 492 | unit = strings.ToLower(strings.TrimSpace(unit)) 493 | switch unit { 494 | case "b", "bytes", "byte", "": 495 | return val 496 | case "kb", "kib": 497 | return val * 1000 498 | case "mb", "mib": 499 | return val * 1000 * 1000 500 | case "gb", "gib": 501 | return val * 1000 * 1000 * 1000 502 | default: 503 | // fallback: if unit ends with b (e.g., kB) treat as *1000 504 | if strings.HasSuffix(unit, "b") { 505 | prefix := strings.TrimSuffix(unit, "b") 506 | if prefix == "k" { 507 | return val * 1000 508 | } 509 | if prefix == "m" { 510 | return val * 1000 * 1000 511 | } 512 | if prefix == "g" { 513 | return val * 1000 * 1000 * 1000 514 | } 515 | } 516 | } 517 | return val 518 | } 519 | 520 | // calculateMaxContainers determines how many containers fit on screen given current layout state 521 | func (m *model) calculateMaxContainers() int { 522 | availableHeight := m.terminalHeight - HEADER_HEIGHT 523 | if m.logsVisible { 524 | availableHeight -= m.logPanelHeight 525 | } 526 | maxContainers := availableHeight / CONTAINER_ROW_HEIGHT 527 | if maxContainers < 1 { 528 | return 1 529 | } 530 | return maxContainers 531 | } 532 | 533 | // updatePagination recalculates page sizing and keeps cursor/page within bounds 534 | func (m *model) updatePagination() { 535 | m.maxContainersPerPage = m.calculateMaxContainers() 536 | if m.maxContainersPerPage < 1 { 537 | m.maxContainersPerPage = 1 538 | } 539 | 540 | if len(m.containers) == 0 { 541 | m.cursor = 0 542 | m.page = 0 543 | return 544 | } 545 | 546 | if m.cursor >= len(m.containers) { 547 | m.cursor = len(m.containers) - 1 548 | } 549 | 550 | maxPage := (len(m.containers) - 1) / m.maxContainersPerPage 551 | if maxPage < 0 { 552 | maxPage = 0 553 | } 554 | if m.page > maxPage { 555 | m.page = maxPage 556 | } 557 | 558 | if m.cursor < m.page*m.maxContainersPerPage { 559 | m.page = m.cursor / m.maxContainersPerPage 560 | } 561 | if m.cursor >= (m.page+1)*m.maxContainersPerPage { 562 | m.page = m.cursor / m.maxContainersPerPage 563 | } 564 | 565 | // keep persistent page indicator up-to-date 566 | if m.maxContainersPerPage > 0 { 567 | maxPage = (len(m.containers) - 1) / m.maxContainersPerPage 568 | if maxPage < 0 { 569 | maxPage = 0 570 | } 571 | m.message = fmt.Sprintf("Page %d/%d", m.page+1, maxPage+1) 572 | } else { 573 | m.message = fmt.Sprintf("Page %d/%d", m.page+1, 1) 574 | } 575 | } 576 | 577 | // ============================================================================ 578 | // Update (event handler) 579 | // ============================================================================ 580 | 581 | // handle all incoming events 582 | func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { 583 | 584 | switch msg := msg.(type) { 585 | 586 | case tea.WindowSizeMsg: 587 | // terminal resized 588 | m.terminalWidth = msg.Width 589 | m.terminalHeight = msg.Height 590 | m.updatePagination() 591 | return m, nil 592 | 593 | case docker.ContainersMsg: 594 | // got container list 595 | m.loading = false 596 | if msg.Err != nil { 597 | m.err = msg.Err 598 | } else { 599 | m.containers = msg.Containers 600 | m.err = nil 601 | // sort with current settings 602 | m.sortContainers() 603 | // If in compose view, just rebuild!! 604 | if m.currentMode == modeComposeView { 605 | m.buildFlatList() 606 | } 607 | } 608 | 609 | // keep cursor in bounds 610 | if m.cursor >= len(m.containers) { 611 | m.cursor = max(0, len(m.containers)-1) 612 | } 613 | 614 | m.updatePagination() 615 | return m, nil 616 | 617 | case composeProjectsMsg: 618 | // received compose projects 619 | m.loading = false 620 | if msg.Err != nil { 621 | m.err = msg.Err 622 | m.statusMessage = fmt.Sprintf("Error fetching compose projects: %v", msg.Err) 623 | } else { 624 | m.projects = msg.Projects 625 | if m.expandedProjects == nil { 626 | m.expandedProjects = make(map[string]bool) 627 | } 628 | // default expand any projects 629 | for name := range m.projects { 630 | if _, exists := m.expandedProjects[name]; !exists { 631 | m.expandedProjects[name] = true 632 | } 633 | } 634 | 635 | // standalone section for lonely containers (not in compose projects) 636 | if _, ok := m.expandedProjects["Standalone Containers"]; !ok { 637 | m.expandedProjects["Standalone Containers"] = true 638 | } 639 | m.buildFlatList() 640 | // keep cursor in bounds 641 | if m.cursor >= len(m.flatList) { 642 | m.cursor = max(0, len(m.flatList)-1) 643 | } 644 | } 645 | // just update pagination 646 | m.updatePagination() 647 | return m, nil 648 | 649 | case docker.LogsMsg: 650 | // got logs 651 | if msg.Err != nil { 652 | m.statusMessage = fmt.Sprintf("Logs error: %v", msg.Err) 653 | m.logsLines = nil 654 | m.logsVisible = false 655 | } else { 656 | m.logsLines = msg.Lines 657 | m.logsContainer = msg.ID 658 | m.logsVisible = true 659 | } 660 | m.updatePagination() 661 | return m, nil 662 | 663 | case actionDoneMsg: 664 | // docker action finished 665 | if msg.err != nil { 666 | m.statusMessage = fmt.Sprintf("Error: %v", msg.err) 667 | } else { 668 | m.statusMessage = "Action completed successfully" 669 | } 670 | // refresh list 671 | return m, fetchContainers() 672 | 673 | case tickMsg: 674 | // wakey wakey - time to refresh 675 | // always refresh container list, if logs panel is open, also refresh logs 676 | // suspend refresh/fetching containers if in settings mode 677 | if m.suspendRefresh { 678 | return m, tickCmd(time.Duration(m.settings.RefreshInterval) * time.Second) 679 | } 680 | if m.logsVisible && m.logsContainer != "" { 681 | return m, tea.Batch(fetchContainers(), tickCmd(time.Duration(m.settings.RefreshInterval)*time.Second), fetchLogsCmd(m.logsContainer)) 682 | } 683 | if m.composeViewMode { 684 | // in compose view , refresh both compose projects and containers as per refresh interval 685 | return m, tea.Batch(fetchComposeProjects(), tickCmd(time.Duration(m.settings.RefreshInterval)*time.Second)) 686 | } 687 | return m, tea.Batch(fetchContainers(), tickCmd(time.Duration(m.settings.RefreshInterval)*time.Second)) 688 | 689 | case tea.KeyMsg: 690 | // keyboard input 691 | m.statusMessage = "" 692 | 693 | // Handle Escape key to return to normal mode 694 | if msg.String() == "esc" { 695 | if m.columnMode { 696 | m.columnMode = false 697 | m.currentMode = modeNormal 698 | m.statusMessage = "Back to normal mode" 699 | return m, nil 700 | } 701 | if m.logsVisible { 702 | m.logsVisible = false 703 | m.currentMode = modeNormal 704 | m.updatePagination() 705 | m.statusMessage = "Logs closed" 706 | return m, nil 707 | } 708 | } 709 | 710 | // special keys that work in both modes 711 | switch msg.String() { 712 | // for debugging: press backtick (`) to dump a state snapshot to the debug logger 713 | case "`": 714 | debugLogger.Printf( 715 | "STATE SNAPSHOT: width=%d height=%d page=%d cursor=%d perPage=%d selectedColumn=%d", 716 | m.terminalWidth, m.terminalHeight, m.page, m.cursor, m.maxContainersPerPage, m.selectedColumn, 717 | ) 718 | m.statusMessage = "Dumped debug snapshot" 719 | return m, nil 720 | 721 | case "tab": 722 | // toggle column/row mode 723 | m.columnMode = !m.columnMode 724 | if m.columnMode { 725 | m.currentMode = modeColumnSelect 726 | m.statusMessage = "Column mode: Use ← → to navigate, Enter to sort" 727 | } else { 728 | m.currentMode = modeNormal 729 | m.statusMessage = "Row mode: Use ↑ ↓ and ← → to navigate containers" 730 | } 731 | return m, nil 732 | 733 | case "f2": 734 | // toggle settings mode - say yes to settings or no to settings 735 | if m.currentMode == modeSettings { 736 | m.currentMode = modeNormal 737 | m.suspendRefresh = false 738 | m.statusMessage = "Settings closed" 739 | // normalize percents to sum 100 740 | total := 0 741 | for _, p := range m.settings.ColumnPercents { 742 | total += p 743 | } 744 | if total == 0 { 745 | m.settings.ColumnPercents = []int{8, 14, 6, 6, 10, 12, 18, 13, 13} 746 | } else if total != 100 { 747 | // normalize proportionally 748 | newp := make([]int, len(m.settings.ColumnPercents)) 749 | acc := 0 750 | for i, p := range m.settings.ColumnPercents { 751 | np := (p * 100) / total 752 | newp[i] = np 753 | acc += np 754 | } 755 | // fix rounding 756 | if acc < 100 { 757 | newp[0] += 100 - acc 758 | } 759 | m.settings.ColumnPercents = newp 760 | } 761 | return m, nil 762 | } 763 | m.currentMode = modeSettings 764 | m.suspendRefresh = true 765 | m.statusMessage = "Settings: adjust column % and refresh interval" 766 | return m, nil 767 | 768 | case "L": 769 | // Toggle logs panel visibility without fetching new logs 770 | m.logsVisible = !m.logsVisible 771 | if m.logsVisible { 772 | m.currentMode = modeLogs 773 | } else { 774 | m.currentMode = modeNormal 775 | } 776 | m.updatePagination() 777 | return m, nil 778 | 779 | case "enter": 780 | // in column mode, sort by selected column 781 | if m.columnMode { 782 | // map column index to sort enum 783 | var col sortColumn 784 | var canSort bool = true 785 | switch m.selectedColumn { 786 | case 0: 787 | col = sortByID 788 | case 1: 789 | col = sortByName 790 | case 2: 791 | col = sortByMemory 792 | case 3: 793 | col = sortByCPU 794 | case 4: 795 | col = sortByNetIO 796 | case 5: 797 | col = sortByBlockIO 798 | case 6: 799 | col = sortByImage 800 | case 7: 801 | col = sortByStatus 802 | case 8: 803 | col = sortByPorts 804 | } 805 | 806 | if canSort { 807 | // toggle direction if same column, else reset 808 | if m.sortBy == col { 809 | m.sortAsc = !m.sortAsc 810 | } else { 811 | m.sortBy = col 812 | m.sortAsc = true 813 | } 814 | m.sortContainers() 815 | 816 | // show feedback 817 | dir := "asc" 818 | if !m.sortAsc { 819 | dir = "desc" 820 | } 821 | colNames := []string{"ID", "Name", "Memory", "CPU", "NET I/O", "Disk I/O", "Image", "Status", "PORTS"} 822 | m.statusMessage = fmt.Sprintf("Sorted by %s (%s)", colNames[m.selectedColumn], dir) 823 | } 824 | } 825 | return m, nil 826 | 827 | case "left", "h": 828 | // In column mode, move selection left 829 | if m.columnMode { 830 | if m.selectedColumn > 0 { 831 | m.selectedColumn-- 832 | } 833 | return m, nil 834 | } 835 | 836 | case "right", "l": 837 | // In column mode, move selection right 838 | if m.columnMode { 839 | if m.selectedColumn < 8 { // 0-8 for 9 columns 840 | m.selectedColumn++ 841 | } 842 | return m, nil 843 | } 844 | } 845 | 846 | // If we're in settings mode, handle settings navigation and edits 847 | if m.currentMode == modeSettings { 848 | switch msg.String() { 849 | case "up", "k": 850 | if m.settingsSelected > 0 { 851 | m.settingsSelected-- 852 | } 853 | return m, nil 854 | case "down", "j": 855 | // now support 11 rows 856 | if m.settingsSelected < 9 { 857 | m.settingsSelected++ 858 | } 859 | return m, nil 860 | case "left", "h", "-": 861 | if m.settings.ColumnPercents == nil || len(m.settings.ColumnPercents) != 9 { 862 | m.settings.ColumnPercents = []int{8, 14, 6, 6, 10, 12, 18, 13, 13} 863 | } 864 | if m.settingsSelected >= 0 && m.settingsSelected <= 8 { 865 | if m.settings.ColumnPercents[m.settingsSelected] > 1 { 866 | m.settings.ColumnPercents[m.settingsSelected]-- 867 | } 868 | } else if m.settingsSelected == 9 { 869 | // adjust refresh interval (min 1s) 870 | if m.settings.RefreshInterval > 1 { 871 | m.settings.RefreshInterval-- 872 | } 873 | } 874 | // } else if m.settingsSelected == 10 { 875 | // // toggle runtime option btwn docker and podman 876 | // if m.settings.Runtime == RuntimeDocker { 877 | // m.settings.Runtime = RuntimePodman 878 | // } else { 879 | // m.settings.Runtime = RuntimeDocker 880 | // } 881 | // } 882 | return m, nil 883 | case "right", "l", "+": 884 | if m.settings.ColumnPercents == nil || len(m.settings.ColumnPercents) != 9 { 885 | m.settings.ColumnPercents = []int{8, 14, 6, 6, 10, 12, 18, 13, 13} 886 | } 887 | if m.settingsSelected >= 0 && m.settingsSelected <= 8 { 888 | m.settings.ColumnPercents[m.settingsSelected]++ 889 | } else if m.settingsSelected == 9 { 890 | // increase refresh interval (cap at 300s) 891 | if m.settings.RefreshInterval < 300 { 892 | m.settings.RefreshInterval++ 893 | } 894 | } 895 | // test feature - 896 | // } else if m.settingsSelected == 10 { 897 | // // toggle runtime option 898 | // if m.settings.Runtime == RuntimeDocker { 899 | // m.settings.Runtime = RuntimePodman 900 | // } else { 901 | // m.settings.Runtime = RuntimeDocker 902 | // } 903 | // } 904 | return m, nil 905 | case "enter": 906 | // normalize and exit settings 907 | total := 0 908 | for _, p := range m.settings.ColumnPercents { 909 | total += p 910 | } 911 | if total == 0 { 912 | m.settings.ColumnPercents = []int{8, 14, 6, 6, 10, 12, 18, 13, 13} 913 | } else if total != 100 { 914 | newp := make([]int, len(m.settings.ColumnPercents)) 915 | acc := 0 916 | for i, p := range m.settings.ColumnPercents { 917 | np := (p * 100) / total 918 | newp[i] = np 919 | acc += np 920 | } 921 | if acc < 100 { 922 | newp[0] += 100 - acc 923 | } 924 | m.settings.ColumnPercents = newp 925 | } 926 | m.currentMode = modeNormal 927 | m.suspendRefresh = false 928 | m.statusMessage = "setting saved" 929 | // apply new interval immediately and refresh once 930 | return m, tea.Batch(fetchContainers(), tickCmd(time.Duration(m.settings.RefreshInterval)*time.Second)) 931 | case "esc": 932 | m.currentMode = modeNormal 933 | m.suspendRefresh = false 934 | m.statusMessage = "Settings closed" 935 | return m, nil 936 | } 937 | } 938 | 939 | // Handle key bindings 940 | switch { 941 | case key.Matches(msg, Keys.Quit): 942 | return m, tea.Quit 943 | 944 | case key.Matches(msg, Keys.Up): 945 | // Move cursor up (only in row mode) 946 | if !m.columnMode { 947 | if m.composeViewMode { 948 | if len(m.flatList) > 0 { 949 | // in compose view mode, move cursor up tree 950 | m.moveCursorUpTree() 951 | } 952 | } else { 953 | if m.cursor > 0 { 954 | m.cursor-- 955 | } 956 | } 957 | if m.maxContainersPerPage > 0 && m.cursor < m.page*m.maxContainersPerPage { 958 | m.page-- 959 | if m.page < 0 { 960 | m.page = 0 961 | } 962 | } 963 | if m.cursor < 0 { 964 | m.cursor = 0 965 | } 966 | } 967 | 968 | case key.Matches(msg, Keys.Down): 969 | // Move cursor down (only in row mode) 970 | if !m.columnMode { 971 | if m.composeViewMode { 972 | if len(m.flatList) > 0 { 973 | m.moveCursorDownTree() 974 | } 975 | } else { 976 | maxItems := len(m.containers) - 1 977 | if m.cursor < maxItems { 978 | m.cursor++ 979 | } 980 | } 981 | if m.maxContainersPerPage > 0 && m.cursor >= (m.page+1)*m.maxContainersPerPage { 982 | m.page++ 983 | } 984 | } 985 | 986 | case key.Matches(msg, Keys.PageUp): 987 | // Go to previous page (left arrow) 988 | if m.page > 0 { 989 | m.page-- 990 | if m.maxContainersPerPage > 0 { 991 | if m.composeViewMode { 992 | pageStart := m.page * m.maxContainersPerPage 993 | if pageStart < 0 { 994 | pageStart = 0 995 | } 996 | pageEnd := pageStart + m.maxContainersPerPage 997 | if pageEnd > len(m.flatList) { 998 | pageEnd = len(m.flatList) 999 | } 1000 | found := -1 1001 | for i := pageStart; i < pageEnd && i < len(m.flatList); i++ { 1002 | if !m.flatList[i].isProject { 1003 | found = i 1004 | break 1005 | } 1006 | } 1007 | if found != -1 { 1008 | m.cursor = found 1009 | } else if len(m.flatList) > 0 { 1010 | for i := pageStart - 1; i >= 0; i-- { 1011 | if !m.flatList[i].isProject { 1012 | m.cursor = i 1013 | break 1014 | } 1015 | } 1016 | } 1017 | } else { 1018 | m.cursor = m.page * m.maxContainersPerPage 1019 | if m.cursor >= len(m.containers) { 1020 | m.cursor = max(0, len(m.containers)-1) 1021 | } 1022 | } 1023 | } 1024 | } 1025 | m.updatePagination() 1026 | 1027 | case key.Matches(msg, Keys.PageDown): 1028 | // Go to next page (right arrow) 1029 | maxPage := 0 1030 | if m.maxContainersPerPage > 0 { 1031 | count := len(m.containers) 1032 | if m.composeViewMode { 1033 | count = len(m.flatList) 1034 | } 1035 | maxPage = (count - 1) / m.maxContainersPerPage 1036 | } 1037 | if maxPage < 0 { 1038 | maxPage = 0 1039 | } 1040 | if m.page < maxPage { 1041 | m.page++ 1042 | if m.maxContainersPerPage > 0 { 1043 | if m.composeViewMode { 1044 | pageStart := m.page * m.maxContainersPerPage 1045 | if pageStart < 0 { 1046 | pageStart = 0 1047 | } 1048 | pageEnd := pageStart + m.maxContainersPerPage 1049 | if pageEnd > len(m.flatList) { 1050 | pageEnd = len(m.flatList) 1051 | } 1052 | found := -1 1053 | for i := pageStart; i < pageEnd && i < len(m.flatList); i++ { 1054 | if !m.flatList[i].isProject { 1055 | found = i 1056 | break 1057 | } 1058 | } 1059 | if found != -1 { 1060 | m.cursor = found 1061 | } else if len(m.flatList) > 0 { 1062 | for i := pageStart; i < len(m.flatList); i++ { 1063 | if !m.flatList[i].isProject { 1064 | m.cursor = i 1065 | break 1066 | } 1067 | } 1068 | } 1069 | } else { 1070 | m.cursor = m.page * m.maxContainersPerPage 1071 | if m.cursor >= len(m.containers) { 1072 | m.cursor = max(0, len(m.containers)-1) 1073 | } 1074 | } 1075 | } 1076 | } 1077 | m.updatePagination() 1078 | 1079 | case key.Matches(msg, Keys.Refresh): 1080 | // Manually refresh container list 1081 | m.loading = true 1082 | m.logsVisible = false 1083 | m.updatePagination() 1084 | return m, fetchContainers() 1085 | 1086 | // Toggle compose view mode using C 1087 | case msg.String() == "c", msg.String() == "C": 1088 | // Toggle compose view mode 1089 | m.composeViewMode = !m.composeViewMode 1090 | m.currentMode = modeComposeView 1091 | if m.composeViewMode { 1092 | // Entering compose view 1093 | m.statusMessage = "Switched to Compose view " 1094 | m.expandedProjects = make(map[string]bool) 1095 | m.expandedProjects["Standalone Containers"] = true 1096 | m.cursor = 0 1097 | m.page = 0 1098 | 1099 | // to save up performance and API calls 1100 | return m, tea.Batch(fetchComposeProjects(), tickCmd(time.Duration(m.settings.RefreshInterval)*time.Second)) 1101 | } 1102 | // Exiting compose view - back to normal 1103 | m.statusMessage = "Switched to Container View" 1104 | m.cursor = 0 1105 | m.page = 0 1106 | m.updatePagination() 1107 | return m, nil 1108 | 1109 | case key.Matches(msg, Keys.Start): 1110 | // Start selected container 1111 | if m.composeViewMode { 1112 | // In compose view mode, get container from flatList 1113 | if m.cursor < len(m.flatList) && !m.flatList[m.cursor].isProject { 1114 | container := m.flatList[m.cursor].container 1115 | m.statusMessage = "Starting container..." 1116 | return m, doAction("start", container.ID) 1117 | } 1118 | } else { 1119 | // Normal mode 1120 | if len(m.containers) > 0 { 1121 | m.statusMessage = "Starting container..." 1122 | return m, doAction("start", m.containers[m.cursor].ID) 1123 | } 1124 | } 1125 | 1126 | case key.Matches(msg, Keys.Stop): 1127 | // Stop selected container 1128 | if m.composeViewMode { 1129 | if m.cursor < len(m.flatList) && !m.flatList[m.cursor].isProject { 1130 | container := m.flatList[m.cursor].container 1131 | m.statusMessage = "Stopping container..." 1132 | return m, doAction("stop", container.ID) 1133 | } 1134 | } else { 1135 | // Normal mode 1136 | if len(m.containers) > 0 { 1137 | m.statusMessage = "Stopping container..." 1138 | return m, doAction("stop", m.containers[m.cursor].ID) 1139 | } 1140 | } 1141 | 1142 | case key.Matches(msg, Keys.Logs): 1143 | // Fetch and display logs for selected container 1144 | var containerID string 1145 | if m.composeViewMode { 1146 | if m.cursor < len(m.flatList) && !m.flatList[m.cursor].isProject { 1147 | containerID = m.flatList[m.cursor].container.ID 1148 | } 1149 | } else { 1150 | if len(m.containers) > 0 { 1151 | containerID = m.containers[m.cursor].ID 1152 | } 1153 | } 1154 | if containerID != "" { 1155 | m.statusMessage = "Fetching logs..." 1156 | m.currentMode = modeLogs 1157 | // recompute pagination and persistent page indicator 1158 | m.updatePagination() 1159 | return m, fetchLogsCmd(containerID) 1160 | } 1161 | 1162 | case key.Matches(msg, Keys.Exec): 1163 | // Open interactive shell in selected container (only if running) 1164 | var container *docker.Container 1165 | if m.composeViewMode { 1166 | if m.cursor < len(m.flatList) && !m.flatList[m.cursor].isProject { 1167 | container = m.flatList[m.cursor].container 1168 | } 1169 | } else { 1170 | if len(m.containers) > 0 { 1171 | container = &m.containers[m.cursor] 1172 | } 1173 | } 1174 | if container != nil && container.State == "running" { 1175 | containerID := container.ID 1176 | m.statusMessage = "Opening interactive shell..." 1177 | // Use bash to clear terminal and exec into container shell 1178 | cmdStr := fmt.Sprintf("echo '# you are in interactive shell'; exec docker exec -it %s /bin/sh", containerID) 1179 | c := exec.Command("bash", "-lc", cmdStr) 1180 | return m, tea.ExecProcess(c, func(err error) tea.Msg { 1181 | if err != nil { 1182 | return actionDoneMsg{err: fmt.Errorf("shell error: %v", err)} 1183 | } 1184 | return actionDoneMsg{err: nil} 1185 | }) 1186 | } 1187 | 1188 | case key.Matches(msg, Keys.Restart): 1189 | // Restart selected container 1190 | if m.composeViewMode { 1191 | // In compose view mode, get container from flatList 1192 | if m.cursor < len(m.flatList) && !m.flatList[m.cursor].isProject { 1193 | container := m.flatList[m.cursor].container 1194 | m.statusMessage = "Restarting container..." 1195 | return m, doAction("restart", container.ID) 1196 | } 1197 | } else { 1198 | // Normal mode 1199 | if len(m.containers) > 0 { 1200 | m.statusMessage = "Restarting container..." 1201 | return m, doAction("restart", m.containers[m.cursor].ID) 1202 | } 1203 | } 1204 | 1205 | case key.Matches(msg, Keys.Remove): 1206 | // Remove selected container 1207 | if m.composeViewMode { 1208 | if m.cursor < len(m.flatList) && !m.flatList[m.cursor].isProject { 1209 | container := m.flatList[m.cursor].container 1210 | m.statusMessage = "Removing container..." 1211 | return m, doAction("rm", container.ID) 1212 | } 1213 | } else { 1214 | // Normal mode 1215 | if len(m.containers) > 0 { 1216 | m.statusMessage = "Removing container..." 1217 | return m, doAction("rm", m.containers[m.cursor].ID) 1218 | } 1219 | } 1220 | } 1221 | } 1222 | return m, nil 1223 | } 1224 | 1225 | // ============================================================================ 1226 | // View (render UI) 1227 | // ============================================================================ 1228 | 1229 | // render everything 1230 | func (m model) View() string { 1231 | if m.terminalWidth == 0 { 1232 | return "Initializing..." 1233 | } 1234 | 1235 | // If in settings mode, render settings in fullscreen to save some performance and get rid of render bugs 1236 | if m.currentMode == modeSettings { 1237 | return m.renderSettings(m.terminalWidth) 1238 | } 1239 | 1240 | var b strings.Builder 1241 | 1242 | // Ensure minimum width 1243 | width := m.terminalWidth 1244 | if width < 80 { 1245 | width = 80 1246 | } 1247 | 1248 | // title bar 1249 | 1250 | titleBar := m.renderTitleBar(width) 1251 | b.WriteString(titleBar) 1252 | b.WriteString("\n") 1253 | 1254 | // stats section (running/stopped bars) 1255 | 1256 | // count by state 1257 | running := 0 1258 | stopped := 0 1259 | for _, c := range m.containers { 1260 | if strings.ToLower(c.State) == "running" { 1261 | running++ 1262 | } else { 1263 | stopped++ 1264 | } 1265 | } 1266 | total := len(m.containers) 1267 | uptime := time.Since(m.startTime).Round(time.Second) 1268 | 1269 | statsSection := m.renderStatsSection(running, stopped, total, uptime, width) 1270 | b.WriteString(statsSection) 1271 | b.WriteString("\n") 1272 | 1273 | // table header 1274 | 1275 | // column widths - compute with smart allocation to prevent overflow 1276 | usableWidth := width - 2 // account for padding and separators 1277 | 1278 | // define minimum widths for each column 1279 | mins := []int{13, 17, 8, 6, 10, 11, 11, 13, 15} 1280 | 1281 | // get user-defined percents; fall back to defaults if malformed 1282 | percents := m.settings.ColumnPercents 1283 | if len(percents) != 9 { 1284 | percents = []int{8, 14, 6, 6, 10, 12, 11, 13, 15} 1285 | } 1286 | 1287 | // allocate widths by percent, respecting minimums 1288 | widths := make([]int, len(mins)) 1289 | allocated := 0 1290 | for i := range mins { 1291 | desired := (usableWidth * percents[i]) / 100 1292 | widths[i] = max(mins[i], desired) 1293 | allocated += widths[i] 1294 | } 1295 | 1296 | // if we have remaining space, distribute one char at a time across columns 1297 | if allocated < usableWidth { 1298 | remaining := usableWidth - allocated 1299 | for remaining > 0 { 1300 | for i := range widths { 1301 | if remaining == 0 { 1302 | break 1303 | } 1304 | widths[i]++ 1305 | remaining-- 1306 | } 1307 | } 1308 | } 1309 | // debugLogger.Printf( 1310 | // "width=%d usableWidth=%d allocated=%d widths=%v specs=%+v", 1311 | // width, usableWidth, allocated, widths, specs, 1312 | // ) 1313 | 1314 | // assign to individual variables 1315 | idW := widths[0] 1316 | nameW := widths[1] 1317 | memoryW := widths[2] 1318 | cpuW := widths[3] 1319 | netIOW := widths[4] 1320 | blockIOW := widths[5] 1321 | imageW := widths[6] 1322 | statusW := widths[7] 1323 | portsW := widths[8] 1324 | 1325 | // debugLogger.Printf( 1326 | // "Column widths: ID=%d NAME=%d MEMORY=%d CPU=%d NET I/O=%d Disk I/O=%d IMAGE=%d STATUS=%d PORTS=%d", 1327 | // idW, nameW, memoryW, cpuW, netIOW, blockIOW, imageW, statusW, portsW, 1328 | // ) 1329 | // sort indicator (▲/▼) 1330 | sortIndicator := func(col sortColumn) string { 1331 | if m.sortBy == col { 1332 | if m.sortAsc { 1333 | return " ▲" 1334 | } 1335 | return " ▼" 1336 | } 1337 | return "" 1338 | } 1339 | 1340 | // highlight selected column in column mode 1341 | highlightStyle := lipgloss.NewStyle().Background(lipgloss.Color("#58cdffff")).Foreground(lipgloss.Color("#000000")).Bold(true) 1342 | 1343 | // buildColumn builds a complete cell with spacing, padding, and title 1344 | buildColumn := func(colIdx int, title string, width int, indicator string) string { 1345 | text := title + indicator 1346 | // Pad to width (width includes the space before the column) 1347 | // Use visibleLen to account for multi-byte/ANSI characters so padding stays correct 1348 | paddingNeeded := width - visibleLen(text) 1349 | if paddingNeeded > 0 { 1350 | text += strings.Repeat(" ", paddingNeeded) 1351 | } 1352 | // Add leading space and apply style 1353 | cell := " " + text 1354 | if m.columnMode && m.selectedColumn == colIdx { 1355 | return highlightStyle.Render(cell) 1356 | } 1357 | return headerStyle.Render(cell) 1358 | } 1359 | 1360 | // build all 9 columns 1361 | col0 := buildColumn(0, "CONTAINER ID", idW-1, sortIndicator(sortByID)) 1362 | col1 := buildColumn(1, "NAME", nameW-1, sortIndicator(sortByName)) 1363 | col2 := buildColumn(2, "MEMORY", memoryW-2, sortIndicator(sortByMemory)) 1364 | col3 := buildColumn(3, "CPU", cpuW-2, sortIndicator(sortByCPU)) 1365 | col4 := buildColumn(4, "NET I/O", netIOW-1, sortIndicator(sortByNetIO)) 1366 | col5 := buildColumn(5, "DISK I/O", blockIOW-1, sortIndicator(sortByBlockIO)) 1367 | col6 := buildColumn(6, "IMAGE", imageW-1, sortIndicator(sortByImage)) 1368 | col7 := buildColumn(7, "STATUS", statusW, sortIndicator(sortByStatus)) 1369 | col8 := buildColumn(8, "PORTS", portsW, sortIndicator(sortByPorts)) // last column gets full width 1370 | 1371 | // combine into header - separators only 1372 | sepStyle := lipgloss.NewStyle(). 1373 | Foreground(lipgloss.Color("#000000")). 1374 | Background(meterGreen) 1375 | sep := sepStyle.Render("│") 1376 | 1377 | var hdrBuilder strings.Builder 1378 | hdrBuilder.WriteString(col0) 1379 | hdrBuilder.WriteString(sep) 1380 | hdrBuilder.WriteString(col1) 1381 | hdrBuilder.WriteString(sep) 1382 | hdrBuilder.WriteString(col2) 1383 | hdrBuilder.WriteString(sep) 1384 | hdrBuilder.WriteString(col3) 1385 | hdrBuilder.WriteString(sep) 1386 | hdrBuilder.WriteString(col4) 1387 | hdrBuilder.WriteString(sep) 1388 | hdrBuilder.WriteString(col5) 1389 | hdrBuilder.WriteString(sep) 1390 | hdrBuilder.WriteString(col6) 1391 | hdrBuilder.WriteString(sep) 1392 | hdrBuilder.WriteString(col7) 1393 | hdrBuilder.WriteString(sep) 1394 | hdrBuilder.WriteString(col8) 1395 | 1396 | hdr := hdrBuilder.String() 1397 | // pad header to fill width 1398 | if visibleLen(hdr) < width { 1399 | hdr += headerStyle.Render(strings.Repeat(" ", width-visibleLen(hdr))) 1400 | } 1401 | b.WriteString(hdr) 1402 | b.WriteString("\n") 1403 | // container list (paginated) 1404 | 1405 | rowsToShow := m.maxContainersPerPage 1406 | if rowsToShow < 1 { 1407 | rowsToShow = m.calculateMaxContainers() 1408 | } 1409 | if rowsToShow < 1 { 1410 | rowsToShow = 1 1411 | } 1412 | 1413 | // render rows 1414 | rowsRendered := 0 1415 | 1416 | if m.composeViewMode { 1417 | // Compose view mode -- render from flatList 1418 | pageStart := m.page * rowsToShow 1419 | if pageStart > len(m.flatList) { 1420 | pageStart = 0 1421 | if len(m.flatList) > rowsToShow { 1422 | pageStart = len(m.flatList) - rowsToShow 1423 | } 1424 | } 1425 | pageEnd := pageStart + rowsToShow 1426 | if pageEnd > len(m.flatList) { 1427 | pageEnd = len(m.flatList) 1428 | } 1429 | 1430 | for i := pageStart; i < pageEnd; i++ { 1431 | row := m.renderTreeRow(m.flatList[i], i == m.cursor, idW, nameW, memoryW, cpuW, netIOW, blockIOW, imageW, statusW, portsW, width) 1432 | b.WriteString(row) 1433 | b.WriteString("\n") 1434 | rowsRendered++ 1435 | } 1436 | } else { 1437 | // Normal mode: render from containers 1438 | pageStart := m.page * rowsToShow 1439 | if pageStart > len(m.containers) { 1440 | pageStart = 0 1441 | if len(m.containers) > rowsToShow { 1442 | pageStart = len(m.containers) - rowsToShow 1443 | } 1444 | } 1445 | pageEnd := pageStart + rowsToShow 1446 | if pageEnd > len(m.containers) { 1447 | pageEnd = len(m.containers) 1448 | } 1449 | 1450 | for i := pageStart; i < pageEnd; i++ { 1451 | c := m.containers[i] 1452 | row := m.renderContainerRow(c, i == m.cursor, idW, nameW, memoryW, cpuW, netIOW, blockIOW, imageW, statusW, portsW, width) 1453 | b.WriteString(row) 1454 | b.WriteString("\n") 1455 | rowsRendered++ 1456 | } 1457 | } 1458 | 1459 | // fill empty space 1460 | emptyRow := normalStyle.Render(strings.Repeat(" ", width)) 1461 | for i := rowsRendered; i < rowsToShow; i++ { 1462 | b.WriteString(emptyRow) 1463 | b.WriteString("\n") 1464 | } 1465 | 1466 | // logs panel (if visible) 1467 | 1468 | if m.logsVisible { 1469 | b.WriteString(m.renderLogsPanel(width)) 1470 | } 1471 | 1472 | // page indicator (persistent) - always render 1473 | pageLine := m.message 1474 | if pageLine == "" { 1475 | pageLine = fmt.Sprintf("Page %d/%d", m.page+1, 1) 1476 | } 1477 | if len(pageLine) < width { 1478 | pageLine += strings.Repeat(" ", width-len(pageLine)) 1479 | } 1480 | b.WriteString(messageStyle.Render(pageLine)) 1481 | b.WriteString("\n") 1482 | 1483 | // transient status message (if any) 1484 | if m.statusMessage != "" { 1485 | sm := m.statusMessage 1486 | if len(sm) < width { 1487 | sm += strings.Repeat(" ", width-len(sm)) 1488 | } 1489 | b.WriteString(messageStyle.Render(sm)) 1490 | b.WriteString("\n") 1491 | } 1492 | 1493 | // 1-row bottom padding after messages 1494 | b.WriteString(normalStyle.Render(strings.Repeat(" ", width))) 1495 | b.WriteString("\n") 1496 | 1497 | // footer (keybinds) 1498 | footer := m.renderFooter(width) 1499 | b.WriteString(footer) 1500 | 1501 | return b.String() 1502 | } 1503 | 1504 | // ============================================================================ 1505 | // Rendering helpers 1506 | // ============================================================================ 1507 | 1508 | // render centered title bar 1509 | func (m model) renderTitleBar(width int) string { 1510 | appName := appNameStyle.Render("┌─ DockMate🐳 ─┐") 1511 | 1512 | // center it 1513 | padding := (width - visibleLen(appName)) / 2 1514 | if padding < 0 { 1515 | padding = 0 1516 | } 1517 | 1518 | line := strings.Repeat(" ", padding) + appName 1519 | if visibleLen(line) < width { 1520 | line += strings.Repeat(" ", width-visibleLen(line)) 1521 | } 1522 | 1523 | return line 1524 | } 1525 | 1526 | // render running/stopped bars with session info 1527 | // two lines: running bar + info, stopped bar + loading 1528 | func (m model) renderStatsSection(running, stopped, total int, uptime time.Duration, width int) string { 1529 | var b strings.Builder 1530 | 1531 | // calculate bar widths 1532 | halfWidth := (width - 6) / 2 1533 | barWidth := halfWidth - 16 1534 | if barWidth < 10 { 1535 | barWidth = 10 1536 | } 1537 | 1538 | // line 1: running bar + session info on right 1539 | runPct := 0.0 1540 | if total > 0 { 1541 | runPct = float64(running) / float64(total) 1542 | } 1543 | runBar := renderBar(runPct, barWidth, meterGreen, textMuted) 1544 | runningLine := fmt.Sprintf(" %s%s%s%s %s", 1545 | meterLabelStyle.Render("Running "), 1546 | meterBracketStyle.Render("["), 1547 | runBar, 1548 | meterBracketStyle.Render("]"), 1549 | infoValueStyle.Render(fmt.Sprintf("%d/%d", running, total))) 1550 | 1551 | // right side: total, uptime, refresh interval 1552 | infoLine := fmt.Sprintf("%s %s %s %s %s %s", 1553 | infoLabelStyle.Render("Total:"), 1554 | infoValueStyle.Render(fmt.Sprintf("%d", total)), 1555 | infoLabelStyle.Render("Session:"), 1556 | infoValueStyle.Render(formatDuration(uptime)), 1557 | infoLabelStyle.Render("Refresh:"), 1558 | infoValueStyle.Render(fmt.Sprintf("%ds", m.settings.RefreshInterval))) 1559 | 1560 | // padding between left and right 1561 | leftLen := visibleLen(runningLine) 1562 | rightLen := visibleLen(infoLine) 1563 | middlePad := width - leftLen - rightLen - 2 1564 | if middlePad < 2 { 1565 | middlePad = 2 1566 | } 1567 | 1568 | b.WriteString(runningLine) 1569 | b.WriteString(strings.Repeat(" ", middlePad)) 1570 | b.WriteString(infoLine) 1571 | b.WriteString("\n") 1572 | 1573 | // line 2: stopped bar + loading indicator 1574 | stopPct := 0.0 1575 | if total > 0 { 1576 | stopPct = float64(stopped) / float64(total) 1577 | } 1578 | stopBar := renderBar(stopPct, barWidth, meterRed, textMuted) 1579 | stoppedLine := fmt.Sprintf(" %s%s%s%s %s", 1580 | meterLabelStyle.Render("Stopped "), 1581 | meterBracketStyle.Render("["), 1582 | stopBar, 1583 | meterBracketStyle.Render("]"), 1584 | infoValueStyle.Render(fmt.Sprintf("%d/%d", stopped, total))) 1585 | 1586 | b.WriteString(stoppedLine) 1587 | 1588 | // loading spinner if fetching 1589 | if m.loading { 1590 | loadingPad := width - visibleLen(stoppedLine) - 12 1591 | if loadingPad > 0 { 1592 | b.WriteString(strings.Repeat(" ", loadingPad)) 1593 | b.WriteString(messageStyle.Render("⟳ Loading...")) 1594 | } 1595 | } 1596 | 1597 | return b.String() 1598 | } 1599 | 1600 | // draw a progress bar with filled/empty chars 1601 | // pct is 0.0 to 1.0 1602 | func renderBar(pct float64, width int, fgColor, bgColor lipgloss.Color) string { 1603 | // clamp percentage 1604 | if pct < 0 { 1605 | pct = 0 1606 | } 1607 | if pct > 1 { 1608 | pct = 1 1609 | } 1610 | 1611 | // Calculate filled and empty counts 1612 | filled := int(pct * float64(width)) 1613 | empty := width - filled 1614 | 1615 | filledStyle := lipgloss.NewStyle().Foreground(fgColor).Bold(true) 1616 | emptyStyle := lipgloss.NewStyle().Foreground(bgColor) 1617 | 1618 | bar := "" 1619 | if filled > 0 { 1620 | bar += filledStyle.Render(strings.Repeat("█", filled)) 1621 | } 1622 | if empty > 0 { 1623 | bar += emptyStyle.Render(strings.Repeat("░", empty)) 1624 | } 1625 | 1626 | return bar 1627 | } 1628 | 1629 | // get visible length without ansi codes 1630 | // important for width calculations with lipgloss 1631 | func visibleLen(s string) int { 1632 | count := 0 1633 | inEscape := false 1634 | for _, r := range s { 1635 | if r == '\x1b' { 1636 | inEscape = true 1637 | } else if inEscape && r == 'm' { 1638 | inEscape = false 1639 | } else if !inEscape { 1640 | count++ 1641 | } 1642 | } 1643 | return count 1644 | } 1645 | 1646 | // truncateToWidth truncates a string to fit within the given visible width 1647 | // preserving ANSI codes and adding ellipsis if truncated 1648 | func truncateToWidth(s string, width int) string { 1649 | if width < 1 { 1650 | return "" 1651 | } 1652 | 1653 | visLen := visibleLen(s) 1654 | if visLen <= width { 1655 | return s 1656 | } 1657 | 1658 | // need to truncate - account for ellipsis 1659 | targetWidth := width - 1 1660 | if targetWidth < 1 { 1661 | return "…" 1662 | } 1663 | 1664 | // walk through string counting visible chars 1665 | visCount := 0 1666 | inEscape := false 1667 | result := "" 1668 | 1669 | for _, r := range s { 1670 | if r == '\x1b' { 1671 | inEscape = true 1672 | result += string(r) 1673 | } else if inEscape { 1674 | result += string(r) 1675 | if r == 'm' { 1676 | inEscape = false 1677 | } 1678 | } else { 1679 | if visCount >= targetWidth { 1680 | break 1681 | } 1682 | result += string(r) 1683 | visCount++ 1684 | } 1685 | } 1686 | 1687 | return result + "…" 1688 | } 1689 | 1690 | // render one container row 1691 | // applies styles based on selection and state 1692 | func (m model) renderContainerRow(c docker.Container, selected bool, idW, nameW, memoryW, cpuW, netIOW, blockIOW, imageW, statusW, portsW, totalWidth int) string { 1693 | // get name from names array 1694 | name := "" 1695 | if len(c.Names) > 0 { 1696 | name = c.Names[0] 1697 | } 1698 | 1699 | // truncate fields to fit 1700 | id := c.ID 1701 | if visibleLen(id) > idW-2 { 1702 | id = truncateToWidth(id, idW-2) 1703 | } 1704 | 1705 | if visibleLen(name) > nameW-2 { 1706 | name = truncateToWidth(name, nameW-2) 1707 | } 1708 | img := c.Image 1709 | if visibleLen(img) > imageW-2 { 1710 | img = truncateToWidth(img, imageW-2) 1711 | } 1712 | status := c.Status 1713 | if visibleLen(status) > statusW-2 { 1714 | status = truncateToWidth(status, statusW-2) 1715 | } 1716 | // STATE column hidden for testing; keep state value for styling only 1717 | // state := c.State 1718 | 1719 | // net IO 1720 | netio := c.NetIO 1721 | if netio == "" { 1722 | netio = "─" 1723 | } 1724 | if visibleLen(netio) > netIOW-2 { 1725 | // truncate to fit with ellipsis 1726 | netio = truncateToWidth(netio, netIOW-2) 1727 | } 1728 | 1729 | // block IO 1730 | blockio := c.BlockIO 1731 | if blockio == "" { 1732 | blockio = "─" 1733 | } 1734 | if visibleLen(blockio) > blockIOW-2 { 1735 | blockio = truncateToWidth(blockio, blockIOW-2) 1736 | } 1737 | 1738 | // placeholder for empty stuff 1739 | mem := c.Memory 1740 | if mem == "" { 1741 | mem = "─" 1742 | } 1743 | if visibleLen(mem) > memoryW-2 { 1744 | mem = truncateToWidth(mem, memoryW-2) 1745 | } 1746 | 1747 | cpu := c.CPU 1748 | if visibleLen(cpu) > cpuW-2 { 1749 | // truncate to fit with ellipsis 1750 | cpu = truncateToWidth(cpu, cpuW-2) 1751 | } 1752 | if cpu == "" { 1753 | cpu = "─" 1754 | } 1755 | ports := c.Ports 1756 | if ports == "" { 1757 | ports = "─" 1758 | } 1759 | if visibleLen(ports) > portsW-7 { 1760 | ports = truncateToWidth(ports, portsW-6) 1761 | } 1762 | 1763 | // Format row (STATE column omitted) 1764 | row := fmt.Sprintf(" %-*s│ %-*s│ %-*s│ %-*s│ %-*s│ %-*s│ %-*s│ %-*s│ %-*s", 1765 | idW-1, id, 1766 | nameW-1, name, 1767 | memoryW-2, mem, 1768 | cpuW-2, cpu, 1769 | netIOW-1, netio, 1770 | blockIOW-1, blockio, 1771 | imageW-1, img, 1772 | statusW, status, 1773 | portsW-2, ports) 1774 | 1775 | // Pad row to totalWidth BEFORE styling to ensure color extends to edge 1776 | if visibleLen(row) < totalWidth { 1777 | row += strings.Repeat(" ", totalWidth-visibleLen(row)) 1778 | } 1779 | 1780 | // Apply style based on selection and state 1781 | if selected { 1782 | return selectedStyle.Render(row) 1783 | } 1784 | 1785 | switch strings.ToLower(c.State) { 1786 | case "running": 1787 | return runningStyle.Render(row) 1788 | case "paused": 1789 | return pausedStyle.Render(row) 1790 | case "exited", "dead": 1791 | return stoppedStyle.Render(row) 1792 | default: 1793 | return normalStyle.Render(row) 1794 | } 1795 | } 1796 | 1797 | // renderLogsPanel prints a fixed-height logs section respecting the configured panel height 1798 | func (m model) renderLogsPanel(width int) string { 1799 | var b strings.Builder 1800 | 1801 | b.WriteString(dividerStyle.Render(strings.Repeat("─", width))) 1802 | b.WriteString("\n") 1803 | 1804 | logsTitle := fmt.Sprintf("Logs: %s ", m.logsContainer) 1805 | if len(logsTitle) < width { 1806 | logsTitle += strings.Repeat(" ", width-len(logsTitle)) 1807 | } 1808 | b.WriteString(titleStyle.Render(logsTitle)) 1809 | b.WriteString("\n") 1810 | 1811 | maxLogLines := m.logPanelHeight - 2 // account for divider and title 1812 | if maxLogLines < 1 { 1813 | maxLogLines = 1 1814 | } 1815 | 1816 | startLog := 0 1817 | if len(m.logsLines) > maxLogLines { 1818 | startLog = len(m.logsLines) - maxLogLines 1819 | } 1820 | 1821 | for i := startLog; i < len(m.logsLines); i++ { 1822 | logLine := m.logsLines[i] 1823 | if len(logLine) > width-4 { 1824 | logLine = logLine[:width-7] + "..." 1825 | } 1826 | b.WriteString(normalStyle.Render(" " + logLine)) 1827 | b.WriteString("\n") 1828 | } 1829 | 1830 | renderedLines := len(m.logsLines) - startLog 1831 | for i := renderedLines; i < maxLogLines; i++ { 1832 | b.WriteString(normalStyle.Render(strings.Repeat(" ", width))) 1833 | b.WriteString("\n") 1834 | } 1835 | 1836 | return b.String() 1837 | } 1838 | 1839 | // renderSettings shows a full-screen settings view where users can 1840 | // adjust column percent allocations . 1841 | func (m model) renderSettings(width int) string { 1842 | var b strings.Builder 1843 | 1844 | title := titleStyle.Render("┌─ Settings 🛠️─┐") 1845 | padding := (width - visibleLen(title)) / 2 1846 | if padding < 0 { 1847 | padding = 0 1848 | } 1849 | header := strings.Repeat(" ", padding) + title 1850 | if visibleLen(header) < width { 1851 | header += strings.Repeat(" ", width-visibleLen(header)) 1852 | } 1853 | b.WriteString(header) 1854 | b.WriteString("\n") 1855 | 1856 | // Column list 1857 | colNames := []string{"CONTAINER ID", "NAME", "MEMORY", "CPU", "NET I/O", "Disk I/O", "IMAGE", "STATUS", "PORTS"} 1858 | if m.settings.ColumnPercents == nil || len(m.settings.ColumnPercents) != 9 { 1859 | m.settings.ColumnPercents = []int{8, 14, 6, 6, 10, 12, 18, 13, 13} 1860 | } 1861 | 1862 | for i, name := range colNames { 1863 | pct := m.settings.ColumnPercents[i] 1864 | line := fmt.Sprintf(" %2d%% %s", pct, name) 1865 | if m.settingsSelected == i { 1866 | // highlight selected 1867 | b.WriteString(selectedStyle.Render(padRight(line, width))) 1868 | } else { 1869 | b.WriteString(normalStyle.Render(padRight(line, width))) 1870 | } 1871 | b.WriteString("\n") 1872 | } 1873 | 1874 | // Refresh interval row (index 9) 1875 | b.WriteString("\n") 1876 | refreshLine := fmt.Sprintf(" %2ds Refresh Interval", m.settings.RefreshInterval) 1877 | if m.settingsSelected == 9 { 1878 | b.WriteString(selectedStyle.Render(padRight(refreshLine, width))) 1879 | } else { 1880 | b.WriteString(normalStyle.Render(padRight(refreshLine, width))) 1881 | } 1882 | b.WriteString("\n") 1883 | 1884 | // // runtime row (index 10) 1885 | // b.WriteString("\n") 1886 | // runtime := fmt.Sprintf("Runtime: %s", m.settings.Runtime) 1887 | // if m.settingsSelected == 10 { 1888 | // b.WriteString(selectedStyle.Render(padRight(runtime, width))) 1889 | // } else { 1890 | // b.WriteString(normalStyle.Render(padRight(runtime, width))) 1891 | // } 1892 | // b.WriteString("\n") 1893 | 1894 | b.WriteString("\n") 1895 | instr := "←/→ or +/- adjust • ↑/↓ navigate • Enter save • Esc cancel" 1896 | if visibleLen(instr) < width { 1897 | instr += strings.Repeat(" ", width-visibleLen(instr)) 1898 | } 1899 | b.WriteString(infoValueStyle.Render(instr)) 1900 | b.WriteString("\n") 1901 | 1902 | return b.String() 1903 | } 1904 | 1905 | // padRight pads a string to visible width 1906 | func padRight(s string, width int) string { 1907 | if visibleLen(s) >= width { 1908 | return s 1909 | } 1910 | return s + strings.Repeat(" ", width-visibleLen(s)) 1911 | } 1912 | 1913 | // render keyboard shortcuts at bottom (mode-aware) 1914 | func (m model) renderFooter(width int) string { 1915 | var keys []struct { 1916 | key string 1917 | desc string 1918 | } 1919 | 1920 | // Show different shortcuts based on current mode 1921 | switch m.currentMode { 1922 | case modeColumnSelect: 1923 | keys = []struct { 1924 | key string 1925 | desc string 1926 | }{ 1927 | {"←→", "Select Col"}, 1928 | {"Enter", "Sort"}, 1929 | {"Esc", "Back"}, 1930 | } 1931 | case modeLogs: 1932 | keys = []struct { 1933 | key string 1934 | desc string 1935 | }{ 1936 | {"Shift + l", "Close Logs"}, 1937 | {"↑↓", "Scroll"}, 1938 | {"E", "Interactive Shell"}, 1939 | {"Esc", "Back"}, 1940 | } 1941 | default: // modeNormal 1942 | keys = []struct { 1943 | key string 1944 | desc string 1945 | }{ 1946 | {"↑↓", "Nav"}, 1947 | {"←→", "Nav pages"}, 1948 | {"Tab", "Col Mode"}, 1949 | {"s", "Start"}, 1950 | {"x", "Stop"}, 1951 | {"r", "Restart"}, 1952 | {"l", "Logs"}, 1953 | {"e", "Shell"}, 1954 | {"d", "Remove"}, 1955 | {"c", "Compose View"}, 1956 | {"f2", "Settings"}, 1957 | {"q", "Quit"}, 1958 | } 1959 | if m.composeViewMode { 1960 | keys = []struct { 1961 | key string 1962 | desc string 1963 | }{ 1964 | {"↑↓", "Nav"}, 1965 | {"←→", "Nav pages"}, 1966 | {"Tab", "Col Mode"}, 1967 | {"s", "Start"}, 1968 | {"x", "Stop"}, 1969 | {"r", "Restart"}, 1970 | {"l", "Logs"}, 1971 | {"e", "Shell"}, 1972 | {"d", "Remove"}, 1973 | {"c", "Normal View"}, 1974 | {"f2", "Settings"}, 1975 | {"q", "Quit"}, 1976 | } 1977 | } 1978 | } 1979 | 1980 | var footer strings.Builder 1981 | footer.WriteString(" ") 1982 | 1983 | // build key action format 1984 | for i, k := range keys { 1985 | footer.WriteString(meterBracketStyle.Render("[")) 1986 | footer.WriteString(footerKeyStyle.Render(k.key)) 1987 | footer.WriteString(meterBracketStyle.Render("]")) 1988 | footer.WriteString(footerArrowStyle.Render("→")) 1989 | footer.WriteString(footerDescStyle.Render(k.desc)) 1990 | if i < len(keys)-1 { 1991 | footer.WriteString(" ") 1992 | } 1993 | } 1994 | 1995 | // pad footer 1996 | footerStr := footer.String() 1997 | footerVisible := visibleLen(footerStr) 1998 | if footerVisible < width { 1999 | footerStr += strings.Repeat(" ", width-footerVisible) 2000 | } 2001 | 2002 | return footerStr 2003 | } 2004 | 2005 | func max(a, b int) int { 2006 | if a > b { 2007 | return a 2008 | } 2009 | return b 2010 | } 2011 | 2012 | func min(a, b int) int { 2013 | if a < b { 2014 | return a 2015 | } 2016 | return b 2017 | } 2018 | 2019 | // format duration like HH:MM:SS 2020 | func formatDuration(d time.Duration) string { 2021 | h := int(d.Hours()) 2022 | m := int(d.Minutes()) % 60 2023 | s := int(d.Seconds()) % 60 2024 | 2025 | if h > 0 { 2026 | return fmt.Sprintf("%02d:%02d:%02d", h, m, s) 2027 | } 2028 | return fmt.Sprintf("%02d:%02d", m, s) 2029 | } 2030 | 2031 | // buildFlatList creates a flat list from the tree structure for rendering 2032 | func (m *model) buildFlatList() { 2033 | m.flatList = []treeRow{} 2034 | 2035 | // sort projects by name 2036 | projectNames := []string{} 2037 | for name := range m.projects { 2038 | projectNames = append(projectNames, name) 2039 | } 2040 | sort.Strings(projectNames) 2041 | 2042 | // Add compose projects 2043 | for _, projectName := range projectNames { 2044 | project := m.projects[projectName] 2045 | running := 0 2046 | for _, c := range project.Containers { 2047 | if strings.ToLower(c.State) == "running" { 2048 | running++ 2049 | } 2050 | } 2051 | total := len(project.Containers) 2052 | 2053 | // Add project row 2054 | m.flatList = append(m.flatList, treeRow{ 2055 | isProject: true, 2056 | projectName: projectName, 2057 | running: running, 2058 | total: total, 2059 | indent: 0, 2060 | }) 2061 | 2062 | // Add container rows if expanded 2063 | if m.expandedProjects[projectName] { 2064 | for i := range project.Containers { 2065 | m.flatList = append(m.flatList, treeRow{ 2066 | isProject: false, 2067 | container: &project.Containers[i], 2068 | indent: 1, 2069 | }) 2070 | } 2071 | } 2072 | } 2073 | 2074 | // Find standalone containers 2075 | standaloneContainers := []*docker.Container{} 2076 | composeContainerIDs := make(map[string]bool) 2077 | 2078 | for _, project := range m.projects { 2079 | for _, c := range project.Containers { 2080 | composeContainerIDs[c.ID] = true 2081 | } 2082 | } 2083 | 2084 | for i := range m.containers { 2085 | if !composeContainerIDs[m.containers[i].ID] { 2086 | standaloneContainers = append(standaloneContainers, &m.containers[i]) 2087 | } 2088 | } 2089 | 2090 | // Add standalone section if any exist 2091 | if len(standaloneContainers) > 0 { 2092 | m.flatList = append(m.flatList, treeRow{ 2093 | isProject: true, 2094 | projectName: "Standalone Containers", 2095 | total: len(standaloneContainers), 2096 | indent: 0, 2097 | }) 2098 | 2099 | if m.expandedProjects["Standalone Containers"] { 2100 | for _, container := range standaloneContainers { 2101 | m.flatList = append(m.flatList, treeRow{ 2102 | isProject: false, 2103 | container: container, 2104 | indent: 1, 2105 | }) 2106 | } 2107 | } 2108 | } 2109 | } 2110 | 2111 | // moveCursorUpTree moves the cursor to the previous non-project row in flatList 2112 | func (m *model) moveCursorUpTree() { 2113 | if len(m.flatList) == 0 { 2114 | m.cursor = 0 2115 | return 2116 | } 2117 | i := m.cursor - 1 2118 | for i >= 0 && m.flatList[i].isProject { 2119 | i-- 2120 | } 2121 | if i >= 0 { 2122 | m.cursor = i 2123 | } else { 2124 | // clamp to first non-project if any 2125 | for j := 0; j < len(m.flatList); j++ { 2126 | if !m.flatList[j].isProject { 2127 | m.cursor = j 2128 | return 2129 | } 2130 | } 2131 | m.cursor = 0 2132 | } 2133 | } 2134 | 2135 | // moveCursorDownTree moves the cursor to the next non-project row in flatList 2136 | func (m *model) moveCursorDownTree() { 2137 | if len(m.flatList) == 0 { 2138 | m.cursor = 0 2139 | return 2140 | } 2141 | i := m.cursor + 1 2142 | for i < len(m.flatList) && m.flatList[i].isProject { 2143 | i++ 2144 | } 2145 | if i < len(m.flatList) { 2146 | m.cursor = i 2147 | } else { 2148 | // clamp to last non-project if any 2149 | for j := len(m.flatList) - 1; j >= 0; j-- { 2150 | if !m.flatList[j].isProject { 2151 | m.cursor = j 2152 | return 2153 | } 2154 | } 2155 | m.cursor = len(m.flatList) - 1 2156 | } 2157 | } 2158 | 2159 | // renderTreeRow renders a single tree row (project header or container) 2160 | func (m model) renderTreeRow(row treeRow, selected bool, idW, nameW, memoryW, cpuW, netIOW, blockIOW, imageW, statusW, portsW, totalWidth int) string { 2161 | if row.isProject { 2162 | // Project header row 2163 | expandIcon := "▼" 2164 | if !m.expandedProjects[row.projectName] { 2165 | expandIcon = "▶" 2166 | } 2167 | 2168 | projectLabel := fmt.Sprintf(" %s %s [%d/%d running]", expandIcon, row.projectName, row.running, row.total) 2169 | if visibleLen(projectLabel) < totalWidth { 2170 | projectLabel += strings.Repeat(" ", totalWidth-visibleLen(projectLabel)) 2171 | } 2172 | 2173 | // Project row style 2174 | projectStyle := lipgloss.NewStyle().Bold(true).Foreground(accent) 2175 | return projectStyle.Render(projectLabel) 2176 | } 2177 | 2178 | // Container row -- same format as normal view but with tree indent 2179 | c := row.container 2180 | if c == nil { 2181 | return normalStyle.Render(strings.Repeat(" ", totalWidth)) 2182 | } 2183 | 2184 | name := "" 2185 | if len(c.Names) > 0 { 2186 | name = c.Names[0] 2187 | name = strings.TrimPrefix(name, "/") 2188 | } 2189 | 2190 | // Add tree indent 2191 | indentStr := "" 2192 | if row.indent > 0 { 2193 | indentStr = " ├─ " 2194 | } 2195 | 2196 | id := c.ID 2197 | if visibleLen(id) > idW-2 { 2198 | id = truncateToWidth(id, idW-2) 2199 | } 2200 | 2201 | containerName := indentStr + name 2202 | if visibleLen(containerName) > nameW-2 { 2203 | containerName = truncateToWidth(containerName, nameW-2) 2204 | } 2205 | 2206 | img := c.Image 2207 | if visibleLen(img) > imageW-2 { 2208 | img = truncateToWidth(img, imageW-2) 2209 | } 2210 | 2211 | status := c.Status 2212 | if visibleLen(status) > statusW-2 { 2213 | status = truncateToWidth(status, statusW-2) 2214 | } 2215 | 2216 | mem := c.Memory 2217 | if mem == "" { 2218 | mem = "─" 2219 | } 2220 | if visibleLen(mem) > memoryW-2 { 2221 | mem = truncateToWidth(mem, memoryW-2) 2222 | } 2223 | 2224 | cpu := c.CPU 2225 | if cpu == "" { 2226 | cpu = "─" 2227 | } 2228 | if visibleLen(cpu) > cpuW-2 { 2229 | cpu = truncateToWidth(cpu, cpuW-2) 2230 | } 2231 | 2232 | netio := c.NetIO 2233 | if netio == "" { 2234 | netio = "─" 2235 | } 2236 | if visibleLen(netio) > netIOW-2 { 2237 | netio = truncateToWidth(netio, netIOW-2) 2238 | } 2239 | 2240 | blockio := c.BlockIO 2241 | if blockio == "" { 2242 | blockio = "─" 2243 | } 2244 | if visibleLen(blockio) > blockIOW-2 { 2245 | blockio = truncateToWidth(blockio, blockIOW-2) 2246 | } 2247 | 2248 | ports := c.Ports 2249 | if ports == "" { 2250 | ports = "─" 2251 | } 2252 | if visibleLen(ports) > portsW-7 { 2253 | ports = truncateToWidth(ports, portsW-6) 2254 | } 2255 | 2256 | rowStr := fmt.Sprintf(" %-*s│ %-*s│ %-*s│ %-*s│ %-*s│ %-*s│ %-*s│ %-*s│ %-*s", 2257 | idW-1, id, 2258 | nameW-1, containerName, 2259 | memoryW-2, mem, 2260 | cpuW-2, cpu, 2261 | netIOW-1, netio, 2262 | blockIOW-1, blockio, 2263 | imageW-1, img, 2264 | statusW, status, 2265 | portsW-2, ports) 2266 | 2267 | if visibleLen(rowStr) < totalWidth { 2268 | rowStr += strings.Repeat(" ", totalWidth-visibleLen(rowStr)) 2269 | } 2270 | 2271 | if selected { 2272 | return selectedStyle.Render(rowStr) 2273 | } 2274 | 2275 | switch strings.ToLower(c.State) { 2276 | case "running": 2277 | return runningStyle.Render(rowStr) 2278 | case "paused": 2279 | return pausedStyle.Render(rowStr) 2280 | case "exited", "dead": 2281 | return stoppedStyle.Render(rowStr) 2282 | default: 2283 | return normalStyle.Render(rowStr) 2284 | } 2285 | } 2286 | --------------------------------------------------------------------------------