├── 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 | 
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 |
--------------------------------------------------------------------------------