├── .github ├── FUNDING.yml ├── dependabot.yml └── workflows │ ├── docker.yml │ ├── go-lint.yml │ └── go.yml ├── .gitignore ├── .godir ├── .travis.yml ├── APPVERSION ├── BUILD.md ├── CODE_OF_CONDUCT.md ├── DEBUG.md ├── Dockerfile ├── LICENSE ├── Makefile ├── README.md ├── RELEASE_NOTES.md ├── app ├── cmenu_events.go ├── config.go ├── container_events.go ├── df_events.go ├── dry.go ├── events.go ├── filter_event.go ├── help_texts.go ├── image_events.go ├── loop.go ├── misc.go ├── misc_test.go ├── monitor_events.go ├── network_events.go ├── node_events.go ├── nodetasks_events.go ├── render.go ├── service_events.go ├── servicetasks_events.go ├── stack_events.go ├── stacktasks_events.go ├── view.go ├── volume_events.go └── widget_registry.go ├── appui ├── appui.go ├── container.go ├── container_details.go ├── container_details_test.go ├── container_menu.go ├── container_menu_test.go ├── container_row.go ├── containers.go ├── containers_test.go ├── disk_usage.go ├── disk_usage_test.go ├── docker_info.go ├── docker_info_test.go ├── events.go ├── header.go ├── header_test.go ├── image_history.go ├── image_row.go ├── images.go ├── images_test.go ├── info.go ├── input.go ├── inspect.go ├── less.go ├── monitor.go ├── monitor_header.go ├── monitor_test.go ├── network_row.go ├── networks.go ├── prompt.go ├── row.go ├── row_filter.go ├── row_filter_test.go ├── run_image.go ├── screen.go ├── stats_row.go ├── stats_row_test.go ├── stream.go ├── swarm │ ├── node_row.go │ ├── node_row_test.go │ ├── node_tasks.go │ ├── nodes.go │ ├── nodes_test.go │ ├── service_info.go │ ├── service_info_test.go │ ├── service_row.go │ ├── service_tasks.go │ ├── services.go │ ├── stack_row.go │ ├── stack_row_test.go │ ├── stack_tasks.go │ ├── stacks.go │ ├── task_row.go │ ├── task_row_test.go │ ├── tasks.go │ └── testdata │ │ └── service_info.golden ├── testdata │ ├── DiskUsageTest.golden │ ├── DiskUsageTest_noPruneReport.golden │ ├── TestVolumesWidget_double_sort_volumes.golden │ ├── TestVolumesWidget_filter_volumes.golden │ ├── TestVolumesWidget_mounted_widget_no_volumes.golden │ ├── TestVolumesWidget_mounted_widget_two_volumes.golden │ ├── TestVolumesWidget_show_first_4_volumes.golden │ ├── TestVolumesWidget_show_last_4_volumes.golden │ ├── TestVolumesWidget_sort_volumes.golden │ └── TestVolumesWidget_unmounted_widget.golden ├── testing.go ├── theme.go ├── top.go ├── ui.go ├── ui_events.go ├── volume_row.go ├── volumes.go └── volumes_test.go ├── demos └── dry-m.gif ├── docker ├── api.go ├── beutifier.go ├── commands.go ├── commands_test.go ├── connection.go ├── container_config.go ├── container_config_test.go ├── daemon.go ├── daemon_test.go ├── docker_environment.go ├── docker_test.go ├── event_listener.go ├── event_listener_test.go ├── events.go ├── events_log.go ├── events_log_test.go ├── filter.go ├── filter_test.go ├── formatter │ ├── container_formatter.go │ ├── context.go │ ├── id.go │ ├── id_test.go │ ├── image_formatter.go │ ├── image_formatter_test.go │ ├── network_formatter.go │ ├── pretty.go │ ├── swarm.go │ └── task.go ├── images.go ├── images_test.go ├── memory_store.go ├── memory_store_test.go ├── mock │ ├── docker_api_client.go │ └── swarm_api.go ├── prune_report.go ├── resolver.go ├── sort.go ├── sort_images.go ├── sort_images_test.go ├── sort_networks.go ├── sort_nodes.go ├── sort_nodes_test.go ├── sort_services.go ├── sort_services_tasks.go ├── sort_services_test.go ├── sort_stacks.go ├── sort_test.go ├── stack.go ├── stack_remove.go ├── stats.go ├── stats_test.go ├── swarm.go ├── swarm_test.go └── whale.go ├── docs └── dry.png ├── dryup.sh ├── go.mod ├── go.sum ├── main.go ├── mocks ├── docker_daemon.go └── docker_swarmDaemon.go ├── revive.toml ├── search ├── search.go └── search_test.go ├── snap └── snapcraft.yaml ├── terminal ├── ansiparser.go └── ansiparser_test.go ├── tls └── config.go ├── ui ├── color.go ├── colorize.go ├── colorize_test.go ├── cursor.go ├── cursor_test.go ├── events.go ├── expiring_message.go ├── focus.go ├── input.go ├── key.go ├── less.go ├── less_test.go ├── list.go ├── list_test.go ├── markup.go ├── markup_test.go ├── par.go ├── par_test.go ├── render.go ├── screen.go ├── screen_dimension.go ├── tcell.go ├── termbox.go ├── termui │ ├── cursor.go │ ├── gauge_column.go │ ├── gauge_column_test.go │ ├── header.go │ ├── header_test.go │ ├── keyvalue.go │ ├── keyvalue_test.go │ ├── par_column.go │ ├── par_column_test.go │ ├── par_markup.go │ ├── row.go │ ├── row_test.go │ ├── stringer.go │ ├── stringer_test.go │ ├── table.go │ ├── textbuilder.go │ ├── textbuilder_test.go │ ├── textinput.go │ ├── textinput_test.go │ ├── types.go │ └── widget.go ├── theme.go ├── view.go └── view_test.go └── version └── version.go /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | # These are supported funding model platforms 2 | 3 | github: moncho 4 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | # To get started with Dependabot version updates, you'll need to specify which 2 | # package ecosystems to update and where the package manifests are located. 3 | # Please see the documentation for all configuration options: 4 | # https://docs.github.com/github/administering-a-repository/configuration-options-for-dependency-updates 5 | 6 | version: 2 7 | updates: 8 | - package-ecosystem: "gomod" # See documentation for possible values 9 | directory: "/" # Location of package manifests 10 | schedule: 11 | interval: "weekly" 12 | -------------------------------------------------------------------------------- /.github/workflows/docker.yml: -------------------------------------------------------------------------------- 1 | name: Build & Push Docker image 2 | 3 | on: 4 | push: 5 | branches: 6 | - master 7 | paths: 8 | - 'APPVERSION' 9 | # Allow manual runs. 10 | workflow_dispatch: 11 | 12 | jobs: 13 | build: 14 | runs-on: ubuntu-latest 15 | steps: 16 | - name: checkout code 17 | uses: actions/checkout@v2 18 | 19 | - name: Setup QEMU 20 | uses: docker/setup-qemu-action@v1.0.1 21 | 22 | - name: Setup Buildx 23 | uses: docker/setup-buildx-action@v1.0.3 24 | 25 | - name: Login to Docker Hub 26 | uses: docker/login-action@v1 27 | with: 28 | username: ${{ secrets.DOCKERHUB_USERNAME }} 29 | password: ${{ secrets.DOCKERHUB_TOKEN }} 30 | 31 | - name: Build and push Docker image 32 | uses: docker/build-push-action@v2.1.0 33 | with: 34 | context: . 35 | file: ./Dockerfile 36 | tags: moncho/dry:latest 37 | platforms: linux/arm/v6,linux/arm/v7,linux/arm64/v8,linux/amd64 38 | push: true 39 | 40 | -------------------------------------------------------------------------------- /.github/workflows/go-lint.yml: -------------------------------------------------------------------------------- 1 | name: go-lint 2 | concurrency: 3 | # Run only for most recent commit in PRs but for all tags and commits on main 4 | # Ref: https://docs.github.com/en/actions/using-jobs/using-concurrency 5 | group: ${{ github.workflow }}-${{ github.head_ref || github.sha }} 6 | cancel-in-progress: true 7 | on: 8 | pull_request: 9 | paths: 10 | - '**.go' 11 | - '.github/workflows/go-lint.yml' # This file 12 | schedule: 13 | - cron: 0 0 * * 1 14 | workflow_dispatch: 15 | 16 | permissions: 17 | contents: read 18 | pull-requests: read # required for `on: pull_request` with `only_new_issues: true` 19 | jobs: 20 | golangci: 21 | name: lint 22 | runs-on: ubuntu-latest 23 | timeout-minutes: 3 24 | steps: 25 | - uses: actions/setup-go@v5 26 | with: 27 | go-version: '1.22' 28 | - uses: actions/checkout@v4 29 | - name: golangci-lint 30 | uses: golangci/golangci-lint-action@v4 31 | with: 32 | # Required: the version of golangci-lint is required and must be specified without patch version: we always use the latest patch version. 33 | version: v1.56.1 34 | only-new-issues: true -------------------------------------------------------------------------------- /.github/workflows/go.yml: -------------------------------------------------------------------------------- 1 | name: Go build and test 2 | 3 | on: 4 | push: 5 | branches: [ master ] 6 | 7 | jobs: 8 | 9 | build: 10 | runs-on: ubuntu-latest 11 | steps: 12 | - uses: actions/checkout@v4 13 | - uses: actions/setup-go@v5 14 | with: 15 | go-version: '1.22' 16 | - name: Test 17 | run: go test -v ./... 18 | - name: Build 19 | run: go build -v ./... -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | ###Go### 2 | 3 | # Compiled Object files, Static and Dynamic libs (Shared Objects) 4 | *.o 5 | *.a 6 | *.so 7 | 8 | # Folders 9 | _obj 10 | _test 11 | 12 | # Architecture specific extensions/prefixes 13 | *.[568vq] 14 | [568vq].out 15 | 16 | *.cgo1.go 17 | *.cgo2.c 18 | _cgo_defun.c 19 | _cgo_gotypes.go 20 | _cgo_export.* 21 | 22 | _testmain.go 23 | 24 | *.exe 25 | *.test 26 | 27 | 28 | ###OSX### 29 | 30 | .DS_Store 31 | .AppleDouble 32 | .LSOverride 33 | 34 | # Icon must ends with two \r. 35 | Icon 36 | 37 | 38 | # Thumbnails 39 | ._* 40 | 41 | # Files that might appear on external disk 42 | .Spotlight-V100 43 | .Trashes 44 | 45 | # Profiling files 46 | *.pprof 47 | 48 | 49 | binaries 50 | cross 51 | dry 52 | bla 53 | Godeps/_workspace/ 54 | .vscode 55 | .idea 56 | .wercker 57 | .realize 58 | -------------------------------------------------------------------------------- /.godir: -------------------------------------------------------------------------------- 1 | github.com/moncho/dry 2 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: go 2 | sudo: false 3 | go: 4 | - 1.20 5 | env: 6 | - GO111MODULE=on 7 | script: 8 | - go test 9 | - go build 10 | -------------------------------------------------------------------------------- /APPVERSION: -------------------------------------------------------------------------------- 1 | 0.11.2 -------------------------------------------------------------------------------- /BUILD.md: -------------------------------------------------------------------------------- 1 | ### Build instructions 2 | 3 | If you just run what you compile, use the source. 4 | 5 | Clone the repo: 6 | ``` 7 | $ git clone https://github.com/moncho/dry.git 8 | $ cd dry 9 | ``` 10 | 11 | Build **dry**: 12 | ``` 13 | $ make install 14 | ``` 15 | -------------------------------------------------------------------------------- /DEBUG.md: -------------------------------------------------------------------------------- 1 | ### How to debug 2 | 3 | Debugging can be done using [godebug](https://github.com/mailgun/godebug). 4 | 5 | Install it, then insert a breakpoint anywhere you want: 6 | ``` 7 | _ = "breakpoint" 8 | ``` 9 | And run the debugger: 10 | ``` 11 | godebug run -instrument github.com/moncho/dry,github.com/moncho/dry/app,github.com/moncho/dry/docker,github.com/moncho/dry/ui,github.com/moncho/dry/appui main.go 12 | ``` 13 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | # build stage 2 | FROM alpine:latest AS build-phase 3 | 4 | LABEL VERSION 0.11.2 5 | 6 | RUN set -x && \ 7 | apk update && \ 8 | apk upgrade && \ 9 | apk add curl file && \ 10 | curl https://moncho.github.io/dry/dryup.sh | sh && \ 11 | apk del curl file && \ 12 | rm -rf /var/cache/apk/* && \ 13 | chmod 755 /usr/local/bin/dry 14 | 15 | # final stage 16 | FROM alpine 17 | WORKDIR /app 18 | COPY --from=build-phase /usr/local/bin/dry /app 19 | 20 | CMD sleep 1;/app/dry 21 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2019 Moncho 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 | -------------------------------------------------------------------------------- /RELEASE_NOTES.md: -------------------------------------------------------------------------------- 1 | ## v0.6-alpha.1 2 | 3 | This version of **dry** is the first one built using Go 1.7, which has resulted in a smaller binary size and maybe in some performance improvements (no measure has been done on this). 4 | 5 | The capability to use [termui](https://github.com/gizak/termui) widgets has been added. So far this has been used to add the container menu and to improve the stats screen. 6 | 7 | #### Improvements 8 | 9 | * Pressing [Enter] on a container now shows a menu with the all the commands that can be executed on a container. Existing keybinds still work, but this change should make it easier to explore what can be done with **dry** on a container. #18 10 | * Improve stats screen to show detailed container information and stats in a nicer way. 11 | * Remove dangling images with [Ctrl+D]. #19 12 | * Container inspect now binded to [I], was on [Enter]. 13 | 14 | #### Notices 15 | 16 | **dry** has been built using Go 1.7 (1.7rc5, the latest beta version available at the time of this writing). 17 | 18 | As stated in the 1.7 [release notes](https://tip.golang.org/doc/go1.7#compiler), changes in the compiler toolchain and standard libraries should result in smaller binaries. 19 | 20 | The following table shows a comparison of **dry** binary sizes (in bytes) using 1.6.3 and 1.7rc5. 21 | ``` 22 | os-cpu 1.6.3 1.7 Binary size decrease 23 | dry-darwin-amd64 9666128 7321376 -24,26% 24 | dry-freebsd-amd64 9670081 7350169 -23,99% 25 | dry-linux-amd64 9666625 7333489 -24,14% 26 | dry-windows-amd64 9664000 7298048 -24,48% 27 | dry-darwin-386 7629836 6464384 -15,27% 28 | dry-freebsd-386 7603305 6457457 -15,07% 29 | dry-linux-386 7652591 6473134 -15,41% 30 | dry-windows-386 7690752 6481920 -15,72% 31 | dry-freebsd-arm 7621922 6647331 -12,79% 32 | dry-linux-arm 7613761 6617809 -13,08% 33 | ``` 34 | 35 | So, changing to the **Go 1.7** has resulted in, on average, a 24% decrease in binary sizes for x86-64 architectures. Good job, Go team! 36 | -------------------------------------------------------------------------------- /app/config.go: -------------------------------------------------------------------------------- 1 | package app 2 | 3 | import ( 4 | "github.com/moncho/dry/docker" 5 | ) 6 | 7 | // Config dry initial configuration 8 | type Config struct { 9 | DockerHost string 10 | DockerCertPath string 11 | DockerTLSVerify bool 12 | MonitorMode bool 13 | MonitorRefreshRate int 14 | } 15 | 16 | func (c Config) dockerEnv() docker.Env { 17 | env := docker.NewEnv() 18 | env.DockerHost = c.DockerHost 19 | env.DockerTLSVerify = c.DockerTLSVerify 20 | env.DockerCertPath = c.DockerCertPath 21 | return env 22 | } 23 | -------------------------------------------------------------------------------- /app/df_events.go: -------------------------------------------------------------------------------- 1 | package app 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/gdamore/tcell" 7 | "github.com/moncho/dry/appui" 8 | "github.com/moncho/dry/ui" 9 | ) 10 | 11 | const ( 12 | confirmation = `WARNING! This will remove all unused data. Are you sure you want to continue? [y/N]` 13 | ) 14 | 15 | type diskUsageScreenEventHandler struct { 16 | baseEventHandler 17 | } 18 | 19 | func (h *diskUsageScreenEventHandler) handle(event *tcell.EventKey, f func(eventHandler)) { 20 | 21 | handled := false 22 | switch event.Key() { 23 | case tcell.KeyUp | tcell.KeyDown: 24 | //To avoid the base handler handling this 25 | handled = true 26 | } 27 | switch event.Rune() { 28 | case 'p', 'P': 29 | handled = true 30 | 31 | rw := appui.NewPrompt(confirmation) 32 | widgets.add(rw) 33 | forwarder := newEventForwarder() 34 | f(forwarder) 35 | refreshScreen() 36 | go func() { 37 | events := ui.EventSource{ 38 | Events: forwarder.events(), 39 | EventHandledCallback: func(e *tcell.EventKey) error { 40 | return refreshScreen() 41 | }, 42 | } 43 | refreshScreen() 44 | 45 | rw.OnFocus(events) 46 | widgets.remove(rw) 47 | confirmation, canceled := rw.Text() 48 | f(h) 49 | if canceled || (confirmation != "y" && confirmation != "Y") { 50 | return 51 | } 52 | 53 | pr, err := h.dry.dockerDaemon.Prune() 54 | if err == nil { 55 | if du, err := h.dry.dockerDaemon.DiskUsage(); err == nil { 56 | widgets.DiskUsage.PrepareToRender(&du, pr) 57 | } 58 | } else { 59 | h.dry.message( 60 | fmt.Sprintf( 61 | "Error running prune. %s", err)) 62 | } 63 | refreshScreen() 64 | }() 65 | } 66 | if !handled { 67 | h.baseEventHandler.handle(event, f) 68 | } 69 | 70 | } 71 | -------------------------------------------------------------------------------- /app/filter_event.go: -------------------------------------------------------------------------------- 1 | package app 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/moncho/dry/appui" 7 | "github.com/moncho/dry/ui" 8 | ) 9 | 10 | func showFilterInput(es ui.EventSource, onDone func(string, bool)) { 11 | rw := appui.NewPrompt("Filter? (blank to remove current filter)") 12 | widgets.add(rw) 13 | go func() { 14 | err := rw.OnFocus(es) 15 | if err != nil { 16 | fmt.Println(err) 17 | } 18 | widgets.remove(rw) 19 | onDone(rw.Text()) 20 | }() 21 | } 22 | -------------------------------------------------------------------------------- /app/loop.go: -------------------------------------------------------------------------------- 1 | package app 2 | 3 | import ( 4 | "sync" 5 | "time" 6 | 7 | "github.com/gdamore/tcell" 8 | "github.com/moncho/dry/ui" 9 | log "github.com/sirupsen/logrus" 10 | ) 11 | 12 | var refreshScreen func() error 13 | var refreshIfView func(v viewMode) error 14 | var widgets *widgetRegistry 15 | 16 | type nextHandler func(eh eventHandler) 17 | 18 | // RenderLoop runs dry 19 | // nolint: gocyclo 20 | func RenderLoop(dry *Dry) { 21 | if ok, err := dry.Ok(); !ok { 22 | log.Error(err.Error()) 23 | return 24 | } 25 | screen := dry.screen 26 | termuiEvents, done := ui.EventChannel() 27 | 28 | //use to signal rendering 29 | renderChan := make(chan struct{}) 30 | 31 | var closingLock sync.RWMutex 32 | refreshScreen = func() error { 33 | closingLock.RLock() 34 | defer closingLock.RUnlock() 35 | 36 | renderChan <- struct{}{} 37 | return nil 38 | } 39 | 40 | refreshIfView = func(v viewMode) error { 41 | if v == dry.viewMode() { 42 | return refreshScreen() 43 | } 44 | return nil 45 | } 46 | 47 | dryOutputChan := dry.OutputChannel() 48 | 49 | var wg sync.WaitGroup 50 | wg.Add(1) 51 | go func() { 52 | defer wg.Done() 53 | 54 | for range renderChan { 55 | if !screen.Closing() { 56 | screen.Clear() 57 | render(dry, screen) 58 | } 59 | } 60 | }() 61 | 62 | refreshScreen() 63 | 64 | go func() { 65 | statusBar := widgets.MessageBar 66 | for { 67 | select { 68 | case dryMessage, ok := <-dryOutputChan: 69 | if ok { 70 | statusBar.Message(dryMessage, 10*time.Second) 71 | statusBar.Render() 72 | } else { 73 | return 74 | } 75 | } 76 | } 77 | }() 78 | 79 | handler := viewsToHandlers[dry.viewMode()] 80 | //main loop that handles termui events 81 | loop: 82 | for event := range termuiEvents { 83 | switch ev := event.(type) { 84 | case *tcell.EventInterrupt: 85 | break loop 86 | case *tcell.EventKey: 87 | //Ctrl+C breaks the loop (and exits dry) no matter what 88 | if ev.Key() == tcell.KeyCtrlC || ev.Rune() == 'Q' { 89 | break loop 90 | } 91 | handler.handle(ev, func(eh eventHandler) { 92 | handler = eh 93 | }) 94 | 95 | case *tcell.EventResize: 96 | screen.Resize() 97 | //Reload dry ui elements 98 | //TODO widgets.reload() 99 | } 100 | } 101 | 102 | log.Debug("something broke the loop. Time to die") 103 | 104 | //Close terminal event channel 105 | close(done) 106 | //make the global refreshScreen func a noop before closing 107 | closingLock.Lock() 108 | refreshScreen = func() error { 109 | return nil 110 | } 111 | closingLock.Unlock() 112 | 113 | //Close the channel used to notify the rendering goroutine 114 | close(renderChan) 115 | //Wait for the rendering goroutine to exit 116 | wg.Wait() 117 | } 118 | -------------------------------------------------------------------------------- /app/misc.go: -------------------------------------------------------------------------------- 1 | package app 2 | 3 | import ( 4 | "strings" 5 | 6 | "github.com/gdamore/tcell" 7 | "github.com/moncho/dry/appui" 8 | "github.com/moncho/dry/ui" 9 | ) 10 | 11 | func logsPrompt() *appui.Prompt { 12 | return appui.NewPrompt("Show logs since timestamp (e.g. 2013-01-02T13:23:37) or relative (e.g. 42m for 42 minutes) or leave empty") 13 | } 14 | 15 | func newEventSource(events <-chan *tcell.EventKey) ui.EventSource { 16 | return ui.EventSource{ 17 | Events: events, 18 | EventHandledCallback: func(e *tcell.EventKey) error { 19 | return refreshScreen() 20 | }, 21 | } 22 | } 23 | 24 | func inspect( 25 | screen *ui.Screen, 26 | events <-chan *tcell.EventKey, 27 | inspect func(id string) (interface{}, error), 28 | onClose func()) func(id string) error { 29 | return func(id string) error { 30 | inspected, err := inspect(id) 31 | if err != nil { 32 | return err 33 | } 34 | renderer := appui.NewJSONRenderer(inspected) 35 | go appui.Less(renderer.String(), screen, events, onClose) 36 | return nil 37 | } 38 | } 39 | 40 | func curateLogsDuration(s string) string { 41 | neg := strings.Index(s, "-") 42 | if neg >= 0 { 43 | return s[neg+1:] 44 | } 45 | return s 46 | 47 | } 48 | -------------------------------------------------------------------------------- /app/misc_test.go: -------------------------------------------------------------------------------- 1 | package app 2 | 3 | import "testing" 4 | 5 | func Test_curateLogsDuration(t *testing.T) { 6 | type args struct { 7 | s string 8 | } 9 | tests := []struct { 10 | name string 11 | args args 12 | want string 13 | }{ 14 | { 15 | "-2", 16 | args{ 17 | "-2", 18 | }, 19 | "2", 20 | }, 21 | { 22 | "2", 23 | args{ 24 | "2", 25 | }, 26 | "2", 27 | }, 28 | } 29 | for _, tt := range tests { 30 | t.Run(tt.name, func(t *testing.T) { 31 | if got := curateLogsDuration(tt.args.s); got != tt.want { 32 | t.Errorf("curateLogsDuration() = %v, want %v", got, tt.want) 33 | } 34 | }) 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /app/node_events.go: -------------------------------------------------------------------------------- 1 | package app 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/gdamore/tcell" 7 | "github.com/moncho/dry/appui" 8 | "github.com/moncho/dry/appui/swarm" 9 | "github.com/moncho/dry/docker" 10 | "github.com/moncho/dry/ui" 11 | ) 12 | 13 | type nodesScreenEventHandler struct { 14 | baseEventHandler 15 | widget *swarm.NodesWidget 16 | } 17 | 18 | func (h *nodesScreenEventHandler) handle(event *tcell.EventKey, f func(eventHandler)) { 19 | 20 | handled := false 21 | 22 | switch event.Key() { 23 | case tcell.KeyF1: //sort 24 | handled = true 25 | widgets.Nodes.Sort() 26 | case tcell.KeyF5: // refresh 27 | h.widget.Unmount() 28 | handled = true 29 | case tcell.KeyCtrlA: 30 | dry := h.dry 31 | rw := appui.NewPrompt("Changing node availability, please type one of ('active'|'pause'|'drain')") 32 | forwarder := newEventForwarder() 33 | f(forwarder) 34 | refreshScreen() 35 | handled = true 36 | widgets.add(rw) 37 | go func() { 38 | events := ui.EventSource{ 39 | Events: forwarder.events(), 40 | EventHandledCallback: func(e *tcell.EventKey) error { 41 | return refreshScreen() 42 | }, 43 | } 44 | rw.OnFocus(events) 45 | widgets.remove(rw) 46 | availability, canceled := rw.Text() 47 | f(h) 48 | if canceled { 49 | return 50 | } 51 | if availability != "active" && availability != "pause" && availability != "drain" { 52 | dry.message(fmt.Sprintf("Invalid availability: %s", availability)) 53 | return 54 | } 55 | 56 | changeNode := func(nodeID string) error { 57 | err := dry.dockerDaemon.NodeChangeAvailability( 58 | nodeID, 59 | docker.NewNodeAvailability(availability)) 60 | 61 | if err == nil { 62 | dry.message(fmt.Sprintf("Node %s availability is now %s", nodeID, availability)) 63 | } else { 64 | dry.message(fmt.Sprintf("Could not change node availability, error %s", err.Error())) 65 | return err 66 | } 67 | return refreshScreen() 68 | } 69 | h.widget.OnEvent(changeNode) 70 | }() 71 | 72 | case tcell.KeyEnter: 73 | showServices := func(nodeID string) error { 74 | h.screen.Cursor().Reset() 75 | widgets.NodeTasks.ForNode(nodeID) 76 | h.dry.changeView(Tasks) 77 | f(viewsToHandlers[Tasks]) 78 | return refreshScreen() 79 | } 80 | h.widget.OnEvent(showServices) 81 | handled = true 82 | } 83 | if !handled { 84 | switch event.Rune() { 85 | case '%': 86 | handled = true 87 | forwarder := newEventForwarder() 88 | f(forwarder) 89 | applyFilter := func(filter string, canceled bool) { 90 | if !canceled { 91 | h.widget.Filter(filter) 92 | } 93 | f(h) 94 | } 95 | showFilterInput(newEventSource(forwarder.events()), applyFilter) 96 | } 97 | } 98 | if !handled { 99 | h.baseEventHandler.handle(event, f) 100 | } else { 101 | refreshScreen() 102 | } 103 | } 104 | -------------------------------------------------------------------------------- /app/nodetasks_events.go: -------------------------------------------------------------------------------- 1 | package app 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/gdamore/tcell" 7 | "github.com/moncho/dry/appui/swarm" 8 | ) 9 | 10 | type taskScreenEventHandler struct { 11 | baseEventHandler 12 | widget *swarm.NodeTasksWidget 13 | } 14 | 15 | func (h *taskScreenEventHandler) handle(event *tcell.EventKey, f func(eventHandler)) { 16 | 17 | handled := true 18 | switch event.Key() { 19 | case tcell.KeyEsc: 20 | f(viewsToHandlers[Nodes]) 21 | h.dry.changeView(Nodes) 22 | case tcell.KeyF1: //sort 23 | widgets.NodeTasks.Sort() 24 | case tcell.KeyF5: // refresh 25 | h.widget.Unmount() 26 | case tcell.KeyEnter: 27 | forwarder := newEventForwarder() 28 | f(forwarder) 29 | if err := h.widget.OnEvent( 30 | inspect( 31 | h.screen, 32 | forwarder.events(), 33 | func(id string) (interface{}, error) { 34 | return h.dry.dockerDaemon.Task(id) 35 | }, 36 | func() { 37 | h.dry.changeView(Tasks) 38 | f(h) 39 | refreshScreen() 40 | })); err != nil { 41 | h.dry.message( 42 | fmt.Sprintf("Error inspecting stack: %s", err.Error())) 43 | } 44 | default: 45 | handled = false 46 | } 47 | if !handled { 48 | switch event.Rune() { 49 | case '%': 50 | handled = true 51 | forwarder := newEventForwarder() 52 | f(forwarder) 53 | refreshScreen() 54 | applyFilter := func(filter string, canceled bool) { 55 | if !canceled { 56 | h.widget.Filter(filter) 57 | } 58 | f(h) 59 | } 60 | showFilterInput(newEventSource(forwarder.events()), applyFilter) 61 | } 62 | } 63 | if !handled { 64 | h.baseEventHandler.handle(event, f) 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /app/servicetasks_events.go: -------------------------------------------------------------------------------- 1 | package app 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/gdamore/tcell" 7 | "github.com/moncho/dry/appui/swarm" 8 | ) 9 | 10 | type serviceTasksScreenEventHandler struct { 11 | baseEventHandler 12 | widget *swarm.ServiceTasksWidget 13 | } 14 | 15 | func (h *serviceTasksScreenEventHandler) handle(event *tcell.EventKey, f func(eventHandler)) { 16 | handled := true 17 | 18 | switch event.Key() { 19 | case tcell.KeyEsc: 20 | f(viewsToHandlers[Services]) 21 | h.dry.changeView(Services) 22 | refreshScreen() 23 | case tcell.KeyF1: //sort 24 | widgets.ServiceTasks.Sort() 25 | case tcell.KeyF5: // refresh 26 | h.widget.Unmount() 27 | case tcell.KeyEnter: 28 | forwarder := newEventForwarder() 29 | f(forwarder) 30 | if err := h.widget.OnEvent( 31 | inspect( 32 | h.screen, 33 | forwarder.events(), 34 | func(id string) (interface{}, error) { 35 | return h.dry.dockerDaemon.Task(id) 36 | }, 37 | func() { 38 | h.dry.changeView(ServiceTasks) 39 | f(h) 40 | refreshScreen() 41 | })); err != nil { 42 | h.dry.message( 43 | fmt.Sprintf("Error inspecting stack: %s", err.Error())) 44 | } 45 | 46 | default: 47 | handled = false 48 | } 49 | if !handled { 50 | switch event.Rune() { 51 | case '%': 52 | handled = true 53 | forwarder := newEventForwarder() 54 | f(forwarder) 55 | refreshScreen() 56 | applyFilter := func(filter string, canceled bool) { 57 | if !canceled { 58 | h.widget.Filter(filter) 59 | } 60 | f(h) 61 | } 62 | showFilterInput(newEventSource(forwarder.events()), applyFilter) 63 | } 64 | } 65 | if !handled { 66 | h.baseEventHandler.handle(event, f) 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /app/stack_events.go: -------------------------------------------------------------------------------- 1 | package app 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/gdamore/tcell" 7 | "github.com/moncho/dry/appui" 8 | "github.com/moncho/dry/appui/swarm" 9 | "github.com/moncho/dry/ui" 10 | ) 11 | 12 | type stacksScreenEventHandler struct { 13 | baseEventHandler 14 | widget *swarm.StacksWidget 15 | } 16 | 17 | func (h *stacksScreenEventHandler) handle(event *tcell.EventKey, f func(eventHandler)) { 18 | handled := true 19 | switch event.Key() { 20 | case tcell.KeyF1: //sort 21 | widgets.Stacks.Sort() 22 | case tcell.KeyF5: // refresh 23 | h.dry.message("Refreshing stack list") 24 | h.widget.Unmount() 25 | case tcell.KeyEnter: //inspect 26 | showTasks := func(stack string) error { 27 | widgets.StackTasks.ForStack(stack) 28 | h.dry.changeView(StackTasks) 29 | f(viewsToHandlers[StackTasks]) 30 | return refreshScreen() 31 | } 32 | h.widget.OnEvent(showTasks) 33 | case tcell.KeyCtrlR: //remove stack 34 | rw := appui.NewPrompt("The selected stack will be removed. Do you want to proceed? y/N") 35 | widgets.add(rw) 36 | forwarder := newEventForwarder() 37 | f(forwarder) 38 | refreshScreen() 39 | go func() { 40 | events := ui.EventSource{ 41 | Events: forwarder.events(), 42 | EventHandledCallback: func(e *tcell.EventKey) error { 43 | return refreshScreen() 44 | }, 45 | } 46 | rw.OnFocus(events) 47 | widgets.remove(rw) 48 | confirmation, canceled := rw.Text() 49 | f(h) 50 | if canceled || (confirmation != "y" && confirmation != "Y") { 51 | return 52 | } 53 | removeStack := func(stack string) error { 54 | err := h.dry.dockerDaemon.StackRemove(stack) 55 | if err == nil { 56 | h.dry.message(fmt.Sprintf("Stack %s removed", stack)) 57 | } 58 | return err 59 | } 60 | if err := h.widget.OnEvent(removeStack); err != nil { 61 | h.dry.message("There was an error removing the stack: " + err.Error()) 62 | } 63 | refreshScreen() 64 | }() 65 | default: 66 | handled = false 67 | } 68 | if !handled { 69 | switch event.Rune() { 70 | case '7': 71 | //already in stack screen 72 | handled = true 73 | case '%': 74 | handled = true 75 | forwarder := newEventForwarder() 76 | f(forwarder) 77 | applyFilter := func(filter string, canceled bool) { 78 | if !canceled { 79 | h.widget.Filter(filter) 80 | } 81 | f(h) 82 | } 83 | showFilterInput(newEventSource(forwarder.events()), applyFilter) 84 | } 85 | } 86 | if handled { 87 | refreshScreen() 88 | } else { 89 | h.baseEventHandler.handle(event, f) 90 | } 91 | } 92 | -------------------------------------------------------------------------------- /app/stacktasks_events.go: -------------------------------------------------------------------------------- 1 | package app 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/gdamore/tcell" 7 | "github.com/moncho/dry/appui/swarm" 8 | ) 9 | 10 | type stackTasksScreenEventHandler struct { 11 | baseEventHandler 12 | widget *swarm.StacksTasksWidget 13 | } 14 | 15 | func (h *stackTasksScreenEventHandler) handle(event *tcell.EventKey, f func(eventHandler)) { 16 | handled := true 17 | 18 | switch event.Key() { 19 | case tcell.KeyEsc: 20 | f(viewsToHandlers[Stacks]) 21 | h.dry.changeView(Stacks) 22 | case tcell.KeyF1: //sort 23 | h.widget.Sort() 24 | case tcell.KeyF5: // refresh 25 | h.dry.message("Refreshing stack tasks list") 26 | h.widget.Unmount() 27 | case tcell.KeyEnter: 28 | forwarder := newEventForwarder() 29 | f(forwarder) 30 | if err := h.widget.OnEvent( 31 | inspect( 32 | h.screen, 33 | forwarder.events(), 34 | func(id string) (interface{}, error) { 35 | return h.dry.dockerDaemon.Task(id) 36 | }, 37 | func() { 38 | h.dry.changeView(StackTasks) 39 | f(h) 40 | refreshScreen() 41 | })); err != nil { 42 | h.dry.message( 43 | fmt.Sprintf("Error inspecting stack: %s", err.Error())) 44 | } 45 | default: 46 | handled = false 47 | } 48 | switch event.Rune() { 49 | case '%': 50 | handled = true 51 | forwarder := newEventForwarder() 52 | f(forwarder) 53 | applyFilter := func(filter string, canceled bool) { 54 | if !canceled { 55 | h.widget.Filter(filter) 56 | } 57 | f(h) 58 | } 59 | showFilterInput(newEventSource(forwarder.events()), applyFilter) 60 | } 61 | 62 | if handled { 63 | refreshScreen() 64 | } else { 65 | h.baseEventHandler.handle(event, f) 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /app/view.go: -------------------------------------------------------------------------------- 1 | package app 2 | 3 | // viewMode represents dry possible views 4 | type viewMode uint16 5 | 6 | // existing view modes 7 | const ( 8 | Main viewMode = iota //This is the container list view 9 | DiskUsage 10 | Images 11 | Monitor 12 | Networks 13 | EventsMode 14 | HelpMode 15 | InfoMode 16 | Nodes 17 | Services 18 | ServiceTasks 19 | Stacks 20 | StackTasks 21 | Tasks 22 | ContainerMenu 23 | Volumes 24 | NoView 25 | ) 26 | -------------------------------------------------------------------------------- /app/widget_registry.go: -------------------------------------------------------------------------------- 1 | package app 2 | 3 | import ( 4 | "sync" 5 | 6 | "github.com/moncho/dry/appui" 7 | "github.com/moncho/dry/appui/swarm" 8 | "github.com/moncho/dry/ui" 9 | "github.com/moncho/dry/ui/termui" 10 | ) 11 | 12 | // widgetRegistry holds references to two types of widgets: 13 | // - widgets that hold information that does not change or widgets 14 | // that hold information that is worth updating only when is changed. 15 | // These are all the widget tracked with a field in the struct. 16 | // - a set of widgets to be rendered on the next rendering phase. 17 | type widgetRegistry struct { 18 | ContainerList *appui.ContainersWidget 19 | ContainerMenu *appui.ContainerMenuWidget 20 | DiskUsage *appui.DockerDiskUsageRenderer 21 | DockerInfo *appui.DockerInfo 22 | ImageList *appui.DockerImagesWidget 23 | MessageBar *ui.ExpiringMessageWidget 24 | Monitor *appui.Monitor 25 | Networks *appui.DockerNetworksWidget 26 | Nodes *swarm.NodesWidget 27 | NodeTasks *swarm.NodeTasksWidget 28 | ServiceTasks *swarm.ServiceTasksWidget 29 | ServiceList *swarm.ServicesWidget 30 | Stacks *swarm.StacksWidget 31 | StackTasks *swarm.StacksTasksWidget 32 | Volumes *appui.VolumesWidget 33 | sync.RWMutex 34 | widgets map[string]termui.Widget 35 | } 36 | 37 | func (wr *widgetRegistry) add(w termui.Widget) error { 38 | wr.Lock() 39 | defer wr.Unlock() 40 | err := w.Mount() 41 | if err == nil { 42 | wr.widgets[w.Name()] = w 43 | } 44 | return err 45 | } 46 | 47 | func (wr *widgetRegistry) remove(w termui.Widget) error { 48 | wr.Lock() 49 | defer wr.Unlock() 50 | delete(wr.widgets, w.Name()) 51 | return w.Unmount() 52 | } 53 | 54 | func (wr *widgetRegistry) activeWidgets() []termui.Widget { 55 | wr.RLock() 56 | defer wr.RUnlock() 57 | widgets := make([]termui.Widget, len(wr.widgets)) 58 | i := 0 59 | for _, widget := range wr.widgets { 60 | widgets[i] = widget 61 | i++ 62 | } 63 | return widgets 64 | } 65 | func (wr *widgetRegistry) reload() { 66 | 67 | } 68 | -------------------------------------------------------------------------------- /appui/appui.go: -------------------------------------------------------------------------------- 1 | package appui 2 | 3 | import "fmt" 4 | 5 | type invalidRow struct { 6 | selected int 7 | max int 8 | } 9 | 10 | // Error returns the error message 11 | func (i invalidRow) Error() string { 12 | return fmt.Sprintf("invalid row index (selected: %d) (max: %d)", i.selected, i.max) 13 | } 14 | -------------------------------------------------------------------------------- /appui/container.go: -------------------------------------------------------------------------------- 1 | package appui 2 | 3 | import ( 4 | "bytes" 5 | "strconv" 6 | 7 | "github.com/moncho/dry/docker" 8 | "github.com/moncho/dry/docker/formatter" 9 | "github.com/moncho/dry/ui" 10 | "github.com/olekukonko/tablewriter" 11 | ) 12 | 13 | var newLine = []byte{'\n'} 14 | 15 | // NewContainerInfo returns detailed container information. Returned int value 16 | // is the number of lines. 17 | func NewContainerInfo(container *docker.Container) (string, int) { 18 | var buffer bytes.Buffer 19 | var status string 20 | if docker.IsContainerRunning(container) { 21 | status = ui.Yellow(container.Status) 22 | } else { 23 | status = ui.Red(container.Status) 24 | } 25 | data := [][]string{ 26 | {ui.Blue("Container Name:"), ui.Yellow(container.Names[0]), ui.Blue("ID:"), ui.Yellow(docker.TruncateID(container.ID)), ui.Blue("Status:"), status}, 27 | {ui.Blue("Image:"), ui.Yellow(container.Image), ui.Blue("Created:"), ui.Yellow(docker.DurationForHumans(container.Created) + " ago")}, 28 | {ui.Blue("Command:"), ui.Yellow(container.Command)}, 29 | {ui.Blue("Port mapping:"), ui.Yellow(formatter.DisplayablePorts(container.Ports))}, 30 | } 31 | var networkNames []string 32 | var networkIps []string 33 | var networkIpv6s []string 34 | for k, v := range container.Container.NetworkSettings.Networks { 35 | networkNames = append(networkNames, ui.Blue("Network Name: ")) 36 | networkNames = append(networkNames, ui.Yellow(k)) 37 | networkIps = append(networkIps, ui.Blue("\tIP Address:")) 38 | networkIps = append(networkIps, ui.Yellow(v.IPAddress)) 39 | if v.GlobalIPv6Address != "" { 40 | networkIpv6s = append(networkIpv6s, ui.Blue("\tIPv6 Address:")) 41 | networkIpv6s = append(networkIpv6s, ui.Yellow(v.GlobalIPv6Address + "/" + strconv.Itoa(v.GlobalIPv6PrefixLen))) 42 | } 43 | } 44 | data = append(data, networkNames) 45 | data = append(data, networkIps) 46 | data = append(data, networkIpv6s) 47 | 48 | data = append(data, []string{ui.Blue("Labels"), ui.Yellow( 49 | strconv.Itoa(len(container.Labels)))}) 50 | 51 | table := tablewriter.NewWriter(&buffer) 52 | table.SetBorder(false) 53 | table.SetColumnSeparator("") 54 | table.SetAlignment(tablewriter.ALIGN_LEFT) 55 | table.AppendBulk(data) 56 | table.Render() 57 | res := buffer.Bytes() 58 | 59 | return string(res), bytes.Count(res, newLine) 60 | } 61 | -------------------------------------------------------------------------------- /appui/container_details.go: -------------------------------------------------------------------------------- 1 | package appui 2 | 3 | import ( 4 | termui "github.com/gizak/termui" 5 | "github.com/moncho/dry/docker" 6 | drytermui "github.com/moncho/dry/ui/termui" 7 | ) 8 | 9 | // ContainerDetailsWidget shows service information 10 | type ContainerDetailsWidget struct { 11 | drytermui.SizableBufferer 12 | } 13 | 14 | // NewContainerDetailsWidget creates ContainerDetailsWidget with information about the service with the given ID 15 | func NewContainerDetailsWidget(container *docker.Container, y int) *ContainerDetailsWidget { 16 | info, lines := NewContainerInfo(container) 17 | 18 | cInfo := drytermui.NewParFromMarkupText(DryTheme, info) 19 | cInfo.Y = y 20 | cInfo.Height = lines + 1 21 | cInfo.BorderLeft = false 22 | cInfo.BorderRight = false 23 | cInfo.BorderTop = false 24 | 25 | cInfo.Bg = termui.Attribute(DryTheme.Bg) 26 | cInfo.BorderBg = termui.Attribute(DryTheme.Bg) 27 | cInfo.BorderFg = termui.Attribute(DryTheme.Footer) 28 | cInfo.TextBgColor = termui.Attribute(DryTheme.Bg) 29 | 30 | return &ContainerDetailsWidget{cInfo} 31 | } 32 | -------------------------------------------------------------------------------- /appui/container_details_test.go: -------------------------------------------------------------------------------- 1 | package appui 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/docker/docker/api/types" 7 | "github.com/docker/docker/api/types/network" 8 | "github.com/moncho/dry/docker" 9 | ) 10 | 11 | func TestNewContainerDetailsWidget(t *testing.T) { 12 | type args struct { 13 | container *docker.Container 14 | y int 15 | } 16 | tests := []struct { 17 | name string 18 | args args 19 | }{ 20 | { 21 | "a widget with container details can be created", 22 | args{ 23 | &docker.Container{ 24 | Container: types.Container{ 25 | Names: []string{"container1"}, 26 | NetworkSettings: &types.SummaryNetworkSettings{ 27 | Networks: make(map[string]*network.EndpointSettings), 28 | }, 29 | }, 30 | ContainerJSON: types.ContainerJSON{ 31 | NetworkSettings: &types.NetworkSettings{ 32 | Networks: make(map[string]*network.EndpointSettings), 33 | }, 34 | }, 35 | }, 36 | 0, 37 | }, 38 | }, 39 | } 40 | for _, tt := range tests { 41 | t.Run(tt.name, func(t *testing.T) { 42 | w := NewContainerDetailsWidget(tt.args.container, tt.args.y) 43 | 44 | if w == nil { 45 | t.Error("ContainerDetailsWidget was not created") 46 | } 47 | }) 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /appui/container_menu_test.go: -------------------------------------------------------------------------------- 1 | package appui 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/google/go-cmp/cmp" 7 | "github.com/google/go-cmp/cmp/cmpopts" 8 | "github.com/moncho/dry/ui/termui" 9 | ) 10 | 11 | func TestContainerMenuWidget_OnEvent(t *testing.T) { 12 | type fields struct { 13 | rows []*Row 14 | selectedIndex int 15 | } 16 | type args struct { 17 | event EventCommand 18 | } 19 | tests := []struct { 20 | name string 21 | fields fields 22 | args args 23 | wantErr error 24 | }{ 25 | { 26 | name: "happy path", 27 | fields: fields{ 28 | rows: []*Row{ 29 | { 30 | ParColumns: []*termui.ParColumn{ 31 | {}, 32 | }, 33 | }, 34 | { 35 | ParColumns: []*termui.ParColumn{ 36 | {}, 37 | }, 38 | }, 39 | }, 40 | selectedIndex: 1, 41 | }, 42 | args: args{ 43 | event: func(string) error { return nil }, 44 | }, 45 | wantErr: nil, 46 | }, 47 | { 48 | name: "no rows in menu", 49 | wantErr: invalidRow{selected: 0, max: 0}, 50 | }, 51 | { 52 | name: "selected index is out of range", 53 | fields: fields{ 54 | rows: []*Row{ 55 | {}, 56 | }, 57 | selectedIndex: 2, 58 | }, 59 | wantErr: invalidRow{selected: 2, max: 1}, 60 | }, 61 | } 62 | for _, tt := range tests { 63 | t.Run(tt.name, func(t *testing.T) { 64 | s := &ContainerMenuWidget{ 65 | rows: tt.fields.rows, 66 | selectedIndex: tt.fields.selectedIndex, 67 | } 68 | err := s.OnEvent(tt.args.event) 69 | if diff := cmp.Diff(err, tt.wantErr, cmpopts.EquateErrors()); diff != "" { 70 | t.Errorf("ContainerMenuWidget.OnEvent() error = %v, wantErr %v", err, tt.wantErr) 71 | } 72 | }) 73 | } 74 | } 75 | -------------------------------------------------------------------------------- /appui/disk_usage_test.go: -------------------------------------------------------------------------------- 1 | package appui 2 | 3 | import ( 4 | "os" 5 | "path/filepath" 6 | "testing" 7 | "time" 8 | 9 | "github.com/docker/docker/api/types" 10 | "github.com/moncho/dry/docker" 11 | ) 12 | 13 | const ( 14 | screenHeight = 20 15 | ) 16 | 17 | func TestDiskUsageRendererCreation(t *testing.T) { 18 | 19 | r := NewDockerDiskUsageRenderer(screenHeight) 20 | 21 | if r == nil { 22 | t.Error("DiskUsageRenderer was not created") 23 | } 24 | 25 | if r.height != screenHeight { 26 | t.Errorf("DiskUsageRenderer was not initialized correctly: %v", r) 27 | } 28 | } 29 | 30 | func TestDockerDiskUsageRenderer_Render(t *testing.T) { 31 | type args struct { 32 | diskUsage *types.DiskUsage 33 | pruneReport *docker.PruneReport 34 | timeStamp string 35 | } 36 | tests := []struct { 37 | name string 38 | args args 39 | }{ 40 | { 41 | "DiskUsageTest_noPruneReport", 42 | args{ 43 | diskUsage: &types.DiskUsage{}, 44 | }, 45 | }, 46 | { 47 | "DiskUsageTest", 48 | args{ 49 | diskUsage: &types.DiskUsage{}, 50 | pruneReport: &docker.PruneReport{}, 51 | timeStamp: "1970-Jan-01", 52 | }, 53 | }, 54 | } 55 | for _, tt := range tests { 56 | t.Run(tt.name, func(t *testing.T) { 57 | 58 | r := NewDockerDiskUsageRenderer(screenHeight) 59 | 60 | r.PrepareToRender(tt.args.diskUsage, tt.args.pruneReport) 61 | //Last prune timestamp is manually set for testing 62 | timeStamp, _ := time.Parse("2006-Jan-02", tt.args.timeStamp) 63 | r.lastPrune = timeStamp 64 | actual := r.String() 65 | 66 | golden := filepath.Join("testdata", tt.name+".golden") 67 | if *update { 68 | os.WriteFile(golden, []byte(actual), 0644) 69 | } 70 | expected, _ := os.ReadFile(golden) 71 | if string(expected) != actual { 72 | t.Errorf("DockerDiskUsageRenderer.Render(), got: \n%v\nWant: \n%s", actual, expected) 73 | } 74 | }) 75 | } 76 | } 77 | -------------------------------------------------------------------------------- /appui/docker_info_test.go: -------------------------------------------------------------------------------- 1 | package appui 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/moncho/dry/mocks" 7 | ) 8 | 9 | var expectedDockerInfoWithSwarm = ` Docker Host: dry.io Docker Version: 1.0 Hostname: test Swarm: active 10 | Cert Path: APIVersion: 1.27 CPU: 2 Node role: manager 11 | Verify Certificate: false OS/Arch/Kernel: dry/amd64/42 Memory: 1KiB Nodes: 0 12 | ` 13 | 14 | var expectedDockerInfoNoSwarm = ` Docker Host: dry.io Docker Version: 1.0 Hostname: test Swarm: inactive 15 | Cert Path: APIVersion: 1.27 CPU: 2 16 | Verify Certificate: false OS/Arch/Kernel: dry/amd64/42 Memory: 1KiB 17 | ` 18 | 19 | func TestDockerInfo(t *testing.T) { 20 | daemon := &mocks.DockerDaemonMock{} 21 | di := NewDockerInfo(daemon) 22 | 23 | if di == nil { 24 | t.Error("Docker info widget is nil") 25 | } 26 | content := di.Buffer() 27 | if content.Area.Dy() != di.GetHeight() { 28 | t.Error("Docker info widget does not have the expected height") 29 | } 30 | 31 | if len(content.CellMap) == 0 { 32 | t.Errorf("Docker info widget does not have the expected content: %v", content.CellMap) 33 | } 34 | } 35 | 36 | func TestNoSwarmDockerInfoContent(t *testing.T) { 37 | daemon := &mocks.DockerDaemonMock{} 38 | di := dockerInfo(daemon) 39 | 40 | if di == "" { 41 | t.Error("Docker info is empty") 42 | } 43 | 44 | if di != expectedDockerInfoNoSwarm { 45 | t.Errorf("Docker info output does not match. Expected: \n'%q'\n, got: \n'%q'", expectedDockerInfoNoSwarm, di) 46 | } 47 | } 48 | 49 | func TestSwarmDockerInfoContent(t *testing.T) { 50 | daemon := &mocks.SwarmDockerDaemon{} 51 | di := dockerInfo(daemon) 52 | 53 | if di == "" { 54 | t.Error("Docker info is empty") 55 | } 56 | 57 | if di != expectedDockerInfoWithSwarm { 58 | t.Errorf("Docker info output does not match. Expected: \n'%q'\n, got: \n'%q'", expectedDockerInfoWithSwarm, di) 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /appui/events.go: -------------------------------------------------------------------------------- 1 | package appui 2 | 3 | import ( 4 | "bytes" 5 | "fmt" 6 | "io" 7 | "sort" 8 | "strings" 9 | "text/tabwriter" 10 | "time" 11 | 12 | "github.com/docker/docker/api/types/events" 13 | ) 14 | 15 | const ( 16 | // RFC3339NanoFixed is our own version of RFC339Nano because we want one 17 | // that pads the nano seconds part with zeros to ensure 18 | // the timestamps are aligned in the logs. 19 | RFC3339NanoFixed = "2006-01-02T15:04:05.000000000Z07:00" 20 | ) 21 | 22 | type eventsRenderer struct { 23 | events []events.Message 24 | } 25 | 26 | // NewDockerEventsRenderer creates a renderer for docker events 27 | func NewDockerEventsRenderer(events []events.Message) fmt.Stringer { 28 | return &eventsRenderer{ 29 | events: events, 30 | } 31 | } 32 | 33 | func (r *eventsRenderer) String() string { 34 | buf := bytes.NewBufferString("") 35 | 36 | w := tabwriter.NewWriter(buf, 20, 1, 3, ' ', 0) 37 | io.WriteString(w, "\n") 38 | 39 | io.WriteString(w, "EVENTS - showing the last 10 events\n\n") 40 | 41 | if len(r.events) == 0 { 42 | io.WriteString(w, "Docker daemon has not reported events.\n\n") 43 | } 44 | for _, event := range r.events { 45 | printEvent(w, event) 46 | } 47 | w.Flush() 48 | return buf.String() 49 | } 50 | 51 | func printEvent(w io.Writer, event events.Message) { 52 | io.WriteString(w, "") 53 | 54 | if event.TimeNano != 0 { 55 | fmt.Fprintf(w, "%s ", time.Unix(0, event.TimeNano).Format(RFC3339NanoFixed)) 56 | } else if event.Time != 0 { 57 | fmt.Fprintf(w, "%s ", time.Unix(event.Time, 0).Format(RFC3339NanoFixed)) 58 | } 59 | 60 | fmt.Fprintf(w, "%s %s %s", event.Type, event.Action, event.Actor.ID) 61 | 62 | if len(event.Actor.Attributes) > 0 { 63 | var attrs []string 64 | var keys []string 65 | for k := range event.Actor.Attributes { 66 | keys = append(keys, k) 67 | } 68 | sort.Strings(keys) 69 | for _, k := range keys { 70 | v := event.Actor.Attributes[k] 71 | attrs = append(attrs, fmt.Sprintf("%s=%s", k, v)) 72 | } 73 | fmt.Fprintf(w, " (%s)", strings.Join(attrs, ", ")) 74 | } 75 | fmt.Fprint(w, "\n\n") 76 | } 77 | -------------------------------------------------------------------------------- /appui/header.go: -------------------------------------------------------------------------------- 1 | package appui 2 | 3 | import ( 4 | "fmt" 5 | "strings" 6 | 7 | gizaktermui "github.com/gizak/termui" 8 | "github.com/moncho/dry/ui/termui" 9 | ) 10 | 11 | // SortMode sort mode for widgets 12 | type SortMode int 13 | 14 | // SortableColumnHeader is a column header associated to a sort mode 15 | type SortableColumnHeader struct { 16 | Title string // Title to display in the tableHeader. 17 | Mode SortMode 18 | } 19 | 20 | // WidgetHeader is a widget for the header of a widget 21 | type WidgetHeader struct { 22 | elements map[string]string 23 | keys []string 24 | Y int 25 | } 26 | 27 | // NewWidgetHeader creates WidgetHeader 28 | func NewWidgetHeader() *WidgetHeader { 29 | return &WidgetHeader{ 30 | elements: make(map[string]string), 31 | } 32 | } 33 | 34 | // HeaderEntry adds a new key-value entry to this header 35 | func (header *WidgetHeader) HeaderEntry(key, value string) { 36 | header.keys = append(header.keys, key) 37 | header.elements[key] = value 38 | } 39 | 40 | // GetHeight returns the widget height 41 | func (header *WidgetHeader) GetHeight() int { 42 | return 1 43 | } 44 | 45 | // Buffer return this widget content as a termui.Buffer 46 | func (header *WidgetHeader) Buffer() gizaktermui.Buffer { 47 | var entries []string 48 | width := 0 49 | for _, k := range header.keys { 50 | entries = append(entries, 51 | fmt.Sprintf("%s: %s", k, header.elements[k])) 52 | width += len(k) + len(header.elements[k]) 53 | } 54 | s := strings.Join(entries, " | ") 55 | par := termui.NewParFromMarkupText(DryTheme, s) 56 | 57 | par.SetX(0) 58 | par.SetY(header.Y) 59 | par.Border = false 60 | par.Width = len([]rune(s)) 61 | par.TextBgColor = gizaktermui.Attribute(DryTheme.Bg) 62 | par.Bg = gizaktermui.Attribute(DryTheme.Bg) 63 | return par.Buffer() 64 | } 65 | -------------------------------------------------------------------------------- /appui/header_test.go: -------------------------------------------------------------------------------- 1 | package appui 2 | 3 | import ( 4 | "strings" 5 | "testing" 6 | 7 | "github.com/moncho/dry/ui/termui" 8 | ) 9 | 10 | func TestWidgetHeader(t *testing.T) { 11 | type args struct { 12 | keys []string 13 | values []string 14 | } 15 | tests := []struct { 16 | name string 17 | args args 18 | want string 19 | }{ 20 | { 21 | "WidgetHeader content is valid", 22 | args{ 23 | keys: []string{ 24 | "1", "2", "3", 25 | }, 26 | values: []string{ 27 | "111", "222", "333", 28 | }, 29 | }, 30 | "1: 111 | 2: 222 | 3: 333", 31 | }, 32 | } 33 | for _, tt := range tests { 34 | t.Run(tt.name, func(t *testing.T) { 35 | header := NewWidgetHeader() 36 | for i, key := range tt.args.keys { 37 | header.HeaderEntry(key, tt.args.values[i]) 38 | } 39 | got, err := termui.String(header) 40 | 41 | if err != nil { 42 | t.Fatal(err) 43 | } 44 | 45 | //Trimming the string to ignore whitespaces caused by the length 46 | //of the widget being the length of the markup tags + 47 | //the length of the header entries. 48 | got = strings.TrimSpace(got) 49 | if got != tt.want { 50 | t.Errorf("WidgetHeader got = '%s', want '%s'", got, tt.want) 51 | } 52 | }) 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /appui/image_history.go: -------------------------------------------------------------------------------- 1 | package appui 2 | 3 | import ( 4 | "bytes" 5 | "fmt" 6 | "strings" 7 | 8 | "github.com/docker/docker/api/types/image" 9 | "github.com/docker/go-units" 10 | drydocker "github.com/moncho/dry/docker" 11 | "github.com/moncho/dry/ui" 12 | "github.com/olekukonko/tablewriter" 13 | ) 14 | 15 | // DockerImageHistoryRenderer knows how render history image 16 | type DockerImageHistoryRenderer struct { 17 | imageHistory []image.HistoryResponseItem 18 | } 19 | 20 | // NewDockerImageHistoryRenderer creates a renderer for the history of an image 21 | func NewDockerImageHistoryRenderer(imageHistory []image.HistoryResponseItem) fmt.Stringer { 22 | return &DockerImageHistoryRenderer{imageHistory: imageHistory} 23 | } 24 | 25 | // Render docker ps 26 | func (r *DockerImageHistoryRenderer) String() string { 27 | 28 | buffer := new(bytes.Buffer) 29 | 30 | table := tablewriter.NewWriter(buffer) 31 | table.SetHeader([]string{"IMAGE", "CREATED", "CREATED BY", "SIZE", "COMMENT"}) 32 | table.SetBorder(false) 33 | table.SetColumnSeparator(" ") 34 | //table.SetAlignment(tablewriter.ALIGN_LEFT) 35 | table.SetRowLine(true) 36 | 37 | for _, history := range r.imageHistory { 38 | table.Append(historyColumns(history)) 39 | } 40 | table.Render() 41 | return ui.White(buffer.String()) 42 | } 43 | 44 | func historyColumns(history image.HistoryResponseItem) []string { 45 | result := make([]string, 5) 46 | 47 | if strings.HasPrefix(history.ID, "<") { 48 | result[0] = history.ID 49 | } else { 50 | result[0] = (drydocker.ShortImageID(history.ID)) 51 | } 52 | result[1] = drydocker.DurationForHumans(history.Created) 53 | result[2] = history.CreatedBy 54 | result[3] = units.HumanSize(float64(history.Size)) 55 | if history.Tags != nil { 56 | result[4] = strings.Join(history.Tags, ", ") 57 | } 58 | return result 59 | } 60 | -------------------------------------------------------------------------------- /appui/image_row.go: -------------------------------------------------------------------------------- 1 | package appui 2 | 3 | import ( 4 | "github.com/docker/docker/api/types/image" 5 | termui "github.com/gizak/termui" 6 | "github.com/moncho/dry/docker/formatter" 7 | drytermui "github.com/moncho/dry/ui/termui" 8 | ) 9 | 10 | // ImageRow is a Grid row showing information about a Docker image 11 | type ImageRow struct { 12 | image image.Summary 13 | Repository *drytermui.ParColumn 14 | Tag *drytermui.ParColumn 15 | ID *drytermui.ParColumn 16 | CreatedSinceValue int64 17 | CreatedSince *drytermui.ParColumn 18 | SizeValue int64 19 | Size *drytermui.ParColumn 20 | 21 | Row 22 | } 23 | 24 | // NewImageRow creates a new ImageRow widget 25 | func NewImageRow(image image.Summary, table drytermui.Table) *ImageRow { 26 | iformatter := formatter.NewImageFormatter(image, true) 27 | 28 | row := &ImageRow{ 29 | image: image, 30 | Repository: drytermui.NewThemedParColumn(DryTheme, iformatter.Repository()), 31 | Tag: drytermui.NewThemedParColumn(DryTheme, iformatter.Tag()), 32 | ID: drytermui.NewThemedParColumn(DryTheme, iformatter.ID()), 33 | CreatedSince: drytermui.NewThemedParColumn(DryTheme, iformatter.CreatedSince()), 34 | CreatedSinceValue: image.Created, 35 | Size: drytermui.NewThemedParColumn(DryTheme, iformatter.Size()), 36 | SizeValue: image.VirtualSize, 37 | } 38 | row.Height = 1 39 | row.Table = table 40 | //Columns are rendered following the slice order 41 | row.Columns = []termui.GridBufferer{ 42 | row.Repository, 43 | row.Tag, 44 | row.ID, 45 | row.CreatedSince, 46 | row.Size, 47 | } 48 | row.ParColumns = []*drytermui.ParColumn{ 49 | row.Repository, 50 | row.Tag, 51 | row.ID, 52 | row.CreatedSince, 53 | row.Size, 54 | } 55 | 56 | return row 57 | 58 | } 59 | 60 | // ColumnsForFilter returns the columns that are used to filter 61 | func (row *ImageRow) ColumnsForFilter() []*drytermui.ParColumn { 62 | return []*drytermui.ParColumn{row.Repository, row.Tag, row.ID} 63 | } 64 | -------------------------------------------------------------------------------- /appui/input.go: -------------------------------------------------------------------------------- 1 | package appui 2 | 3 | // Input reads input 4 | func Input(done chan<- struct{}) { 5 | done <- struct{}{} 6 | } 7 | -------------------------------------------------------------------------------- /appui/inspect.go: -------------------------------------------------------------------------------- 1 | package appui 2 | 3 | import ( 4 | "bytes" 5 | "encoding/json" 6 | "fmt" 7 | ) 8 | 9 | type jsonRenderer struct { 10 | data interface{} 11 | } 12 | 13 | // NewJSONRenderer creates a renderer that renders the given data as a JSON 14 | func NewJSONRenderer(data interface{}) fmt.Stringer { 15 | return &jsonRenderer{ 16 | data: data, 17 | } 18 | } 19 | 20 | // Render low-level information on a network 21 | func (r *jsonRenderer) String() string { 22 | c, _ := json.Marshal(r.data) 23 | 24 | buf := new(bytes.Buffer) 25 | buf.WriteString("[\n") 26 | if err := json.Indent(buf, c, "", " "); err == nil { 27 | if buf.Len() > 1 { 28 | // Remove trailing ',' 29 | buf.Truncate(buf.Len() - 1) 30 | } 31 | } else { 32 | buf.WriteString("There was an error inspecting service information") 33 | } 34 | buf.WriteString("]\n") 35 | 36 | return buf.String() 37 | } 38 | -------------------------------------------------------------------------------- /appui/less.go: -------------------------------------------------------------------------------- 1 | package appui 2 | 3 | import ( 4 | "io" 5 | 6 | "github.com/gdamore/tcell" 7 | "github.com/moncho/dry/ui" 8 | ) 9 | 10 | // Less renders the given renderer output in a "less" buffer 11 | func Less(s string, screen *ui.Screen, events <-chan *tcell.EventKey, onDone func()) { 12 | defer onDone() 13 | screen.ClearAndFlush() 14 | 15 | less := ui.NewLess(DryTheme) 16 | less.MarkupSupport() 17 | io.WriteString(less, s) 18 | 19 | //Focus blocks until less decides that it does not want focus any more 20 | less.Focus(events) 21 | screen.HideCursor() 22 | screen.ClearAndFlush() 23 | 24 | screen.Sync() 25 | } 26 | -------------------------------------------------------------------------------- /appui/monitor_header.go: -------------------------------------------------------------------------------- 1 | package appui 2 | 3 | import "github.com/moncho/dry/ui/termui" 4 | 5 | // defaultMonitorTableHeader is the default header for the container monitor table 6 | var defaultMonitorTableHeader = NewMonitorTableHeader() 7 | 8 | // MonitorTableHeader is the header for container monitor tables 9 | type MonitorTableHeader struct { 10 | *termui.TableHeader 11 | } 12 | 13 | // NewMonitorTableHeader creates a table header for the monitor screen 14 | func NewMonitorTableHeader() *MonitorTableHeader { 15 | fields := []string{"NAME", "CPU", "MEM", "NET RX/TX", "BLOCK I/O"} 16 | 17 | header := termui.NewHeader(DryTheme) 18 | header.ColumnSpacing = DefaultColumnSpacing 19 | //Status indicator header 20 | header.AddFixedWidthColumn("", 2) 21 | header.AddFixedWidthColumn("CONTAINER", IDColumnWidth) 22 | for _, f := range fields { 23 | header.AddColumn(f) 24 | } 25 | header.AddFixedWidthColumn("PIDS", 5) 26 | header.AddFixedWidthColumn("UPTIME", IDColumnWidth) 27 | return &MonitorTableHeader{header} 28 | } 29 | -------------------------------------------------------------------------------- /appui/monitor_test.go: -------------------------------------------------------------------------------- 1 | package appui 2 | 3 | import ( 4 | "image" 5 | "testing" 6 | 7 | termui "github.com/gizak/termui" 8 | "github.com/moncho/dry/docker" 9 | "github.com/moncho/dry/ui" 10 | ) 11 | 12 | type dockerMonitor struct { 13 | } 14 | 15 | func (dockerMonitor) Containers(filters []docker.ContainerFilter, mode docker.SortMode) []*docker.Container { 16 | return nil 17 | } 18 | 19 | func (dockerMonitor) StatsChannel(container *docker.Container) (*docker.StatsChannel, error) { 20 | return nil, nil 21 | } 22 | 23 | type screenBuffererRender struct { 24 | } 25 | 26 | func (screenBuffererRender) Bounds() image.Rectangle { 27 | return image.Rectangle{} 28 | } 29 | func (screenBuffererRender) Cursor() *ui.Cursor { 30 | return &ui.Cursor{} 31 | } 32 | 33 | func (screenBuffererRender) Flush() *ui.Screen { 34 | return nil 35 | } 36 | func (screenBuffererRender) RenderBufferer(bs ...termui.Bufferer) { 37 | 38 | } 39 | 40 | func TestMonitor_RepeatedUnmount(t *testing.T) { 41 | type fields struct { 42 | daemon DockerMonitor 43 | renderer ScreenBuffererRender 44 | } 45 | m := NewMonitor(dockerMonitor{}, screenBuffererRender{}) 46 | m.Mount() 47 | m.Unmount() 48 | m.Unmount() 49 | } 50 | -------------------------------------------------------------------------------- /appui/network_row.go: -------------------------------------------------------------------------------- 1 | package appui 2 | 3 | import ( 4 | "github.com/docker/docker/api/types" 5 | termui "github.com/gizak/termui" 6 | "github.com/moncho/dry/docker/formatter" 7 | drytermui "github.com/moncho/dry/ui/termui" 8 | ) 9 | 10 | // NetworkRow is a Grid row showing information about a Docker image 11 | type NetworkRow struct { 12 | network types.NetworkResource 13 | ID *drytermui.ParColumn 14 | Name *drytermui.ParColumn 15 | Driver *drytermui.ParColumn 16 | Containers *drytermui.ParColumn 17 | Services *drytermui.ParColumn 18 | Scope *drytermui.ParColumn 19 | Subnet *drytermui.ParColumn 20 | Gateway *drytermui.ParColumn 21 | Row 22 | } 23 | 24 | // NewNetworkRow creates a new NetworkRow widget 25 | func NewNetworkRow(network types.NetworkResource, table drytermui.Table) *NetworkRow { 26 | networkFormatter := formatter.NewNetworkFormatter(network, true) 27 | 28 | row := &NetworkRow{ 29 | network: network, 30 | ID: drytermui.NewThemedParColumn(DryTheme, networkFormatter.ID()), 31 | Name: drytermui.NewThemedParColumn(DryTheme, networkFormatter.Name()), 32 | Driver: drytermui.NewThemedParColumn(DryTheme, networkFormatter.Driver()), 33 | Containers: drytermui.NewThemedParColumn(DryTheme, networkFormatter.Containers()), 34 | Services: drytermui.NewThemedParColumn(DryTheme, networkFormatter.Services()), 35 | Scope: drytermui.NewThemedParColumn(DryTheme, networkFormatter.Scope()), 36 | Subnet: drytermui.NewThemedParColumn(DryTheme, networkFormatter.Subnet()), 37 | Gateway: drytermui.NewThemedParColumn(DryTheme, networkFormatter.Gateway()), 38 | } 39 | row.Height = 1 40 | row.Table = table 41 | //Columns are rendered following the slice order 42 | row.Columns = []termui.GridBufferer{ 43 | row.ID, 44 | row.Name, 45 | row.Driver, 46 | row.Containers, 47 | row.Services, 48 | row.Scope, 49 | row.Subnet, 50 | row.Gateway, 51 | } 52 | row.ParColumns = []*drytermui.ParColumn{ 53 | row.ID, 54 | row.Name, 55 | row.Driver, 56 | row.Containers, 57 | row.Services, 58 | row.Scope, 59 | row.Subnet, 60 | row.Gateway, 61 | } 62 | 63 | return row 64 | 65 | } 66 | 67 | // ColumnsForFilter returns the columns that are used to filter 68 | func (row *NetworkRow) ColumnsForFilter() []*drytermui.ParColumn { 69 | return []*drytermui.ParColumn{row.ID, row.Name, row.Driver, row.Services, row.Scope, row.Subnet, row.Gateway} 70 | } 71 | -------------------------------------------------------------------------------- /appui/prompt.go: -------------------------------------------------------------------------------- 1 | package appui 2 | 3 | import ( 4 | gtermui "github.com/gizak/termui" 5 | "github.com/moncho/dry/ui" 6 | "github.com/moncho/dry/ui/termui" 7 | ) 8 | 9 | // Prompt is a prompt widget 10 | type Prompt struct { 11 | termui.TextInput 12 | } 13 | 14 | // NewPrompt creates a new Prompt with the given title 15 | func NewPrompt(title string) *Prompt { 16 | w := &Prompt{ 17 | TextInput: *termui.NewTextInput(ui.ActiveScreen, ""), 18 | } 19 | w.Height = 3 20 | w.Width = len(title) + 4 21 | w.X = (ui.ActiveScreen.Dimensions().Width - w.Width) / 2 22 | w.Y = ui.ActiveScreen.Dimensions().Height / 2 23 | w.Bg = gtermui.Attribute(DryTheme.Bg) 24 | w.TextBgColor = gtermui.Attribute(DryTheme.Bg) 25 | w.TextFgColor = gtermui.ColorWhite 26 | w.BorderLabel = title 27 | w.BorderLabelFg = gtermui.ColorWhite 28 | 29 | return w 30 | } 31 | 32 | // Mount callback 33 | func (w *Prompt) Mount() error { 34 | return nil 35 | } 36 | 37 | // Unmount callback 38 | func (w *Prompt) Unmount() error { 39 | return nil 40 | } 41 | 42 | // Name returns the widget name 43 | func (w *Prompt) Name() string { 44 | return "Prompt" 45 | } 46 | -------------------------------------------------------------------------------- /appui/row.go: -------------------------------------------------------------------------------- 1 | package appui 2 | 3 | import ( 4 | "image" 5 | 6 | termui "github.com/gizak/termui" 7 | drytermui "github.com/moncho/dry/ui/termui" 8 | ) 9 | 10 | // Row is a base row implementation 11 | type Row struct { 12 | ParColumns []*drytermui.ParColumn 13 | drytermui.Row 14 | } 15 | 16 | // Highlighted marks this rows as being highlighted 17 | func (row *Row) Highlighted() { 18 | row.changeTextColor( 19 | termui.Attribute(DryTheme.Fg), 20 | termui.Attribute(DryTheme.CursorLineBg)) 21 | } 22 | 23 | // NotHighlighted marks this rows as being not highlighted 24 | func (row *Row) NotHighlighted() { 25 | 26 | row.changeTextColor( 27 | termui.Attribute(DryTheme.ListItem), 28 | termui.Attribute(DryTheme.Bg)) 29 | } 30 | 31 | // Buffer returns this Row data as a termui.Buffer 32 | func (row *Row) Buffer() termui.Buffer { 33 | buf := termui.NewBuffer() 34 | //This set the background of the whole row 35 | buf.Area.Min = image.Point{row.X, row.Y} 36 | buf.Area.Max = image.Point{row.X + row.Width, row.Y + row.Height} 37 | buf.Fill(' ', row.ParColumns[0].TextFgColor, row.ParColumns[0].TextBgColor) 38 | 39 | for _, col := range row.Columns { 40 | buf.Merge(col.Buffer()) 41 | } 42 | return buf 43 | } 44 | 45 | func (row *Row) changeTextColor(fg, bg termui.Attribute) { 46 | for _, c := range row.ParColumns { 47 | c.TextFgColor = fg 48 | c.TextBgColor = bg 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /appui/row_filter.go: -------------------------------------------------------------------------------- 1 | package appui 2 | 3 | import ( 4 | "strings" 5 | 6 | "github.com/moncho/dry/ui/termui" 7 | ) 8 | 9 | // FilterableRow is the interface for filterable columns 10 | type FilterableRow interface { 11 | ColumnsForFilter() []*termui.ParColumn 12 | } 13 | 14 | // RowFilter function for filtering rows 15 | type RowFilter func(FilterableRow) bool 16 | 17 | // RowFilters holds the existing RowFilter 18 | var RowFilters RowFilter 19 | 20 | // ByPattern filters row by the given pattern 21 | func (rf RowFilter) ByPattern(pattern string) RowFilter { 22 | return func(row FilterableRow) bool { 23 | columns := row.ColumnsForFilter() 24 | for _, column := range columns { 25 | if strings.Contains(column.Text, pattern) { 26 | return true 27 | } 28 | } 29 | return false 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /appui/row_filter_test.go: -------------------------------------------------------------------------------- 1 | package appui 2 | 3 | import ( 4 | "testing" 5 | 6 | gizak "github.com/gizak/termui" 7 | "github.com/moncho/dry/ui/termui" 8 | ) 9 | 10 | func TestRowFilter_ByPattern(t *testing.T) { 11 | type args struct { 12 | pattern string 13 | } 14 | tests := []struct { 15 | name string 16 | filterable FilterableRow 17 | args args 18 | want bool 19 | }{ 20 | { 21 | "blabla -> true", 22 | filterable{ 23 | columns: []*termui.ParColumn{ 24 | { 25 | Paragraph: gizak.Paragraph{Text: "blabla"}, 26 | }, 27 | }, 28 | }, 29 | args{ 30 | "blabla", 31 | }, 32 | true, 33 | }, 34 | { 35 | "nope -> false", 36 | filterable{ 37 | columns: []*termui.ParColumn{ 38 | { 39 | Paragraph: gizak.Paragraph{Text: "yes"}, 40 | }, 41 | }, 42 | }, 43 | args{ 44 | "nope", 45 | }, 46 | false, 47 | }, 48 | } 49 | for _, tt := range tests { 50 | t.Run(tt.name, func(t *testing.T) { 51 | filter := RowFilters.ByPattern(tt.args.pattern) 52 | got := filter(tt.filterable) 53 | if got != tt.want { 54 | t.Errorf("RowFilter.ByPattern() = %v, want %v", got, tt.want) 55 | } 56 | }) 57 | } 58 | } 59 | 60 | type filterable struct { 61 | columns []*termui.ParColumn 62 | } 63 | 64 | func (f filterable) ColumnsForFilter() []*termui.ParColumn { 65 | return f.columns 66 | } 67 | -------------------------------------------------------------------------------- /appui/run_image.go: -------------------------------------------------------------------------------- 1 | package appui 2 | 3 | import ( 4 | "github.com/docker/docker/api/types/image" 5 | gtermui "github.com/gizak/termui" 6 | "github.com/moncho/dry/ui" 7 | "github.com/moncho/dry/ui/termui" 8 | ) 9 | 10 | // ImageRunWidget is an input widget to run images 11 | type ImageRunWidget struct { 12 | image image.Summary 13 | termui.TextInput 14 | } 15 | 16 | // NewImageRunWidget creates a new ImageRunWidget for the given image 17 | func NewImageRunWidget(image image.Summary) *ImageRunWidget { 18 | w := &ImageRunWidget{ 19 | image: image, 20 | TextInput: *termui.NewTextInput(ui.ActiveScreen, ""), 21 | } 22 | w.Height = 3 23 | w.Width = ui.ActiveScreen.Dimensions().Width / 2 24 | w.X = (ui.ActiveScreen.Dimensions().Width - w.Width) / 2 25 | w.Y = ui.ActiveScreen.Dimensions().Height / 2 26 | w.Bg = gtermui.Attribute(DryTheme.Bg) 27 | w.TextBgColor = gtermui.Attribute(DryTheme.Bg) 28 | w.TextFgColor = gtermui.ColorWhite 29 | w.BorderLabel = widgetTitle(&image) 30 | w.BorderLabelFg = gtermui.ColorWhite 31 | 32 | return w 33 | } 34 | 35 | // Mount callback 36 | func (w *ImageRunWidget) Mount() error { 37 | return nil 38 | } 39 | 40 | // Unmount callback 41 | func (w *ImageRunWidget) Unmount() error { 42 | return nil 43 | } 44 | 45 | // Name returns the widget name 46 | func (w *ImageRunWidget) Name() string { 47 | return "ImageRunWidget." + w.image.ID 48 | } 49 | 50 | func widgetTitle(image *image.Summary) string { 51 | if len(image.RepoTags) > 0 { 52 | return " docker run " + image.RepoTags[0] 53 | } else if len(image.RepoDigests) > 0 { 54 | return " docker run " + image.RepoDigests[0] 55 | } 56 | return " docker run " 57 | } 58 | -------------------------------------------------------------------------------- /appui/screen.go: -------------------------------------------------------------------------------- 1 | package appui 2 | 3 | import ( 4 | "image" 5 | 6 | termui "github.com/gizak/termui" 7 | "github.com/moncho/dry/ui" 8 | ) 9 | 10 | // Screen is a representation of a terminal screen, limited by its 11 | // bounds and having an active cursor. 12 | type Screen interface { 13 | Bounds() image.Rectangle 14 | Cursor() *ui.Cursor 15 | } 16 | 17 | // ScreenBuffererRender is a rendering device for termui Bufferers. 18 | type ScreenBuffererRender interface { 19 | Screen 20 | Flush() *ui.Screen 21 | RenderBufferer(bs ...termui.Bufferer) 22 | } 23 | -------------------------------------------------------------------------------- /appui/stream.go: -------------------------------------------------------------------------------- 1 | package appui 2 | 3 | import ( 4 | "io" 5 | 6 | "github.com/docker/docker/pkg/stdcopy" 7 | "github.com/gdamore/tcell" 8 | "github.com/moncho/dry/ui" 9 | ) 10 | 11 | // Stream shows the content of the given stream on screen 12 | func Stream(stream io.ReadCloser, keyboardQueue <-chan *tcell.EventKey, done func()) { 13 | defer done() 14 | ui.ActiveScreen.ClearAndFlush() 15 | v := ui.NewLess(DryTheme) 16 | //TODO do something with io errors 17 | go stdcopy.StdCopy(v, v, stream) 18 | v.Focus(keyboardQueue) 19 | 20 | stream.Close() 21 | ui.ActiveScreen.HideCursor() 22 | ui.ActiveScreen.ClearAndFlush() 23 | ui.ActiveScreen.Sync() 24 | } 25 | -------------------------------------------------------------------------------- /appui/swarm/node_row_test.go: -------------------------------------------------------------------------------- 1 | package swarm 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/docker/docker/api/types/swarm" 7 | ) 8 | 9 | func TestNodeRow(t *testing.T) { 10 | node := swarm.Node{ 11 | Description: swarm.NodeDescription{ 12 | Engine: swarm.EngineDescription{ 13 | EngineVersion: "1.0", 14 | }, 15 | Hostname: "test.local", 16 | Resources: swarm.Resources{ 17 | NanoCPUs: 2 * 1e9, 18 | MemoryBytes: 1024 * 1024, 19 | }, 20 | }, 21 | Spec: swarm.NodeSpec{ 22 | Role: swarm.NodeRoleManager, 23 | Availability: swarm.NodeAvailabilityActive, 24 | }, 25 | Status: swarm.NodeStatus{ 26 | State: swarm.NodeStateReady, 27 | Addr: "6.6.6.6", 28 | }, 29 | } 30 | 31 | row := NewNodeRow(node, nodeTableHeader()) 32 | 33 | if row == nil { 34 | t.Error("NodeRow was not created") 35 | } 36 | 37 | if row.Name.Text != node.Description.Hostname { 38 | t.Errorf("NodeRow name is not 'test', got %s", row.Name.Text) 39 | } 40 | 41 | if row.Role.Text != string(swarm.NodeRoleManager) { 42 | t.Errorf("Unexpected NodeRow role, got %s, expected %s", row.Role.Text, swarm.NodeRoleManager) 43 | } 44 | if row.CPU.Text != "2" { 45 | t.Error("NodeRow does not have 2 CPUs") 46 | } 47 | if row.Memory.Text != "1MiB" { 48 | t.Errorf("NodeRow does not have 1 MiB of memory, got %s", row.Memory.Text) 49 | } 50 | if row.Engine.Text != "1.0" { 51 | t.Errorf("Unexpected NodeRow engine version, got %s, expected 1.0", row.Engine.Text) 52 | } 53 | if row.IPAddress.Text != node.Status.Addr { 54 | t.Errorf("Unexpected NodeRow IP address, got %s, expected %s", row.IPAddress.Text, node.Status.Addr) 55 | } 56 | if row.Status.Text != string(node.Status.State) { 57 | t.Errorf("Unexpected NodeRow state, got %s, expected %s", row.Status.Text, node.Status.State) 58 | } 59 | if row.Availability.Text != string(node.Spec.Availability) { 60 | t.Errorf("Unexpected NodeRow availability, got %s, expected %s", row.Availability.Text, node.Spec.Availability) 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /appui/swarm/nodes_test.go: -------------------------------------------------------------------------------- 1 | package swarm 2 | 3 | import ( 4 | "image" 5 | "testing" 6 | 7 | "github.com/moncho/dry/mocks" 8 | "github.com/moncho/dry/ui" 9 | ) 10 | 11 | type testScreen struct { 12 | cursor *ui.Cursor 13 | dimensions ui.Dimensions 14 | } 15 | 16 | func (ts *testScreen) Cursor() *ui.Cursor { 17 | return ts.cursor 18 | } 19 | func (ts *testScreen) Bounds() image.Rectangle { 20 | y := 1 21 | return image.Rect(0, y, ts.dimensions.Width, y+ts.dimensions.Height) 22 | } 23 | 24 | func TestNodesWidgetCreation(t *testing.T) { 25 | screen := &testScreen{ 26 | dimensions: ui.Dimensions{Height: 14, Width: 100}, 27 | cursor: ui.NewCursor()} 28 | w := NewNodesWidget(&mocks.SwarmDockerDaemon{}, screen) 29 | if w == nil { 30 | t.Error("Swarm widget is nil") 31 | } 32 | if w.swarmClient == nil { 33 | t.Error("Swarm widget does not have a reference to the swarmclient") 34 | } 35 | 36 | if w.header.ColumnCount() != len(nodeTableHeaders) { 37 | t.Error("Swarm widget does not have a the expected number of columns") 38 | } 39 | 40 | } 41 | 42 | func TestNodesWidgetMount(t *testing.T) { 43 | screen := &testScreen{ 44 | dimensions: ui.Dimensions{Height: 14, Width: 100}, 45 | cursor: ui.NewCursor()} 46 | w := NewNodesWidget(&mocks.SwarmDockerDaemon{}, screen) 47 | 48 | if len(w.totalRows) != 0 { 49 | t.Errorf("Swarm widget is not showing the expected number of totalRows. Got: %d", len(w.totalRows)) 50 | } 51 | 52 | w.Mount() 53 | 54 | if len(w.totalRows) != 1 { 55 | t.Errorf("Swarm widget is not showing the expected number of totalRows. Got: %d", len(w.totalRows)) 56 | } 57 | 58 | } 59 | -------------------------------------------------------------------------------- /appui/swarm/service_info_test.go: -------------------------------------------------------------------------------- 1 | package swarm 2 | 3 | import ( 4 | "flag" 5 | "os" 6 | "path/filepath" 7 | "testing" 8 | 9 | "github.com/docker/docker/api/types/swarm" 10 | "github.com/moncho/dry/docker" 11 | "github.com/moncho/dry/mocks" 12 | "github.com/moncho/dry/ui" 13 | ) 14 | 15 | var updateGoldenFiles = flag.Bool("updateGoldenFiles", false, "update .golden files") 16 | 17 | var testService = &swarm.Service{ 18 | Spec: swarm.ServiceSpec{ 19 | TaskTemplate: swarm.TaskSpec{ 20 | ContainerSpec: &swarm.ContainerSpec{ 21 | Image: "bla", 22 | }, 23 | Placement: &swarm.Placement{ 24 | Constraints: []string{"constraint", "magic"}, 25 | }, 26 | }, 27 | EndpointSpec: &swarm.EndpointSpec{}, 28 | }, 29 | } 30 | 31 | func TestServiceInfo(t *testing.T) { 32 | daemon := &mocks.SwarmDockerDaemon{} 33 | screen := &testScreen{ 34 | dimensions: ui.Dimensions{Height: 14, Width: 100}, 35 | cursor: ui.NewCursor()} 36 | di := NewServiceInfoWidget(daemon, testService, screen) 37 | 38 | if di == nil { 39 | t.Error("Service info widget is nil") 40 | } 41 | content := di.Buffer() 42 | if content.Area.Dy() != di.GetHeight() { 43 | t.Error("Service info widget does not have the expected height") 44 | } 45 | 46 | if len(content.CellMap) == 0 { 47 | t.Errorf("Service info widget does not have the expected content: %v", content.CellMap) 48 | } 49 | } 50 | 51 | func Test_serviceInfo(t *testing.T) { 52 | type args struct { 53 | swarmClient docker.SwarmAPI 54 | name string 55 | service *swarm.Service 56 | } 57 | tests := []struct { 58 | name string 59 | args args 60 | }{ 61 | { 62 | "service_info", 63 | args{ 64 | &mocks.SwarmDockerDaemon{}, 65 | "serviceName", 66 | testService, 67 | }, 68 | }, 69 | } 70 | for _, tt := range tests { 71 | t.Run(tt.name, func(t *testing.T) { 72 | 73 | got := serviceInfo(tt.args.swarmClient, tt.args.name, tt.args.service) 74 | 75 | golden := filepath.Join("testdata", tt.name+".golden") 76 | if *updateGoldenFiles { 77 | os.WriteFile(golden, []byte(got), 0644) 78 | } 79 | expected, _ := os.ReadFile(golden) 80 | 81 | if got != string(expected) { 82 | t.Errorf("serviceInfo() = %v, want %v", got, expected) 83 | } 84 | }) 85 | } 86 | } 87 | -------------------------------------------------------------------------------- /appui/swarm/service_row.go: -------------------------------------------------------------------------------- 1 | package swarm 2 | 3 | import ( 4 | "strings" 5 | 6 | "github.com/docker/docker/api/types/swarm" 7 | termui "github.com/gizak/termui" 8 | "github.com/moncho/dry/appui" 9 | dryformatter "github.com/moncho/dry/docker/formatter" 10 | drytermui "github.com/moncho/dry/ui/termui" 11 | ) 12 | 13 | // ServiceRow is a Grid row showing service information 14 | type ServiceRow struct { 15 | service swarm.Service 16 | ID *drytermui.ParColumn 17 | Name *drytermui.ParColumn 18 | Mode *drytermui.ParColumn 19 | Replicas *drytermui.ParColumn 20 | Image *drytermui.ParColumn 21 | ServicePorts *drytermui.ParColumn 22 | 23 | appui.Row 24 | } 25 | 26 | // NewServiceRow creates a new ServiceRow widget 27 | func NewServiceRow(service swarm.Service, serviceInfo ServiceListInfo, table drytermui.Table) *ServiceRow { 28 | row := &ServiceRow{ 29 | service: service, 30 | ID: drytermui.NewThemedParColumn(appui.DryTheme, service.ID), 31 | Name: drytermui.NewThemedParColumn(appui.DryTheme, service.Spec.Name), 32 | Mode: drytermui.NewThemedParColumn(appui.DryTheme, serviceInfo.Mode), 33 | Replicas: drytermui.NewThemedParColumn(appui.DryTheme, serviceInfo.Replicas), 34 | Image: drytermui.NewThemedParColumn( 35 | appui.DryTheme, serviceImage(service)), 36 | ServicePorts: drytermui.NewThemedParColumn(appui.DryTheme, dryformatter.FormatPorts(service.Spec.EndpointSpec.Ports)), 37 | } 38 | row.Height = 1 39 | row.Table = table 40 | //Columns are rendered following the slice order 41 | row.Columns = []termui.GridBufferer{ 42 | row.Name, 43 | row.Mode, 44 | row.Replicas, 45 | row.ServicePorts, 46 | row.Image, 47 | } 48 | row.ParColumns = []*drytermui.ParColumn{ 49 | row.Name, 50 | row.Mode, 51 | row.Replicas, 52 | row.ServicePorts, 53 | row.Image, 54 | } 55 | return row 56 | 57 | } 58 | 59 | // ColumnsForFilter returns the columns that are used to filter 60 | func (row *ServiceRow) ColumnsForFilter() []*drytermui.ParColumn { 61 | return []*drytermui.ParColumn{row.Name, row.Image, row.Mode} 62 | } 63 | 64 | func serviceImage(service swarm.Service) string { 65 | image := service.Spec.TaskTemplate.ContainerSpec.Image 66 | digestMark := strings.LastIndex(image, "@") 67 | if digestMark > 0 { 68 | return image[:digestMark] 69 | } 70 | return image 71 | } 72 | -------------------------------------------------------------------------------- /appui/swarm/stack_row.go: -------------------------------------------------------------------------------- 1 | package swarm 2 | 3 | import ( 4 | "strconv" 5 | 6 | termui "github.com/gizak/termui" 7 | "github.com/moncho/dry/appui" 8 | "github.com/moncho/dry/docker" 9 | drytermui "github.com/moncho/dry/ui/termui" 10 | ) 11 | 12 | // StackRow is a Grid row showing stack information 13 | type StackRow struct { 14 | stack docker.Stack 15 | Name *drytermui.ParColumn 16 | Services *drytermui.ParColumn 17 | Orchestrator *drytermui.ParColumn 18 | Networks *drytermui.ParColumn 19 | Configs *drytermui.ParColumn 20 | Secrets *drytermui.ParColumn 21 | 22 | appui.Row 23 | } 24 | 25 | // NewStackRow creates a new StackRow widget 26 | func NewStackRow(stack docker.Stack, table drytermui.Table) *StackRow { 27 | row := &StackRow{ 28 | stack: stack, 29 | Name: drytermui.NewThemedParColumn(appui.DryTheme, stack.Name), 30 | Services: drytermui.NewThemedParColumn(appui.DryTheme, strconv.Itoa(stack.Services)), 31 | Orchestrator: drytermui.NewThemedParColumn(appui.DryTheme, stack.Orchestrator), 32 | Networks: drytermui.NewThemedParColumn(appui.DryTheme, strconv.Itoa(stack.Networks)), 33 | Configs: drytermui.NewThemedParColumn(appui.DryTheme, strconv.Itoa(stack.Configs)), 34 | Secrets: drytermui.NewThemedParColumn(appui.DryTheme, strconv.Itoa(stack.Secrets)), 35 | } 36 | row.Height = 1 37 | row.Table = table 38 | //Columns are rendered following the slice order 39 | row.Columns = []termui.GridBufferer{ 40 | row.Name, 41 | row.Services, 42 | row.Orchestrator, 43 | row.Networks, 44 | row.Configs, 45 | row.Secrets, 46 | } 47 | row.ParColumns = []*drytermui.ParColumn{ 48 | row.Name, 49 | row.Services, 50 | row.Orchestrator, 51 | row.Networks, 52 | row.Configs, 53 | row.Secrets, 54 | } 55 | return row 56 | 57 | } 58 | 59 | // ColumnsForFilter returns the columns that are used to filter 60 | func (row *StackRow) ColumnsForFilter() []*drytermui.ParColumn { 61 | return []*drytermui.ParColumn{row.Name, row.Orchestrator} 62 | } 63 | -------------------------------------------------------------------------------- /appui/swarm/stack_row_test.go: -------------------------------------------------------------------------------- 1 | package swarm 2 | 3 | import ( 4 | "strconv" 5 | "testing" 6 | 7 | "github.com/moncho/dry/docker" 8 | ) 9 | 10 | func TestStackRow(t *testing.T) { 11 | type args struct { 12 | stack docker.Stack 13 | } 14 | tests := []struct { 15 | name string 16 | args args 17 | }{ 18 | { 19 | "stack row test", 20 | args{ 21 | docker.Stack{ 22 | Name: "My name is my name", 23 | Services: 5, 24 | Orchestrator: "Swarm", 25 | Networks: 4, 26 | Configs: 2, 27 | Secrets: 300, 28 | }, 29 | }, 30 | }, 31 | } 32 | for _, tt := range tests { 33 | t.Run(tt.name, func(t *testing.T) { 34 | stack := tt.args.stack 35 | row := NewStackRow(stack, nil) 36 | 37 | if row.Name.Text != stack.Name { 38 | t.Errorf("Unexpected Name. Got %s, expected %s", row.Name.Text, stack.Name) 39 | } 40 | if row.Services.Text != strconv.Itoa(stack.Services) { 41 | t.Errorf("Unexpected Services. Got %s, expected %d", row.Services.Text, stack.Services) 42 | } 43 | if row.Orchestrator.Text != stack.Orchestrator { 44 | t.Errorf("Unexpected Orchestrator. Got %s, expected %s", row.Orchestrator.Text, stack.Orchestrator) 45 | } 46 | if row.Networks.Text != strconv.Itoa(stack.Networks) { 47 | t.Errorf("Unexpected Networks. Got %s, expected %d", row.Networks.Text, stack.Networks) 48 | } 49 | if row.Configs.Text != strconv.Itoa(stack.Configs) { 50 | t.Errorf("Unexpected Configs. Got %s, expected %d", row.Configs.Text, stack.Configs) 51 | } 52 | if row.Secrets.Text != strconv.Itoa(stack.Secrets) { 53 | t.Errorf("Unexpected Secrets. Got %s, expected %d", row.Secrets.Text, stack.Secrets) 54 | } 55 | }) 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /appui/swarm/stack_tasks.go: -------------------------------------------------------------------------------- 1 | package swarm 2 | 3 | import ( 4 | "fmt" 5 | 6 | gizaktermui "github.com/gizak/termui" 7 | "github.com/moncho/dry/appui" 8 | "github.com/moncho/dry/docker" 9 | ) 10 | 11 | // StacksTasksWidget shows a service's task information 12 | type StacksTasksWidget struct { 13 | TasksWidget 14 | stack string 15 | } 16 | 17 | // NewStacksTasksWidget creates a StacksTasksWidget 18 | func NewStacksTasksWidget(swarmClient docker.SwarmAPI, s appui.Screen) *StacksTasksWidget { 19 | w := StacksTasksWidget{ 20 | TasksWidget: TasksWidget{ 21 | swarmClient: swarmClient, 22 | header: defaultTasksTableHeader, 23 | mounted: false, 24 | offset: 0, 25 | selectedIndex: 0, 26 | sortMode: docker.SortByTaskService, 27 | screen: s, 28 | tableTitle: createStackTableTitle()}, 29 | } 30 | return &w 31 | } 32 | 33 | // Buffer returns the content of this widget as a termui.Buffer 34 | func (s *StacksTasksWidget) Buffer() gizaktermui.Buffer { 35 | s.Lock() 36 | defer s.Unlock() 37 | buf := gizaktermui.NewBuffer() 38 | if !s.mounted { 39 | return buf 40 | } 41 | y := s.screen.Bounds().Min.Y 42 | 43 | s.prepareForRendering() 44 | var filter string 45 | if s.filterPattern != "" { 46 | filter = fmt.Sprintf( 47 | " | Active filter: %s ", s.filterPattern) 48 | } 49 | s.tableTitle.Content(fmt.Sprintf( 50 | "Stack %s tasks: %d", s.stack, s.RowCount()) + " " + filter) 51 | 52 | s.tableTitle.Y = y 53 | buf.Merge(s.tableTitle.Buffer()) 54 | y += s.tableTitle.GetHeight() 55 | 56 | s.updateHeader() 57 | s.header.SetY(y) 58 | buf.Merge(s.header.Buffer()) 59 | y += s.header.GetHeight() 60 | 61 | selected := s.selectedIndex - s.startIndex 62 | 63 | for i, serviceRow := range s.visibleRows() { 64 | serviceRow.SetY(y) 65 | y += serviceRow.GetHeight() 66 | if i != selected { 67 | serviceRow.NotHighlighted() 68 | } else { 69 | serviceRow.Highlighted() 70 | } 71 | buf.Merge(serviceRow.Buffer()) 72 | } 73 | return buf 74 | } 75 | 76 | // ForStack sets the stack for which this widget is showing tasks 77 | func (s *StacksTasksWidget) ForStack(stack string) { 78 | s.Lock() 79 | defer s.Unlock() 80 | 81 | s.stack = stack 82 | s.mounted = false 83 | s.sortMode = docker.SortByTaskService 84 | 85 | } 86 | 87 | // Mount prepares this widget for rendering 88 | func (s *StacksTasksWidget) Mount() error { 89 | s.Lock() 90 | defer s.Unlock() 91 | if !s.mounted { 92 | s.mounted = true 93 | var rows []*TaskRow 94 | if tasks, err := s.swarmClient.StackTasks(s.stack); err == nil { 95 | for _, task := range tasks { 96 | rows = append(rows, NewTaskRow(s.swarmClient, task, s.header)) 97 | } 98 | s.totalRows = rows 99 | } else { 100 | return err 101 | } 102 | } 103 | s.align() 104 | return nil 105 | } 106 | 107 | // Name returns this widget name 108 | func (s *StacksTasksWidget) Name() string { 109 | return "StacksTasksWidget" 110 | } 111 | -------------------------------------------------------------------------------- /appui/swarm/task_row_test.go: -------------------------------------------------------------------------------- 1 | package swarm 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/docker/docker/api/types/swarm" 7 | "github.com/moncho/dry/docker/formatter" 8 | "github.com/moncho/dry/mocks" 9 | ) 10 | 11 | func TestTaskRow(t *testing.T) { 12 | task := swarm.Task{ 13 | ID: "task1", 14 | ServiceID: "1", 15 | Spec: swarm.TaskSpec{ 16 | ContainerSpec: &swarm.ContainerSpec{}, 17 | }, 18 | Slot: 0, 19 | NodeID: "1", 20 | } 21 | client := &mocks.SwarmDockerDaemon{} 22 | ts := formatter.NewTaskStringer(client, task, true) 23 | row := NewTaskRow(client, task, taskTableHeader()) 24 | 25 | if row == nil { 26 | t.Error("TaskRow was not created") 27 | } 28 | 29 | if row.Name.Text != "Service1.1" { 30 | t.Errorf("TaskRow name is not %s, got %s", "Service1.1 ", row.Name.Text) 31 | } 32 | 33 | if row.Image.Text != ts.Image() { 34 | t.Errorf("Unexpected TaskRow image, got %s, expected %s", row.Image.Text, ts.Image()) 35 | } 36 | if row.Node.Text != ts.NodeID() { 37 | t.Errorf("Unexpected TaskRow node, got %s, expected %s", row.Node.Text, ts.NodeID()) 38 | } 39 | if row.DesiredState.Text != ts.DesiredState() { 40 | t.Errorf("Unexpected TaskRow DesiredState, got %s, expected %s", row.DesiredState.Text, ts.DesiredState()) 41 | } 42 | if row.CurrentState.Text != ts.CurrentState() { 43 | t.Errorf("Unexpected TaskRow CurrentState, got %s, expected %s", row.CurrentState.Text, ts.CurrentState()) 44 | } 45 | if row.Error.Text != ts.Error() { 46 | t.Errorf("Unexpected TaskRow error, got %s, expected %s", row.Error.Text, ts.Error()) 47 | } 48 | if row.Ports.Text != ts.Ports() { 49 | t.Errorf("Unexpected TaskRow ports, got %s, expected %s", row.Ports.Text, ts.Ports()) 50 | 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /appui/swarm/testdata/service_info.golden: -------------------------------------------------------------------------------- 1 | Service Name: serviceName Image: bla 2 | Service Mode: Labels: Created at: 01 Jan 01 00:00 UTC 3 | Replicas: Constraints: constraint,magic Updated at: 01 Jan 01 00:00 UTC 4 | Networks: Ports: 5 | Configs: 0 Secrets: 0 6 | -------------------------------------------------------------------------------- /appui/testdata/DiskUsageTest.golden: -------------------------------------------------------------------------------- 1 | TYPE TOTAL ACTIVE SIZE RECLAIMABLE 2 | 3 | Images 0 0 0B 0B 4 | Containers 0 0 0B 0B 5 | Local Volumes 0 0 0B 0B 6 | Build Cache 0B 0B 7 | 8 | Docker system prune executed on 1970-01-01 00:00:00, results: 9 | 10 | Deleted containers: 0 11 | Deleted images: 0 12 | Deleted networks: 0 13 | Deleted volumes: 0 14 | Total reclaimed space: 0B 15 | 16 | -------------------------------------------------------------------------------- /appui/testdata/DiskUsageTest_noPruneReport.golden: -------------------------------------------------------------------------------- 1 | TYPE TOTAL ACTIVE SIZE RECLAIMABLE 2 | 3 | Images 0 0 0B 0B 4 | Containers 0 0 0B 0B 5 | Local Volumes 0 0 0B 0B 6 | Build Cache 0B 0B 7 | 8 | 9 | 10 | 11 | -------------------------------------------------------------------------------- /appui/testdata/TestVolumesWidget_double_sort_volumes.golden: -------------------------------------------------------------------------------- 1 | Volumes: 5 2 | 3 | ↓DRIVER VOLUME NAME 4 | local1 volume5 5 | local1 volume4 6 | local2 volume3 7 | local2 volume2 8 | -------------------------------------------------------------------------------- /appui/testdata/TestVolumesWidget_filter_volumes.golden: -------------------------------------------------------------------------------- 1 | Volumes: 1 | Active filter: volume3 2 | 3 | ↓DRIVER VOLUME NAME 4 | local volume3 5 | -------------------------------------------------------------------------------- /appui/testdata/TestVolumesWidget_mounted_widget_no_volumes.golden: -------------------------------------------------------------------------------- 1 | Volumes: 0 2 | 3 | ↓DRIVER VOLUME NAME -------------------------------------------------------------------------------- /appui/testdata/TestVolumesWidget_mounted_widget_two_volumes.golden: -------------------------------------------------------------------------------- 1 | Volumes: 2 2 | 3 | ↓DRIVER VOLUME NAME 4 | local volume1 5 | local volume2 6 | -------------------------------------------------------------------------------- /appui/testdata/TestVolumesWidget_show_first_4_volumes.golden: -------------------------------------------------------------------------------- 1 | Volumes: 5 2 | 3 | ↓DRIVER VOLUME NAME 4 | local volume1 5 | local volume2 6 | local volume3 7 | local volume4 8 | -------------------------------------------------------------------------------- /appui/testdata/TestVolumesWidget_show_last_4_volumes.golden: -------------------------------------------------------------------------------- 1 | Volumes: 5 2 | 3 | ↓DRIVER VOLUME NAME 4 | local volume2 5 | local volume3 6 | local volume4 7 | local volume5 8 | -------------------------------------------------------------------------------- /appui/testdata/TestVolumesWidget_sort_volumes.golden: -------------------------------------------------------------------------------- 1 | Volumes: 5 2 | 3 | DRIVER ↓VOLUME NAME 4 | local volume1 5 | local volume2 6 | local volume3 7 | local volume4 8 | -------------------------------------------------------------------------------- /appui/testdata/TestVolumesWidget_unmounted_widget.golden: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/moncho/dry/ec1c1426b9f9d07ecee968316199953f2ff36134/appui/testdata/TestVolumesWidget_unmounted_widget.golden -------------------------------------------------------------------------------- /appui/testing.go: -------------------------------------------------------------------------------- 1 | package appui 2 | 3 | import ( 4 | "flag" 5 | "image" 6 | 7 | "github.com/moncho/dry/ui" 8 | ) 9 | 10 | var update = flag.Bool("update", false, "update .golden files") 11 | 12 | type testScreen struct { 13 | cursor *ui.Cursor 14 | x0, y0, x1, y1 int 15 | } 16 | 17 | func (ts *testScreen) Cursor() *ui.Cursor { 18 | return ts.cursor 19 | } 20 | 21 | func (ts *testScreen) Bounds() image.Rectangle { 22 | return image.Rect(ts.x0, ts.y0, ts.x1, ts.y1) 23 | } 24 | -------------------------------------------------------------------------------- /appui/theme.go: -------------------------------------------------------------------------------- 1 | package appui 2 | 3 | import ( 4 | "github.com/gizak/termui" 5 | "github.com/moncho/dry/ui" 6 | ) 7 | 8 | const ( 9 | //Running is the color used to identify a running element (e.g container, task) 10 | Running = termui.Attribute(ui.Color108) 11 | //NotRunning is the color used to identify a non-running element 12 | NotRunning = termui.Attribute(ui.Color161) 13 | ) 14 | 15 | // Default16 default theme for 16-color mode 16 | var Default16 = &ui.ColorTheme{ 17 | Fg: ui.ColorWhite, 18 | Bg: ui.ColorBlack, 19 | DarkBg: ui.ColorBlack, 20 | Prompt: ui.ColorBlue, 21 | Key: ui.ColorGreen, 22 | Current: ui.ColorYellow, 23 | CurrentMatch: ui.ColorGreen, 24 | Spinner: ui.ColorGreen, 25 | Info: ui.ColorWhite, 26 | Cursor: ui.ColorRed, 27 | Selected: ui.ColorPurple, 28 | Header: ui.ColorLime, 29 | Footer: ui.ColorLime} 30 | 31 | // Black256 black bg theme for 256-color mode 32 | var Black256 = &ui.ColorTheme{ 33 | Fg: ui.Color255, 34 | Bg: ui.ColorBlack, 35 | DarkBg: ui.ColorBlack, 36 | Prompt: ui.Color110, 37 | Key: ui.Color108, 38 | Current: ui.Color254, 39 | CurrentMatch: ui.Color151, 40 | Spinner: ui.Color148, 41 | Info: ui.Color144, 42 | Cursor: ui.Color161, 43 | Selected: ui.Color168, 44 | Header: ui.Color25, 45 | Footer: ui.Color25} 46 | 47 | // Dark256 dark theme for 256-color mode 48 | var Dark256 = &ui.ColorTheme{ 49 | Fg: ui.Color255, 50 | Bg: ui.Color234, 51 | DarkBg: ui.ColorBlack, 52 | Prompt: ui.Color110, 53 | Key: ui.Color108, 54 | Current: ui.Color254, 55 | CurrentMatch: ui.Color151, 56 | Spinner: ui.Color148, 57 | Info: ui.Color144, 58 | Cursor: ui.Color161, 59 | Selected: ui.Color168, 60 | Header: ui.Color25, 61 | Footer: ui.Color25, 62 | ListItem: ui.Color181, 63 | CursorLineBg: ui.Color25} 64 | 65 | // Light256 light theme for 256-color mode 66 | var Light256 = &ui.ColorTheme{ 67 | Fg: ui.Color241, 68 | Bg: ui.Color231, 69 | DarkBg: ui.Color251, 70 | Prompt: ui.Color25, 71 | Key: ui.Color66, 72 | Current: ui.Color237, 73 | CurrentMatch: ui.Color23, 74 | Spinner: ui.Color65, 75 | Info: ui.Color101, 76 | Cursor: ui.Color161, 77 | Selected: ui.Color168, 78 | Header: ui.Color31, 79 | Footer: ui.Color31} 80 | 81 | // DryTheme is the active theme for dry 82 | var DryTheme = Dark256 83 | 84 | // ColorThemes holds the list of dry color themes 85 | var ColorThemes = []*ui.ColorTheme{Black256, Dark256} 86 | 87 | // RotateColorTheme changes the color theme to the next one in the 88 | // rotation order. 89 | func RotateColorTheme() { 90 | if DryTheme == ColorThemes[0] { 91 | DryTheme = ColorThemes[1] 92 | } else { 93 | DryTheme = ColorThemes[0] 94 | } 95 | } 96 | -------------------------------------------------------------------------------- /appui/top.go: -------------------------------------------------------------------------------- 1 | package appui 2 | 3 | import ( 4 | "bytes" 5 | "fmt" 6 | "io" 7 | "sort" 8 | "strings" 9 | "text/tabwriter" 10 | 11 | "github.com/docker/docker/api/types/container" 12 | "github.com/gizak/termui" 13 | "github.com/moncho/dry/ui" 14 | ) 15 | 16 | const ( 17 | // title + borders 18 | minimumHeight = 3 19 | ) 20 | 21 | type topRenderer struct { 22 | processList *container.ContainerTopOKBody 23 | } 24 | 25 | // NewDockerTopRenderer creates renderer for docker top result 26 | func NewDockerTopRenderer(processList *container.ContainerTopOKBody) fmt.Stringer { 27 | return &topRenderer{ 28 | processList: processList, 29 | } 30 | } 31 | 32 | func (r *topRenderer) String() string { 33 | buf := bytes.NewBufferString("") 34 | 35 | procList := r.processList 36 | 37 | w := tabwriter.NewWriter(buf, 20, 1, 3, ' ', 0) 38 | 39 | io.WriteString(w, "PROCESS LIST\n\n") 40 | 41 | fmt.Fprintln(w, 42 | fmt.Sprintf("%s", 43 | strings.Join(procList.Titles, "\t"))) 44 | 45 | for _, proc := range procList.Processes { 46 | fmt.Fprintln(w, 47 | fmt.Sprintf("%s", 48 | strings.Join(proc, "\t"))) 49 | } 50 | w.Flush() 51 | return buf.String() 52 | } 53 | 54 | // NewDockerTop creates termui bufferer for docker top 55 | func NewDockerTop(processList *container.ContainerTopOKBody, x, y, height, width int) (termui.Bufferer, int) { 56 | 57 | if processList != nil { 58 | buf := bytes.NewBufferString("") 59 | w := tabwriter.NewWriter(buf, 20, 1, 3, ' ', 0) 60 | lines := minimumHeight // title + borders 61 | 62 | fmt.Fprintln(w, 63 | fmt.Sprintf("[%s](fg-red)", 64 | strings.Join(processList.Titles, "\t"))) 65 | 66 | if len(processList.Processes) > 1 { 67 | pidColumnIndex := findPIDColumn(processList) 68 | if pidColumnIndex != -1 { 69 | sort.Slice(processList.Processes, 70 | func(i, j int) bool { 71 | return processList.Processes[i][pidColumnIndex] < processList.Processes[j][pidColumnIndex] 72 | }) 73 | } 74 | } 75 | 76 | for _, proc := range processList.Processes { 77 | fmt.Fprintln(w, 78 | fmt.Sprintf("[%s](fg-white)", 79 | strings.Join(proc, "\t"))) 80 | lines++ 81 | } 82 | w.Flush() 83 | p := ui.NewPar(buf.String(), DryTheme) 84 | p.X = x 85 | p.Y = y 86 | p.Height = height - minimumHeight 87 | p.Width = width 88 | p.BorderLabel = " PROCESS LIST " 89 | p.Border = true 90 | p.BorderBottom = false 91 | p.BorderLeft = false 92 | p.BorderRight = false 93 | p.BorderTop = true 94 | 95 | if p.Height < lines { 96 | return p, p.Height 97 | } 98 | 99 | return p, lines 100 | } 101 | return ui.NewPar("", DryTheme), 0 102 | } 103 | 104 | func findPIDColumn(process *container.ContainerTopOKBody) int { 105 | for i, title := range process.Titles { 106 | if title == "PID" { 107 | return i 108 | } 109 | } 110 | return -1 111 | 112 | } 113 | -------------------------------------------------------------------------------- /appui/ui.go: -------------------------------------------------------------------------------- 1 | package appui 2 | 3 | import ( 4 | "github.com/moncho/dry/docker" 5 | ) 6 | 7 | const ( 8 | //DownArrow character 9 | DownArrow = string('\U00002193') 10 | //DownArrowLength is the length of the DownArrow string 11 | DownArrowLength = len(DownArrow) 12 | //RightArrow character 13 | RightArrow = string('\U00002192') 14 | 15 | //MainScreenHeaderSize is the number of lines the header of the main screen uses 16 | MainScreenHeaderSize = 5 17 | //MainScreenFooterLength is the number of lines the footer of the main screen uses 18 | MainScreenFooterLength = 1 19 | //DefaultColumnSpacing defines the minimum space between columns in pixels 20 | DefaultColumnSpacing = 1 21 | //IDColumnWidth defines a fixed width for ID columns 22 | IDColumnWidth = docker.ShortLen 23 | ) 24 | 25 | // CalcItemWidth calculates the width of each item for the given total width and item count 26 | func CalcItemWidth(width, items int) int { 27 | spacing := DefaultColumnSpacing * items 28 | return (width - spacing) / items 29 | } 30 | -------------------------------------------------------------------------------- /appui/ui_events.go: -------------------------------------------------------------------------------- 1 | package appui 2 | 3 | import "github.com/moncho/dry/ui/termui" 4 | 5 | // EventCommand type alias for a func to be run by an EventableWidget 6 | type EventCommand func(string) error 7 | 8 | // EventableWidget interface defines how widgets receive events 9 | type EventableWidget interface { 10 | OnEvent(EventCommand) error 11 | } 12 | 13 | // FilterableWidget interface defines how widgets filter 14 | type FilterableWidget interface { 15 | Filter(filter string) 16 | } 17 | 18 | // SortableWidget interface defines how widgets sort 19 | type SortableWidget interface { 20 | Sort() 21 | } 22 | 23 | // AppWidget groups common behaviour for appui widgets 24 | type AppWidget interface { 25 | termui.Widget 26 | EventableWidget 27 | FilterableWidget 28 | SortableWidget 29 | } 30 | -------------------------------------------------------------------------------- /appui/volume_row.go: -------------------------------------------------------------------------------- 1 | package appui 2 | 3 | import ( 4 | "github.com/docker/docker/api/types/volume" 5 | termui "github.com/gizak/termui" 6 | drytermui "github.com/moncho/dry/ui/termui" 7 | ) 8 | 9 | // VolumeRow is a Grid row showing information about a Docker volume. 10 | type VolumeRow struct { 11 | volume *volume.Volume 12 | Driver *drytermui.ParColumn 13 | Name *drytermui.ParColumn 14 | Row 15 | } 16 | 17 | // NewVolumeRow creates VolumeRow widgets. 18 | func NewVolumeRow(volume *volume.Volume, table drytermui.Table) *VolumeRow { 19 | 20 | row := &VolumeRow{ 21 | volume: volume, 22 | Driver: drytermui.NewThemedParColumn(DryTheme, volume.Driver), 23 | Name: drytermui.NewThemedParColumn(DryTheme, volume.Name), 24 | } 25 | row.Height = 1 26 | row.Table = table 27 | //Columns are rendered following the slice order 28 | row.Columns = []termui.GridBufferer{ 29 | row.Driver, 30 | row.Name, 31 | } 32 | row.ParColumns = []*drytermui.ParColumn{ 33 | row.Driver, 34 | row.Name, 35 | } 36 | 37 | return row 38 | 39 | } 40 | 41 | // ColumnsForFilter returns the columns that are used to filter 42 | func (row *VolumeRow) ColumnsForFilter() []*drytermui.ParColumn { 43 | return []*drytermui.ParColumn{row.Name, row.Driver} 44 | } 45 | -------------------------------------------------------------------------------- /demos/dry-m.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/moncho/dry/ec1c1426b9f9d07ecee968316199953f2ff36134/demos/dry-m.gif -------------------------------------------------------------------------------- /docker/beutifier.go: -------------------------------------------------------------------------------- 1 | package docker 2 | 3 | import ( 4 | "strings" 5 | "time" 6 | 7 | "github.com/docker/go-units" 8 | ) 9 | 10 | const ( 11 | imagePrefixForV10 = "sha256:" 12 | //ShortLen defines the default size of shortened ID 13 | ShortLen = 12 14 | ) 15 | 16 | // DurationForHumans returns a human-readable approximation of a duration 17 | // represented as an int64 nanosecond count. 18 | func DurationForHumans(duration int64) string { 19 | return units.HumanDuration(time.Now().UTC().Sub( 20 | time.Unix(duration, 0))) 21 | 22 | } 23 | 24 | // ImageID removes anything that is not part of the ID but is being added 25 | // by the docker library 26 | func ImageID(uglyID string) string { 27 | id := uglyID 28 | if strings.HasPrefix(uglyID, imagePrefixForV10) { 29 | id = strings.TrimPrefix(uglyID, imagePrefixForV10) 30 | } 31 | return id 32 | } 33 | 34 | // ShortImageID shortens and beutifies an id 35 | func ShortImageID(uglyID string) string { 36 | return TruncateID(ImageID(uglyID)) 37 | } 38 | 39 | // TruncateID returns a shorthand version of a string identifier for convenience. 40 | // A collision with other shorthands is very unlikely, but possible. 41 | // In case of a collision a lookup with TruncIndex.Get() will fail, and the caller 42 | // will need to use a longer prefix, or the full-length Id. 43 | func TruncateID(id string) string { 44 | if i := strings.IndexRune(id, ':'); i >= 0 { 45 | id = id[i+1:] 46 | } 47 | trimTo := ShortLen 48 | if len(id) < ShortLen { 49 | trimTo = len(id) 50 | } 51 | return id[:trimTo] 52 | } 53 | -------------------------------------------------------------------------------- /docker/commands.go: -------------------------------------------------------------------------------- 1 | package docker 2 | 3 | import "errors" 4 | 5 | // Command represents a docker command 6 | type Command int 7 | 8 | const ( 9 | //HISTORY Image history command 10 | HISTORY Command = iota 11 | //INSPECT Inspect command 12 | INSPECT 13 | //KILL kill command 14 | KILL 15 | //LOGS logs command 16 | LOGS 17 | //RM remove command 18 | RM 19 | //RESTART restart command 20 | RESTART 21 | //STATS stats command 22 | STATS 23 | //STOP stop command 24 | STOP 25 | ) 26 | 27 | // ContainerCommands is the list of container commands 28 | var ContainerCommands = []CommandDescription{ 29 | {LOGS, "Fetch logs"}, 30 | {INSPECT, "Inspect container"}, 31 | {KILL, "Kill container"}, 32 | {RM, "Remove container"}, 33 | {RESTART, "Restart"}, 34 | {HISTORY, "Show image history"}, 35 | {STATS, "Stats + Top"}, 36 | {STOP, "Stop"}, 37 | } 38 | 39 | // CommandDescriptions lists command descriptions in the same order 40 | // as they are found in ContainerCommands 41 | var CommandDescriptions = justDescriptions(ContainerCommands) 42 | 43 | // CommandDescription describes docker commands 44 | type CommandDescription struct { 45 | Command Command 46 | Description string 47 | } 48 | 49 | // CommandFromDescription returns the command with the given description, if any 50 | func CommandFromDescription(d string) (Command, error) { 51 | for _, cc := range ContainerCommands { 52 | if cc.Description == d { 53 | return cc.Command, nil 54 | } 55 | } 56 | 57 | return -1, errors.New("Command for description not found") 58 | } 59 | 60 | // justDescriptions extract from the given list of commands a copy 61 | // of the descriptions 62 | func justDescriptions(commands []CommandDescription) []string { 63 | commandsLen := len(commands) 64 | descriptions := make([]string, commandsLen) 65 | for i := 0; i < commandsLen; i++ { 66 | descriptions[i] = commands[i].Description 67 | } 68 | return descriptions 69 | } 70 | -------------------------------------------------------------------------------- /docker/commands_test.go: -------------------------------------------------------------------------------- 1 | package docker 2 | 3 | import "testing" 4 | 5 | func TestCommandFromDescription(t *testing.T) { 6 | type args struct { 7 | d string 8 | } 9 | tests := []struct { 10 | name string 11 | args args 12 | want Command 13 | wantErr bool 14 | }{ 15 | { 16 | "known description returns expected command", 17 | args{ 18 | ContainerCommands[0].Description, 19 | }, 20 | ContainerCommands[0].Command, 21 | false, 22 | }, 23 | { 24 | "invalid description returns error", 25 | args{ 26 | "yup, I dont know", 27 | }, 28 | -1, 29 | true, 30 | }, 31 | } 32 | for _, tt := range tests { 33 | t.Run(tt.name, func(t *testing.T) { 34 | got, err := CommandFromDescription(tt.args.d) 35 | if (err != nil) != tt.wantErr { 36 | t.Errorf("CommandFromDescription() error = %v, wantErr %v", err, tt.wantErr) 37 | return 38 | } 39 | if got != tt.want { 40 | t.Errorf("CommandFromDescription() = %v, want %v", got, tt.want) 41 | } 42 | }) 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /docker/container_config.go: -------------------------------------------------------------------------------- 1 | package docker 2 | 3 | import ( 4 | "strings" 5 | 6 | "github.com/docker/docker/api/types/container" 7 | "github.com/docker/docker/api/types/strslice" 8 | "github.com/docker/go-connections/nat" 9 | ) 10 | 11 | type containerConfigBuilder struct { 12 | config container.Config 13 | hostConfig container.HostConfig 14 | err error 15 | } 16 | 17 | func newCCB() *containerConfigBuilder { 18 | return &containerConfigBuilder{ 19 | config: container.Config{}, 20 | hostConfig: container.HostConfig{}, 21 | } 22 | } 23 | func (cc *containerConfigBuilder) build() (container.Config, container.HostConfig, error) { 24 | return cc.config, cc.hostConfig, cc.err 25 | } 26 | 27 | func (cc *containerConfigBuilder) image(image string) *containerConfigBuilder { 28 | cc.config.Image = image 29 | return cc 30 | } 31 | 32 | func (cc *containerConfigBuilder) command(command string) *containerConfigBuilder { 33 | if command != "" { 34 | splitCommand := strings.Split(command, " ") 35 | if len(splitCommand) > 0 { 36 | cc.config.Cmd = strslice.StrSlice(splitCommand) 37 | } 38 | } 39 | return cc 40 | } 41 | 42 | func (cc *containerConfigBuilder) ports(portSet nat.PortSet) *containerConfigBuilder { 43 | if len(portSet) > 0 { 44 | cc.config.ExposedPorts = portSet 45 | bindings := make(map[nat.Port][]nat.PortBinding) 46 | for rawPort := range portSet { 47 | portMappings, err := nat.ParsePortSpec(rawPort.Port()) 48 | if err != nil { 49 | cc.err = err 50 | break 51 | } 52 | 53 | for _, portMapping := range portMappings { 54 | port := portMapping.Port 55 | bindings[port] = append(bindings[port], nat.PortBinding{ 56 | HostPort: rawPort.Port(), 57 | }) 58 | } 59 | } 60 | cc.hostConfig.PortBindings = bindings 61 | 62 | } 63 | return cc 64 | } 65 | -------------------------------------------------------------------------------- /docker/container_config_test.go: -------------------------------------------------------------------------------- 1 | package docker 2 | 3 | import ( 4 | "reflect" 5 | "strings" 6 | "testing" 7 | 8 | "github.com/docker/go-connections/nat" 9 | 10 | "github.com/docker/docker/api/types/container" 11 | ) 12 | 13 | func Test_containerConfigBuilder_build(t *testing.T) { 14 | type fields struct { 15 | image string 16 | command string 17 | ports nat.PortSet 18 | } 19 | tests := []struct { 20 | name string 21 | fields fields 22 | want container.Config 23 | want1 container.HostConfig 24 | wantErr bool 25 | }{ 26 | { 27 | "no information given to the builder -> empty configs", 28 | fields{ 29 | "", 30 | "", 31 | nat.PortSet{}, 32 | }, 33 | container.Config{}, 34 | container.HostConfig{}, 35 | false, 36 | }, 37 | { 38 | "image and command given to the builder -> no errors", 39 | fields{ 40 | "image", 41 | "command", 42 | nat.PortSet{}, 43 | }, 44 | container.Config{ 45 | Image: "image", 46 | Cmd: strings.Split("command", " "), 47 | }, 48 | container.HostConfig{}, 49 | false, 50 | }, 51 | { 52 | "ports are set -> port configuration is created", 53 | fields{ 54 | "image", 55 | "command", 56 | map[nat.Port]struct{}{ 57 | "8080:8080": {}, 58 | }, 59 | }, 60 | container.Config{ 61 | Image: "image", 62 | Cmd: strings.Split("command", " "), 63 | ExposedPorts: map[nat.Port]struct{}{ 64 | "8080:8080": {}, 65 | }, 66 | }, 67 | container.HostConfig{ 68 | PortBindings: map[nat.Port][]nat.PortBinding{ 69 | "8080/tcp": {{ 70 | HostPort: "8080:8080", 71 | }}, 72 | }, 73 | }, 74 | false, 75 | }, 76 | { 77 | "invalid port set -> error is reported", 78 | fields{ 79 | "image", 80 | "command", 81 | map[nat.Port]struct{}{ 82 | "asd": {}, 83 | }, 84 | }, 85 | container.Config{ 86 | Image: "image", 87 | Cmd: strings.Split("command", " "), 88 | }, 89 | container.HostConfig{}, 90 | true, 91 | }, 92 | } 93 | for _, tt := range tests { 94 | t.Run(tt.name, func(t *testing.T) { 95 | cc := newCCB() 96 | cc = cc.command(tt.fields.command). 97 | image(tt.fields.image). 98 | ports(tt.fields.ports) 99 | got, got1, err := cc.build() 100 | if (err != nil) != tt.wantErr { 101 | t.Errorf("containerConfigBuilder.build() error = %v, wantErr %v", err, tt.wantErr) 102 | return 103 | } else if tt.wantErr == true { 104 | return 105 | } 106 | if !reflect.DeepEqual(got, tt.want) { 107 | t.Errorf("containerConfigBuilder.build() config = %v, want %v", got, tt.want) 108 | } 109 | if !reflect.DeepEqual(got1, tt.want1) { 110 | t.Errorf("containerConfigBuilder.build() hostConfig = %v, want %v", got1, tt.want1) 111 | } 112 | }) 113 | } 114 | } 115 | -------------------------------------------------------------------------------- /docker/daemon_test.go: -------------------------------------------------------------------------------- 1 | package docker 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "testing" 7 | "time" 8 | 9 | dockerTypes "github.com/docker/docker/api/types" 10 | "github.com/docker/docker/api/types/filters" 11 | "github.com/docker/docker/api/types/image" 12 | dockerAPI "github.com/docker/docker/client" 13 | ) 14 | 15 | type imageAPIClientMock struct { 16 | dockerAPI.APIClient 17 | err error 18 | imagesDeleted []image.DeleteResponse 19 | } 20 | 21 | func (c imageAPIClientMock) ImagesPrune(ctx context.Context, f filters.Args) (dockerTypes.ImagesPruneReport, error) { 22 | return dockerTypes.ImagesPruneReport{ 23 | ImagesDeleted: c.imagesDeleted, 24 | }, c.err 25 | } 26 | 27 | type timedOutImageAPIClientMock struct { 28 | dockerAPI.APIClient 29 | } 30 | 31 | func (c timedOutImageAPIClientMock) ImagesPrune(ctx context.Context, f filters.Args) (dockerTypes.ImagesPruneReport, error) { 32 | ctx, cancel := context.WithTimeout(ctx, 500*time.Millisecond) 33 | defer cancel() 34 | select { 35 | case <-time.After(600 * time.Millisecond): 36 | return dockerTypes.ImagesPruneReport{}, nil 37 | case <-ctx.Done(): //This should always happen 38 | return dockerTypes.ImagesPruneReport{}, ctx.Err() 39 | } 40 | } 41 | func TestDockerDaemon_RemoveUnusedImages(t *testing.T) { 42 | type fields struct { 43 | client dockerAPI.APIClient 44 | } 45 | tests := []struct { 46 | name string 47 | fields fields 48 | want int 49 | wantErr bool 50 | }{ 51 | { 52 | "Remove unused images, 1 image deleted, no errors", 53 | fields{ 54 | client: imageAPIClientMock{ 55 | imagesDeleted: []image.DeleteResponse{ 56 | {}, 57 | }, 58 | }, 59 | }, 60 | 1, 61 | false, 62 | }, 63 | { 64 | "Remove unused images fails", 65 | fields{ 66 | client: imageAPIClientMock{ 67 | err: errors.New("Not today"), 68 | }, 69 | }, 70 | 0, 71 | true, 72 | }, 73 | { 74 | "Remove unused imagess timeout", 75 | fields{ 76 | client: timedOutImageAPIClientMock{}, 77 | }, 78 | 0, 79 | true, 80 | }, 81 | } 82 | for _, tt := range tests { 83 | t.Run(tt.name, func(t *testing.T) { 84 | daemon := &DockerDaemon{ 85 | client: tt.fields.client, 86 | } 87 | got, err := daemon.RemoveUnusedImages() 88 | if (err != nil) != tt.wantErr { 89 | t.Errorf("DockerDaemon.RemoveUnusedImages() error = %v, wantErr %v", err, tt.wantErr) 90 | return 91 | } 92 | if got != tt.want { 93 | t.Errorf("DockerDaemon.RemoveUnusedImages() = %v, want %v", got, tt.want) 94 | } 95 | }) 96 | } 97 | } 98 | -------------------------------------------------------------------------------- /docker/docker_environment.go: -------------------------------------------------------------------------------- 1 | package docker 2 | 3 | import ( 4 | "os" 5 | 6 | "github.com/docker/docker/api" 7 | ) 8 | 9 | // Env holds Docker-related environment variables 10 | type Env struct { 11 | DockerHost string 12 | DockerTLSVerify bool //tls must be verified 13 | DockerCertPath string 14 | DockerAPIVersion string 15 | } 16 | 17 | // NewEnv creates a new docker environment struct 18 | func NewEnv() Env { 19 | version := os.Getenv("DOCKER_API_VERSION") 20 | if version == "" { 21 | version = api.DefaultVersion 22 | } 23 | return Env{DockerAPIVersion: version} 24 | } 25 | -------------------------------------------------------------------------------- /docker/docker_test.go: -------------------------------------------------------------------------------- 1 | package docker 2 | 3 | import ( 4 | "strconv" 5 | "testing" 6 | 7 | "github.com/docker/docker/api/types" 8 | dockerAPI "github.com/docker/docker/client" 9 | "github.com/moncho/dry/docker/mock" 10 | ) 11 | 12 | func TestContainerListRetrieval(t *testing.T) { 13 | c, _ := containers(createClient()) 14 | 15 | for i, container := range c { 16 | if container.ID != strconv.Itoa(i) { 17 | t.Errorf("Invalid container %v", container) 18 | } 19 | } 20 | } 21 | 22 | func TestContainerConversionToPointerList(t *testing.T) { 23 | var containers []types.Container 24 | 25 | for i := 0; i < 10; i++ { 26 | containers = append(containers, types.Container{ 27 | ID: strconv.Itoa(i), 28 | }) 29 | } 30 | var cPointers []*types.Container 31 | for i := range containers { 32 | cPointers = append(cPointers, &containers[i]) 33 | } 34 | 35 | for i, container := range cPointers { 36 | if container.ID != strconv.Itoa(i) { 37 | t.Errorf("Invalid container %v", container) 38 | } 39 | } 40 | } 41 | func createClient() dockerAPI.ContainerAPIClient { 42 | return mock.ContainerAPIClientMock{} 43 | } 44 | -------------------------------------------------------------------------------- /docker/event_listener.go: -------------------------------------------------------------------------------- 1 | package docker 2 | 3 | import ( 4 | "context" 5 | "sync" 6 | 7 | "github.com/docker/docker/api/types/events" 8 | ) 9 | 10 | // SourceType is a representation of the sources types that might emit Docker events 11 | type SourceType string 12 | 13 | const ( 14 | //ContainerSource for events emitted by Docker containers 15 | ContainerSource = SourceType("container") 16 | 17 | //DaemonSource for events emitted by the Docker daemon 18 | DaemonSource = SourceType("daemon") 19 | 20 | //ImageSource for events emitted by Docker images 21 | ImageSource = SourceType("image") 22 | 23 | //NetworkSource for events emitted by Docker networks 24 | NetworkSource = SourceType("network") 25 | 26 | //PluginSource for events emitted by Docker plugins 27 | PluginSource = SourceType("plugin") 28 | 29 | //VolumeSource for events emitted by Docker volumes 30 | VolumeSource = SourceType("volume") 31 | 32 | //ServiceSource for events emitted by Docker services 33 | ServiceSource = SourceType("service") 34 | 35 | //NodeSource for events emitted by Docker nodes 36 | NodeSource = SourceType("node") 37 | 38 | //SecretSource for events emitted by Docker secrets 39 | SecretSource = SourceType("secret") 40 | ) 41 | 42 | // CallbackRegistry d 43 | type CallbackRegistry interface { 44 | Register(actor SourceType, callback EventCallback) 45 | } 46 | 47 | // GlobalRegistry is a globally available CallbackRegistry 48 | var GlobalRegistry CallbackRegistry 49 | 50 | // callbackNotifier should be registered to receive events from Docker 51 | var callbackNotifier EventCallback 52 | 53 | func init() { 54 | r := ®istry{actions: make(map[SourceType][]EventCallback)} 55 | GlobalRegistry = r 56 | callbackNotifier = notifyCallbacks(r) 57 | } 58 | 59 | type registry struct { 60 | actions map[SourceType][]EventCallback 61 | sync.RWMutex 62 | } 63 | 64 | // Register registers the interest of the given callback on messages from the given source 65 | func (r *registry) Register(source SourceType, callback EventCallback) { 66 | r.Lock() 67 | defer r.Unlock() 68 | 69 | r.actions[source] = append(r.actions[source], callback) 70 | } 71 | 72 | func notifyCallbacks(r *registry) EventCallback { 73 | return func(ctx context.Context, message events.Message) error { 74 | r.RLock() 75 | defer r.RUnlock() 76 | actor := SourceType(message.Type) 77 | for _, c := range r.actions[actor] { 78 | select { 79 | case <-ctx.Done(): 80 | return ctx.Err() 81 | default: 82 | go func(callback EventCallback) { 83 | callback(ctx, message) 84 | }(c) 85 | } 86 | } 87 | return nil 88 | } 89 | } 90 | -------------------------------------------------------------------------------- /docker/events.go: -------------------------------------------------------------------------------- 1 | package docker 2 | 3 | import ( 4 | "errors" 5 | 6 | "context" 7 | 8 | "github.com/docker/docker/api/types/events" 9 | ) 10 | 11 | // EventCallback defines a callback function for messages 12 | type EventCallback func(ctx context.Context, event events.Message) error 13 | 14 | // streamEvents sends incoming events to the provided channel. 15 | func streamEvents(out chan<- events.Message) EventCallback { 16 | return func(ctx context.Context, event events.Message) error { 17 | select { 18 | case <-ctx.Done(): 19 | case out <- event: 20 | } 21 | return nil 22 | } 23 | } 24 | 25 | func logEvents(log *EventLog) EventCallback { 26 | return func(ctx context.Context, event events.Message) error { 27 | if log == nil { 28 | return errors.New("No logger given") 29 | } 30 | log.Push(&event) 31 | return nil 32 | } 33 | } 34 | 35 | func handleEvent( 36 | ctx context.Context, 37 | event events.Message, 38 | processors ...EventCallback) error { 39 | 40 | for _, ep := range processors { 41 | go ep(ctx, event) 42 | } 43 | 44 | return nil 45 | } 46 | -------------------------------------------------------------------------------- /docker/events_log.go: -------------------------------------------------------------------------------- 1 | package docker 2 | 3 | import ( 4 | "sync" 5 | 6 | "github.com/docker/docker/api/types/events" 7 | ) 8 | 9 | const ( 10 | //DefaultCapacity of a new EventLog. 11 | DefaultCapacity = 50 12 | ) 13 | 14 | // EventLog keeps track of docker events. It has a limited capacity and 15 | // behaves as a Circular Buffer - adding a new event removes the oldest one if 16 | // the buffer is at its max capacity. 17 | type EventLog struct { 18 | head int // the most recent value written 19 | tail int // the least recent value written 20 | capacity int 21 | messages []*events.Message 22 | sync.RWMutex 23 | } 24 | 25 | // NewEventLog creates an event log with the default capacity 26 | func NewEventLog() *EventLog { 27 | log := &EventLog{} 28 | log.Init(DefaultCapacity) 29 | 30 | return log 31 | } 32 | 33 | // Capacity returns the capacity of the event log. 34 | func (el *EventLog) Capacity() int { 35 | return el.capacity 36 | } 37 | 38 | // Count returns the number of events in the buffer 39 | func (el *EventLog) Count() int { 40 | return el.tail - el.head 41 | } 42 | 43 | // Events returns a copy of the event buffer 44 | func (el *EventLog) Events() []events.Message { 45 | el.RLock() 46 | defer el.RUnlock() 47 | if el.Count() == 0 { 48 | return nil 49 | } 50 | messages := make([]events.Message, el.Count()) 51 | for i, message := range el.messages[el.head:el.tail] { 52 | messages[i] = *message 53 | } 54 | return messages 55 | } 56 | 57 | // Init sets the log in a working state. Must be 58 | // called before doing any other operation 59 | func (el *EventLog) Init(capacity int) { 60 | el.messages = make([]*events.Message, capacity, capacity*2) 61 | el.capacity = capacity 62 | } 63 | 64 | // Peek the latest event added 65 | func (el *EventLog) Peek() *events.Message { 66 | el.RLock() 67 | defer el.RUnlock() 68 | return el.messages[el.tail-1] 69 | } 70 | 71 | // Push the given event to this log 72 | func (el *EventLog) Push(message *events.Message) { 73 | el.Lock() 74 | defer el.Unlock() 75 | // if the array is full, rewind 76 | if el.tail == el.capacity { 77 | el.rewind() 78 | } 79 | el.messages[el.tail] = message 80 | // check if the buffer is full, 81 | // and move head pointer appropriately 82 | if el.tail-el.head >= el.capacity { 83 | el.head++ 84 | } 85 | el.tail++ 86 | } 87 | 88 | func (el *EventLog) rewind() { 89 | l := len(el.messages) 90 | for i := 0; i < el.capacity-1; i++ { 91 | el.messages[i] = el.messages[l-el.capacity+i+1] 92 | } 93 | el.head, el.tail = 0, el.capacity-1 94 | } 95 | -------------------------------------------------------------------------------- /docker/events_log_test.go: -------------------------------------------------------------------------------- 1 | package docker 2 | 3 | import ( 4 | "strconv" 5 | "testing" 6 | 7 | "github.com/docker/docker/api/types/events" 8 | ) 9 | 10 | func TestEventLogCreation(t *testing.T) { 11 | eventLog := EventLog{} 12 | eventLog.Init(5) 13 | if eventLog.Capacity() != 5 || eventLog.Count() != 0 { 14 | t.Errorf("Event log did not initialize correctly: %d, %d", eventLog.Capacity(), eventLog.Count()) 15 | } else if eventLog.head != 0 || eventLog.tail != 0 { 16 | t.Errorf("Event log state is: head %d, tail %d", eventLog.head, eventLog.tail) 17 | } 18 | } 19 | 20 | func TestEventLog(t *testing.T) { 21 | eventLog := EventLog{} 22 | eventLog.Init(5) 23 | eventLog.Push(&events.Message{Action: "1"}) 24 | eventLog.Push(&events.Message{Action: "2"}) 25 | eventLog.Push(&events.Message{Action: "3"}) 26 | eventLog.Push(&events.Message{Action: "4"}) 27 | if eventLog.Count() != 4 { 28 | t.Errorf("Event log is reporting a wrong number of events: %d", eventLog.Count()) 29 | } 30 | eventLog.Push(&events.Message{Action: "5"}) 31 | eventLog.Push(&events.Message{Action: "6"}) 32 | if eventLog.Count() != 5 { 33 | t.Errorf("Event log is reporting a wrong number of events: %d", eventLog.Count()) 34 | } 35 | 36 | if eventLog.Peek().Action != "6" { 37 | t.Errorf("Last message is not correct: %s", eventLog.Peek().Action) 38 | } 39 | for i, event := range eventLog.Events() { 40 | if string(event.Action) != strconv.Itoa(i+2) { 41 | t.Errorf("Last message is not correct: %s", event.Action) 42 | } 43 | } 44 | if eventLog.Capacity() != 5 || eventLog.Count() != 5 { 45 | t.Errorf("Event log is reporting a wrong number of elements: %d, %d", eventLog.Capacity(), eventLog.Count()) 46 | } 47 | } 48 | 49 | func TestEventLogCapacity(t *testing.T) { 50 | eventLog := EventLog{} 51 | eventLog.Init(5) 52 | for i := 0; i < 100; i++ { 53 | eventLog.Push(&events.Message{Action: events.Action(strconv.Itoa(i))}) 54 | } 55 | if eventLog.Capacity() != 5 || eventLog.Count() != 5 { 56 | t.Errorf("Event log is reporting a wrong number of elements: %d, %d", eventLog.Capacity(), eventLog.Count()) 57 | } 58 | 59 | if eventLog.Peek().Action != "99" { 60 | t.Errorf("Last message is not correct: %s", eventLog.Peek().Action) 61 | } 62 | } 63 | 64 | func BenchmarkEventLog(b *testing.B) { 65 | eventLog := NewEventLog() 66 | for i := 0; i < b.N; i++ { 67 | eventLog.Push(&events.Message{Action: events.Action(strconv.Itoa(i))}) 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /docker/filter.go: -------------------------------------------------------------------------------- 1 | package docker 2 | 3 | import "strings" 4 | 5 | // ContainerFilter defines a function to filter container 6 | type ContainerFilter func(*Container) bool 7 | 8 | // ContainerFilters is a holder of predefined ContainerFilter(s) 9 | // The intentions is that something like 'ContainerFilters.ByName("name")' 10 | // can be used to declare a filter. 11 | var ContainerFilters ContainerFilter 12 | 13 | // Unfiltered does not filter containers 14 | func (cf ContainerFilter) Unfiltered() ContainerFilter { 15 | return func(c *Container) bool { return true } 16 | } 17 | 18 | // ByName filters containers by name 19 | func (cf ContainerFilter) ByName(name string) ContainerFilter { 20 | return func(c *Container) bool { 21 | for _, containerName := range c.Names { 22 | 23 | if strings.Contains(containerName, name) { 24 | return true 25 | } 26 | } 27 | return false 28 | } 29 | } 30 | 31 | // ByID filters containers by ID 32 | func (cf ContainerFilter) ByID(id string) ContainerFilter { 33 | return func(c *Container) bool { 34 | return strings.Contains(c.ID, id) 35 | } 36 | } 37 | 38 | // ByRunningState filters containers by its running state 39 | func (cf ContainerFilter) ByRunningState(running bool) ContainerFilter { 40 | return func(c *Container) bool { 41 | return IsContainerRunning(c) == running 42 | } 43 | } 44 | 45 | // Running filters out container that are not running 46 | func (cf ContainerFilter) Running() ContainerFilter { 47 | return cf.ByRunningState(true) 48 | } 49 | 50 | // NotRunning filters out container that are running 51 | func (cf ContainerFilter) NotRunning() ContainerFilter { 52 | return cf.ByRunningState(false) 53 | } 54 | 55 | // Apply applies this filter to the given slice of containers 56 | func (cf ContainerFilter) Apply(c []*Container) []*Container { 57 | var containers []*Container 58 | for _, cont := range c { 59 | if cf(cont) { 60 | containers = append(containers, cont) 61 | } 62 | } 63 | return containers 64 | } 65 | -------------------------------------------------------------------------------- /docker/filter_test.go: -------------------------------------------------------------------------------- 1 | package docker 2 | 3 | import ( 4 | "testing" 5 | 6 | dockerTypes "github.com/docker/docker/api/types" 7 | ) 8 | 9 | func TestFilterByName(t *testing.T) { 10 | 11 | c := &Container{ 12 | Container: dockerTypes.Container{Names: []string{"bla"}}, 13 | } 14 | 15 | filter := ContainerFilters.ByName("bla") 16 | if !filter(c) { 17 | t.Error("Filter by name is filtering out when it should not") 18 | } 19 | 20 | c = &Container{ 21 | Container: dockerTypes.Container{Names: []string{"123"}}, 22 | } 23 | if filter(c) { 24 | t.Error("Filter by name is not filtering") 25 | } 26 | 27 | c = &Container{ 28 | Container: dockerTypes.Container{Names: []string{"123bla123"}}, 29 | } 30 | if !filter(c) { 31 | t.Error("Filter by name is filtering out when it should not") 32 | 33 | } 34 | 35 | } 36 | 37 | func TestFilterByID(t *testing.T) { 38 | 39 | c := &Container{ 40 | Container: dockerTypes.Container{ID: "bla"}, 41 | } 42 | 43 | filter := ContainerFilters.ByID("bla") 44 | if !filter(c) { 45 | t.Error("Filter by ID is filtering out when it should not") 46 | } 47 | 48 | c = &Container{ 49 | Container: dockerTypes.Container{ID: "123"}, 50 | } 51 | if filter(c) { 52 | t.Error("Filter by ID is not filtering") 53 | } 54 | 55 | c = &Container{ 56 | Container: dockerTypes.Container{ID: "123bla123"}, 57 | } 58 | if !filter(c) { 59 | t.Error("Filter by ID is filtering out when it should not") 60 | 61 | } 62 | 63 | } 64 | -------------------------------------------------------------------------------- /docker/formatter/id.go: -------------------------------------------------------------------------------- 1 | package formatter 2 | 3 | import ( 4 | "strings" 5 | ) 6 | 7 | const shortLen = 12 8 | 9 | // TruncateID returns a shorthand version of the given string identifier 10 | func TruncateID(id string) string { 11 | if i := strings.IndexRune(id, ':'); i >= 0 { 12 | id = id[i+1:] 13 | } 14 | if len(id) > shortLen { 15 | id = id[:shortLen] 16 | } 17 | return id 18 | } 19 | -------------------------------------------------------------------------------- /docker/formatter/id_test.go: -------------------------------------------------------------------------------- 1 | package formatter 2 | 3 | import "testing" 4 | 5 | func TestTruncateID(t *testing.T) { 6 | 7 | tests := []struct { 8 | name string 9 | args string 10 | want string 11 | }{ 12 | { 13 | "Truncate id", 14 | "129a7f8c0fae8c3251a8df9370577d9d6b96ca6f1bea33ada677bc153d7b1867", 15 | "129a7f8c0fae", 16 | }, 17 | { 18 | "Truncate SHA256 id", 19 | "sha256:129a7f8c0fae8c3251a8df9370577d9d6b96ca6f1bea33ada677bc153d7b1867", 20 | "129a7f8c0fae", 21 | }, 22 | { 23 | "Truncate empty id", 24 | "", 25 | "", 26 | }, 27 | } 28 | for _, tt := range tests { 29 | t.Run(tt.name, func(t *testing.T) { 30 | if got := TruncateID(tt.args); got != tt.want { 31 | t.Errorf("TruncateID() = %v, want %v", got, tt.want) 32 | } 33 | }) 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /docker/formatter/image_formatter_test.go: -------------------------------------------------------------------------------- 1 | package formatter 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/docker/docker/api/types/image" 7 | ) 8 | 9 | func TestImageNameFormatting(t *testing.T) { 10 | formatter := ImageFormatter{ 11 | trunc: false, 12 | image: image.Summary{ 13 | RepoTags: []string{"nginx:1.10.0-alpine"}, 14 | }, 15 | } 16 | 17 | repo := formatter.Repository() 18 | tag := formatter.Tag() 19 | if repo != "nginx" { 20 | t.Errorf("Repo value not what expected after formatting: %s", repo) 21 | } else if tag != "1.10.0-alpine" { 22 | t.Errorf("Tag value not what expected after formatting: %s", tag) 23 | } 24 | } 25 | func TestImageNameFormattingPrivateRegistry(t *testing.T) { 26 | formatter := ImageFormatter{ 27 | trunc: false, 28 | image: image.Summary{ 29 | RepoTags: []string{"localhost:5000/nginx:1.10.0-alpine"}, 30 | }, 31 | } 32 | 33 | repo := formatter.Repository() 34 | tag := formatter.Tag() 35 | if repo != "localhost:5000/nginx" { 36 | t.Errorf("Repo value not what expected after formatting: %s", repo) 37 | } else if tag != "1.10.0-alpine" { 38 | t.Errorf("Tag value not what expected after formatting: %s", tag) 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /docker/formatter/pretty.go: -------------------------------------------------------------------------------- 1 | package formatter 2 | 3 | import ( 4 | "fmt" 5 | "strings" 6 | ) 7 | 8 | // capitalizeFirst capitalizes the first character of string 9 | func capitalizeFirst(s string) string { 10 | switch l := len(s); l { 11 | case 0: 12 | return s 13 | case 1: 14 | return strings.ToLower(s) 15 | default: 16 | return strings.ToUpper(string(s[0])) + strings.ToLower(s[1:]) 17 | } 18 | } 19 | 20 | // PrettyPrint creates a string representation of the given vale for human formatted output by uppercasing the first letter. 21 | func PrettyPrint(i interface{}) string { 22 | switch t := i.(type) { 23 | case nil: 24 | return "None" 25 | case string: 26 | return capitalizeFirst(t) 27 | default: 28 | return capitalizeFirst(fmt.Sprintf("%s", t)) 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /docker/formatter/swarm.go: -------------------------------------------------------------------------------- 1 | package formatter 2 | 3 | import ( 4 | "fmt" 5 | "strings" 6 | 7 | "github.com/docker/docker/api/types/swarm" 8 | ) 9 | 10 | // FormatPorts returns the string representation of the given PortConfig 11 | func FormatPorts(ports []swarm.PortConfig) string { 12 | result := []string{} 13 | for _, pConfig := range ports { 14 | result = append(result, fmt.Sprintf("*:%d->%d/%s", 15 | pConfig.PublishedPort, 16 | pConfig.TargetPort, 17 | pConfig.Protocol, 18 | )) 19 | } 20 | return strings.Join(result, ",") 21 | } 22 | 23 | // FormatSwarmNetworks returns the string representation of the given slice of NetworkAttachmentConfig 24 | func FormatSwarmNetworks(networks []swarm.NetworkAttachmentConfig) string { 25 | result := []string{} 26 | for _, network := range networks { 27 | result = append(result, network.Target) 28 | } 29 | return strings.Join(result, ",") 30 | } 31 | -------------------------------------------------------------------------------- /docker/images.go: -------------------------------------------------------------------------------- 1 | package docker 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | 7 | "github.com/docker/docker/api/types/container" 8 | "github.com/docker/docker/api/types/image" 9 | "golang.org/x/net/context" 10 | ) 11 | 12 | // History returns image history 13 | func (daemon *DockerDaemon) History(id string) ([]image.HistoryResponseItem, error) { 14 | 15 | ctx, cancel := context.WithTimeout(context.Background(), defaultOperationTimeout) 16 | defer cancel() 17 | 18 | return daemon.client.ImageHistory( 19 | ctx, id) 20 | } 21 | 22 | // ImageByID returns the image with the given ID 23 | func (daemon *DockerDaemon) ImageByID(id string) (image.Summary, error) { 24 | var result image.Summary 25 | images, err := daemon.Images() 26 | if err != nil { 27 | return result, err 28 | } 29 | for _, image := range images { 30 | if image.ID == id { 31 | return image, nil 32 | } 33 | } 34 | 35 | return result, fmt.Errorf("image %s not found", id) 36 | 37 | } 38 | 39 | // Images returns the list of Docker images 40 | func (daemon *DockerDaemon) Images() ([]image.Summary, error) { 41 | 42 | return images(daemon.client, defaultImageListOptions) 43 | 44 | } 45 | 46 | // RunImage creates a container based on the given image and runs the given command 47 | // Kind of like running "docker run $image $command" from the command line. 48 | func (daemon *DockerDaemon) RunImage(image image.Summary, command string) error { 49 | ctx, cancel := context.WithTimeout(context.Background(), defaultOperationTimeout) 50 | defer cancel() 51 | 52 | var imageName string 53 | if len(image.RepoTags) > 0 { 54 | imageName = image.RepoTags[0] 55 | } else if len(image.RepoDigests) > 0 { 56 | imageName = image.RepoDigests[0] 57 | 58 | } else { 59 | return errors.New("run image: image has no tag or digest") 60 | } 61 | 62 | imageDetails, err := daemon.InspectImage(imageName) 63 | if err != nil { 64 | return fmt.Errorf("run image: inspect image %s: %w", imageName, err) 65 | } 66 | 67 | cc, hc, err := newCCB().image(imageName).command(command).ports(imageDetails.ContainerConfig.ExposedPorts).build() 68 | if err != nil { 69 | return fmt.Errorf("run image: %w", err) 70 | } 71 | 72 | cCreated, err := daemon.client.ContainerCreate(ctx, &cc, &hc, nil, nil, "") 73 | 74 | if err != nil { 75 | return fmt.Errorf("run image: create container for image %s: %w", imageName, err) 76 | } 77 | 78 | if err := daemon.client.ContainerStart(ctx, cCreated.ID, container.StartOptions{}); err != nil { 79 | return fmt.Errorf("run image: start container for image %s: %w", imageName, err) 80 | 81 | } 82 | return nil 83 | } 84 | -------------------------------------------------------------------------------- /docker/images_test.go: -------------------------------------------------------------------------------- 1 | package docker 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/docker/docker/api/types/image" 7 | "github.com/moncho/dry/docker/mock" 8 | ) 9 | 10 | func TestImageRun(t *testing.T) { 11 | daemon := DockerDaemon{client: mock.ImageAPIClientMock{}} 12 | err := daemon.RunImage(image.Summary{ 13 | RepoTags: []string{"nope:latest"}, 14 | }, "command") 15 | 16 | if err != nil { 17 | t.Errorf("Running an image resulted in error %s", err.Error()) 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /docker/memory_store.go: -------------------------------------------------------------------------------- 1 | package docker 2 | 3 | import ( 4 | "sync" 5 | 6 | dockerAPI "github.com/docker/docker/client" 7 | ) 8 | 9 | // ContainerStore defines a container storage. 10 | type ContainerStore interface { 11 | Get(id string) *Container 12 | List() []*Container 13 | Remove(id string) 14 | Size() int 15 | } 16 | 17 | // inMemoryContainerStore is an in-memory container store backed up by a Docker daemon. 18 | type inMemoryContainerStore struct { 19 | s map[string]*Container 20 | c []*Container 21 | client dockerAPI.ContainerAPIClient 22 | sync.RWMutex 23 | } 24 | 25 | // NewDockerContainerStore creates a new Docker container store that will use the given Docker 26 | // daemon client to retrieve container information. 27 | func NewDockerContainerStore(client dockerAPI.ContainerAPIClient) (ContainerStore, error) { 28 | containers, err := containers(client) 29 | if err != nil { 30 | return nil, err 31 | } 32 | store := &inMemoryContainerStore{ 33 | s: make(map[string]*Container), 34 | client: client, 35 | } 36 | for _, container := range containers { 37 | store.add(container) 38 | } 39 | return store, nil 40 | } 41 | 42 | func (c *inMemoryContainerStore) add(cont *Container) { 43 | c.Lock() 44 | //If a container with the given ID exists already it is replaced 45 | if _, ok := c.s[cont.ID]; ok { 46 | for pos, container := range c.c { 47 | if container.ID == cont.ID { 48 | c.c = append(c.c[0:pos], c.c[pos:]...) 49 | break 50 | } 51 | } 52 | } else { 53 | c.c = append(c.c, cont) 54 | } 55 | c.s[cont.ID] = cont 56 | c.Unlock() 57 | 58 | } 59 | 60 | // Get returns a container from the store by id. 61 | func (c *inMemoryContainerStore) Get(id string) *Container { 62 | c.RLock() 63 | res := c.s[id] 64 | c.RUnlock() 65 | return res 66 | } 67 | 68 | // List returns a list of containers from the store. 69 | func (c *inMemoryContainerStore) List() []*Container { 70 | return c.all(nil) 71 | } 72 | 73 | // Remove removes a container from the store by id. 74 | func (c *inMemoryContainerStore) Remove(id string) { 75 | c.Lock() 76 | delete(c.s, id) 77 | for pos, container := range c.c { 78 | if container.ID == id { 79 | c.c = append(c.c[0:pos], c.c[pos+1:]...) 80 | break 81 | } 82 | } 83 | c.Unlock() 84 | } 85 | 86 | // Size returns the number of containers in the store. 87 | func (c *inMemoryContainerStore) Size() int { 88 | c.RLock() 89 | defer c.RUnlock() 90 | return len(c.c) 91 | } 92 | 93 | // Filter returns containers found in the store by the given filter. 94 | func (c *inMemoryContainerStore) Filter(filter ContainerFilter) []*Container { 95 | return c.all(filter) 96 | } 97 | 98 | func (c *inMemoryContainerStore) all(filter ContainerFilter) []*Container { 99 | c.RLock() 100 | defer c.RUnlock() 101 | 102 | var containers []*Container 103 | for _, cont := range c.c { 104 | if filter == nil || filter(cont) { 105 | containers = append(containers, cont) 106 | } 107 | } 108 | return containers 109 | } 110 | -------------------------------------------------------------------------------- /docker/memory_store_test.go: -------------------------------------------------------------------------------- 1 | package docker 2 | 3 | import ( 4 | "strconv" 5 | "testing" 6 | 7 | dockerTypes "github.com/docker/docker/api/types" 8 | "github.com/moncho/dry/docker/mock" 9 | ) 10 | 11 | var testContainers = createTestContainers(10) 12 | var containerCount = len(testContainers) 13 | var hundredContainers = createTestContainers(100) 14 | 15 | func BenchmarkMemoryStoreContainerCreation(b *testing.B) { 16 | c := mock.ContainerAPIClientMock{Containers: hundredContainers} 17 | for i := 0; i < b.N; i++ { 18 | NewDockerContainerStore(c) 19 | } 20 | } 21 | 22 | func BenchmarkMemoryStoreContainerListing(b *testing.B) { 23 | c := mock.ContainerAPIClientMock{Containers: hundredContainers} 24 | memStore, _ := NewDockerContainerStore(c) 25 | b.ResetTimer() 26 | for i := 0; i < b.N; i++ { 27 | memStore.List() 28 | } 29 | } 30 | 31 | func TestMemoryStoreCreation(t *testing.T) { 32 | c := mock.ContainerAPIClientMock{} 33 | memStore, _ := NewDockerContainerStore(c) 34 | if memStore == nil { 35 | t.Error("Memstore is nil") 36 | } 37 | if memStore.Size() != containerCount { 38 | t.Errorf("Memstore does not contain the expected the number of elements, expected: %d, got: %d", containerCount, memStore.Size()) 39 | } 40 | 41 | if memStore.Get("1") == nil { 42 | t.Error("Memstore does not contain expected container") 43 | } 44 | 45 | if memStore.Get("11") != nil { 46 | t.Error("Memstore contains an unexpected container") 47 | } 48 | 49 | checkMemoryStore(memStore.(*inMemoryContainerStore), containerCount, t) 50 | } 51 | 52 | func createTestContainers(numberOfContainers int) []dockerTypes.Container { 53 | var containers []dockerTypes.Container 54 | 55 | for i := 0; i < numberOfContainers; i++ { 56 | containers = append(containers, dockerTypes.Container{ 57 | ID: strconv.Itoa(i), 58 | Names: []string{"Name" + strconv.Itoa(i)}, 59 | }) 60 | } 61 | 62 | return containers 63 | } 64 | 65 | func checkMemoryStore(memStore *inMemoryContainerStore, containerCount int, t *testing.T) { 66 | containers := memStore.List() 67 | if memStore.Size() != containerCount { 68 | t.Errorf("Memstore does not contain the expected the number of elements, expected: %d, got: %d", containerCount, memStore.Size()) 69 | } 70 | 71 | if len(containers) != containerCount { 72 | t.Errorf("Element list from memstore does not contain the expected the number of elements, expected: %d, got: %d", containerCount, len(containers)) 73 | } 74 | 75 | if len(memStore.c) != len(memStore.s) { 76 | t.Error("Memstore internal state is incorrect") 77 | } 78 | } 79 | -------------------------------------------------------------------------------- /docker/mock/docker_api_client.go: -------------------------------------------------------------------------------- 1 | package mock 2 | 3 | import ( 4 | "context" 5 | "strconv" 6 | 7 | "github.com/docker/docker/api/types" 8 | "github.com/docker/docker/api/types/container" 9 | "github.com/docker/docker/api/types/network" 10 | dockerAPI "github.com/docker/docker/client" 11 | v1 "github.com/opencontainers/image-spec/specs-go/v1" 12 | ) 13 | 14 | //Docker repo is vendoring x/net/context and it seems that it conflicts 15 | //with whatever version of the same package the vendoring tools retrieve 16 | //A way to fix this is by removing the vendored package from the docker 17 | //directory of the vendor tool of dry, so: 18 | //rm -rf vendor/github.com/moby/moby/vendor/golang.org/x/net 19 | 20 | // ContainerAPIClientMock mocks docker ContainerAPIClient 21 | type ContainerAPIClientMock struct { 22 | dockerAPI.ContainerAPIClient 23 | Containers []types.Container 24 | } 25 | 26 | // NetworkAPIClientMock mocks docker NetworkAPIClient 27 | type NetworkAPIClientMock struct { 28 | dockerAPI.NetworkAPIClient 29 | } 30 | 31 | // ImageAPIClientMock mocks docker ImageAPIClient 32 | type ImageAPIClientMock struct { 33 | dockerAPI.APIClient 34 | } 35 | 36 | // ContainerList returns a list with 10 container with IDs from 0 to 9. 37 | func (m ContainerAPIClientMock) ContainerList(ctx context.Context, options container.ListOptions) ([]types.Container, error) { 38 | if len(m.Containers) > 0 { 39 | return m.Containers, nil 40 | } 41 | var containers []types.Container 42 | 43 | for i := 0; i < 10; i++ { 44 | containers = append(containers, types.Container{ 45 | ID: strconv.Itoa(i), 46 | }) 47 | } 48 | 49 | return containers, nil 50 | } 51 | 52 | // ContainerInspect returns an empty inspection result. 53 | func (m ContainerAPIClientMock) ContainerInspect(ctx context.Context, container string) (types.ContainerJSON, error) { 54 | return types.ContainerJSON{}, nil 55 | } 56 | 57 | // ContainerCreate mocks container creation 58 | func (mock ImageAPIClientMock) ContainerCreate(ctx context.Context, config *container.Config, hostConfig *container.HostConfig, networkingConfig *network.NetworkingConfig, platform *v1.Platform, containerName string) (container.CreateResponse, error) { 59 | return container.CreateResponse{ID: "NewContainer"}, nil 60 | } 61 | 62 | // ContainerStart mock, accepts everything without complains 63 | func (mock ImageAPIClientMock) ContainerStart(ctx context.Context, container string, options container.StartOptions) error { 64 | return nil 65 | } 66 | 67 | // InspectImage mock 68 | func (mock ImageAPIClientMock) InspectImage(ctx context.Context, image string) (types.ImageInspect, error) { 69 | return types.ImageInspect{ 70 | ContainerConfig: &container.Config{}, 71 | }, nil 72 | } 73 | 74 | // ImageInspectWithRaw mock 75 | func (mock ImageAPIClientMock) ImageInspectWithRaw(ctx context.Context, image string) (types.ImageInspect, []byte, error) { 76 | return types.ImageInspect{ 77 | ContainerConfig: &container.Config{}, 78 | }, nil, nil 79 | } 80 | -------------------------------------------------------------------------------- /docker/mock/swarm_api.go: -------------------------------------------------------------------------------- 1 | package mock 2 | 3 | import ( 4 | "context" 5 | 6 | "github.com/docker/docker/api/types" 7 | "github.com/docker/docker/api/types/swarm" 8 | dockerAPI "github.com/docker/docker/client" 9 | ) 10 | 11 | const ( 12 | // LabelNamespace is the label used to track stack resources 13 | //Copied from https://github.com/docker/cli/blob/master/cli/compose/convert/compose.go 14 | LabelNamespace = "com.docker.stack.namespace" 15 | ) 16 | 17 | // SwarmAPIClientMock mocks docker SwarmAPIClient 18 | type SwarmAPIClientMock struct { 19 | dockerAPI.APIClient 20 | } 21 | 22 | // NodeList returns a list with one Node 23 | func (mock SwarmAPIClientMock) NodeList(context context.Context, options types.NodeListOptions) ([]swarm.Node, error) { 24 | return []swarm.Node{{ 25 | ID: "1", 26 | }}, nil 27 | } 28 | 29 | // TaskList returns a list of tasks, node with id 1 will return a non empty list 30 | func (mock SwarmAPIClientMock) TaskList(context context.Context, options types.TaskListOptions) ([]swarm.Task, error) { 31 | nodeID := options.Filters.Get("node")[0] 32 | if nodeID == "1" { 33 | return []swarm.Task{ 34 | { 35 | ID: "1", 36 | NodeID: "1", 37 | }, 38 | }, nil 39 | } 40 | 41 | return nil, nil 42 | } 43 | 44 | // ServiceList returns a list of services 45 | func (mock SwarmAPIClientMock) ServiceList(context context.Context, options types.ServiceListOptions) ([]swarm.Service, error) { 46 | 47 | return []swarm.Service{ 48 | { 49 | ID: "1", 50 | Spec: swarm.ServiceSpec{ 51 | Annotations: swarm.Annotations{ 52 | Labels: map[string]string{LabelNamespace: "stack1"}, 53 | }, 54 | }, 55 | }, 56 | { 57 | ID: "2", 58 | Spec: swarm.ServiceSpec{ 59 | Annotations: swarm.Annotations{ 60 | Labels: map[string]string{LabelNamespace: "stack1"}, 61 | }, 62 | }, 63 | }, 64 | { 65 | ID: "3", 66 | Spec: swarm.ServiceSpec{ 67 | Annotations: swarm.Annotations{ 68 | Labels: map[string]string{LabelNamespace: "stack2"}, 69 | }, 70 | }, 71 | }, 72 | }, nil 73 | 74 | } 75 | 76 | // ConfigList mock 77 | func (mock SwarmAPIClientMock) ConfigList( 78 | context context.Context, opts types.ConfigListOptions) ([]swarm.Config, error) { 79 | return nil, nil 80 | } 81 | 82 | // NetworkList mock 83 | func (mock SwarmAPIClientMock) NetworkList( 84 | context context.Context, opts types.NetworkListOptions) ([]types.NetworkResource, error) { 85 | return nil, nil 86 | } 87 | 88 | // SecretList mock 89 | func (mock SwarmAPIClientMock) SecretList( 90 | context context.Context, opts types.SecretListOptions) ([]swarm.Secret, error) { 91 | return nil, nil 92 | } 93 | -------------------------------------------------------------------------------- /docker/prune_report.go: -------------------------------------------------------------------------------- 1 | package docker 2 | 3 | import "github.com/docker/docker/api/types" 4 | 5 | // PruneReport represents the result of a prune operation 6 | type PruneReport struct { 7 | ContainerReport types.ContainersPruneReport 8 | ImagesReport types.ImagesPruneReport 9 | NetworksReport types.NetworksPruneReport 10 | VolumesReport types.VolumesPruneReport 11 | } 12 | 13 | // TotalSpaceReclaimed reports the total space reclaimed 14 | func (p *PruneReport) TotalSpaceReclaimed() uint64 { 15 | total := p.ContainerReport.SpaceReclaimed 16 | total += p.ImagesReport.SpaceReclaimed 17 | total += p.VolumesReport.SpaceReclaimed 18 | return total 19 | } 20 | -------------------------------------------------------------------------------- /docker/resolver.go: -------------------------------------------------------------------------------- 1 | package docker 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | 7 | "github.com/docker/docker/api/types" 8 | "github.com/docker/docker/api/types/swarm" 9 | "github.com/docker/docker/client" 10 | ) 11 | 12 | // idResolver provides ID to Name resolution. 13 | type idResolver struct { 14 | client client.APIClient 15 | noResolve bool 16 | cache map[string]string 17 | } 18 | 19 | // New creates a new IDResolver. 20 | func newResolver(client client.APIClient, noResolve bool) *idResolver { 21 | return &idResolver{ 22 | client: client, 23 | noResolve: noResolve, 24 | cache: make(map[string]string), 25 | } 26 | } 27 | 28 | func (r *idResolver) get(ctx context.Context, t interface{}, id string) (string, error) { 29 | switch t.(type) { 30 | case swarm.Node: 31 | node, _, err := r.client.NodeInspectWithRaw(ctx, id) 32 | if err != nil { 33 | return id, nil 34 | } 35 | if node.Spec.Annotations.Name != "" { 36 | return node.Spec.Annotations.Name, nil 37 | } 38 | if node.Description.Hostname != "" { 39 | return node.Description.Hostname, nil 40 | } 41 | return id, nil 42 | case swarm.Service: 43 | service, _, err := r.client.ServiceInspectWithRaw(ctx, id, types.ServiceInspectOptions{}) 44 | if err != nil { 45 | return id, nil 46 | } 47 | return service.Spec.Annotations.Name, nil 48 | default: 49 | return "", errors.New("unsupported type") 50 | } 51 | 52 | } 53 | 54 | // Resolve will attempt to resolve an ID to a Name by querying the manager. 55 | func (r *idResolver) Resolve(ctx context.Context, t interface{}, id string) (string, error) { 56 | if r.noResolve { 57 | return id, nil 58 | } 59 | if name, ok := r.cache[id]; ok { 60 | return name, nil 61 | } 62 | name, err := r.get(ctx, t, id) 63 | if err != nil { 64 | return "", err 65 | } 66 | r.cache[id] = name 67 | return name, nil 68 | } 69 | -------------------------------------------------------------------------------- /docker/sort.go: -------------------------------------------------------------------------------- 1 | package docker 2 | 3 | import "sort" 4 | 5 | // Allowed sort methods 6 | const ( 7 | NoSort SortMode = iota 8 | SortByContainerID 9 | SortByImage 10 | SortByStatus 11 | SortByName 12 | ) 13 | 14 | // SortMode represents allowed modes to sort a container slice 15 | type SortMode uint16 16 | 17 | type apiContainers []*Container 18 | 19 | func (a apiContainers) Len() int { return len(a) } 20 | func (a apiContainers) Swap(i, j int) { a[i], a[j] = a[j], a[i] } 21 | 22 | type byContainerID struct{ apiContainers } 23 | 24 | func (a byContainerID) Less(i, j int) bool { return a.apiContainers[i].ID < a.apiContainers[j].ID } 25 | 26 | type byImage struct{ apiContainers } 27 | 28 | func (a byImage) Less(i, j int) bool { 29 | //If the image is the same, sorting is done by name 30 | if a.apiContainers[i].Image == a.apiContainers[j].Image { 31 | return byName(a).Less(i, j) 32 | } 33 | return a.apiContainers[i].Image < a.apiContainers[j].Image 34 | } 35 | 36 | type byStatus struct{ apiContainers } 37 | 38 | func (a byStatus) Less(i, j int) bool { 39 | //If the status is the same, sorting is done by name 40 | if a.apiContainers[i].Status == a.apiContainers[j].Status { 41 | return byName(a).Less(i, j) 42 | } 43 | return a.apiContainers[i].Status < a.apiContainers[j].Status 44 | } 45 | 46 | type byName struct{ apiContainers } 47 | 48 | func (a byName) Less(i, j int) bool { 49 | if len(a.apiContainers[i].Names) > 0 { 50 | if len(a.apiContainers[j].Names) > 0 { 51 | return a.apiContainers[i].Names[0] < a.apiContainers[j].Names[0] 52 | } 53 | return true 54 | } 55 | return false 56 | } 57 | 58 | // SortContainers sorts the given containers slice using the given mode 59 | func SortContainers(containers []*Container, mode SortMode) { 60 | switch mode { 61 | case NoSort: 62 | case SortByContainerID: 63 | sort.Sort(byContainerID{containers}) 64 | case SortByImage: 65 | sort.Sort(byImage{containers}) 66 | case SortByStatus: 67 | sort.Sort(byStatus{containers}) 68 | case SortByName: 69 | sort.Sort(byName{containers}) 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /docker/sort_images.go: -------------------------------------------------------------------------------- 1 | package docker 2 | 3 | import ( 4 | "sort" 5 | 6 | "github.com/docker/docker/api/types/image" 7 | ) 8 | 9 | // Allowed sort methods 10 | const ( 11 | NoSortImages SortMode = iota 12 | SortImagesByID 13 | SortImagesByRepo 14 | SortImagesBySize 15 | SortImagesByCreationDate 16 | ) 17 | 18 | type apiImages []image.Summary 19 | 20 | func (s apiImages) Len() int { return len(s) } 21 | func (s apiImages) Swap(i, j int) { s[i], s[j] = s[j], s[i] } 22 | 23 | type byID struct{ apiImages } 24 | 25 | func (s byID) Less(i, j int) bool { return s.apiImages[i].ID < s.apiImages[j].ID } 26 | 27 | type byRepository struct{ apiImages } 28 | 29 | func (s byRepository) Less(i, j int) bool { 30 | if len(s.apiImages[i].RepoTags) > 0 { 31 | if len(s.apiImages[j].RepoTags) > 0 { 32 | return s.apiImages[i].RepoTags[0] < s.apiImages[j].RepoTags[0] 33 | } 34 | return true 35 | } 36 | return false 37 | } 38 | 39 | type bySize struct{ apiImages } 40 | 41 | func (s bySize) Less(i, j int) bool { 42 | return s.apiImages[i].Size < s.apiImages[j].Size 43 | } 44 | 45 | type byCreationDate struct{ apiImages } 46 | 47 | func (s byCreationDate) Less(i, j int) bool { 48 | //More recent first 49 | return s.apiImages[i].Created > s.apiImages[j].Created 50 | } 51 | 52 | // SortImages sorts the given image slice using the given mode 53 | func SortImages(images []image.Summary, mode SortMode) { 54 | switch mode { 55 | case SortImagesByID: 56 | sort.Sort(byID{images}) 57 | case SortImagesByRepo: 58 | sort.Sort(byRepository{images}) 59 | case SortImagesBySize: 60 | sort.Sort(bySize{images}) 61 | case SortImagesByCreationDate: 62 | sort.Sort(byCreationDate{images}) 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /docker/sort_images_test.go: -------------------------------------------------------------------------------- 1 | package docker 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/docker/docker/api/types/image" 7 | ) 8 | 9 | func TestSortImages(t *testing.T) { 10 | type args struct { 11 | images []image.Summary 12 | mode SortMode 13 | } 14 | tests := []struct { 15 | name string 16 | args args 17 | }{ 18 | { 19 | "Sort by repo ", 20 | args{ 21 | []image.Summary{ 22 | { 23 | ID: "1", 24 | RepoTags: []string{"dry/dry:2"}, 25 | }, 26 | { 27 | ID: "0", 28 | RepoTags: []string{"dry/dry:1"}, 29 | }, 30 | }, 31 | SortImagesByRepo, 32 | }, 33 | }, 34 | { 35 | "Sort by ID ", 36 | args{ 37 | []image.Summary{ 38 | { 39 | ID: "3", 40 | }, 41 | { 42 | ID: "1", 43 | }, 44 | { 45 | ID: "0", 46 | }, 47 | }, 48 | SortImagesByID, 49 | }, 50 | }, 51 | { 52 | "Sort by Creation Date ", 53 | args{ 54 | []image.Summary{ 55 | { 56 | ID: "1", 57 | Created: 1, 58 | }, 59 | { 60 | ID: "3", 61 | Created: 2, 62 | }, 63 | { 64 | ID: "0", 65 | Created: 3, 66 | }, 67 | }, 68 | SortImagesByCreationDate, 69 | }, 70 | }, 71 | } 72 | for _, tt := range tests { 73 | t.Run(tt.name, func(t *testing.T) { 74 | images := tt.args.images 75 | SortImages(images, tt.args.mode) 76 | if images[0].ID != "0" { 77 | t.Errorf("Unexpected image as first element %v", images[0]) 78 | } 79 | if images[0].ID > images[1].ID { 80 | t.Errorf("Unexpected order %v", images) 81 | } 82 | }) 83 | } 84 | } 85 | -------------------------------------------------------------------------------- /docker/sort_networks.go: -------------------------------------------------------------------------------- 1 | package docker 2 | 3 | import ( 4 | "sort" 5 | 6 | "github.com/docker/docker/api/types" 7 | ) 8 | 9 | // Allowed sort methods 10 | const ( 11 | NoSortNetworks SortMode = iota 12 | SortNetworksByID 13 | SortNetworksByName 14 | SortNetworksByDriver 15 | SortNetworksByContainerCount 16 | SortNetworksByServiceCount 17 | SortNetworksBySubnet 18 | ) 19 | 20 | type dockerNetworks []types.NetworkResource 21 | 22 | func (s dockerNetworks) Len() int { return len(s) } 23 | func (s dockerNetworks) Swap(i, j int) { s[i], s[j] = s[j], s[i] } 24 | 25 | type networksByID struct{ dockerNetworks } 26 | 27 | func (s networksByID) Less(i, j int) bool { return s.dockerNetworks[i].ID < s.dockerNetworks[j].ID } 28 | 29 | type networksByName struct{ dockerNetworks } 30 | 31 | func (s networksByName) Less(i, j int) bool { 32 | if len(s.dockerNetworks[i].Name) > 0 { 33 | if len(s.dockerNetworks[j].Name) > 0 { 34 | return s.dockerNetworks[i].Name[0] < s.dockerNetworks[j].Name[0] 35 | } 36 | return true 37 | } 38 | return false 39 | } 40 | 41 | type networksByDriver struct{ dockerNetworks } 42 | 43 | func (s networksByDriver) Less(i, j int) bool { 44 | return s.dockerNetworks[i].Driver < s.dockerNetworks[j].Driver 45 | } 46 | 47 | type networksByContainerCount struct{ dockerNetworks } 48 | 49 | func (s networksByContainerCount) Less(i, j int) bool { 50 | return len(s.dockerNetworks[i].Containers) < len(s.dockerNetworks[j].Containers) 51 | } 52 | 53 | type networksByServiceCount struct{ dockerNetworks } 54 | 55 | func (s networksByServiceCount) Less(i, j int) bool { 56 | return len(s.dockerNetworks[i].Services) < len(s.dockerNetworks[j].Services) 57 | } 58 | 59 | type networksBySubnet struct{ dockerNetworks } 60 | 61 | func (s networksBySubnet) Less(i, j int) bool { 62 | if len(s.dockerNetworks[i].IPAM.Config) > 0 { 63 | if len(s.dockerNetworks[j].IPAM.Config) > 0 { 64 | return s.dockerNetworks[i].IPAM.Config[0].Subnet < s.dockerNetworks[j].IPAM.Config[0].Subnet 65 | } 66 | return true 67 | } 68 | return false 69 | } 70 | 71 | // SortNetworks sorts the given network slice using the given mode 72 | func SortNetworks(networks []types.NetworkResource, mode SortMode) { 73 | switch mode { 74 | case SortNetworksByID: 75 | sort.Sort(networksByID{networks}) 76 | case SortNetworksByName: 77 | sort.Sort(networksByName{networks}) 78 | case SortNetworksByDriver: 79 | sort.Sort(networksByDriver{networks}) 80 | case SortNetworksByContainerCount: 81 | sort.Sort(networksByContainerCount{networks}) 82 | case SortNetworksByServiceCount: 83 | sort.Sort(networksByServiceCount{networks}) 84 | case SortNetworksBySubnet: 85 | sort.Sort(networksBySubnet{networks}) 86 | } 87 | } 88 | -------------------------------------------------------------------------------- /docker/sort_nodes.go: -------------------------------------------------------------------------------- 1 | package docker 2 | 3 | import ( 4 | "sort" 5 | 6 | "github.com/docker/docker/api/types/swarm" 7 | ) 8 | 9 | // Allowed sort methods 10 | const ( 11 | NoSortNode SortMode = iota 12 | SortByNodeName 13 | SortByNodeRole 14 | SortByNodeCPU 15 | SortByNodeMem 16 | SortByNodeStatus 17 | ) 18 | 19 | type swarmNodes []swarm.Node 20 | 21 | func (s swarmNodes) Len() int { return len(s) } 22 | func (s swarmNodes) Swap(i, j int) { s[i], s[j] = s[j], s[i] } 23 | 24 | type nodesByName struct{ swarmNodes } 25 | 26 | func (s nodesByName) Less(i, j int) bool { 27 | return s.swarmNodes[i].Description.Hostname < s.swarmNodes[j].Description.Hostname 28 | } 29 | 30 | type nodesByRole struct{ swarmNodes } 31 | 32 | func (s nodesByRole) Less(i, j int) bool { 33 | return s.swarmNodes[i].Spec.Role < s.swarmNodes[j].Spec.Role 34 | } 35 | 36 | type nodesByCPU struct{ swarmNodes } 37 | 38 | func (s nodesByCPU) Less(i, j int) bool { 39 | return s.swarmNodes[i].Description.Resources.NanoCPUs < s.swarmNodes[j].Description.Resources.NanoCPUs 40 | } 41 | 42 | type nodesByMem struct{ swarmNodes } 43 | 44 | func (s nodesByMem) Less(i, j int) bool { 45 | return s.swarmNodes[i].Description.Resources.MemoryBytes < s.swarmNodes[j].Description.Resources.MemoryBytes 46 | } 47 | 48 | type nodesByStatus struct{ swarmNodes } 49 | 50 | func (s nodesByStatus) Less(i, j int) bool { 51 | return s.swarmNodes[i].Status.State < s.swarmNodes[j].Status.State 52 | } 53 | 54 | // SortNodes sorts the given nodes slice using the given mode 55 | func SortNodes(nodes []swarm.Node, mode SortMode) { 56 | switch mode { 57 | case SortByNodeName: 58 | sort.Sort(nodesByName{nodes}) 59 | case SortByNodeRole: 60 | sort.Sort(nodesByRole{nodes}) 61 | case SortByNodeCPU: 62 | sort.Sort(nodesByCPU{nodes}) 63 | case SortByNodeMem: 64 | sort.Sort(nodesByMem{nodes}) 65 | case SortByNodeStatus: 66 | sort.Sort(nodesByStatus{nodes}) 67 | } 68 | 69 | } 70 | -------------------------------------------------------------------------------- /docker/sort_services.go: -------------------------------------------------------------------------------- 1 | package docker 2 | 3 | import ( 4 | "sort" 5 | 6 | "github.com/docker/docker/api/types/swarm" 7 | ) 8 | 9 | // Allowed sort methods 10 | const ( 11 | NoSortService SortMode = iota 12 | SortByServiceName 13 | SortByServiceImage 14 | ) 15 | 16 | type swarmServices []swarm.Service 17 | 18 | func (s swarmServices) Len() int { return len(s) } 19 | func (s swarmServices) Swap(i, j int) { s[i], s[j] = s[j], s[i] } 20 | 21 | type servicesByName struct{ swarmServices } 22 | 23 | func (s servicesByName) Less(i, j int) bool { 24 | 25 | return s.swarmServices[i].Spec.Name < s.swarmServices[j].Spec.Name 26 | } 27 | 28 | type servicesByImage struct{ swarmServices } 29 | 30 | func (s servicesByImage) Less(i, j int) bool { 31 | return s.swarmServices[i].Spec.TaskTemplate.ContainerSpec.Image < s.swarmServices[j].Spec.TaskTemplate.ContainerSpec.Image 32 | } 33 | 34 | // SortServices sorts the given service slice using the given mode 35 | func SortServices(services []swarm.Service, mode SortMode) { 36 | switch mode { 37 | case SortByServiceName: 38 | sort.SliceStable(servicesByName{services}.swarmServices, servicesByName{services}.Less) 39 | case SortByServiceImage: 40 | sort.SliceStable(servicesByImage{services}.swarmServices, servicesByImage{services}.Less) 41 | 42 | } 43 | 44 | } 45 | -------------------------------------------------------------------------------- /docker/sort_services_tasks.go: -------------------------------------------------------------------------------- 1 | package docker 2 | 3 | import ( 4 | "sort" 5 | 6 | "github.com/docker/docker/api/types/swarm" 7 | ) 8 | 9 | // Allowed sort methods 10 | const ( 11 | NoSortTask SortMode = iota 12 | SortByTaskImage 13 | SortByTaskService 14 | SortByTaskState 15 | SortByTaskDesiredState 16 | ) 17 | 18 | type swarmTasks []swarm.Task 19 | 20 | func (s swarmTasks) Len() int { return len(s) } 21 | func (s swarmTasks) Swap(i, j int) { s[i], s[j] = s[j], s[i] } 22 | 23 | type tasksByImage struct{ swarmTasks } 24 | 25 | func (s tasksByImage) Less(i, j int) bool { 26 | return s.swarmTasks[i].Spec.ContainerSpec.Image < s.swarmTasks[j].Spec.ContainerSpec.Image 27 | } 28 | 29 | type tasksByService struct{ swarmTasks } 30 | 31 | func (s tasksByService) Less(i, j int) bool { 32 | return s.swarmTasks[i].ServiceID < s.swarmTasks[j].ServiceID 33 | } 34 | 35 | type tasksByState struct{ swarmTasks } 36 | 37 | func (s tasksByState) Less(i, j int) bool { 38 | return s.swarmTasks[i].Status.State < s.swarmTasks[j].Status.State 39 | } 40 | 41 | type tasksByDesiredState struct{ swarmTasks } 42 | 43 | func (s tasksByDesiredState) Less(i, j int) bool { 44 | return s.swarmTasks[i].DesiredState < s.swarmTasks[j].DesiredState 45 | } 46 | 47 | // SortTasks sorts the given Task slice using the given mode 48 | func SortTasks(tasks []swarm.Task, mode SortMode) { 49 | 50 | switch mode { 51 | case SortByTaskImage: 52 | sortingAlg := tasksByImage{tasks} 53 | sort.SliceStable(sortingAlg.swarmTasks, sortingAlg.Less) 54 | case SortByTaskService: 55 | sortingAlg := tasksByService{tasks} 56 | sort.SliceStable(sortingAlg.swarmTasks, sortingAlg.Less) 57 | case SortByTaskState: 58 | sortingAlg := tasksByState{tasks} 59 | sort.SliceStable(sortingAlg.swarmTasks, sortingAlg.Less) 60 | case SortByTaskDesiredState: 61 | sortingAlg := tasksByDesiredState{tasks} 62 | sort.SliceStable(sortingAlg.swarmTasks, sortingAlg.Less) 63 | } 64 | 65 | } 66 | -------------------------------------------------------------------------------- /docker/sort_services_test.go: -------------------------------------------------------------------------------- 1 | package docker 2 | 3 | import ( 4 | "sort" 5 | "testing" 6 | 7 | "github.com/docker/docker/api/types/swarm" 8 | ) 9 | 10 | func TestSortServices(t *testing.T) { 11 | type args struct { 12 | services []swarm.Service 13 | mode SortMode 14 | } 15 | tests := []struct { 16 | name string 17 | args args 18 | }{ 19 | { 20 | "SortByServiceName", 21 | args{ 22 | []swarm.Service{ 23 | { 24 | Spec: swarm.ServiceSpec{ 25 | Annotations: swarm.Annotations{Name: "a"}, 26 | }, 27 | }, 28 | { 29 | Spec: swarm.ServiceSpec{ 30 | Annotations: swarm.Annotations{Name: "c"}, 31 | }, 32 | }, 33 | { 34 | Spec: swarm.ServiceSpec{ 35 | Annotations: swarm.Annotations{Name: "b"}, 36 | }, 37 | }, 38 | }, 39 | SortByServiceName, 40 | }, 41 | }, 42 | { 43 | "SortByServiceImage", 44 | args{ 45 | []swarm.Service{ 46 | { 47 | Spec: swarm.ServiceSpec{ 48 | Annotations: swarm.Annotations{Name: "b"}, 49 | TaskTemplate: swarm.TaskSpec{ 50 | ContainerSpec: &swarm.ContainerSpec{ 51 | Image: "b", 52 | }, 53 | }, 54 | }, 55 | }, 56 | { 57 | Spec: swarm.ServiceSpec{ 58 | Annotations: swarm.Annotations{Name: "c"}, 59 | TaskTemplate: swarm.TaskSpec{ 60 | ContainerSpec: &swarm.ContainerSpec{ 61 | Image: "c", 62 | }, 63 | }, 64 | }, 65 | }, 66 | { 67 | Spec: swarm.ServiceSpec{ 68 | Annotations: swarm.Annotations{Name: "a"}, 69 | TaskTemplate: swarm.TaskSpec{ 70 | ContainerSpec: &swarm.ContainerSpec{ 71 | Image: "a", 72 | }, 73 | }, 74 | }, 75 | }, 76 | }, 77 | SortByServiceImage, 78 | }, 79 | }, 80 | } 81 | for _, tt := range tests { 82 | t.Run(tt.name, func(t *testing.T) { 83 | services := tt.args.services 84 | SortServices(services, tt.args.mode) 85 | 86 | if !sort.IsSorted(toSortInterface(services, tt.args.mode)) { 87 | t.Errorf("Unexpected service ordering %v", services) 88 | } 89 | }) 90 | } 91 | } 92 | 93 | func toSortInterface(services []swarm.Service, mode SortMode) sort.Interface { 94 | 95 | switch mode { 96 | case SortByServiceName: 97 | return servicesByName{services} 98 | case SortByServiceImage: 99 | return servicesByImage{services} 100 | default: 101 | return nil 102 | } 103 | 104 | } 105 | -------------------------------------------------------------------------------- /docker/sort_stacks.go: -------------------------------------------------------------------------------- 1 | package docker 2 | 3 | import ( 4 | "sort" 5 | ) 6 | 7 | // Allowed sort methods 8 | const ( 9 | NoSortStack SortMode = iota 10 | SortByStackName 11 | ) 12 | 13 | type swarmStacks []Stack 14 | 15 | func (s swarmStacks) Len() int { return len(s) } 16 | func (s swarmStacks) Swap(i, j int) { s[i], s[j] = s[j], s[i] } 17 | 18 | type stacksByName struct{ swarmStacks } 19 | 20 | func (s stacksByName) Less(i, j int) bool { 21 | 22 | return s.swarmStacks[i].Name < s.swarmStacks[j].Name 23 | } 24 | 25 | // SortStacks sorts the given stack slice using the given mode 26 | func SortStacks(stacks []Stack, mode SortMode) { 27 | switch mode { 28 | case SortByStackName: 29 | sort.SliceStable(stacksByName{stacks}.swarmStacks, stacksByName{stacks}.Less) 30 | } 31 | 32 | } 33 | -------------------------------------------------------------------------------- /docker/stack.go: -------------------------------------------------------------------------------- 1 | package docker 2 | 3 | // Stack contains information about a Docker Swarm stack 4 | type Stack struct { 5 | Name string 6 | Orchestrator string 7 | Services int 8 | Networks int 9 | Configs int 10 | Secrets int 11 | } 12 | -------------------------------------------------------------------------------- /docker/whale.go: -------------------------------------------------------------------------------- 1 | package docker 2 | 3 | const ( 4 | //Whale ascii art 5 | Whale = ` 6 | ## . 7 | ## ## ## == 8 | ## ## ## ## === 9 | /""""""""""""""""\___/ === 10 | ~~~ {~~ ~~~~ ~~~ ~~~~ ~~ ~ / ===- ~~~ 11 | \______ o __/ 12 | \ \ __/ 13 | \____\______/ 14 | ` 15 | //Whale0 ascii art for animations 16 | Whale0 = ` 17 | . 18 | == 19 | === 20 | /""""""""""""""""\___/ === 21 | ~~~ {~~ ~~~~ ~~~ ~~~~ ~~ ~ / ===- ~~~ 22 | \______ o __/ 23 | \ \ __/ 24 | \____\______/ 25 | ` 26 | 27 | //Whale1 ascii art for animations 28 | Whale1 = ` 29 | . 30 | == 31 | ## === 32 | /""""""""""""""""\___/ === 33 | ~~~ {~~ ~~~~ ~~~ ~~~~ ~~ ~ / ===- ~~~ 34 | \______ o __/ 35 | \ \ __/ 36 | \____\______/ 37 | ` 38 | //Whale2 ascii art for animations 39 | Whale2 = ` 40 | . 41 | == 42 | ## ## === 43 | /""""""""""""""""\___/ === 44 | ~~~ {~~ ~~~~ ~~~ ~~~~ ~~ ~ / ===- ~~~ 45 | \______ o __/ 46 | \ \ __/ 47 | \____\______/ 48 | ` 49 | //Whale3 ascii art for animations 50 | Whale3 = ` 51 | . 52 | == 53 | ## ## ## === 54 | /""""""""""""""""\___/ === 55 | ~~~ {~~ ~~~~ ~~~ ~~~~ ~~ ~ / ===- ~~~ 56 | \______ o __/ 57 | \ \ __/ 58 | \____\______/ 59 | ` 60 | //Whale4 ascii art for animations 61 | Whale4 = ` 62 | . 63 | == 64 | ## ## ## ## === 65 | /""""""""""""""""\___/ === 66 | ~~~ {~~ ~~~~ ~~~ ~~~~ ~~ ~ / ===- ~~~ 67 | \______ o __/ 68 | \ \ __/ 69 | \____\______/ 70 | ` 71 | //Whale5 ascii art for animations 72 | Whale5 = ` 73 | . 74 | ## == 75 | ## ## ## ## === 76 | /""""""""""""""""\___/ === 77 | ~~~ {~~ ~~~~ ~~~ ~~~~ ~~ ~ / ===- ~~~ 78 | \______ o __/ 79 | \ \ __/ 80 | \____\______/ 81 | ` 82 | //Whale6 ascii art for animations 83 | Whale6 = ` 84 | . 85 | ## ## == 86 | ## ## ## ## === 87 | /""""""""""""""""\___/ === 88 | ~~~ {~~ ~~~~ ~~~ ~~~~ ~~ ~ / ===- ~~~ 89 | \______ o __/ 90 | \ \ __/ 91 | \____\______/ 92 | ` 93 | //Whale7 ascii art for animations 94 | Whale7 = ` 95 | . 96 | ## ## ## == 97 | ## ## ## ## === 98 | /""""""""""""""""\___/ === 99 | ~~~ {~~ ~~~~ ~~~ ~~~~ ~~ ~ / ===- ~~~ 100 | \______ o __/ 101 | \ \ __/ 102 | \____\______/ 103 | ` 104 | ) 105 | -------------------------------------------------------------------------------- /docs/dry.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/moncho/dry/ec1c1426b9f9d07ecee968316199953f2ff36134/docs/dry.png -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/moncho/dry 2 | 3 | go 1.23 4 | 5 | require ( 6 | github.com/Microsoft/go-winio v0.6.2 // indirect 7 | github.com/docker/cli v27.5.1+incompatible 8 | github.com/docker/docker v27.5.1+incompatible 9 | github.com/docker/go-connections v0.5.0 10 | github.com/docker/go-units v0.5.0 11 | github.com/gdamore/tcell v1.4.0 12 | github.com/gizak/termui v0.0.0-20190118200331-b3075f731367 13 | github.com/gogo/protobuf v1.3.2 // indirect 14 | github.com/jessevdk/go-flags v1.6.1 15 | github.com/json-iterator/go v1.1.12 16 | github.com/kevinburke/ssh_config v1.2.0 17 | github.com/mattn/go-runewidth v0.0.16 18 | github.com/mitchellh/go-homedir v1.1.0 19 | github.com/mitchellh/go-wordwrap v1.0.1 20 | github.com/moby/term v0.0.0-20210619224110-3f7ff695adc6 // indirect 21 | github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect 22 | github.com/modern-go/reflect2 v1.0.2 // indirect 23 | github.com/olekukonko/tablewriter v0.0.5 24 | github.com/opencontainers/go-digest v1.0.0 // indirect 25 | github.com/opencontainers/image-spec v1.1.0 26 | github.com/sirupsen/logrus v1.9.3 27 | go.uber.org/goleak v1.1.12 28 | golang.org/x/crypto v0.33.0 29 | golang.org/x/net v0.34.0 30 | ) 31 | 32 | require ( 33 | github.com/gdamore/encoding v1.0.1 // indirect 34 | github.com/google/go-cmp v0.6.0 35 | github.com/lucasb-eyer/go-colorful v1.2.0 // indirect 36 | github.com/nsf/termbox-go v1.1.1 // indirect 37 | golang.org/x/sys v0.30.0 // indirect 38 | golang.org/x/text v0.22.0 // indirect 39 | ) 40 | 41 | require github.com/distribution/reference v0.6.0 42 | 43 | require ( 44 | github.com/containerd/log v0.1.0 // indirect 45 | github.com/felixge/httpsnoop v1.0.4 // indirect 46 | github.com/go-logr/logr v1.4.2 // indirect 47 | github.com/go-logr/stdr v1.2.2 // indirect 48 | github.com/moby/docker-image-spec v1.3.1 // indirect 49 | github.com/morikuni/aec v1.0.0 // indirect 50 | github.com/pkg/errors v0.9.1 // indirect 51 | github.com/rivo/uniseg v0.4.7 // indirect 52 | go.opentelemetry.io/auto/sdk v1.1.0 // indirect 53 | go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.59.0 // indirect 54 | go.opentelemetry.io/otel v1.34.0 // indirect 55 | go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.24.0 // indirect 56 | go.opentelemetry.io/otel/metric v1.34.0 // indirect 57 | go.opentelemetry.io/otel/sdk v1.24.0 // indirect 58 | go.opentelemetry.io/otel/trace v1.34.0 // indirect 59 | golang.org/x/lint v0.0.0-20200302205851-738671d3881b // indirect 60 | golang.org/x/time v0.0.0-20210723032227-1f47c861a9ac // indirect 61 | golang.org/x/tools v0.29.0 // indirect 62 | gotest.tools/v3 v3.5.0 // indirect 63 | ) 64 | -------------------------------------------------------------------------------- /mocks/docker_swarmDaemon.go: -------------------------------------------------------------------------------- 1 | package mocks 2 | 3 | import ( 4 | "strings" 5 | 6 | "github.com/docker/docker/api/types/swarm" 7 | "github.com/docker/docker/api/types/system" 8 | ) 9 | 10 | const ( 11 | //TestNodeID defines the ID of the swarm node for testing 12 | TestNodeID = "1" 13 | ) 14 | 15 | // SwarmDockerDaemon mocks a DockerDaemon operating in Swarm mode 16 | type SwarmDockerDaemon struct { 17 | DockerDaemonMock 18 | } 19 | 20 | // Info provides a mock function with given fields: 21 | func (_m *SwarmDockerDaemon) Info() (system.Info, error) { 22 | clusterInfo := swarm.ClusterInfo{ID: "MyClusterID"} 23 | swarmInfo := swarm.Info{ 24 | LocalNodeState: swarm.LocalNodeStateActive, 25 | NodeID: "ThisNodeID", 26 | Cluster: &clusterInfo, 27 | ControlAvailable: true} 28 | return system.Info{ 29 | Name: "test", 30 | NCPU: 2, 31 | MemTotal: 1024, 32 | Swarm: swarmInfo}, nil 33 | } 34 | 35 | // Node returns a node with the given id 36 | func (_m *SwarmDockerDaemon) Node(id string) (*swarm.Node, error) { 37 | return &swarm.Node{ID: id}, nil 38 | } 39 | 40 | // Nodes returns a list of nodes with 1 element 41 | func (_m *SwarmDockerDaemon) Nodes() ([]swarm.Node, error) { 42 | return []swarm.Node{{ID: TestNodeID}}, nil 43 | } 44 | 45 | // NodeTasks mock 46 | func (_m *SwarmDockerDaemon) NodeTasks(nodeID string) ([]swarm.Task, error) { 47 | return []swarm.Task{{NodeID: nodeID}}, nil 48 | } 49 | 50 | // ResolveNode mock 51 | func (_m *SwarmDockerDaemon) ResolveNode(id string) (string, error) { 52 | return strings.Join([]string{"Node", id}, ""), nil 53 | } 54 | 55 | // ResolveService mock 56 | func (_m *SwarmDockerDaemon) ResolveService(id string) (string, error) { 57 | return strings.Join([]string{"Service", id}, ""), nil 58 | } 59 | 60 | // Services returns a list of services with 1 element 61 | func (_m *SwarmDockerDaemon) Services() ([]swarm.Service, error) { 62 | return []swarm.Service{ 63 | {ID: "ServiceID"}}, nil 64 | } 65 | 66 | // ServiceTasks returns one task per service, the task belongs to node with id "1" 67 | func (_m *SwarmDockerDaemon) ServiceTasks(services ...string) ([]swarm.Task, error) { 68 | tasks := make([]swarm.Task, 0, len(services)) 69 | for _, service := range services { 70 | tasks = append(tasks, swarm.Task{ServiceID: service, NodeID: TestNodeID}) 71 | } 72 | 73 | return tasks, nil 74 | } 75 | -------------------------------------------------------------------------------- /revive.toml: -------------------------------------------------------------------------------- 1 | ignoreGeneratedHeader = false 2 | severity = "warning" 3 | confidence = 0.8 4 | errorCode = 0 5 | warningCode = 0 6 | 7 | [rule.blank-imports] 8 | [rule.context-as-argument] 9 | [rule.context-keys-type] 10 | [rule.dot-imports] 11 | [rule.error-return] 12 | [rule.error-strings] 13 | [rule.error-naming] 14 | [rule.exported] 15 | [rule.if-return] 16 | [rule.increment-decrement] 17 | [rule.var-naming] 18 | [rule.var-declaration] 19 | [rule.package-comments] 20 | [rule.range] 21 | [rule.receiver-naming] 22 | [rule.time-naming] 23 | [rule.unexported-return] 24 | [rule.indent-error-flow] 25 | [rule.errorf] 26 | [rule.empty-block] 27 | [rule.superfluous-else] 28 | [rule.unused-parameter] 29 | [rule.unreachable-code] 30 | [rule.redefines-builtin-id] -------------------------------------------------------------------------------- /search/search_test.go: -------------------------------------------------------------------------------- 1 | package search 2 | 3 | import ( 4 | "reflect" 5 | "testing" 6 | ) 7 | 8 | const ( 9 | searchPattern = "line" 10 | 11 | lines = `line 1 12 | lien 2 13 | line3 14 | line 4 15 | line 5 16 | Nope 17 | Still nope 18 | Really, nope` 19 | ) 20 | 21 | // TestSearch tests basic search 22 | func TestSearch(t *testing.T) { 23 | expected := Result{ 24 | Hits: 5, 25 | Lines: []int{2, 3, 4, 8, 9}, 26 | Pattern: searchPattern, 27 | } 28 | rs, _ := NewSearch(testText(), searchPattern) 29 | 30 | if expected.Hits != rs.Hits || 31 | !reflect.DeepEqual(expected.Lines, rs.Lines) || 32 | expected.Pattern != rs.Pattern { 33 | t.Errorf("Expected search result: %s, got: %s", expected.String(), rs.String()) 34 | } 35 | } 36 | 37 | // TestResultIteration tests iterating the search results 38 | func TestResultIteration(t *testing.T) { 39 | rs, _ := NewSearch(testText(), searchPattern) 40 | line, _ := rs.NextLine() 41 | if line != 2 { 42 | t.Errorf("Expected line: %d, got: %d", 2, line) 43 | } 44 | 45 | line, _ = rs.PreviousLine() 46 | 47 | if line != 2 { 48 | t.Errorf("Expected line: %d, got: %d", 2, line) 49 | } 50 | 51 | line, _ = rs.NextLine() 52 | 53 | if line != 3 { 54 | t.Errorf("Expected line: %d, got: %d", 3, line) 55 | } 56 | 57 | line, _ = rs.NextLine() 58 | if line != 4 { 59 | t.Errorf("Expected line: %d, got: %d", 4, line) 60 | 61 | } 62 | 63 | line, _ = rs.PreviousLine() 64 | if line != 3 { 65 | t.Errorf("Expected line: %d, got: %d", 3, line) 66 | 67 | } 68 | 69 | line, _ = rs.NextLine() 70 | if line != 4 { 71 | t.Errorf("Expected line: %d, got: %d", 4, line) 72 | 73 | } 74 | 75 | line, _ = rs.NextLine() 76 | if line != 8 { 77 | t.Errorf("Expected line: %d, got: %d", 8, line) 78 | 79 | } 80 | 81 | rs.InitialLine(5) 82 | line, _ = rs.NextLine() 83 | if line != 8 { 84 | t.Errorf("Expected line: %d, got: %d", 8, line) 85 | 86 | } 87 | 88 | rs.InitialLine(9) 89 | line, _ = rs.NextLine() 90 | if line != 9 { 91 | t.Errorf("Expected line: %d, got: %d", 9, line) 92 | 93 | } 94 | 95 | line, _ = rs.NextLine() 96 | if line != 9 { 97 | t.Errorf("Expected line: %d, got: %d", 9, line) 98 | 99 | } 100 | 101 | } 102 | 103 | func testText() [][]rune { 104 | return [][]rune{[]rune("one 1 nope"), 105 | []rune("lien 2 nope"), 106 | []rune("line3 yes"), 107 | []rune("line 4"), 108 | []rune("line 5"), 109 | []rune("Nope 6"), 110 | []rune("Still nope 7"), 111 | []rune("Really, nope 8"), 112 | []rune("yes line 9"), 113 | []rune("line 10"), 114 | []rune("lin 11")} 115 | } 116 | -------------------------------------------------------------------------------- /snap/snapcraft.yaml: -------------------------------------------------------------------------------- 1 | name: dry 2 | version: v0.11.2 3 | summary: A Docker manager for the terminal 4 | description: | 5 | Dry is a terminal application to manage Docker. 6 | It can manage Containers, Images and Networks, and, 7 | if running a Docker Swarm, it can manage a Swarm cluster. 8 | It can be used with both local and remote Docker daemons. 9 | base: core20 10 | 11 | grade: stable 12 | confinement: strict 13 | 14 | apps: 15 | dry: 16 | command: bin/dry 17 | 18 | parts: 19 | dry: 20 | source-type: git 21 | source: https://github.com/moncho/dry 22 | plugin: go -------------------------------------------------------------------------------- /terminal/ansiparser_test.go: -------------------------------------------------------------------------------- 1 | package terminal 2 | 3 | import "testing" 4 | 5 | var ( 6 | green = string([]byte{27, 91, 57, 55, 59, 52, 50, 109}) 7 | reset = string([]byte{27, 91, 48, 109}) 8 | testText = "Dry have seen things you people wouldn't believe" 9 | ) 10 | 11 | func TestRemoveEscapeCharacters(t *testing.T) { 12 | text := green 13 | text += testText 14 | text += reset 15 | parsed := RemoveANSIEscapeCharacters(text) 16 | if len(parsed) != 1 { 17 | t.Fatalf("Parsing returned wrong number of lines, expected: %d, got: %d", 1, len(parsed)) 18 | } 19 | if string(parsed[0]) != testText { 20 | t.Errorf("Parsing did not work, result: \"%s\"", string(parsed[0])) 21 | } 22 | } 23 | 24 | func BenchmarkRemoveEscapeCharacters(b *testing.B) { 25 | text := green 26 | text += testText 27 | text += reset 28 | var lastResult [][]rune 29 | for i := 0; i < b.N; i++ { 30 | lastResult = RemoveANSIEscapeCharacters(text) 31 | } 32 | last := lastResult 33 | if len(last) < 0 { 34 | b.Errorf("Parsing returned wrong number of lines, expected: %d, got: %d", 1, len(last)) 35 | } 36 | 37 | } 38 | -------------------------------------------------------------------------------- /ui/colorize.go: -------------------------------------------------------------------------------- 1 | package ui 2 | 3 | import "fmt" 4 | 5 | // Blue blues the given string 6 | func Blue(text string) string { 7 | return fmt.Sprintf("%s", text) 8 | } 9 | 10 | // Red reddens the given string 11 | func Red(text string) string { 12 | return fmt.Sprintf("%s", text) 13 | } 14 | 15 | // White whites the given string 16 | func White(text string) string { 17 | return fmt.Sprintf("%s", text) 18 | } 19 | 20 | // Yellow yellows the given string 21 | func Yellow(text string) string { 22 | return fmt.Sprintf("%s", text) 23 | } 24 | 25 | // Cyan cyans the given string 26 | func Cyan(text string) string { 27 | return fmt.Sprintf("%s", text) 28 | } 29 | -------------------------------------------------------------------------------- /ui/colorize_test.go: -------------------------------------------------------------------------------- 1 | package ui 2 | 3 | import "testing" 4 | 5 | var colorizeTestTable = []struct { 6 | colorFunc func(string) string 7 | in string 8 | out string 9 | }{ 10 | {Blue, "textToColorize", "textToColorize"}, 11 | {Red, "textToColorize", "textToColorize"}, 12 | {White, "textToColorize", "textToColorize"}, 13 | {Yellow, "textToColorize", "textToColorize"}, 14 | {Cyan, "textToColorize", "textToColorize"}, 15 | } 16 | 17 | func TestColorizers(t *testing.T) { 18 | 19 | for _, tt := range colorizeTestTable { 20 | if tt.colorFunc(tt.in) != tt.out { 21 | t.Errorf("%T => %s, want %s", tt.colorFunc, tt.colorFunc(tt.in), tt.out) 22 | } 23 | } 24 | 25 | } 26 | -------------------------------------------------------------------------------- /ui/cursor.go: -------------------------------------------------------------------------------- 1 | package ui 2 | 3 | import ( 4 | "fmt" 5 | "sync" 6 | ) 7 | 8 | // Cursor represents the cursor position on the screen 9 | type Cursor struct { 10 | pos int 11 | downwards bool 12 | unlimited bool 13 | max int 14 | sync.RWMutex 15 | } 16 | 17 | // NewCursor creates a new unlimited cursor at position 0. 18 | func NewCursor() *Cursor { 19 | return &Cursor{pos: 0, downwards: true, unlimited: true} 20 | } 21 | 22 | // MovingDown returns true if the cursor is moving downwards after the last movement. 23 | func (cursor *Cursor) MovingDown() bool { 24 | return cursor.downwards 25 | } 26 | 27 | // Position tells on which screen pos the cursor is 28 | func (cursor *Cursor) Position() int { 29 | cursor.RLock() 30 | defer cursor.RUnlock() 31 | return cursor.pos 32 | } 33 | 34 | // Reset sets the cursor to its initial state (position 0, direction downwards). 35 | func (cursor *Cursor) Reset() { 36 | cursor.Lock() 37 | defer cursor.Unlock() 38 | cursor.pos = 0 39 | cursor.downwards = true 40 | 41 | } 42 | 43 | // ScrollCursorDown moves the cursor to the pos below the current one 44 | func (cursor *Cursor) ScrollCursorDown() { 45 | cursor.Lock() 46 | defer cursor.Unlock() 47 | if cursor.unlimited || cursor.pos < cursor.max { 48 | cursor.pos++ 49 | } 50 | cursor.downwards = true 51 | } 52 | 53 | // ScrollCursorUp moves the cursor to the pos above the current one 54 | func (cursor *Cursor) ScrollCursorUp() { 55 | cursor.Lock() 56 | defer cursor.Unlock() 57 | if cursor.pos > 0 { 58 | cursor.pos-- 59 | } else { 60 | cursor.pos = 0 61 | } 62 | cursor.downwards = false 63 | } 64 | 65 | // ScrollTo moves the cursor to the given pos 66 | func (cursor *Cursor) ScrollTo(pos int) { 67 | cursor.Lock() 68 | defer cursor.Unlock() 69 | if pos > cursor.pos { 70 | cursor.downwards = true 71 | } else { 72 | cursor.downwards = false 73 | } 74 | cursor.pos = pos 75 | } 76 | 77 | // Bottom moves the cursor to the bottom, if max is not set this a noop 78 | func (cursor *Cursor) Bottom() { 79 | cursor.Lock() 80 | defer cursor.Unlock() 81 | if !cursor.unlimited { 82 | cursor.pos = cursor.max 83 | } 84 | cursor.downwards = true 85 | 86 | } 87 | 88 | // Top moves the cursor to the top 89 | func (cursor *Cursor) Top() { 90 | cursor.Lock() 91 | defer cursor.Unlock() 92 | cursor.pos = 0 93 | cursor.downwards = false 94 | 95 | } 96 | 97 | // Max sets the max position allowed to this cursor 98 | func (cursor *Cursor) Max(max int) { 99 | cursor.Lock() 100 | defer cursor.Unlock() 101 | cursor.max = max 102 | cursor.unlimited = false 103 | } 104 | 105 | func (cursor *Cursor) String() string { 106 | return fmt.Sprintf("[%d, %t, %d]", cursor.pos, cursor.downwards, cursor.max) 107 | } 108 | -------------------------------------------------------------------------------- /ui/cursor_test.go: -------------------------------------------------------------------------------- 1 | package ui 2 | 3 | import "testing" 4 | 5 | func TestCursor(t *testing.T) { 6 | c := NewCursor() 7 | 8 | if c.Position() != 0 { 9 | t.Error("Default new cursor does not start at position 0") 10 | } 11 | 12 | if !c.unlimited { 13 | t.Error("Default new cursor is not unlimited") 14 | } 15 | 16 | c.Max(10) 17 | 18 | if c.max != 10 { 19 | t.Errorf("Cursor does not have expected max value: %d", c.max) 20 | } 21 | 22 | if c.unlimited { 23 | t.Error("Cursor is unlimited but has a max value") 24 | } 25 | 26 | if c.String() != "[0, true, 10]" { 27 | t.Errorf("Unexpected cursor string representation: %s", c.String()) 28 | } 29 | } 30 | 31 | func TestScrollingLimits(t *testing.T) { 32 | 33 | c := NewCursor() 34 | c.ScrollCursorDown() 35 | c.ScrollCursorDown() 36 | 37 | if c.Position() != 2 { 38 | t.Errorf("Cursor is not at expected position, %s", c.String()) 39 | } 40 | 41 | c.Max(3) 42 | c.ScrollCursorDown() 43 | if c.Position() != 3 { 44 | t.Errorf("Cursor is not at expected position, %s", c.String()) 45 | } 46 | 47 | c.ScrollCursorDown() 48 | if c.Position() != 3 { 49 | t.Errorf("Cursor is not at expected position after trying to scroll further than the max, %s", c.String()) 50 | } 51 | 52 | } 53 | 54 | func TestScrolling(t *testing.T) { 55 | c := Cursor{unlimited: true} 56 | c.ScrollCursorDown() 57 | if c.Position() != 1 || !c.MovingDown() { 58 | t.Errorf("Invalid cursor state after moving down. %s", c.String()) 59 | } 60 | 61 | c.Bottom() 62 | 63 | if c.Position() != 1 || !c.MovingDown() { 64 | t.Errorf("Invalid cursor state after scrolling to bottom a scroll with no max. %s", c.String()) 65 | } 66 | c.ScrollCursorUp() 67 | if c.Position() != 0 || c.MovingDown() { 68 | t.Errorf("Invalid cursor state after moving up. %s", c.String()) 69 | } 70 | 71 | c.ScrollCursorUp() 72 | if c.Position() != 0 { 73 | t.Errorf("Invalid cursor state after moving up too many times. %s", c.String()) 74 | } 75 | 76 | c.ScrollCursorDown() 77 | c.ScrollCursorDown() 78 | c.ScrollCursorDown() 79 | 80 | if c.Position() != 3 || !c.MovingDown() { 81 | t.Errorf("Invalid cursor state after moving down 3 times. %s", c.String()) 82 | } 83 | 84 | c.ScrollTo(5) 85 | 86 | if c.Position() != 5 || !c.MovingDown() { 87 | t.Errorf("Invalid cursor state after scrolling to position 5. %s", c.String()) 88 | } 89 | 90 | c.ScrollTo(3) 91 | 92 | if c.Position() != 3 || c.MovingDown() { 93 | t.Errorf("Invalid cursor state after scrolling back to position 3 from pos 5. %s", c.String()) 94 | } 95 | } 96 | -------------------------------------------------------------------------------- /ui/events.go: -------------------------------------------------------------------------------- 1 | package ui 2 | 3 | import "github.com/gdamore/tcell" 4 | 5 | // EventSource defines a source of keypress events. 6 | type EventSource struct { 7 | Events <-chan *tcell.EventKey 8 | EventHandledCallback func(*tcell.EventKey) error 9 | } 10 | -------------------------------------------------------------------------------- /ui/expiring_message.go: -------------------------------------------------------------------------------- 1 | package ui 2 | 3 | import ( 4 | "sync" 5 | "time" 6 | ) 7 | 8 | // ExpiringMessageWidget shows some text for an amount time then clears itself 9 | type ExpiringMessageWidget struct { 10 | y int 11 | screenWidth int 12 | clearTimer *time.Timer 13 | screen *Screen 14 | 15 | sync.RWMutex 16 | message string 17 | } 18 | 19 | // NewExpiringMessageWidget creates a new ExpiringMessageWidget struct 20 | func NewExpiringMessageWidget(y int, screen *Screen) *ExpiringMessageWidget { 21 | return &ExpiringMessageWidget{ 22 | y: y, 23 | screen: screen, 24 | clearTimer: nil, 25 | } 26 | 27 | } 28 | 29 | // Pause pauses this widget from showing any output, setting a new status 30 | // message will activate it again 31 | func (s *ExpiringMessageWidget) Pause() { 32 | s.Lock() 33 | s.stopTimer() 34 | s.Unlock() 35 | 36 | } 37 | 38 | func (s *ExpiringMessageWidget) stopTimer() { 39 | if s.clearTimer != nil { 40 | s.clearTimer.Stop() 41 | s.clearTimer = nil 42 | } 43 | } 44 | 45 | // Message sets the message to show for the given duration 46 | func (s *ExpiringMessageWidget) Message(msg string, clearDelay time.Duration) { 47 | s.Lock() 48 | defer s.Unlock() 49 | s.stopTimer() 50 | s.message = msg 51 | if clearDelay == 0 { 52 | return 53 | } 54 | s.clearTimer = time.AfterFunc(clearDelay, func() { 55 | s.Lock() 56 | s.message = "" 57 | s.Unlock() 58 | ActiveScreen.Fill(0, s.y, len(msg), 1, ' ') 59 | }) 60 | 61 | } 62 | 63 | // Render renders the status message 64 | func (s *ExpiringMessageWidget) Render() { 65 | s.RLock() 66 | s.RUnlock() 67 | if s.message == "" { 68 | return 69 | } 70 | s.screen.RenderLine(0, s.y, s.message) 71 | } 72 | -------------------------------------------------------------------------------- /ui/focus.go: -------------------------------------------------------------------------------- 1 | package ui 2 | 3 | import "github.com/gdamore/tcell" 4 | 5 | // Focusable define ui elements that can focus 6 | type Focusable interface { 7 | Focus(events <-chan *tcell.EventKey, done func()) error 8 | } 9 | -------------------------------------------------------------------------------- /ui/key.go: -------------------------------------------------------------------------------- 1 | package ui 2 | 3 | import "github.com/gdamore/tcell" 4 | 5 | // Key represents a keyboard key 6 | // Not in use. 7 | type Key struct { 8 | KeyCodes []rune 9 | Keys []tcell.Key 10 | HelpText string 11 | } 12 | -------------------------------------------------------------------------------- /ui/list.go: -------------------------------------------------------------------------------- 1 | package ui 2 | 3 | import "github.com/gizak/termui" 4 | 5 | // NewList returns a new list using the given ColorTheme 6 | func NewList(theme *ColorTheme) *termui.List { 7 | l := termui.NewList() 8 | 9 | l.Bg = termui.Attribute(theme.Bg) 10 | l.ItemBgColor = termui.Attribute(theme.Bg) 11 | l.ItemFgColor = termui.Attribute(theme.Fg) 12 | return l 13 | } 14 | -------------------------------------------------------------------------------- /ui/list_test.go: -------------------------------------------------------------------------------- 1 | package ui 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/gizak/termui" 7 | ) 8 | 9 | func TestNewList(t *testing.T) { 10 | theme := &ColorTheme{Bg: 1, Fg: 2} 11 | l := NewList(theme) 12 | 13 | if l == nil { 14 | t.Error("List is nil") 15 | } 16 | 17 | if l.Bg != termui.Attribute(theme.Bg) { 18 | t.Errorf("List Bg does not have expected value. Expected:%v, got: %v ", theme.Bg, l.Bg) 19 | } 20 | if l.ItemBgColor != termui.Attribute(theme.Bg) { 21 | t.Errorf("List ItemBgColor does not have expected value. Expected:%v, got: %v ", theme.Bg, l.ItemBgColor) 22 | } 23 | if l.ItemFgColor != termui.Attribute(theme.Fg) { 24 | t.Errorf("List Bg does not have expected value. Expected:%v, got: %v ", theme.Bg, l.ItemFgColor) 25 | } 26 | 27 | } 28 | -------------------------------------------------------------------------------- /ui/markup_test.go: -------------------------------------------------------------------------------- 1 | package ui 2 | 3 | import ( 4 | "reflect" 5 | "regexp" 6 | "strings" 7 | "testing" 8 | ) 9 | 10 | func TestTokenize(t *testing.T) { 11 | result := Tokenize("Dry is an interactive console application", regexp.MustCompile(" ")) 12 | expected := []string{"Dry", " ", "is", " ", "an", " ", "interactive", " ", "console", " ", "application"} 13 | 14 | if len(result) != len(expected) { 15 | t.Errorf("Tokenization didn't work, expected: %d tokens, got: %d.", 16 | len(expected), 17 | len(result)) 18 | } 19 | expectedJoin := strings.Join(expected, "") 20 | resultJoin := strings.Join(result, "") 21 | 22 | if expectedJoin != resultJoin { 23 | t.Errorf("Tokenization didn't work. Expected: '%s', got: '%s'.", 24 | expectedJoin, 25 | resultJoin) 26 | } 27 | if !reflect.DeepEqual(result, expected) { 28 | t.Errorf("Tokenization didn't work. Expected: '%s', got: '%s'.", 29 | expectedJoin, 30 | resultJoin) 31 | } 32 | } 33 | 34 | func TestTokenizeEmptyString(t *testing.T) { 35 | result := Tokenize("", regexp.MustCompile(" ")) 36 | if len(result) != 0 { 37 | t.Errorf("Tokenization didn't work, expected: %d tokens, got: %d.", 38 | 0, 39 | len(result)) 40 | } 41 | 42 | } 43 | 44 | func TestTokenizeNil(t *testing.T) { 45 | var emptyRunes []rune 46 | emptyRunesString := string(emptyRunes) 47 | result := Tokenize(emptyRunesString, regexp.MustCompile(" ")) 48 | if len(result) != 0 { 49 | t.Errorf("Tokenization didn't work, expected: %d tokens, got: %d.", 50 | 0, 51 | len(result)) 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /ui/par.go: -------------------------------------------------------------------------------- 1 | package ui 2 | 3 | import "github.com/gizak/termui" 4 | 5 | // NewPar creates a new termui paragraph with the given content and 6 | // with a look and feel based on the given theme. 7 | func NewPar(s string, theme *ColorTheme) *termui.Paragraph { 8 | p := termui.NewParagraph(s) 9 | p.Bg = termui.Attribute(theme.Bg) 10 | p.BorderFg = termui.Attribute(theme.Fg) 11 | p.BorderBg = termui.Attribute(theme.Bg) 12 | p.TextFgColor = termui.Attribute(theme.Fg) 13 | p.TextBgColor = termui.Attribute(theme.Bg) 14 | p.BorderLabelFg = termui.Attribute(theme.Fg) 15 | p.BorderLabelBg = termui.Attribute(theme.Bg) 16 | return p 17 | } 18 | -------------------------------------------------------------------------------- /ui/par_test.go: -------------------------------------------------------------------------------- 1 | package ui 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/gizak/termui" 7 | ) 8 | 9 | func TestNewPar(t *testing.T) { 10 | 11 | theme := &ColorTheme{Bg: 1, Fg: 2} 12 | p := NewPar("test", theme) 13 | 14 | if p == nil { 15 | t.Error("Par is nil") 16 | } 17 | if p.Text != "test" { 18 | t.Errorf("Par does not have expected text, got: %s ", p.Text) 19 | } 20 | 21 | if p.Bg != termui.Attribute(theme.Bg) { 22 | t.Errorf("Par BG attribute does not have expected value. Got: %v, expected: %v", p.Bg, theme.Bg) 23 | } 24 | if p.BorderFg != termui.Attribute(theme.Fg) { 25 | t.Errorf("Par BorderFg attribute does not have expected value. Got: %v, expected: %v", p.BorderFg, theme.Fg) 26 | } 27 | if p.BorderBg != termui.Attribute(theme.Bg) { 28 | t.Errorf("Par BorderBg attribute does not have expected value. Got: %v, expected: %v", p.BorderBg, theme.Bg) 29 | } 30 | if p.TextFgColor != termui.Attribute(theme.Fg) { 31 | t.Errorf("Par TextFgColor attribute does not have expected value. Got: %v, expected: %v", p.TextFgColor, theme.Fg) 32 | } 33 | if p.TextBgColor != termui.Attribute(theme.Bg) { 34 | t.Errorf("Par TextBgColor attribute does not have expected value. Got: %v, expected: %v", p.TextBgColor, theme.Bg) 35 | } 36 | if p.BorderLabelFg != termui.Attribute(theme.Fg) { 37 | t.Errorf("Par BorderLabelFg attribute does not have expected value. Got: %v, expected: %v", p.BorderLabelFg, theme.Fg) 38 | } 39 | if p.BorderLabelBg != termui.Attribute(theme.Bg) { 40 | t.Errorf("Par BorderLabelBg attribute does not have expected value. Got: %v, expected: %v", p.BorderLabelBg, theme.Bg) 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /ui/render.go: -------------------------------------------------------------------------------- 1 | package ui 2 | 3 | import ( 4 | "github.com/gdamore/tcell" 5 | "github.com/mattn/go-runewidth" 6 | ) 7 | 8 | // TextRenderer renders text 9 | type TextRenderer interface { 10 | Render(string) int 11 | } 12 | 13 | // ScreenTextRenderer renders text on a screen 14 | type ScreenTextRenderer struct { 15 | renderer styledRuneRenderer 16 | style tcell.Style 17 | withStyle bool 18 | x, y int 19 | width int 20 | } 21 | 22 | // NewRenderer creates ScreenTextRenderer 23 | func NewRenderer(s styledRuneRenderer) ScreenTextRenderer { 24 | return ScreenTextRenderer{ 25 | renderer: s, 26 | } 27 | } 28 | 29 | // WithStyle returns a renderer that will use the provided style on rendering 30 | func (renderer ScreenTextRenderer) WithStyle(style tcell.Style) ScreenTextRenderer { 31 | return ScreenTextRenderer{ 32 | renderer: renderer.renderer, 33 | x: renderer.x, 34 | y: renderer.y, 35 | width: renderer.width, 36 | withStyle: true, 37 | style: style, 38 | } 39 | } 40 | 41 | // On returns a renderer that will start rendering on the given positions of the 42 | // screen. 43 | func (renderer ScreenTextRenderer) On(x, y int) ScreenTextRenderer { 44 | return ScreenTextRenderer{ 45 | renderer: renderer.renderer, 46 | x: x, 47 | y: y, 48 | width: renderer.width, 49 | style: renderer.style, 50 | withStyle: renderer.withStyle, 51 | } 52 | } 53 | 54 | // WithWidth returns a renderer with its width set 55 | func (renderer ScreenTextRenderer) WithWidth(w int) ScreenTextRenderer { 56 | return ScreenTextRenderer{ 57 | renderer: renderer.renderer, 58 | x: renderer.x, 59 | y: renderer.y, 60 | width: w, 61 | style: renderer.style, 62 | withStyle: renderer.withStyle, 63 | } 64 | } 65 | 66 | // Render renders the given text on this renderer screen 67 | func (renderer ScreenTextRenderer) Render(s string) int { 68 | stringWidth := 0 69 | maxWidth := renderer.renderer.Dimensions().Width 70 | virtualScreenWidth := renderer.width 71 | //tracks the number of screen lines used to render 72 | additionalLines := 0 73 | startCol := renderer.x 74 | y := renderer.y 75 | 76 | var style tcell.Style 77 | if renderer.withStyle { 78 | style = renderer.style 79 | } else { 80 | style = renderer.renderer.Style() 81 | } 82 | for _, char := range s { 83 | runewidth := runewidth.RuneWidth(char) 84 | stringWidth += runewidth 85 | //Check if a new line is going to be needed 86 | if stringWidth > virtualScreenWidth { 87 | //A new line is going to be used, the virtual screen width has to be 88 | //extended 89 | virtualScreenWidth += virtualScreenWidth + maxWidth 90 | additionalLines++ 91 | y += additionalLines 92 | //new line, start column goes back to the beginning 93 | startCol = renderer.x 94 | } 95 | renderer.renderer.Render(startCol, y, char, style) 96 | startCol += runewidth 97 | } 98 | return additionalLines + 1 99 | } 100 | -------------------------------------------------------------------------------- /ui/screen_dimension.go: -------------------------------------------------------------------------------- 1 | package ui 2 | 3 | // Dimensions is the width and height of a ui element. 4 | type Dimensions struct { 5 | Height, Width int 6 | } 7 | -------------------------------------------------------------------------------- /ui/tcell.go: -------------------------------------------------------------------------------- 1 | package ui 2 | 3 | import ( 4 | "os" 5 | 6 | "github.com/gdamore/tcell" 7 | "github.com/gdamore/tcell/termbox" 8 | ) 9 | 10 | type styledRuneRenderer interface { 11 | Dimensions() *Dimensions 12 | Render(x int, y int, r rune, style tcell.Style) 13 | Style() tcell.Style 14 | } 15 | 16 | type screenStyledRuneRenderer struct { 17 | screen *Screen 18 | } 19 | 20 | func (r screenStyledRuneRenderer) Dimensions() *Dimensions { 21 | return r.screen.dimensions 22 | } 23 | func (r screenStyledRuneRenderer) Render(x int, y int, ru rune, style tcell.Style) { 24 | r.screen.screen.SetContent(x, y, ru, nil, style) 25 | 26 | } 27 | func (r screenStyledRuneRenderer) Style() tcell.Style { 28 | return r.screen.themeStyle 29 | } 30 | 31 | // InitScreen creates and initializes the tcell screen 32 | func initScreen() (tcell.Screen, error) { 33 | // To enable true color, TERM is set to `xterm-truecolor` before 34 | // initializing tcell. Once done after that, TERM is set back to whatever it was 35 | term := os.Getenv("TERM") 36 | defer os.Setenv("TERM", term) 37 | os.Setenv("TERM", "xterm-truecolor") 38 | 39 | tcell.SetEncodingFallback(tcell.EncodingFallbackASCII) 40 | screen, err := tcell.NewScreen() 41 | 42 | if err != nil { 43 | return nil, err 44 | } 45 | if err = screen.Init(); err != nil { 46 | return nil, err 47 | } 48 | 49 | screen.EnableMouse() 50 | return screen, nil 51 | } 52 | 53 | func mkStyle(fg, bg termbox.Attribute) tcell.Style { 54 | st := tcell.StyleDefault 55 | 56 | f := tcell.Color(int(fg)&0x1ff) - 1 57 | b := tcell.Color(int(bg)&0x1ff) - 1 58 | 59 | f = fixColor(termbox.Output256, f) 60 | b = fixColor(termbox.Output256, b) 61 | st = st.Foreground(f).Background(b) 62 | if (fg|bg)&termbox.AttrBold != 0 { 63 | st = st.Bold(true) 64 | } 65 | if (fg|bg)&termbox.AttrUnderline != 0 { 66 | st = st.Underline(true) 67 | } 68 | if (fg|bg)&termbox.AttrReverse != 0 { 69 | st = st.Reverse(true) 70 | } 71 | return st 72 | } 73 | func fixColor(outputMode termbox.OutputMode, c tcell.Color) tcell.Color { 74 | if c == tcell.ColorDefault { 75 | return c 76 | } 77 | switch outputMode { 78 | case termbox.OutputNormal: 79 | c %= tcell.Color(16) 80 | case termbox.Output256: 81 | c %= tcell.Color(256) 82 | case termbox.Output216: 83 | c %= tcell.Color(216) 84 | c += tcell.Color(16) 85 | case termbox.OutputGrayscale: 86 | c %= tcell.Color(24) 87 | c += tcell.Color(232) 88 | default: 89 | c = tcell.ColorDefault 90 | } 91 | return c 92 | } 93 | 94 | func screenDimensions(s tcell.Screen) *Dimensions { 95 | w, h := s.Size() 96 | return &Dimensions{Width: w, Height: h} 97 | } 98 | -------------------------------------------------------------------------------- /ui/termbox.go: -------------------------------------------------------------------------------- 1 | package ui 2 | 3 | import ( 4 | "unicode/utf8" 5 | 6 | "github.com/gdamore/tcell" 7 | 8 | "github.com/mattn/go-runewidth" 9 | ) 10 | 11 | //Functions that serve as building blocks for rendering structs 12 | //defined in the ui package. 13 | 14 | // renderLineWithMarkup renders the given string, using the given markup processor to 15 | // identify and ignore markup elements, at the given location. 16 | // returns the number of screen lines used to render the line 17 | func renderLineWithMarkup(x, y, maxWidth int, str string, markup *Markup) int { 18 | column := x 19 | stringWidth := 0 20 | //tracks the number of screen lines used to render 21 | additionalLines := 0 22 | 23 | availableWidth := maxWidth 24 | 25 | for _, token := range Tokenize(str, SupportedTags) { 26 | // First check if it's a tag. Tags are eaten up and not displayed. 27 | if markup.IsTag(token) { 28 | continue 29 | } 30 | 31 | // Here comes the actual text: display it one character at a time. 32 | for _, char := range token { 33 | runewidth := runewidth.RuneWidth(char) 34 | stringWidth += runewidth 35 | //Check if a new line is going to be needed 36 | if stringWidth > availableWidth { 37 | //A new line is going to be used, the screen width has doubled 38 | availableWidth *= 2 39 | additionalLines++ 40 | y += additionalLines 41 | } 42 | style := mkStyle(markup.Foreground, markup.Background) 43 | ActiveScreen.screen.SetCell(column, y, style, char) 44 | column++ 45 | } 46 | } 47 | //At least one screen line has been used 48 | return additionalLines + 1 49 | } 50 | 51 | func runeAdvanceLen(r rune, pos int) int { 52 | if r == '\t' { 53 | return tabStopLength - pos%tabStopLength 54 | } 55 | return 1 56 | } 57 | 58 | func vOffsetToCOffset(text []byte, boffset int) (voffset, coffset int) { 59 | text = text[:boffset] 60 | for len(text) > 0 { 61 | r, size := utf8.DecodeRune(text) 62 | text = text[size:] 63 | coffset++ 64 | voffset += runeAdvanceLen(r, voffset) 65 | } 66 | return 67 | } 68 | 69 | // EventChannel returns a channel on which termbox's events are published. 70 | func EventChannel() (<-chan tcell.Event, chan struct{}) { 71 | events := make(chan tcell.Event) 72 | done := make(chan struct{}) 73 | go func() { 74 | defer func() { close(events) }() 75 | 76 | for { 77 | events <- ActiveScreen.screen.PollEvent() 78 | select { 79 | case <-done: 80 | return 81 | default: 82 | } 83 | } 84 | 85 | }() 86 | return events, done 87 | } 88 | -------------------------------------------------------------------------------- /ui/termui/cursor.go: -------------------------------------------------------------------------------- 1 | package termui 2 | 3 | type cursor interface { 4 | HideCursor() 5 | ShowCursor(int, int) 6 | } 7 | -------------------------------------------------------------------------------- /ui/termui/gauge_column.go: -------------------------------------------------------------------------------- 1 | package termui 2 | 3 | import ( 4 | termui "github.com/gizak/termui" 5 | "github.com/moncho/dry/ui" 6 | ) 7 | 8 | // GaugeColumn is a termui.Gauge to be used as a Grid column. It is 9 | // borderless, has a height of 1 and its label is left-aligned. 10 | type GaugeColumn struct { 11 | termui.Gauge 12 | } 13 | 14 | // NewThemedGaugeColumn creates a new GaugeColumn using the given theme 15 | func NewThemedGaugeColumn(theme *ui.ColorTheme) *GaugeColumn { 16 | c := NewGaugeColumn() 17 | c.Bg = termui.Attribute(theme.Bg) 18 | return c 19 | } 20 | 21 | // NewGaugeColumn creates a new GaugeColumn 22 | func NewGaugeColumn() *GaugeColumn { 23 | g := termui.NewGauge() 24 | g.Height = 1 25 | g.Border = false 26 | g.Percent = 0 27 | g.PaddingBottom = 0 28 | 29 | return &GaugeColumn{*g} 30 | } 31 | 32 | // Reset resets this GaugeColumn 33 | func (w *GaugeColumn) Reset() { 34 | w.Percent = 0 35 | } 36 | -------------------------------------------------------------------------------- /ui/termui/gauge_column_test.go: -------------------------------------------------------------------------------- 1 | package termui 2 | 3 | import "testing" 4 | 5 | func TestGaugeColumn(t *testing.T) { 6 | c := NewGaugeColumn() 7 | 8 | if c == nil { 9 | t.Error("GaugeColumn is nil") 10 | } 11 | 12 | if c.Border { 13 | t.Error("GaugeColumn has a border") 14 | } 15 | if c.GetHeight() != 1 { 16 | t.Error("GaugeColumn has not the expected height") 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /ui/termui/keyvalue.go: -------------------------------------------------------------------------------- 1 | package termui 2 | 3 | import ( 4 | gtermui "github.com/gizak/termui" 5 | "github.com/moncho/dry/ui" 6 | ) 7 | 8 | // KeyValuePar is a widget for key value pairs 9 | type KeyValuePar struct { 10 | X, Y int 11 | Width int 12 | Height int 13 | key *gtermui.Paragraph 14 | value *gtermui.Paragraph 15 | } 16 | 17 | // NewKeyValuePar creates a KeyValuePar widget with the given values 18 | func NewKeyValuePar(key, value string, theme *ui.ColorTheme) *KeyValuePar { 19 | kv := &KeyValuePar{} 20 | bg := gtermui.Attribute(theme.Bg) 21 | 22 | kv.Height = 1 23 | kv.key = gtermui.NewParagraph(key + ":") 24 | kv.key.Border = false 25 | kv.key.Bg = bg 26 | kv.key.TextBgColor = bg 27 | kv.key.TextFgColor = gtermui.Attribute(theme.Key) 28 | 29 | kv.value = gtermui.NewParagraph(" " + value) 30 | kv.value.Border = false 31 | kv.value.Bg = bg 32 | kv.value.TextBgColor = bg 33 | kv.value.TextFgColor = gtermui.Attribute(theme.Current) 34 | 35 | return kv 36 | } 37 | 38 | // GetHeight returns this kv height 39 | func (kv *KeyValuePar) GetHeight() int { 40 | return kv.Height 41 | } 42 | 43 | // SetX sets the x position of this kv 44 | func (kv *KeyValuePar) SetX(x int) { 45 | kv.key.SetX(x) 46 | kv.value.SetX(x + 1 + len(kv.key.Text)) 47 | kv.X = x 48 | } 49 | 50 | // SetY sets the y position of this kv 51 | func (kv *KeyValuePar) SetY(y int) { 52 | kv.key.SetY(y) 53 | kv.value.SetY(y) 54 | kv.Y = y 55 | } 56 | 57 | // SetWidth sets the width of this kv 58 | func (kv *KeyValuePar) SetWidth(width int) { 59 | kv.key.SetWidth(width) 60 | kv.value.SetWidth(width) 61 | kv.Width = width 62 | } 63 | 64 | // Buffer returns this kv data as a gtermui.Buffer 65 | func (kv *KeyValuePar) Buffer() gtermui.Buffer { 66 | buf := gtermui.NewBuffer() 67 | buf.Merge(kv.key.Buffer()) 68 | buf.Merge(kv.value.Buffer()) 69 | return buf 70 | } 71 | -------------------------------------------------------------------------------- /ui/termui/keyvalue_test.go: -------------------------------------------------------------------------------- 1 | package termui 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/moncho/dry/ui" 7 | ) 8 | 9 | func TestKeyValue(t *testing.T) { 10 | kvp := NewKeyValuePar("key", "value", &ui.ColorTheme{}) 11 | if kvp == nil { 12 | t.Error("KeyValuePar is nil") 13 | } 14 | 15 | if kvp.key.Text != "key:" { 16 | t.Errorf("KeyValuePar key is not what expected, got: %s", kvp.key.Text) 17 | } 18 | 19 | if kvp.value.Text != " value" { 20 | t.Errorf("KeyValuePar value is not what expected, got: %s", kvp.value.Text) 21 | } 22 | 23 | if len(kvp.Buffer().CellMap) <= 0 { 24 | t.Errorf("KeyValuePar cellmap is empty content") 25 | 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /ui/termui/par_column.go: -------------------------------------------------------------------------------- 1 | package termui 2 | 3 | import ( 4 | termui "github.com/gizak/termui" 5 | "github.com/moncho/dry/ui" 6 | ) 7 | 8 | // ParColumn is a termui.Par that can be used in a grid to show text 9 | type ParColumn struct { 10 | termui.Paragraph 11 | } 12 | 13 | // NewThemedParColumn creates a new paragraph column with the given text using the given color theme 14 | func NewThemedParColumn(theme *ui.ColorTheme, s string) *ParColumn { 15 | p := NewParColumn(s) 16 | p.Bg = termui.Attribute(theme.Bg) 17 | p.TextBgColor = termui.Attribute(theme.Bg) 18 | p.TextFgColor = termui.Attribute(theme.ListItem) 19 | return p 20 | } 21 | 22 | // NewParColumn creates a new paragraph column with the given text 23 | func NewParColumn(s string) *ParColumn { 24 | p := termui.NewParagraph(s) 25 | p.Border = false 26 | 27 | return &ParColumn{*p} 28 | } 29 | 30 | // Reset resets the text on this Par 31 | func (w *ParColumn) Reset() { 32 | w.Content("-") 33 | } 34 | 35 | // Content sets the text of this Par to the given content 36 | func (w *ParColumn) Content(s string) { 37 | w.Text = s 38 | w.Width = len(w.Text) 39 | } 40 | 41 | // SetWidth sets par width. 42 | func (w *ParColumn) SetWidth(width int) { 43 | contentWidth := len(w.Text) 44 | if width > contentWidth { 45 | w.Width = contentWidth 46 | } else { 47 | w.Width = width 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /ui/termui/par_column_test.go: -------------------------------------------------------------------------------- 1 | package termui 2 | 3 | import "testing" 4 | 5 | const text = "Move along, nothing to see here" 6 | 7 | func TestParColumn(t *testing.T) { 8 | p := NewParColumn(text) 9 | 10 | if p == nil { 11 | t.Error("ParColumn is nil") 12 | } 13 | 14 | if p.Text != text { 15 | t.Errorf("ParColumn content is not what expected: %s", p.Text) 16 | } 17 | if p.Border { 18 | t.Error("ParColumn has a border") 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /ui/termui/par_markup.go: -------------------------------------------------------------------------------- 1 | package termui 2 | 3 | import ( 4 | gizaktermui "github.com/gizak/termui" 5 | "github.com/moncho/dry/ui" 6 | ) 7 | 8 | // MarkupPar is a paragraph with marked-up text 9 | type MarkupPar struct { 10 | gizaktermui.Paragraph 11 | textBuilder gizaktermui.TextBuilder 12 | } 13 | 14 | // NewParFromMarkupText creates a new termui paragraph from marked-up text. 15 | func NewParFromMarkupText(theme *ui.ColorTheme, str string) *MarkupPar { 16 | return &MarkupPar{Paragraph: *gizaktermui.NewParagraph(str), textBuilder: &markupTextBuilder{ui.NewMarkup(theme)}} 17 | } 18 | 19 | // Content sets the paragraph content to the given text. 20 | func (p *MarkupPar) Content(str string) { 21 | p.Paragraph.Text = str 22 | } 23 | 24 | // Buffer return this paragraph content as a termui.Buffer 25 | func (p *MarkupPar) Buffer() gizaktermui.Buffer { 26 | buf := p.Block.Buffer() 27 | 28 | fg, bg := p.TextFgColor, p.TextBgColor 29 | cs := p.textBuilder.Build(p.Text, fg, bg) 30 | 31 | // wrap if WrapLength set 32 | if p.WrapLength < 0 { 33 | cs = wrapTx(cs, p.Width-2) 34 | } else if p.WrapLength > 0 { 35 | cs = wrapTx(cs, p.WrapLength) 36 | } 37 | 38 | y, x, n := 0, 0, 0 39 | for y < p.InnerHeight() && n < len(cs) { 40 | w := cs[n].Width() 41 | if cs[n].Ch == '\n' || x+w > p.InnerWidth() { 42 | y++ 43 | x = 0 44 | if cs[n].Ch == '\n' { 45 | n++ 46 | } 47 | 48 | if y >= p.InnerHeight() { 49 | break 50 | } 51 | continue 52 | } 53 | 54 | buf.Set(p.InnerX()+x, p.InnerY()+y, cs[n]) 55 | 56 | n++ 57 | x += w 58 | } 59 | 60 | return buf 61 | } 62 | -------------------------------------------------------------------------------- /ui/termui/row.go: -------------------------------------------------------------------------------- 1 | package termui 2 | 3 | import ( 4 | gtermui "github.com/gizak/termui" 5 | ) 6 | 7 | // Row is a widget for table rows 8 | type Row struct { 9 | X, Y int 10 | Width int 11 | Height int 12 | Columns []gtermui.GridBufferer 13 | Table Table 14 | } 15 | 16 | // AddColumn adds the given column to this row 17 | func (row *Row) AddColumn(c gtermui.GridBufferer) { 18 | row.Columns = append(row.Columns, c) 19 | } 20 | 21 | // GetHeight returns this Row height 22 | func (row *Row) GetHeight() int { 23 | return row.Height 24 | } 25 | 26 | // SetX sets the x position of this Row 27 | func (row *Row) SetX(x int) { 28 | row.X = x 29 | } 30 | 31 | // SetY sets the y position of this Row 32 | func (row *Row) SetY(y int) { 33 | if y == row.Y { 34 | return 35 | } 36 | for _, col := range row.Columns { 37 | col.SetY(y) 38 | } 39 | row.Y = y 40 | } 41 | 42 | // SetWidth sets the width of this Row 43 | func (row *Row) SetWidth(width int) { 44 | x := row.X 45 | 46 | if row.Table != nil { 47 | for i, width := range row.Table.ColumnWidths() { 48 | col := row.Columns[i] 49 | col.SetX(x) 50 | col.SetWidth(width) 51 | x += width + DefaultColumnSpacing 52 | } 53 | } else { 54 | if len(row.Columns) > 0 { 55 | cWidth := width / len(row.Columns) 56 | for _, col := range row.Columns { 57 | col.SetX(x) 58 | col.SetWidth(cWidth) 59 | x += cWidth + DefaultColumnSpacing 60 | } 61 | } 62 | } 63 | row.Width = width 64 | } 65 | 66 | // Buffer returns this Row data as a gtermui.Buffer 67 | func (row *Row) Buffer() gtermui.Buffer { 68 | buf := gtermui.NewBuffer() 69 | for _, col := range row.Columns { 70 | buf.Merge(col.Buffer()) 71 | } 72 | return buf 73 | } 74 | -------------------------------------------------------------------------------- /ui/termui/row_test.go: -------------------------------------------------------------------------------- 1 | package termui 2 | 3 | import ( 4 | "testing" 5 | 6 | gtermui "github.com/gizak/termui" 7 | ) 8 | 9 | type testTable func() []int 10 | 11 | func (t testTable) ColumnWidths() []int { 12 | return t() 13 | } 14 | 15 | func TestRowCreation(t *testing.T) { 16 | row := Row{} 17 | row.SetWidth(0) 18 | if len(row.Columns) != 0 { 19 | t.Errorf("Unexpected number of columns on an empty row, got %d", len(row.Columns)) 20 | } 21 | if row.Width != 0 { 22 | t.Errorf("Unexpected width, got %d", row.Width) 23 | } 24 | row.SetWidth(10) 25 | if row.Width != 10 { 26 | t.Errorf("Unexpected width, got %d", row.Width) 27 | } 28 | } 29 | 30 | func TestSettingRowWidth_RowWithNoTable(t *testing.T) { 31 | row := Row{} 32 | c1 := >ermui.Paragraph{} 33 | c2 := >ermui.Paragraph{} 34 | c3 := >ermui.Paragraph{} 35 | 36 | row.AddColumn(c1) 37 | row.AddColumn(c2) 38 | row.AddColumn(c3) 39 | 40 | rowWidth := 10 41 | expectedColWidth := rowWidth / len(row.Columns) 42 | row.SetWidth(rowWidth) 43 | if row.Width != rowWidth { 44 | t.Errorf("Unexpected width, got %d", row.Width) 45 | } 46 | if c1.Width != expectedColWidth { 47 | t.Errorf("Unexpected column width: got %d, expected %d", c1.Width, expectedColWidth) 48 | } 49 | if c2.Width != expectedColWidth { 50 | t.Errorf("Unexpected column width: got %d, expected %d", c2.Width, expectedColWidth) 51 | } 52 | if c3.Width != expectedColWidth { 53 | t.Errorf("Unexpected column width: got %d, expected %d", c3.Width, expectedColWidth) 54 | } 55 | 56 | } 57 | 58 | func TestSettingRowWidth_RowWithTable(t *testing.T) { 59 | row := Row{} 60 | row.Table = testTable(func() []int { 61 | return []int{2, 2, 2} 62 | }) 63 | c1 := >ermui.Paragraph{} 64 | c2 := >ermui.Paragraph{} 65 | c3 := >ermui.Paragraph{} 66 | 67 | row.AddColumn(c1) 68 | row.AddColumn(c2) 69 | row.AddColumn(c3) 70 | 71 | row.SetWidth(10) 72 | if row.Width != 10 { 73 | t.Errorf("Unexpected width, got %d", row.Width) 74 | } 75 | if c1.Width != 2 { 76 | t.Errorf("Unexpected column width, got %d", c1.Width) 77 | } 78 | if c2.Width != 2 { 79 | t.Errorf("Unexpected column width, got %d", c2.Width) 80 | } 81 | if c3.Width != 2 { 82 | t.Errorf("Unexpected column width, got %d", c3.Width) 83 | } 84 | 85 | } 86 | -------------------------------------------------------------------------------- /ui/termui/stringer.go: -------------------------------------------------------------------------------- 1 | package termui 2 | 3 | import ( 4 | "image" 5 | "sort" 6 | "strings" 7 | 8 | "github.com/gizak/termui" 9 | ) 10 | 11 | type bufferer interface { 12 | Buffer() termui.Buffer 13 | } 14 | 15 | // String returns this Buffer content as a string 16 | func String(b bufferer) (string, error) { 17 | cellMap := b.Buffer().CellMap 18 | var builder strings.Builder 19 | curLine := 0 20 | for _, k := range sortedKeys(cellMap) { 21 | if curLine != k.Y { 22 | builder.WriteByte('\n') 23 | curLine = k.Y 24 | } 25 | 26 | _, err := builder.WriteRune(cellMap[k].Ch) 27 | if err != nil { 28 | return "", err 29 | } 30 | } 31 | return builder.String(), nil 32 | } 33 | 34 | func sortedKeys(m map[image.Point]termui.Cell) []image.Point { 35 | keys := make([]image.Point, len(m)) 36 | 37 | i := 0 38 | for k := range m { 39 | keys[i] = k 40 | i++ 41 | } 42 | 43 | sort.Slice(keys, func(i, j int) bool { 44 | if keys[i].Y == keys[j].Y { 45 | return keys[i].X < keys[j].X 46 | } 47 | return keys[i].Y < keys[j].Y 48 | }) 49 | return keys 50 | } 51 | -------------------------------------------------------------------------------- /ui/termui/stringer_test.go: -------------------------------------------------------------------------------- 1 | package termui 2 | 3 | import ( 4 | "testing" 5 | ) 6 | 7 | func TestStringer(t *testing.T) { 8 | type args struct { 9 | b bufferer 10 | } 11 | par := NewParColumn("bla") 12 | par.Height = 1 13 | par.Width = 3 14 | tests := []struct { 15 | name string 16 | args args 17 | want string 18 | wantErr bool 19 | }{ 20 | { 21 | "", 22 | args{ 23 | par, 24 | }, 25 | `bla`, 26 | false, 27 | }, 28 | } 29 | for _, tt := range tests { 30 | t.Run(tt.name, func(t *testing.T) { 31 | got, err := String(tt.args.b) 32 | if (err != nil) != tt.wantErr { 33 | t.Errorf("String() error = %v, wantErr %v", err, tt.wantErr) 34 | return 35 | } 36 | if got != tt.want { 37 | t.Errorf("String() = '%v', want '%v'", got, tt.want) 38 | } 39 | }) 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /ui/termui/table.go: -------------------------------------------------------------------------------- 1 | package termui 2 | 3 | // DefaultColumnSpacing default the spacing (in chars) between columns of a table 4 | const DefaultColumnSpacing = 1 5 | 6 | // Table defines common behaviour for table widgets 7 | type Table interface { 8 | ColumnWidths() []int 9 | } 10 | -------------------------------------------------------------------------------- /ui/termui/textbuilder.go: -------------------------------------------------------------------------------- 1 | package termui 2 | 3 | import ( 4 | gizaktermui "github.com/gizak/termui" 5 | wordwrap "github.com/mitchellh/go-wordwrap" 6 | "github.com/moncho/dry/ui" 7 | ) 8 | 9 | const stop = "stop" 10 | 11 | type markupTextBuilder struct { 12 | markup *ui.Markup 13 | } 14 | 15 | // Build implements TextBuilder interface. 16 | func (mtb markupTextBuilder) Build(str string, fg, bg gizaktermui.Attribute) []gizaktermui.Cell { 17 | 18 | var result []gizaktermui.Cell 19 | markup := mtb.markup 20 | 21 | for _, token := range ui.Tokenize(str, ui.SupportedTags) { 22 | // Tags are ignored. 23 | if markup.IsTag(token) { 24 | continue 25 | } 26 | for _, char := range token { 27 | result = append(result, 28 | gizaktermui.Cell{ 29 | Ch: char, 30 | Fg: gizaktermui.Attribute(markup.Foreground), 31 | Bg: bg}) 32 | } 33 | } 34 | 35 | return result 36 | } 37 | 38 | func wrapTx(cs []gizaktermui.Cell, wl int) []gizaktermui.Cell { 39 | tmpCell := make([]gizaktermui.Cell, len(cs)) 40 | copy(tmpCell, cs) 41 | 42 | // get the plaintext 43 | plain := gizaktermui.CellsToStr(cs) 44 | 45 | // wrap 46 | plainWrapped := wordwrap.WrapString(plain, uint(wl)) 47 | 48 | // find differences and insert 49 | var finalCell []gizaktermui.Cell 50 | 51 | plainRune := []rune(plain) 52 | plainWrappedRune := []rune(plainWrapped) 53 | trigger := "go" 54 | plainRuneNew := plainRune 55 | 56 | for trigger != stop { 57 | plainRune = plainRuneNew 58 | for i := range plainRune { 59 | if plainRune[i] == plainWrappedRune[i] { 60 | trigger = stop 61 | } else if plainRune[i] != plainWrappedRune[i] && plainWrappedRune[i] == 10 { 62 | trigger = "go" 63 | cell := gizaktermui.Cell{Ch: 10, Fg: 0, Bg: 0} 64 | j := i - 0 65 | 66 | // insert a cell into the []Cell in correct position 67 | tmpCell[i] = cell 68 | 69 | // insert the newline into plain so we avoid indexing errors 70 | plainRuneNew = append(plainRune, 10) 71 | copy(plainRuneNew[j+1:], plainRuneNew[j:]) 72 | plainRuneNew[j] = plainWrappedRune[j] 73 | 74 | // restart the inner for loop until plain and plain wrapped are 75 | // the same; yeah, it's inefficient, but the text amounts 76 | // should be small 77 | break 78 | 79 | } else if plainRune[i] != plainWrappedRune[i] && 80 | plainWrappedRune[i-1] == 10 && // if the prior rune is a newline 81 | plainRune[i] == 32 { // and this rune is a space 82 | trigger = "go" 83 | // need to delete plainRune[i] because it gets rid of an extra 84 | // space 85 | plainRuneNew = append(plainRune[:i], plainRune[i+1:]...) 86 | break 87 | 88 | } else { 89 | trigger = stop // stops the outer for loop 90 | } 91 | } 92 | } 93 | 94 | finalCell = tmpCell 95 | 96 | return finalCell 97 | } 98 | -------------------------------------------------------------------------------- /ui/termui/types.go: -------------------------------------------------------------------------------- 1 | package termui 2 | 3 | import gizak "github.com/gizak/termui" 4 | 5 | // SizableBufferer is a termui.Bufferer with dimensions and position 6 | type SizableBufferer interface { 7 | gizak.Bufferer 8 | GetHeight() int 9 | SetWidth(int) 10 | SetX(int) 11 | SetY(int) 12 | } 13 | -------------------------------------------------------------------------------- /ui/termui/widget.go: -------------------------------------------------------------------------------- 1 | package termui 2 | 3 | import ( 4 | gizaktermui "github.com/gizak/termui" 5 | ) 6 | 7 | // Widget defines how a UI widget responds to its lifecycle events: 8 | // - Buffer returns the content of the widget as termui.Buffer, 9 | // it will be invoked every time the widget is render. 10 | // - Mount will be invoked before the Buffer method, 11 | // it can be used to prepare the widget for rendering. 12 | // - Unmount will be invoked to signal that the widget is not going 13 | // to be used anymore, it can be used for cleaning up. 14 | // 15 | // Widget are identified by its name. 16 | type Widget interface { 17 | Buffer() gizaktermui.Buffer 18 | Mount() error 19 | Name() string 20 | Unmount() error 21 | } 22 | -------------------------------------------------------------------------------- /ui/theme.go: -------------------------------------------------------------------------------- 1 | package ui 2 | 3 | // ColorTheme represents a color theme 4 | type ColorTheme struct { 5 | Fg Color 6 | Bg Color 7 | DarkBg Color 8 | Prompt Color 9 | Key Color 10 | Current Color 11 | CurrentMatch Color 12 | Spinner Color 13 | Info Color 14 | Cursor Color 15 | Selected Color 16 | Header Color 17 | Footer Color 18 | ListItem Color 19 | CursorLineBg Color 20 | } 21 | -------------------------------------------------------------------------------- /ui/view_test.go: -------------------------------------------------------------------------------- 1 | package ui 2 | 3 | import ( 4 | "fmt" 5 | "testing" 6 | ) 7 | 8 | func TestCursorScrolling(t *testing.T) { 9 | view := view(10, 10) 10 | testCursor(t, view, 0, 0) 11 | // 12 | view.CursorToBottom() 13 | testCursor(t, view, 0, 10) 14 | 15 | view.CursorToBottom() 16 | testCursor(t, view, 0, 10) 17 | 18 | view.CursorToTop() 19 | testCursor(t, view, 0, 0) 20 | 21 | view.CursorDown() 22 | testCursor(t, view, 0, 1) 23 | 24 | view.CursorUp() 25 | testCursor(t, view, 0, 0) 26 | 27 | view.CursorUp() 28 | testCursor(t, view, 0, 0) 29 | } 30 | 31 | func TestViewBufferPosition(t *testing.T) { 32 | view := view(10, 10) 33 | numberOfLinesToWrite := 20 34 | 35 | for i := 0; i < numberOfLinesToWrite; i++ { 36 | fmt.Fprintf(view, "Line %d\n", i) 37 | } 38 | firstLine, _ := view.Line(0) 39 | if firstLine != "Line 0" { 40 | t.Errorf("Buffer content is not right, expected: %s got: %s", 41 | "Line 0", 42 | firstLine) 43 | } 44 | view.PageDown() 45 | testCursor(t, view, 0, 0) 46 | testViewPosition(t, view, 0, 9) 47 | view.PageDown() 48 | testCursor(t, view, 0, 0) 49 | testViewPosition(t, view, 0, 18) 50 | view.PageDown() 51 | testCursor(t, view, 0, 10) 52 | //The buffer 53 | testViewBufferSize(t, view, numberOfLinesToWrite+1) 54 | 55 | } 56 | 57 | func testViewBufferSize(t *testing.T, view *View, expected int) { 58 | if expected != len(view.lines) { 59 | t.Errorf("View buffer has not the expected size, expected: %d got: %d", 60 | expected, 61 | len(view.lines)) 62 | } 63 | } 64 | 65 | func testCursor(t *testing.T, view *View, expectedX int, expectedY int) { 66 | t.Helper() 67 | x, y := view.Cursor() 68 | if x != expectedX || y != expectedY { 69 | t.Errorf("Cursor is not at the right position, expected: (%d, %d) got: (%d, %d)", 70 | expectedX, 71 | expectedY, 72 | x, 73 | y) 74 | } 75 | } 76 | 77 | func testViewPosition(t *testing.T, view *View, expectedX int, expectedY int) { 78 | t.Helper() 79 | 80 | x, y := view.Position() 81 | if x != expectedX || y != expectedY { 82 | t.Errorf("View buffer is not at the right position, expected: (%d, %d) got: (%d, %d)", 83 | expectedX, 84 | expectedY, 85 | x, 86 | y) 87 | } 88 | 89 | } 90 | 91 | func view(width, height int) *View { 92 | 93 | view := View{ 94 | name: "", 95 | x0: 0, 96 | y0: 0, 97 | x1: width, 98 | y1: height, 99 | width: width, 100 | height: height - 1, 101 | showCursor: true, 102 | theme: nil, 103 | newLineCallback: func() {}, 104 | } 105 | 106 | view.renderer = NewRenderer(screenMock{}).WithWidth(view.width) 107 | 108 | //view.cursorY = height - 1 //Last line i 109 | return &view 110 | } 111 | -------------------------------------------------------------------------------- /version/version.go: -------------------------------------------------------------------------------- 1 | package version 2 | 3 | // VERSION indicates which version of the binary is running. 4 | var VERSION string 5 | 6 | // GITCOMMIT indicates which git hash the binary was built off of 7 | var GITCOMMIT string 8 | --------------------------------------------------------------------------------