├── .github ├── CODEOWNERS └── workflows │ ├── build.yml │ ├── go-tests.yml │ ├── nightly-release-readme.md │ └── nightly-release.yml ├── .gitignore ├── .go-version ├── .release ├── ci.hcl ├── release-metadata.hcl └── security-scan.hcl ├── Dockerfile ├── GNUmakefile ├── LICENSE ├── README.md ├── cmd └── damon │ └── main.go ├── component ├── allocations.go ├── allocations_test.go ├── cluster_info.go ├── cluster_info_test.go ├── commands.go ├── commands_test.go ├── component.go ├── componentfakes │ ├── fake_done_modal_func.go │ ├── fake_drop_down.go │ ├── fake_input_field.go │ ├── fake_modal.go │ ├── fake_select_job_func.go │ ├── fake_selector.go │ ├── fake_table.go │ └── fake_text_view.go ├── dep_table.go ├── dep_table_test.go ├── error.go ├── error_test.go ├── info.go ├── info_test.go ├── job_status.go ├── job_status_test.go ├── job_table.go ├── job_table_test.go ├── jump.go ├── jump_test.go ├── logo.go ├── logo_test.go ├── logs.go ├── logs_test.go ├── modal.go ├── modal_test.go ├── namespaces.go ├── namespaces_test.go ├── search.go ├── search_test.go ├── selections.go ├── selections_test.go ├── selector_modal.go ├── selector_modal_test.go ├── task_events.go ├── task_events_test.go ├── task_group.go ├── task_group_test.go ├── tasks.go └── tasks_test.go ├── go.mod ├── go.sum ├── layout ├── layout.go └── layout_test.go ├── models └── models.go ├── nomad ├── alloc.go ├── alloc_test.go ├── client.go ├── client_test.go ├── deployments.go ├── deployments_test.go ├── events.go ├── events_test.go ├── job_status.go ├── job_status_test.go ├── jobs.go ├── jobs_test.go ├── logs.go ├── logs_test.go ├── namespaces.go ├── namespaces_test.go ├── nomadfakes │ ├── fake_alloc_fsclient.go │ ├── fake_allocations_client.go │ ├── fake_client.go │ ├── fake_deployment_client.go │ ├── fake_events_client.go │ ├── fake_job_client.go │ └── fake_namespace_client.go ├── taskgroups.go └── taskgroups_test.go ├── primitives ├── dropdown.go ├── dropdown_test.go ├── input.go ├── input_test.go ├── modal.go ├── modal_test.go ├── selector.go ├── selector_test.go ├── table.go ├── table_test.go ├── text.go └── text_test.go ├── refresher ├── refresher.go ├── refresher_test.go └── refresherfakes │ ├── fake_activities.go │ └── fake_refresh_func.go ├── scripts ├── docker-entrypoint.sh └── version.sh ├── state └── state.go ├── styles └── styles.go ├── version └── version.go ├── view ├── allocations.go ├── deployments.go ├── handler.go ├── history.go ├── init.go ├── inputs.go ├── job_status.go ├── jobs.go ├── logs.go ├── namespace.go ├── task_events.go ├── task_groups.go ├── tasks.go └── view.go └── watcher ├── activity.go ├── activity_test.go ├── jobStatus.go ├── jobStatus_test.go ├── logs.go ├── logs_test.go ├── namespaces.go ├── namespaces_test.go ├── taskgroups.go ├── taskgroups_test.go ├── watcher.go ├── watcher_test.go └── watcherfakes ├── fake_activities.go └── fake_nomad.go /.github/CODEOWNERS: -------------------------------------------------------------------------------- 1 | # release configuration 2 | /.release/ @hashicorp/release-engineering 3 | /.github/workflows/build.yml @hashicorp/release-engineering 4 | -------------------------------------------------------------------------------- /.github/workflows/go-tests.yml: -------------------------------------------------------------------------------- 1 | name: Go Tests 2 | 3 | on: 4 | push: 5 | 6 | jobs: 7 | test: 8 | name: Test 9 | runs-on: ubuntu-20.04 10 | steps: 11 | - uses: actions/checkout@c85c95e3d7251135ab7dc9ce3241c5835cc595a9 # v3.5.3 12 | - name: Determine Go version 13 | id: get-go-version 14 | run: | 15 | echo "Building with Go $(cat .go-version)" 16 | echo "::set-output name=go-version::$(cat .go-version)" 17 | - uses: actions/setup-go@fac708d6674e30b6ba41289acaab6d4b75aa0753 # v4.0.1 18 | with: 19 | go-version: ${{ steps.get-go-version.outputs.go-version }} 20 | - name: Run tests 21 | run: | 22 | make test 23 | -------------------------------------------------------------------------------- /.github/workflows/nightly-release-readme.md: -------------------------------------------------------------------------------- 1 | Nightly releases are snapshots of the development activity on the Damon project that may include new features and bug fixes scheduled for upcoming releases. These releases are made available to make it easier for users to test their existing build configurations against the latest Damon code base for potential issues or to experiment with new features, with a chance to provide feedback on ways to improve the changes before being released. 2 | 3 | As these releases are snapshots of the latest code, you may encounter an issue compared to the latest stable release. Users are encouraged to run nightly releases in a non production environment. If you encounter an issue, please check our [issue tracker](https://github.com/hashicorp/damon/issues) to see if the issue has already been reported; if a report hasn't been made, please report it so we can review the issue and make any needed fixes. 4 | 5 | **Note**: Nightly releases are only available via GitHub Releases, and artifacts are not codesigned or notarized. Distribution via other [Release Channels](https://www.hashicorp.com/official-release-channels) such as the Releases Site or Homebrew is not yet supported. 6 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | .vscode/* 3 | vendor/* 4 | bin 5 | bin/* 6 | pkg/ 7 | 8 | # assets download path when using bob CLI 9 | .bob/* 10 | -------------------------------------------------------------------------------- /.go-version: -------------------------------------------------------------------------------- 1 | 1.19.1 2 | -------------------------------------------------------------------------------- /.release/ci.hcl: -------------------------------------------------------------------------------- 1 | # Copyright (c) HashiCorp, Inc. 2 | # SPDX-License-Identifier: MPL-2.0 3 | 4 | schema = "1" 5 | 6 | project "damon" { 7 | // the team key is not used by CRT currently 8 | team = "nomad" 9 | slack { 10 | notification_channel = "C03B5EWFW01" 11 | } 12 | github { 13 | organization = "hashicorp" 14 | repository = "damon" 15 | release_branches = [ 16 | "main", 17 | ] 18 | } 19 | } 20 | 21 | event "merge" { 22 | // "entrypoint" to use if build is not run automatically 23 | // i.e. send "merge" complete signal to orchestrator to trigger build 24 | } 25 | 26 | event "build" { 27 | depends = ["merge"] 28 | action "build" { 29 | organization = "hashicorp" 30 | repository = "damon" 31 | workflow = "build" 32 | } 33 | } 34 | 35 | event "prepare" { 36 | depends = ["build"] 37 | action "prepare" { 38 | organization = "hashicorp" 39 | repository = "crt-workflows-common" 40 | workflow = "prepare" 41 | depends = ["build"] 42 | } 43 | 44 | notification { 45 | on = "fail" 46 | } 47 | } 48 | 49 | ## These are promotion and post-publish events 50 | ## they should be added to the end of the file after the verify event stanza. 51 | 52 | event "trigger-staging" { 53 | // This event is dispatched by the bob trigger-promotion command 54 | // and is required - do not delete. 55 | } 56 | 57 | event "promote-staging" { 58 | depends = ["trigger-staging"] 59 | action "promote-staging" { 60 | organization = "hashicorp" 61 | repository = "crt-workflows-common" 62 | workflow = "promote-staging" 63 | config = "release-metadata.hcl" 64 | } 65 | 66 | notification { 67 | on = "always" 68 | } 69 | } 70 | 71 | event "promote-staging-docker" { 72 | depends = ["promote-staging"] 73 | action "promote-staging-docker" { 74 | organization = "hashicorp" 75 | repository = "crt-workflows-common" 76 | workflow = "promote-staging-docker" 77 | } 78 | 79 | notification { 80 | on = "always" 81 | } 82 | } 83 | 84 | event "trigger-production" { 85 | // This event is dispatched by the bob trigger-promotion command 86 | // and is required - do not delete. 87 | } 88 | 89 | event "promote-production" { 90 | depends = ["trigger-production"] 91 | action "promote-production" { 92 | organization = "hashicorp" 93 | repository = "crt-workflows-common" 94 | workflow = "promote-production" 95 | } 96 | 97 | notification { 98 | on = "always" 99 | } 100 | } 101 | 102 | event "promote-production-docker" { 103 | depends = ["promote-production"] 104 | action "promote-production-docker" { 105 | organization = "hashicorp" 106 | repository = "crt-workflows-common" 107 | workflow = "promote-production-docker" 108 | } 109 | 110 | notification { 111 | on = "always" 112 | } 113 | } 114 | 115 | event "promote-production-packaging" { 116 | depends = ["promote-production-docker"] 117 | action "promote-production-packaging" { 118 | organization = "hashicorp" 119 | repository = "crt-workflows-common" 120 | workflow = "promote-production-packaging" 121 | } 122 | 123 | notification { 124 | on = "always" 125 | } 126 | } 127 | -------------------------------------------------------------------------------- /.release/release-metadata.hcl: -------------------------------------------------------------------------------- 1 | # Copyright (c) HashiCorp, Inc. 2 | # SPDX-License-Identifier: MPL-2.0 3 | 4 | url_docker_image_dockerhub = "https://hub.docker.com/r/hashicorp/damon" 5 | url_source_repository = "https://github.com/hashicorp/damon" 6 | url_project_website = "https://github.com/hashicorp/damon" 7 | url_license = "https://github.com/hashicorp/damon/blob/main/LICENSE" 8 | url_release_notes = "https://github.com/hashicorp/damon" 9 | -------------------------------------------------------------------------------- /.release/security-scan.hcl: -------------------------------------------------------------------------------- 1 | # Copyright (c) HashiCorp, Inc. 2 | # SPDX-License-Identifier: MPL-2.0 3 | 4 | container { 5 | dependencies = true 6 | alpine_secdb = true 7 | secrets = true 8 | } 9 | 10 | binary { 11 | secrets = true 12 | go_modules = true 13 | osv = true 14 | oss_index = false 15 | nvd = false 16 | } 17 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | # Copyright (c) HashiCorp, Inc. 2 | # SPDX-License-Identifier: MPL-2.0 3 | 4 | # This Dockerfile contains multiple targets. 5 | # Use 'docker build --target= .' to build one. 6 | 7 | # =================================== 8 | # Non-release images. 9 | # =================================== 10 | 11 | # devbuild compiles the binary 12 | # ----------------------------------- 13 | FROM golang:1.19.1 AS devbuild 14 | 15 | # Disable CGO to make sure we build static binaries 16 | ENV CGO_ENABLED=0 17 | 18 | # Escape the GOPATH 19 | WORKDIR /build 20 | COPY . ./ 21 | RUN go build -o damon ./cmd/damon 22 | 23 | # dev runs the binary from devbuild 24 | # ----------------------------------- 25 | FROM alpine:3.15 AS dev 26 | 27 | COPY --from=devbuild /build/damon /bin/ 28 | COPY ./scripts/docker-entrypoint.sh / 29 | 30 | ENTRYPOINT ["/docker-entrypoint.sh"] 31 | 32 | 33 | # =================================== 34 | # Release images. 35 | # =================================== 36 | 37 | FROM alpine:3.15 AS release 38 | 39 | ARG PRODUCT_NAME=damon 40 | ARG PRODUCT_VERSION 41 | ARG PRODUCT_REVISION 42 | # TARGETARCH and TARGETOS are set automatically when --platform is provided. 43 | ARG TARGETOS TARGETARCH 44 | 45 | LABEL maintainer="Nomad Team " 46 | LABEL version=${PRODUCT_VERSION} 47 | LABEL revision=${PRODUCT_REVISION} 48 | 49 | COPY dist/$TARGETOS/$TARGETARCH/damon /bin/ 50 | COPY ./scripts/docker-entrypoint.sh / 51 | 52 | # Create a non-root user to run the software. 53 | RUN addgroup $PRODUCT_NAME && \ 54 | adduser -S -G $PRODUCT_NAME $PRODUCT_NAME 55 | 56 | USER $PRODUCT_NAME 57 | ENTRYPOINT ["/docker-entrypoint.sh"] 58 | 59 | # =================================== 60 | # Set default target to 'dev'. 61 | # =================================== 62 | FROM dev 63 | -------------------------------------------------------------------------------- /GNUmakefile: -------------------------------------------------------------------------------- 1 | SHELL = bash 2 | default: help 3 | 4 | GIT_COMMIT := $(shell git rev-parse --short HEAD) 5 | GIT_DIRTY := $(if $(shell git status --porcelain),+CHANGES) 6 | 7 | GO_LDFLAGS := "$(GO_LDFLAGS) -X github.com/hcjulz/damon/version.GitCommit=$(GIT_COMMIT)$(GIT_DIRTY)" 8 | 9 | HELP_FORMAT=" \033[36m%-25s\033[0m %s\n" 10 | .PHONY: help 11 | help: ## Display this usage information 12 | @echo "Valid targets:" 13 | @grep -E '^[^ ]+:.*?## .*$$' $(MAKEFILE_LIST) | \ 14 | sort | \ 15 | awk 'BEGIN {FS = ":.*?## "}; \ 16 | {printf $(HELP_FORMAT), $$1, $$2}' 17 | @echo "" 18 | 19 | .PHONY: build 20 | build: 21 | go build -o bin/damon ./cmd/damon 22 | 23 | .PHONY: run 24 | run: 25 | ./bin/damon 26 | 27 | .PHONY: install-osx 28 | install-osx: 29 | cp ./bin/damon /usr/local/bin/damon 30 | 31 | .PHONY: test 32 | test: 33 | go test ./... 34 | 35 | pkg/%/damon: GO_OUT ?= $@ 36 | pkg/windows_%/damon: GO_OUT = $@.exe 37 | pkg/%/damon: ## Build Daemon for GOOS_GOARCH, e.g. pkg/linux_amd64/damon 38 | @echo "==> Building $@ with tags $(GO_TAGS)..." 39 | @CGO_ENABLED=0 \ 40 | GOOS=$(firstword $(subst _, ,$*)) \ 41 | GOARCH=$(lastword $(subst _, ,$*)) \ 42 | go build -trimpath -ldflags $(GO_LDFLAGS) -tags "$(GO_TAGS)" -o $(GO_OUT) ./cmd/damon 43 | 44 | .PRECIOUS: pkg/%/damon 45 | pkg/%.zip: pkg/%/damon ## Build and zip Damon for GOOS_GOARCH, e.g. pkg/linux_amd64.zip 46 | @echo "==> Packaging for $@..." 47 | zip -j $@ $(dir $<)* 48 | 49 | .PHONY: dev 50 | dev: ## Build for the current development version 51 | @echo "==> Building damon..." 52 | @CGO_ENABLED=0 go build -ldflags $(GO_LDFLAGS) -o ./bin/damon ./cmd/damon 53 | @rm -f $(GOPATH)/bin/damon 54 | @cp ./bin/damon $(GOPATH)/bin/damon 55 | @echo "==> Done" 56 | 57 | .PHONY: version 58 | version: 59 | ifneq (,$(wildcard version/version_ent.go)) 60 | @$(CURDIR)/scripts/version.sh version/version.go version/version_ent.go 61 | else 62 | @$(CURDIR)/scripts/version.sh version/version.go version/version.go 63 | endif 64 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Damon - A terminal Dashboard for HashiCorp Nomad 2 | 3 | Damon is a terminal user interface (TUI) for Nomad. It provides functionality to observe and interact with Nomad resources such as Jobs, Deployments, or Allocations. Interactions include: 4 | 5 | - View Jobs and Job allocations 6 | - View Deployments 7 | - View Namespaces 8 | - Show Task events 9 | - Show Job status/information (equally to `$ nomad status `) 10 | - Show Logs in an active log stream (including filtering + highlighting). 11 | - and more... 12 | 13 | **Additional Notes** 14 | 15 | Damon is in an early stage and is under active development. We are working on improving the performance and adding new features to Damon. 16 | Please take a look at the Damon [project board](https://github.com/hashicorp/damon/projects/2) to see what features you can expect in near future. 17 | If you find a bug or you have great ideas how Damon can be improved feel free to open an issue. To avoid duplicates, please check the [project board](https://github.com/hashicorp/damon/projects/2) 18 | before submitting one. Thank you! 19 | 20 | ## Screenshot 21 | 22 | ![image](https://user-images.githubusercontent.com/82210389/126840047-dd96be77-f7fc-4903-972a-c783cc615a33.png) 23 | 24 | 25 | ## Installation 26 | 27 | ### Brew 28 | 29 | --> Coming soon 30 | 31 | ### Building from source and Run Damon 32 | 33 | Make sure you have your go environment setup: 34 | 35 | 1. Clone the project 36 | 1. Run `$ make build` to build the binary 37 | 1. Run `$ make run` to run the binary 38 | 1. You can use `$ make install-osx` on a Mac to cp the binary to `/usr/local/bin/damon` 39 | 40 | or 41 | 42 | ``` 43 | $ go install ./cmd/damon 44 | ``` 45 | 46 | ### How to use it 47 | 48 | Once `Damon` is installed and avialable in your path, simply run: 49 | 50 | ``` 51 | $ damon 52 | ``` 53 | 54 | #### Environment Variables 55 | 56 | Damon reads the following environment variables on startup: 57 | 58 | - `NOMAD_TOKEN` 59 | - `NOMAD_ADDR` 60 | - `NOMAD_REGION` 61 | - `NOMAD_NAMESPACE` 62 | - `NOMAD_HTTP_AUTH` 63 | - `NOMAD_CACERT` 64 | - `NOMAD_CAPATH` 65 | - `NOMAD_CLIENT_CERT` 66 | - `NOMAD_CLIENT_KEY` 67 | - `NOMAD_TLS_SERVER_NAME` 68 | - `NOMAD_SKIP_VERIFY` 69 | 70 | You can read about them in detail [here](https://www.nomadproject.io/docs/runtime/environment). 71 | 72 | ## Navigation 73 | 74 | ### General 75 | 76 | On every table or text view, you can use: 77 | 78 | - `k` or `arrow up` to navigate up 79 | - `j` or `arrow down` to navigate down 80 | 81 | ### Top Level Commands 82 | 83 | - Show Jobs: `ctrl-j` 84 | - Show Deployments: `ctrl-d` 85 | - Show Namespaces: `ctrl-n` 86 | - Jump to a Jobs Allocations: `ctrl-j` 87 | - Switch Namespace: `s` 88 | - Quit: `ctrl-c` 89 | 90 | ### Job View Commands 91 | 92 | - Show Allocations for a Job: `` (on the selected job) 93 | - Show TaskGroups for a Job: `` (on the selected job) 94 | - Show information for a Job: `` (on the selected job) 95 | - Filter Job: `` (on the selected job) 96 | - Show Job Info: `i` (on the selected job) 97 | 98 | ### Task View Commands 99 | 100 | - Show logs on `STDOUT` for a Task: `` 101 | - Show logs on `STDERR` for a Task: `` 102 | - Show events for a Task: `` 103 | 104 | 105 | ### Log View 106 | 107 | When Damon displays logs, you can navigate through the logs using `j`, `k`, `G`, and `g`. 108 | 109 | - To filter logs you can hit `/` which will open an input field to enter the filter string. 110 | - To highligh logs you can hit `h`. This will also open an input field to enter the highlighting string. 111 | - Hit `s` to stop a log stream. 112 | - Hit `r` to resume a log stream. 113 | -------------------------------------------------------------------------------- /cmd/damon/main.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) HashiCorp, Inc. 2 | // SPDX-License-Identifier: MPL-2.0 3 | 4 | package main 5 | 6 | import ( 7 | "fmt" 8 | "log" 9 | "os" 10 | "time" 11 | 12 | "github.com/gdamore/tcell/v2" 13 | "github.com/jessevdk/go-flags" 14 | "github.com/rivo/tview" 15 | 16 | "github.com/hcjulz/damon/nomad" 17 | "github.com/hcjulz/damon/state" 18 | "github.com/hcjulz/damon/styles" 19 | "github.com/hcjulz/damon/version" 20 | "github.com/hcjulz/damon/view" 21 | "github.com/hcjulz/damon/watcher" 22 | 23 | "github.com/hcjulz/damon/component" 24 | ) 25 | 26 | var refreshIntervalDefault = time.Second * 2 27 | 28 | type options struct { 29 | Version bool `short:"v" long:"version" description:"Show Damon version"` 30 | } 31 | 32 | func main() { 33 | // globally overwrite the background color 34 | tview.Styles.PrimitiveBackgroundColor = tcell.NewRGBColor(40, 44, 48) 35 | 36 | var opts options 37 | _, err := flags.ParseArgs(&opts, os.Args) 38 | if err != nil { 39 | os.Exit(1) 40 | } 41 | 42 | if opts.Version { 43 | fmt.Println("Damon", version.GetHumanVersion()) 44 | os.Exit(0) 45 | } 46 | 47 | nomadClient, err := nomad.New(nomad.Default) 48 | if err != nil { 49 | fmt.Println("failed to generate Nomad client: ", err) 50 | os.Exit(1) 51 | } 52 | 53 | state := initializeState(nomadClient) 54 | 55 | clusterInfo := component.NewClusterInfo() 56 | selections := component.NewSelections(state) 57 | selectorModal := component.NewSelectorModal() 58 | commands := component.NewCommands() 59 | logo := component.NewLogo() 60 | jobs := component.NewJobsTable() 61 | jobStatus := component.NewJobStatus() 62 | depl := component.NewDeploymentTable() 63 | namespaces := component.NewNamespaceTable() 64 | allocations := component.NewAllocationTable() 65 | taskGroups := component.NewTaskGroupTable() 66 | taskEvents := component.NewTaskEventsTable() 67 | taskTable := component.NewTaskTable() 68 | logs := component.NewLogger() 69 | jumpToJob := component.NewJumpToJob() 70 | logSearch := component.NewSearchField("/") 71 | logHighlight := component.NewSearchField("highlight") 72 | errorComp := component.NewError() 73 | info := component.NewInfo() 74 | failure := component.NewInfo() 75 | confirm := component.NewModal( 76 | "confirm", 77 | "confirm", 78 | []string{"cancel", "confirm"}, 79 | styles.TcellColorAttention, 80 | ) 81 | 82 | components := &view.Components{ 83 | ClusterInfo: clusterInfo, 84 | Selections: selections, 85 | SelectorModal: selectorModal, 86 | Commands: commands, 87 | Logo: logo, 88 | JobTable: jobs, 89 | JobStatus: jobStatus, 90 | DeploymentTable: depl, 91 | NamespaceTable: namespaces, 92 | AllocationTable: allocations, 93 | TaskGroupTable: taskGroups, 94 | TaskEventsTable: taskEvents, 95 | TaskTable: taskTable, 96 | LogStream: logs, 97 | LogHighlight: logHighlight, 98 | JumpToJob: jumpToJob, 99 | Error: errorComp, 100 | Info: info, 101 | Failure: failure, 102 | LogSearch: logSearch, 103 | Confirm: confirm, 104 | } 105 | 106 | watcher := watcher.NewWatcher(state, nomadClient, refreshIntervalDefault) 107 | go watcher.Watch() 108 | 109 | view := view.New(components, watcher, nomadClient, state) 110 | view.Init(version.GetHumanVersion()) 111 | 112 | err = view.Layout.Container.Run() 113 | if err != nil { 114 | log.Fatal("cannot initialize view.") 115 | } 116 | } 117 | 118 | func initializeState(client *nomad.Nomad) *state.State { 119 | state := state.New() 120 | namespaces, err := client.Namespaces(nil) 121 | if err != nil { 122 | log.Fatal("cannot initialize view. Is Nomad running?") 123 | } 124 | 125 | state.NomadAddress = client.Address() 126 | state.Namespaces = namespaces 127 | 128 | return state 129 | } 130 | -------------------------------------------------------------------------------- /component/allocations.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) HashiCorp, Inc. 2 | // SPDX-License-Identifier: MPL-2.0 3 | 4 | package component 5 | 6 | import ( 7 | "fmt" 8 | 9 | "github.com/gdamore/tcell/v2" 10 | "github.com/rivo/tview" 11 | 12 | "github.com/hcjulz/damon/models" 13 | primitive "github.com/hcjulz/damon/primitives" 14 | "github.com/hcjulz/damon/styles" 15 | ) 16 | 17 | const ( 18 | TableTitleAllocations = "Allocations" 19 | ) 20 | 21 | var ( 22 | TableHeaderAllocations = []string{ 23 | LabelID, 24 | LabelTaskGroup, 25 | LabelJobID, 26 | LabelType, 27 | LabelNamespace, 28 | LabelAddresses, 29 | LabelNodeID, 30 | LabelNodeName, 31 | LabelDesiredStatus, 32 | } 33 | ) 34 | 35 | type SelectAllocationFunc func(allocID string) 36 | 37 | type AllocationTable struct { 38 | Table Table 39 | Props *AllocationTableProps 40 | 41 | slot *tview.Flex 42 | } 43 | 44 | type AllocationTableProps struct { 45 | SelectAllocation SelectAllocationFunc 46 | HandleNoResources models.HandlerFunc 47 | 48 | JobID string 49 | 50 | Data []*models.Alloc 51 | } 52 | 53 | func NewAllocationTable() *AllocationTable { 54 | t := primitive.NewTable() 55 | 56 | return &AllocationTable{ 57 | Table: t, 58 | Props: &AllocationTableProps{}, 59 | } 60 | } 61 | 62 | func (t *AllocationTable) Bind(slot *tview.Flex) { 63 | t.slot = slot 64 | } 65 | 66 | func (t *AllocationTable) Render() error { 67 | if t.Props.SelectAllocation == nil { 68 | return ErrComponentPropsNotSet 69 | } 70 | 71 | if t.Props.HandleNoResources == nil { 72 | return ErrComponentPropsNotSet 73 | } 74 | 75 | if t.slot == nil { 76 | return ErrComponentNotBound 77 | } 78 | 79 | t.reset() 80 | 81 | if len(t.Props.Data) == 0 { 82 | t.Props.HandleNoResources( 83 | "%sno allocations available\n¯%s\\_( ͡• ͜ʖ ͡•)_/¯", 84 | styles.HighlightPrimaryTag, 85 | styles.HighlightSecondaryTag, 86 | ) 87 | 88 | return nil 89 | } 90 | 91 | t.Table.SetSelectedFunc(t.allocationSelected) 92 | 93 | t.Table.RenderHeader(TableHeaderAllocations) 94 | t.renderRows() 95 | 96 | t.Table.SetTitle(fmt.Sprintf("%s (Job: %s)", TableTitleAllocations, t.Props.JobID)) 97 | t.slot.AddItem(t.Table.Primitive(), 0, 1, false) 98 | 99 | return nil 100 | } 101 | 102 | func (t *AllocationTable) reset() { 103 | t.slot.Clear() 104 | t.Table.Clear() 105 | } 106 | 107 | func (t *AllocationTable) renderRows() { 108 | for i, a := range t.Props.Data { 109 | hostAddr := fmt.Sprintf("%v", a.HostAddresses) 110 | row := []string{ 111 | a.ID, 112 | a.TaskGroup, 113 | a.JobID, 114 | a.JobType, 115 | a.Namespace, 116 | hostAddr, 117 | a.NodeID, 118 | a.NodeName, 119 | a.DesiredStatus, 120 | } 121 | 122 | index := i + 1 123 | 124 | c := t.getCellColor(a.DesiredStatus) 125 | t.Table.RenderRow(row, index, c) 126 | } 127 | } 128 | 129 | func (t *AllocationTable) getCellColor(status string) tcell.Color { 130 | c := tcell.ColorWhite 131 | 132 | switch status { 133 | case models.DesiredStatusStop: 134 | c = tcell.ColorDarkGray 135 | } 136 | 137 | return c 138 | } 139 | 140 | func (t *AllocationTable) allocationSelected(row, column int) { 141 | allocID := t.Table.GetCellContent(row, 0) 142 | t.Props.SelectAllocation(allocID) 143 | } 144 | 145 | func (t *AllocationTable) GetIDForSelection() string { 146 | row, _ := t.Table.GetSelection() 147 | return t.Table.GetCellContent(row, 0) 148 | } 149 | -------------------------------------------------------------------------------- /component/cluster_info.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) HashiCorp, Inc. 2 | // SPDX-License-Identifier: MPL-2.0 3 | 4 | package component 5 | 6 | import ( 7 | "github.com/rivo/tview" 8 | 9 | primitive "github.com/hcjulz/damon/primitives" 10 | ) 11 | 12 | type ClusterInfo struct { 13 | TextView TextView 14 | Props *ClusterInfoProps 15 | 16 | slot *tview.Flex 17 | } 18 | 19 | type ClusterInfoProps struct { 20 | Info string 21 | } 22 | 23 | func NewClusterInfo() *ClusterInfo { 24 | return &ClusterInfo{ 25 | TextView: primitive.NewTextView(tview.AlignLeft), 26 | Props: &ClusterInfoProps{}, 27 | } 28 | } 29 | 30 | func (c *ClusterInfo) Render() error { 31 | if c.slot == nil { 32 | return ErrComponentNotBound 33 | } 34 | 35 | c.TextView.SetText(c.Props.Info) 36 | c.slot.AddItem(c.TextView.Primitive(), 0, 1, false) 37 | 38 | return nil 39 | } 40 | 41 | func (c *ClusterInfo) Bind(slot *tview.Flex) { 42 | c.slot = slot 43 | } 44 | -------------------------------------------------------------------------------- /component/cluster_info_test.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) HashiCorp, Inc. 2 | // SPDX-License-Identifier: MPL-2.0 3 | 4 | package component_test 5 | 6 | import ( 7 | "errors" 8 | "testing" 9 | 10 | "github.com/rivo/tview" 11 | "github.com/stretchr/testify/require" 12 | 13 | "github.com/hcjulz/damon/component" 14 | "github.com/hcjulz/damon/component/componentfakes" 15 | ) 16 | 17 | func TestClusterInfo_Happy(t *testing.T) { 18 | r := require.New(t) 19 | 20 | textView := &componentfakes.FakeTextView{} 21 | clusterInfo := component.NewClusterInfo() 22 | clusterInfo.TextView = textView 23 | clusterInfo.Props.Info = "info" 24 | 25 | clusterInfo.Bind(tview.NewFlex()) 26 | 27 | err := clusterInfo.Render() 28 | r.NoError(err) 29 | 30 | text := textView.SetTextArgsForCall(0) 31 | r.Equal(text, "info") 32 | } 33 | 34 | func TestClusterInfo_Render_Sad(t *testing.T) { 35 | r := require.New(t) 36 | 37 | textView := &componentfakes.FakeTextView{} 38 | clusterInfo := component.NewClusterInfo() 39 | clusterInfo.TextView = textView 40 | clusterInfo.Props.Info = "info" 41 | 42 | err := clusterInfo.Render() 43 | r.Error(err) 44 | 45 | r.True(errors.Is(err, component.ErrComponentNotBound)) 46 | r.EqualError(err, "component not bound") 47 | } 48 | -------------------------------------------------------------------------------- /component/commands.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) HashiCorp, Inc. 2 | // SPDX-License-Identifier: MPL-2.0 3 | 4 | package component 5 | 6 | import ( 7 | "fmt" 8 | "strings" 9 | 10 | "github.com/rivo/tview" 11 | 12 | primitive "github.com/hcjulz/damon/primitives" 13 | "github.com/hcjulz/damon/styles" 14 | ) 15 | 16 | var ( 17 | MainCommands = []string{ 18 | fmt.Sprintf("%sCommands:", styles.HighlightSecondaryTag), 19 | fmt.Sprintf("%s%s to display Jobs", styles.HighlightPrimaryTag, styles.StandardColorTag), 20 | fmt.Sprintf("%s%s to display Deployments", styles.HighlightPrimaryTag, styles.StandardColorTag), 21 | fmt.Sprintf("%s%s to display Namespaces", styles.HighlightPrimaryTag, styles.StandardColorTag), 22 | fmt.Sprintf("%s%s to jump to a Job", styles.HighlightPrimaryTag, styles.StandardColorTag), 23 | fmt.Sprintf("%s%s to Quit", styles.HighlightPrimaryTag, styles.StandardColorTag), 24 | } 25 | 26 | JobCommands = []string{ 27 | fmt.Sprintf("\n%sJob Commands:", styles.HighlightSecondaryTag), 28 | fmt.Sprintf("%s%s to display allocations", styles.HighlightPrimaryTag, styles.StandardColorTag), 29 | fmt.Sprintf("%s%s to display TaskGroups for the selected Job", styles.HighlightPrimaryTag, styles.StandardColorTag), 30 | fmt.Sprintf("%s%s to display information for the selected Job", styles.HighlightPrimaryTag, styles.StandardColorTag), 31 | fmt.Sprintf("%s%s start/stop the selected Job", styles.HighlightPrimaryTag, styles.StandardColorTag), 32 | fmt.Sprintf("%s%s apply filter", styles.HighlightPrimaryTag, styles.StandardColorTag), 33 | } 34 | 35 | AllocCommands = []string{ 36 | // fmt.Sprintf("\n%sAlloc Commands:", styles.HighlightSecondaryTag), 37 | } 38 | 39 | TaskCommands = []string{ 40 | fmt.Sprintf("%s%s to display events for a Task", styles.HighlightPrimaryTag, styles.StandardColorTag), 41 | fmt.Sprintf("%s%s to display STDERR logs", styles.HighlightPrimaryTag, styles.StandardColorTag), 42 | fmt.Sprintf("%s%s to display STDOUT logs", styles.HighlightPrimaryTag, styles.StandardColorTag), 43 | } 44 | 45 | LogCommands = []string{ 46 | fmt.Sprintf("\n%sLog Commands:", styles.HighlightSecondaryTag), 47 | fmt.Sprintf("%s | %s to leave", styles.HighlightPrimaryTag, styles.StandardColorTag), 48 | fmt.Sprintf("%s%s apply filter", styles.HighlightPrimaryTag, styles.StandardColorTag), 49 | fmt.Sprintf("%s%s highlight", styles.HighlightPrimaryTag, styles.StandardColorTag), 50 | fmt.Sprintf("%s%s stop log stream", styles.HighlightPrimaryTag, styles.StandardColorTag), 51 | fmt.Sprintf("%s%s resume log stream", styles.HighlightPrimaryTag, styles.StandardColorTag), 52 | } 53 | 54 | DeploymentCommands = []string{} 55 | 56 | NoViewCommands = []string{} 57 | ) 58 | 59 | type Commands struct { 60 | TextView TextView 61 | Props *CommandsProps 62 | slot *tview.Flex 63 | } 64 | 65 | type CommandsProps struct { 66 | MainCommands []string 67 | ViewCommands []string 68 | } 69 | 70 | func NewCommands() *Commands { 71 | return &Commands{ 72 | TextView: primitive.NewTextView(tview.AlignLeft), 73 | Props: &CommandsProps{ 74 | MainCommands: MainCommands, 75 | ViewCommands: JobCommands, 76 | }, 77 | } 78 | } 79 | 80 | func (c *Commands) Update(commands []string) { 81 | c.Props.ViewCommands = commands 82 | 83 | c.updateText() 84 | } 85 | 86 | func (c *Commands) Render() error { 87 | if c.slot == nil { 88 | return ErrComponentNotBound 89 | } 90 | 91 | c.updateText() 92 | 93 | c.slot.AddItem(c.TextView.Primitive(), 0, 1, false) 94 | return nil 95 | } 96 | 97 | func (c *Commands) updateText() { 98 | commands := append(c.Props.MainCommands, c.Props.ViewCommands...) 99 | cmds := strings.Join(commands, "\n") 100 | c.TextView.SetText(cmds) 101 | } 102 | 103 | func (c *Commands) Bind(slot *tview.Flex) { 104 | c.slot = slot 105 | } 106 | -------------------------------------------------------------------------------- /component/commands_test.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) HashiCorp, Inc. 2 | // SPDX-License-Identifier: MPL-2.0 3 | 4 | package component_test 5 | 6 | import ( 7 | "errors" 8 | "testing" 9 | 10 | "github.com/rivo/tview" 11 | "github.com/stretchr/testify/require" 12 | 13 | "github.com/hcjulz/damon/component" 14 | "github.com/hcjulz/damon/component/componentfakes" 15 | ) 16 | 17 | func TestCommands_Happy(t *testing.T) { 18 | r := require.New(t) 19 | 20 | textView := &componentfakes.FakeTextView{} 21 | cmds := component.NewCommands() 22 | cmds.TextView = textView 23 | cmds.Props.MainCommands = []string{"command1", "command2"} 24 | cmds.Props.ViewCommands = []string{"subCmd1", "subCmd2"} 25 | 26 | cmds.Bind(tview.NewFlex()) 27 | 28 | err := cmds.Render() 29 | r.NoError(err) 30 | 31 | text := textView.SetTextArgsForCall(0) 32 | r.Equal(text, "command1\ncommand2\nsubCmd1\nsubCmd2") 33 | } 34 | 35 | func TestCommands_Sad(t *testing.T) { 36 | r := require.New(t) 37 | 38 | textView := &componentfakes.FakeTextView{} 39 | cmds := component.NewCommands() 40 | cmds.TextView = textView 41 | 42 | err := cmds.Render() 43 | r.Error(err) 44 | 45 | r.True(errors.Is(err, component.ErrComponentNotBound)) 46 | r.EqualError(err, "component not bound") 47 | } 48 | -------------------------------------------------------------------------------- /component/component.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) HashiCorp, Inc. 2 | // SPDX-License-Identifier: MPL-2.0 3 | 4 | package component 5 | 6 | import ( 7 | "github.com/gdamore/tcell/v2" 8 | "github.com/rivo/tview" 9 | 10 | "github.com/hcjulz/damon/models" 11 | "github.com/hcjulz/damon/primitives" 12 | ) 13 | 14 | const ( 15 | LabelID = "ID" 16 | LabelJobID = "JobID" 17 | LabelType = "Type" 18 | LabelName = "Name" 19 | LabelNamespace = "Namespace" 20 | LabelState = "State" 21 | LabelStatus = "Status" 22 | LabelStatusDescription = "Description" 23 | LabelStatusSummary = "Summary" 24 | LabelDescription = "Description" 25 | LabelCount = "Count" 26 | LabelSubmitTime = "SubmitTime" 27 | LabelUptime = "Uptime" 28 | LabelDesiredStatus = "DesiredStatus" 29 | LabelTaskGroup = "TaskGroup" 30 | LabelTime = "Time" 31 | LabelMessage = "Message" 32 | 33 | LabelCPU = "CPU" 34 | LabelMemory = "Memory" 35 | LabelDriver = "Driver" 36 | LabelImage = "Image" 37 | LabelHostIP = "Host IP" 38 | LabelHostPorts = "Host Ports" 39 | LabelPorts = "Ports" 40 | LabelAddresses = "Host Addr" 41 | LabelLastEvent = "Last Event" 42 | 43 | LabelRunning = "Running" 44 | LabelStarting = "Starting" 45 | LabelComplete = "Complete" 46 | LabelQueued = "Queued" 47 | LabelLost = "Lost" 48 | LabelFailed = "Failed" 49 | 50 | LabelDesired = "Desired" 51 | LabelHealthy = "Healthy" 52 | LabelUnhealthy = "Unhealthy" 53 | LabelPlaced = "Placed" 54 | LabelProgressDeadline = "Progress Deadline" 55 | LabelVersion = "Version" 56 | LabelStatusDescriptionLong = "Status Description" 57 | LabelPriority = "Priority" 58 | LabelDatacenters = "Datacenters" 59 | LabelPeriodic = "Periodic" 60 | LabelParameterized = "Parameterized" 61 | 62 | LabelLatestDeployment = "Latest Deployment" 63 | LabelDeployed = "Deployed" 64 | LabelAllocations = "Allocations" 65 | 66 | LabelCreated = "Created" 67 | LabelModified = "Modified" 68 | 69 | LabelNodeID = "NodeID" 70 | LabelNodeName = "NodeName" 71 | 72 | ErrComponentNotBound = models.Sentinel("component not bound") 73 | ErrComponentPropsNotSet = models.Sentinel("component properties not set") 74 | ) 75 | 76 | //go:generate counterfeiter . DoneModalFunc 77 | type DoneModalFunc func(buttonIndex int, buttonLabel string) 78 | 79 | type Primitive interface { 80 | Primitive() tview.Primitive 81 | } 82 | 83 | //go:generate counterfeiter . Table 84 | type Table interface { 85 | Primitive 86 | SetTitle(format string, args ...interface{}) 87 | GetCellContent(row, column int) string 88 | GetSelection() (row, column int) 89 | Clear() 90 | RenderHeader(data []string) 91 | RenderRow(data []string, index int, c tcell.Color) 92 | SetSelectedFunc(fn func(row, column int)) 93 | SetInputCapture(capture func(event *tcell.EventKey) *tcell.EventKey) 94 | } 95 | 96 | //go:generate counterfeiter . TextView 97 | type TextView interface { 98 | Primitive 99 | GetText(bool) string 100 | SetText(text string) *tview.TextView 101 | Write(data []byte) (int, error) 102 | Highlight(regionIDs ...string) *tview.TextView 103 | Clear() *tview.TextView 104 | ModifyPrimitive(f func(t *tview.TextView)) 105 | } 106 | 107 | //go:generate counterfeiter . Modal 108 | type Modal interface { 109 | Primitive 110 | SetDoneFunc(handler func(buttonIndex int, buttonLabel string)) 111 | SetText(text string) 112 | SetFocus(index int) 113 | Container() tview.Primitive 114 | } 115 | 116 | //go:generate counterfeiter . InputField 117 | type InputField interface { 118 | Primitive 119 | SetDoneFunc(handler func(k tcell.Key)) 120 | SetChangedFunc(handler func(text string)) 121 | SetAutocompleteFunc(callback func(currentText string) (entries []string)) 122 | SetText(text string) 123 | GetText() string 124 | } 125 | 126 | //go:generate counterfeiter . DropDown 127 | type DropDown interface { 128 | Primitive 129 | SetOptions(options []string, selected func(text string, index int)) 130 | SetCurrentOption(index int) 131 | SetSelectedFunc(selected func(text string, index int)) 132 | } 133 | 134 | //go:generate counterfeiter . Selector 135 | type Selector interface { 136 | Primitive 137 | GetTable() *primitives.Table 138 | Container() tview.Primitive 139 | } 140 | -------------------------------------------------------------------------------- /component/componentfakes/fake_done_modal_func.go: -------------------------------------------------------------------------------- 1 | // Code generated by counterfeiter. DO NOT EDIT. 2 | package componentfakes 3 | 4 | import ( 5 | "sync" 6 | 7 | "github.com/hcjulz/damon/component" 8 | ) 9 | 10 | type FakeDoneModalFunc struct { 11 | Stub func(int, string) 12 | mutex sync.RWMutex 13 | argsForCall []struct { 14 | arg1 int 15 | arg2 string 16 | } 17 | invocations map[string][][]interface{} 18 | invocationsMutex sync.RWMutex 19 | } 20 | 21 | func (fake *FakeDoneModalFunc) Spy(arg1 int, arg2 string) { 22 | fake.mutex.Lock() 23 | fake.argsForCall = append(fake.argsForCall, struct { 24 | arg1 int 25 | arg2 string 26 | }{arg1, arg2}) 27 | stub := fake.Stub 28 | fake.recordInvocation("DoneModalFunc", []interface{}{arg1, arg2}) 29 | fake.mutex.Unlock() 30 | if stub != nil { 31 | fake.Stub(arg1, arg2) 32 | } 33 | } 34 | 35 | func (fake *FakeDoneModalFunc) CallCount() int { 36 | fake.mutex.RLock() 37 | defer fake.mutex.RUnlock() 38 | return len(fake.argsForCall) 39 | } 40 | 41 | func (fake *FakeDoneModalFunc) Calls(stub func(int, string)) { 42 | fake.mutex.Lock() 43 | defer fake.mutex.Unlock() 44 | fake.Stub = stub 45 | } 46 | 47 | func (fake *FakeDoneModalFunc) ArgsForCall(i int) (int, string) { 48 | fake.mutex.RLock() 49 | defer fake.mutex.RUnlock() 50 | return fake.argsForCall[i].arg1, fake.argsForCall[i].arg2 51 | } 52 | 53 | func (fake *FakeDoneModalFunc) Invocations() map[string][][]interface{} { 54 | fake.invocationsMutex.RLock() 55 | defer fake.invocationsMutex.RUnlock() 56 | fake.mutex.RLock() 57 | defer fake.mutex.RUnlock() 58 | copiedInvocations := map[string][][]interface{}{} 59 | for key, value := range fake.invocations { 60 | copiedInvocations[key] = value 61 | } 62 | return copiedInvocations 63 | } 64 | 65 | func (fake *FakeDoneModalFunc) recordInvocation(key string, args []interface{}) { 66 | fake.invocationsMutex.Lock() 67 | defer fake.invocationsMutex.Unlock() 68 | if fake.invocations == nil { 69 | fake.invocations = map[string][][]interface{}{} 70 | } 71 | if fake.invocations[key] == nil { 72 | fake.invocations[key] = [][]interface{}{} 73 | } 74 | fake.invocations[key] = append(fake.invocations[key], args) 75 | } 76 | 77 | var _ component.DoneModalFunc = new(FakeDoneModalFunc).Spy 78 | -------------------------------------------------------------------------------- /component/componentfakes/fake_select_job_func.go: -------------------------------------------------------------------------------- 1 | // Code generated by counterfeiter. DO NOT EDIT. 2 | package componentfakes 3 | 4 | import ( 5 | "sync" 6 | 7 | "github.com/hcjulz/damon/component" 8 | ) 9 | 10 | type FakeSelectJobFunc struct { 11 | Stub func(string) 12 | mutex sync.RWMutex 13 | argsForCall []struct { 14 | arg1 string 15 | } 16 | invocations map[string][][]interface{} 17 | invocationsMutex sync.RWMutex 18 | } 19 | 20 | func (fake *FakeSelectJobFunc) Spy(arg1 string) { 21 | fake.mutex.Lock() 22 | fake.argsForCall = append(fake.argsForCall, struct { 23 | arg1 string 24 | }{arg1}) 25 | stub := fake.Stub 26 | fake.recordInvocation("SelectJobFunc", []interface{}{arg1}) 27 | fake.mutex.Unlock() 28 | if stub != nil { 29 | fake.Stub(arg1) 30 | } 31 | } 32 | 33 | func (fake *FakeSelectJobFunc) CallCount() int { 34 | fake.mutex.RLock() 35 | defer fake.mutex.RUnlock() 36 | return len(fake.argsForCall) 37 | } 38 | 39 | func (fake *FakeSelectJobFunc) Calls(stub func(string)) { 40 | fake.mutex.Lock() 41 | defer fake.mutex.Unlock() 42 | fake.Stub = stub 43 | } 44 | 45 | func (fake *FakeSelectJobFunc) ArgsForCall(i int) string { 46 | fake.mutex.RLock() 47 | defer fake.mutex.RUnlock() 48 | return fake.argsForCall[i].arg1 49 | } 50 | 51 | func (fake *FakeSelectJobFunc) Invocations() map[string][][]interface{} { 52 | fake.invocationsMutex.RLock() 53 | defer fake.invocationsMutex.RUnlock() 54 | fake.mutex.RLock() 55 | defer fake.mutex.RUnlock() 56 | copiedInvocations := map[string][][]interface{}{} 57 | for key, value := range fake.invocations { 58 | copiedInvocations[key] = value 59 | } 60 | return copiedInvocations 61 | } 62 | 63 | func (fake *FakeSelectJobFunc) recordInvocation(key string, args []interface{}) { 64 | fake.invocationsMutex.Lock() 65 | defer fake.invocationsMutex.Unlock() 66 | if fake.invocations == nil { 67 | fake.invocations = map[string][][]interface{}{} 68 | } 69 | if fake.invocations[key] == nil { 70 | fake.invocations[key] = [][]interface{}{} 71 | } 72 | fake.invocations[key] = append(fake.invocations[key], args) 73 | } 74 | 75 | var _ component.SelectJobFunc = new(FakeSelectJobFunc).Spy 76 | -------------------------------------------------------------------------------- /component/dep_table.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) HashiCorp, Inc. 2 | // SPDX-License-Identifier: MPL-2.0 3 | 4 | package component 5 | 6 | import ( 7 | "fmt" 8 | 9 | "github.com/gdamore/tcell/v2" 10 | "github.com/rivo/tview" 11 | 12 | "github.com/hcjulz/damon/models" 13 | primitive "github.com/hcjulz/damon/primitives" 14 | "github.com/hcjulz/damon/styles" 15 | ) 16 | 17 | const ( 18 | TableTitleDeployments = "Deployments" 19 | ) 20 | 21 | var ( 22 | TableHeaderDeployments = []string{ 23 | LabelID, 24 | LabelJobID, 25 | LabelNamespace, 26 | LabelStatus, 27 | LabelStatusDescription, 28 | } 29 | ) 30 | 31 | //go:generate counterfeiter . SelectJobFunc 32 | type SelectFunc func(id string) 33 | 34 | type DeploymentTable struct { 35 | Table Table 36 | Props *DeploymentTableProps 37 | 38 | slot *tview.Flex 39 | } 40 | 41 | type DeploymentTableProps struct { 42 | SelectDeployment SelectFunc 43 | HandleNoResources models.HandlerFunc 44 | 45 | Data []*models.Deployment 46 | Namespace string 47 | } 48 | 49 | func NewDeploymentTable() *DeploymentTable { 50 | t := primitive.NewTable() 51 | 52 | dt := &DeploymentTable{ 53 | Table: t, 54 | Props: &DeploymentTableProps{}, 55 | } 56 | 57 | dt.Table.SetSelectedFunc(dt.deploymentSelected) 58 | 59 | return dt 60 | } 61 | 62 | func (d *DeploymentTable) Bind(slot *tview.Flex) { 63 | d.slot = slot 64 | 65 | } 66 | 67 | func (d *DeploymentTable) Render() error { 68 | if d.Props.SelectDeployment == nil || d.Props.HandleNoResources == nil { 69 | return ErrComponentPropsNotSet 70 | } 71 | 72 | if d.slot == nil { 73 | return ErrComponentNotBound 74 | } 75 | 76 | d.reset() 77 | 78 | if len(d.Props.Data) == 0 { 79 | d.Props.HandleNoResources( 80 | "%sno deployments available\n¯%s\\_( ͡• ͜ʖ ͡•)_/¯", 81 | styles.HighlightPrimaryTag, 82 | styles.HighlightSecondaryTag, 83 | ) 84 | 85 | return nil 86 | } 87 | 88 | d.Table.SetTitle(fmt.Sprintf("%s (%s)", TableTitleDeployments, d.Props.Namespace)) 89 | 90 | d.Table.RenderHeader(TableHeaderDeployments) 91 | d.renderRows() 92 | 93 | d.slot.AddItem(d.Table.Primitive(), 0, 1, false) 94 | return nil 95 | } 96 | 97 | func (d *DeploymentTable) reset() { 98 | d.slot.Clear() 99 | d.Table.Clear() 100 | } 101 | 102 | func (d *DeploymentTable) deploymentSelected(row, column int) { 103 | deplID := d.Table.GetCellContent(row, 0) 104 | d.Props.SelectDeployment(deplID) 105 | } 106 | 107 | func (d *DeploymentTable) renderRows() { 108 | for i, dep := range d.Props.Data { 109 | row := []string{ 110 | dep.ID, 111 | dep.JobID, 112 | dep.Namespace, 113 | dep.Status, 114 | dep.StatusDescription, 115 | } 116 | 117 | index := i + 1 118 | 119 | c := d.getCellColor(dep.Status) 120 | d.Table.RenderRow(row, index, c) 121 | } 122 | } 123 | 124 | func (d *DeploymentTable) getCellColor(status string) tcell.Color { 125 | c := tcell.ColorWhite 126 | 127 | switch status { 128 | case models.StatusRunning: 129 | c = styles.TcellColorHighlighPrimary 130 | case models.StatusPending: 131 | c = tcell.ColorYellow 132 | case models.StatusFailed: 133 | c = tcell.ColorRed 134 | } 135 | 136 | return c 137 | } 138 | -------------------------------------------------------------------------------- /component/error.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) HashiCorp, Inc. 2 | // SPDX-License-Identifier: MPL-2.0 3 | 4 | package component 5 | 6 | import ( 7 | "github.com/gdamore/tcell/v2" 8 | "github.com/rivo/tview" 9 | 10 | primitive "github.com/hcjulz/damon/primitives" 11 | ) 12 | 13 | const PageNameError = "error" 14 | 15 | type Error struct { 16 | Modal Modal 17 | Props *ErrorProps 18 | pages *tview.Pages 19 | } 20 | 21 | type ErrorProps struct { 22 | Done DoneModalFunc 23 | } 24 | 25 | func NewError() *Error { 26 | buttons := []string{"Quit", "OK"} 27 | modal := primitive.NewModal("Error", buttons, tcell.ColorDarkRed) 28 | 29 | return &Error{ 30 | Modal: modal, 31 | Props: &ErrorProps{}, 32 | } 33 | } 34 | 35 | func (e *Error) Render(msg string) error { 36 | if e.Props.Done == nil { 37 | return ErrComponentPropsNotSet 38 | } 39 | 40 | if e.pages == nil { 41 | return ErrComponentNotBound 42 | } 43 | 44 | e.Modal.SetDoneFunc(e.Props.Done) 45 | e.Modal.SetText(msg) 46 | e.pages.AddPage(PageNameError, e.Modal.Container(), true, true) 47 | return nil 48 | } 49 | 50 | func (e *Error) Bind(pages *tview.Pages) { 51 | e.pages = pages 52 | } 53 | -------------------------------------------------------------------------------- /component/error_test.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) HashiCorp, Inc. 2 | // SPDX-License-Identifier: MPL-2.0 3 | 4 | package component_test 5 | 6 | import ( 7 | "errors" 8 | "testing" 9 | 10 | "github.com/rivo/tview" 11 | "github.com/stretchr/testify/require" 12 | 13 | "github.com/hcjulz/damon/component" 14 | "github.com/hcjulz/damon/component/componentfakes" 15 | ) 16 | 17 | func TestError_Happy(t *testing.T) { 18 | r := require.New(t) 19 | 20 | e := component.NewError() 21 | 22 | modal := &componentfakes.FakeModal{} 23 | e.Modal = modal 24 | 25 | var doneCalled bool 26 | e.Props.Done = func(buttonIndex int, buttonLabel string) { 27 | doneCalled = true 28 | } 29 | 30 | pages := tview.NewPages() 31 | e.Bind(pages) 32 | 33 | err := e.Render("error") 34 | r.NoError(err) 35 | 36 | actualDone := modal.SetDoneFuncArgsForCall(0) 37 | text := modal.SetTextArgsForCall(0) 38 | 39 | actualDone(0, "buttonName") 40 | 41 | r.True(doneCalled) 42 | r.Equal(text, "error") 43 | } 44 | 45 | func TestError_Sad(t *testing.T) { 46 | r := require.New(t) 47 | 48 | t.Run("When the component isn't bound", func(t *testing.T) { 49 | e := component.NewError() 50 | 51 | modal := &componentfakes.FakeModal{} 52 | e.Modal = modal 53 | 54 | e.Props.Done = func(buttonIndex int, buttonLabel string) {} 55 | 56 | err := e.Render("error") 57 | r.Error(err) 58 | 59 | // It provides the correct error message 60 | r.EqualError(err, "component not bound") 61 | 62 | // It is the correct error 63 | r.True(errors.Is(err, component.ErrComponentNotBound)) 64 | }) 65 | 66 | t.Run("When DoneFunc is not set", func(t *testing.T) { 67 | e := component.NewError() 68 | 69 | modal := &componentfakes.FakeModal{} 70 | e.Modal = modal 71 | 72 | pages := tview.NewPages() 73 | e.Bind(pages) 74 | 75 | err := e.Render("error") 76 | r.Error(err) 77 | 78 | // It provides the correct error message 79 | r.EqualError(err, "component properties not set") 80 | 81 | // It is the correct error 82 | r.True(errors.Is(err, component.ErrComponentPropsNotSet)) 83 | }) 84 | } 85 | -------------------------------------------------------------------------------- /component/info.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) HashiCorp, Inc. 2 | // SPDX-License-Identifier: MPL-2.0 3 | 4 | package component 5 | 6 | import ( 7 | "github.com/rivo/tview" 8 | 9 | primitive "github.com/hcjulz/damon/primitives" 10 | "github.com/hcjulz/damon/styles" 11 | ) 12 | 13 | const PageNameInfo = "info" 14 | 15 | type Info struct { 16 | Modal Modal 17 | Props *InfoProps 18 | pages *tview.Pages 19 | } 20 | 21 | type InfoProps struct { 22 | Done DoneModalFunc 23 | } 24 | 25 | func NewInfo() *Info { 26 | buttons := []string{"OK"} 27 | modal := primitive.NewModal("Info", buttons, styles.TcellColorModalInfo) 28 | 29 | return &Info{ 30 | Modal: modal, 31 | Props: &InfoProps{}, 32 | } 33 | } 34 | 35 | func (i *Info) Render(msg string) error { 36 | if i.Props.Done == nil { 37 | return ErrComponentPropsNotSet 38 | } 39 | 40 | if i.pages == nil { 41 | return ErrComponentNotBound 42 | } 43 | 44 | i.Modal.SetDoneFunc(i.Props.Done) 45 | i.Modal.SetText(msg) 46 | i.pages.AddPage(PageNameInfo, i.Modal.Container(), true, true) 47 | 48 | return nil 49 | } 50 | 51 | func (i *Info) Bind(pages *tview.Pages) { 52 | i.pages = pages 53 | } 54 | -------------------------------------------------------------------------------- /component/info_test.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) HashiCorp, Inc. 2 | // SPDX-License-Identifier: MPL-2.0 3 | 4 | package component_test 5 | 6 | import ( 7 | "errors" 8 | "testing" 9 | 10 | "github.com/rivo/tview" 11 | "github.com/stretchr/testify/require" 12 | 13 | "github.com/hcjulz/damon/component" 14 | "github.com/hcjulz/damon/component/componentfakes" 15 | ) 16 | 17 | func TestInfo_Happy(t *testing.T) { 18 | r := require.New(t) 19 | 20 | e := component.NewInfo() 21 | 22 | modal := &componentfakes.FakeModal{} 23 | e.Modal = modal 24 | 25 | var doneCalled bool 26 | e.Props.Done = func(buttonIndex int, buttonLabel string) { 27 | doneCalled = true 28 | } 29 | 30 | pages := tview.NewPages() 31 | e.Bind(pages) 32 | 33 | err := e.Render("Info") 34 | r.NoError(err) 35 | 36 | actualDone := modal.SetDoneFuncArgsForCall(0) 37 | text := modal.SetTextArgsForCall(0) 38 | 39 | actualDone(0, "OK") 40 | 41 | r.True(doneCalled) 42 | r.Equal(text, "Info") 43 | } 44 | 45 | func TestInfo_Sad(t *testing.T) { 46 | r := require.New(t) 47 | 48 | t.Run("When the component isn't bound", func(t *testing.T) { 49 | e := component.NewInfo() 50 | 51 | e.Props.Done = func(buttonIndex int, buttonLabel string) {} 52 | 53 | err := e.Render("Info") 54 | r.Error(err) 55 | 56 | // It provides the correct error message 57 | r.EqualError(err, "component not bound") 58 | 59 | // It is the correct error 60 | r.True(errors.Is(err, component.ErrComponentNotBound)) 61 | }) 62 | 63 | t.Run("When DoneFunc is not set", func(t *testing.T) { 64 | e := component.NewInfo() 65 | 66 | pages := tview.NewPages() 67 | e.Bind(pages) 68 | 69 | err := e.Render("error") 70 | r.Error(err) 71 | 72 | // It provides the correct error message 73 | r.EqualError(err, "component properties not set") 74 | 75 | // It is the correct error 76 | r.True(errors.Is(err, component.ErrComponentPropsNotSet)) 77 | }) 78 | } 79 | -------------------------------------------------------------------------------- /component/job_status_test.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) HashiCorp, Inc. 2 | // SPDX-License-Identifier: MPL-2.0 3 | 4 | package component_test 5 | 6 | import ( 7 | "errors" 8 | "strings" 9 | "testing" 10 | "time" 11 | 12 | "github.com/rivo/tview" 13 | "github.com/stretchr/testify/require" 14 | 15 | "github.com/hcjulz/damon/component" 16 | "github.com/hcjulz/damon/component/componentfakes" 17 | "github.com/hcjulz/damon/models" 18 | ) 19 | 20 | func TestJobStatus_Happy(t *testing.T) { 21 | r := require.New(t) 22 | 23 | t.Run("When there is data to render", func(t *testing.T) { 24 | textView := &componentfakes.FakeTextView{} 25 | jobStatus := component.NewJobStatus() 26 | jobStatus.TextView = textView 27 | jobStatus.Props.Data = &models.JobStatus{ 28 | ID: "fakeID", 29 | Name: "fakeName", 30 | Namespace: "fakeNamespace", 31 | Type: "fakeType", 32 | Status: "fakeStatus", 33 | StatusDescription: "Some fake description", 34 | SubmitDate: time.Date(1987, 10, 16, 12, 45, 0, 0, time.UTC), 35 | Priority: 0, 36 | Datacenters: "fakeDC", 37 | Periodic: false, 38 | Parameterized: false, 39 | TaskGroups: []*models.TaskGroup{ 40 | { 41 | Name: "fakeGroup", 42 | JobID: "fakeJobID", 43 | Queued: 10, 44 | Complete: 20, 45 | Failed: 30, 46 | Running: 40, 47 | Starting: 50, 48 | Lost: 60, 49 | }, 50 | }, 51 | TaskGroupStatus: []*models.TaskGroupStatus{ 52 | { 53 | ID: "fakeGroupStatus", 54 | Desired: 70, 55 | Placed: 80, 56 | Healthy: 90, 57 | Unhealthy: 100, 58 | ProgressDeadline: 200, 59 | Status: "fake Group Status", 60 | StatusDescription: "Some fake group description", 61 | }, 62 | }, 63 | Allocations: []*models.Alloc{ 64 | { 65 | ID: "1234567890", 66 | Name: "123", 67 | TaskGroup: "fakeAllocGroup", 68 | Tasks: []models.AllocTask{}, 69 | TaskNames: []string{}, 70 | JobID: "", 71 | JobType: "", 72 | NodeID: "1234567890", 73 | NodeName: "", 74 | DesiredStatus: "fake Desired Status", 75 | Version: 100, 76 | Status: "fake Alloc Status", 77 | Created: time.Now(), 78 | Modified: time.Now(), 79 | }, 80 | }, 81 | } 82 | 83 | jobStatus.Bind(tview.NewFlex()) 84 | 85 | err := jobStatus.Render() 86 | r.NoError(err) 87 | 88 | text := textView.SetTextArgsForCall(0) 89 | r.Equal(strings.ReplaceAll(text, " ", ""), ` 90 | ID=fakeID 91 | Name=fakeName 92 | SubmitTime=1987-10-1612:45:00 93 | Type=fakeType 94 | Priority=0 95 | Datacenters=fakeDC 96 | Namespace=fakeNamespace 97 | Status=fakeStatus 98 | Periodic=false 99 | Parameterized=false 100 | 101 | Summary 102 | TaskGroupQueuedStartingRunningFailedCompleteLost 103 | fakeGroup105040302060 104 | 105 | LatestDeployment 106 | ID=fakeGroupStatus 107 | Status=fakeGroupStatus 108 | StatusDescription=Somefakegroupdescription 109 | 110 | Deployed 111 | TaskGroupDesiredPlacedHealthyUnhealthyProgressDeadline 112 | fakeGroupStatus708090100200ns 113 | 114 | Allocations 115 | IDNodeIDTaskGroupVersionDesiredStatusCreatedModified 116 | 1234567812345678fakeAllocGroup100fakeDesiredStatusfakeAllocStatus0sago0sago 117 | `) 118 | }) 119 | 120 | t.Run("When there is no data to render", func(t *testing.T) { 121 | textView := &componentfakes.FakeTextView{} 122 | jobStatus := component.NewJobStatus() 123 | jobStatus.TextView = textView 124 | jobStatus.Props.Data = &models.JobStatus{} 125 | 126 | jobStatus.Bind(tview.NewFlex()) 127 | 128 | err := jobStatus.Render() 129 | r.NoError(err) 130 | 131 | text := textView.SetTextArgsForCall(0) 132 | r.Equal(text, "Status not available.") 133 | }) 134 | } 135 | 136 | func TestJobStatus_Sad(t *testing.T) { 137 | r := require.New(t) 138 | 139 | t.Run("When the component is not bound", func(t *testing.T) { 140 | jobStatus := component.NewJobStatus() 141 | 142 | err := jobStatus.Render() 143 | r.Error(err) 144 | 145 | r.True(errors.Is(err, component.ErrComponentNotBound)) 146 | r.EqualError(err, "component not bound") 147 | }) 148 | } 149 | -------------------------------------------------------------------------------- /component/job_table.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) HashiCorp, Inc. 2 | // SPDX-License-Identifier: MPL-2.0 3 | 4 | package component 5 | 6 | import ( 7 | "fmt" 8 | "time" 9 | 10 | "github.com/gdamore/tcell/v2" 11 | "github.com/rivo/tview" 12 | 13 | "github.com/hcjulz/damon/models" 14 | primitive "github.com/hcjulz/damon/primitives" 15 | "github.com/hcjulz/damon/styles" 16 | ) 17 | 18 | const ( 19 | TableTitleJobs = "Jobs" 20 | ) 21 | 22 | var ( 23 | TableHeaderJobs = []string{ 24 | LabelID, 25 | LabelName, 26 | LabelType, 27 | LabelNamespace, 28 | LabelStatus, 29 | LabelStatusSummary, 30 | LabelSubmitTime, 31 | LabelUptime, 32 | } 33 | ) 34 | 35 | //go:generate counterfeiter . SelectJobFunc 36 | type SelectJobFunc func(jobID string) 37 | 38 | type JobTable struct { 39 | Table Table 40 | Props *JobTableProps 41 | 42 | slot *tview.Flex 43 | } 44 | 45 | type JobTableProps struct { 46 | SelectJob SelectJobFunc 47 | HandleNoResources models.HandlerFunc 48 | 49 | Data []*models.Job 50 | Namespace string 51 | } 52 | 53 | func NewJobsTable() *JobTable { 54 | t := primitive.NewTable() 55 | 56 | jt := &JobTable{ 57 | Table: t, 58 | Props: &JobTableProps{}, 59 | } 60 | 61 | return jt 62 | } 63 | 64 | func (j *JobTable) Bind(slot *tview.Flex) { 65 | j.slot = slot 66 | } 67 | 68 | func (j *JobTable) Render() error { 69 | if err := j.validate(); err != nil { 70 | return err 71 | } 72 | 73 | j.reset() 74 | 75 | j.Table.SetTitle("%s (%s)", TableTitleJobs, j.Props.Namespace) 76 | 77 | if len(j.Props.Data) == 0 { 78 | j.Props.HandleNoResources( 79 | "%sno jobs available\n¯%s\\_( ͡• ͜ʖ ͡•)_/¯", 80 | styles.HighlightPrimaryTag, 81 | styles.HighlightSecondaryTag, 82 | ) 83 | 84 | return nil 85 | } 86 | 87 | j.Table.SetSelectedFunc(j.jobSelected) 88 | j.Table.RenderHeader(TableHeaderJobs) 89 | j.renderRows() 90 | 91 | j.slot.AddItem(j.Table.Primitive(), 0, 1, false) 92 | return nil 93 | } 94 | 95 | func (j *JobTable) GetIDForSelection() string { 96 | row, _ := j.Table.GetSelection() 97 | return j.Table.GetCellContent(row, 0) 98 | } 99 | 100 | func (j *JobTable) validate() error { 101 | if j.Props.SelectJob == nil || j.Props.HandleNoResources == nil { 102 | return ErrComponentPropsNotSet 103 | } 104 | 105 | if j.slot == nil { 106 | return ErrComponentNotBound 107 | } 108 | 109 | return nil 110 | } 111 | 112 | func (j *JobTable) reset() { 113 | j.slot.Clear() 114 | j.Table.Clear() 115 | } 116 | 117 | func (j *JobTable) jobSelected(row, _ int) { 118 | jobID := j.Table.GetCellContent(row, 0) 119 | j.Props.SelectJob(jobID) 120 | } 121 | 122 | func (j *JobTable) renderRows() { 123 | for i, job := range j.Props.Data { 124 | row := []string{ 125 | job.ID, 126 | job.Name, 127 | job.Type, 128 | job.Namespace, 129 | job.Status, 130 | fmt.Sprintf("%d/%d", job.StatusSummary.Running, job.StatusSummary.Total), 131 | job.SubmitTime.Format(time.RFC3339), 132 | formatTimeSince(time.Since(job.SubmitTime)), 133 | } 134 | 135 | index := i + 1 136 | 137 | c := j.cellColor(job.Status, job.Type, job.StatusSummary) 138 | 139 | j.Table.RenderRow(row, index, c) 140 | } 141 | } 142 | 143 | func (j *JobTable) cellColor(status, typ string, summary models.Summary) tcell.Color { 144 | c := tcell.ColorWhite 145 | 146 | switch status { 147 | case models.StatusRunning: 148 | if summary.Total != summary.Running && 149 | typ == models.TypeService { 150 | c = styles.TcellColorAttention 151 | } 152 | case models.StatusPending: 153 | c = tcell.ColorYellow 154 | case models.StatusDead, models.StatusFailed: 155 | c = tcell.ColorRed 156 | 157 | if typ == models.TypeBatch { 158 | c = tcell.ColorDarkGrey 159 | } 160 | } 161 | 162 | return c 163 | } 164 | 165 | func formatTimeSince(since time.Duration) string { 166 | if since.Seconds() < 60 { 167 | return fmt.Sprintf("%.0fs", since.Seconds()) 168 | } 169 | 170 | if since.Minutes() < 60 { 171 | return fmt.Sprintf("%.0fm", since.Minutes()) 172 | } 173 | 174 | if since.Hours() < 60 { 175 | return fmt.Sprintf("%.0fh", since.Hours()) 176 | } 177 | 178 | return fmt.Sprintf("%.0fd", (since.Hours() / 24)) 179 | } 180 | -------------------------------------------------------------------------------- /component/jump.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) HashiCorp, Inc. 2 | // SPDX-License-Identifier: MPL-2.0 3 | 4 | package component 5 | 6 | import ( 7 | "strings" 8 | 9 | "github.com/gdamore/tcell/v2" 10 | "github.com/rivo/tview" 11 | 12 | "github.com/hcjulz/damon/models" 13 | "github.com/hcjulz/damon/primitives" 14 | ) 15 | 16 | const jumpToJobPlaceholder = "(hit enter or esc to leave)" 17 | 18 | type SetDoneFunc func(key tcell.Key) 19 | 20 | type JumpToJob struct { 21 | InputField InputField 22 | Props *JumpToJobProps 23 | slot *tview.Flex 24 | } 25 | 26 | type JumpToJobProps struct { 27 | DoneFunc SetDoneFunc 28 | Jobs []*models.Job 29 | } 30 | 31 | func NewJumpToJob() *JumpToJob { 32 | jj := &JumpToJob{} 33 | jj.Props = &JumpToJobProps{} 34 | 35 | in := primitives.NewInputField("jump: ", jumpToJobPlaceholder) 36 | 37 | in.SetAutocompleteFunc(func(currentText string) (entries []string) { 38 | return jj.find(currentText) 39 | }) 40 | 41 | jj.InputField = in 42 | return jj 43 | } 44 | 45 | func (jj *JumpToJob) Render() error { 46 | if err := jj.validate(); err != nil { 47 | return err 48 | } 49 | 50 | jj.InputField.SetDoneFunc(jj.Props.DoneFunc) 51 | jj.slot.AddItem(jj.InputField.Primitive(), 0, 2, false) 52 | return nil 53 | } 54 | 55 | func (jj *JumpToJob) validate() error { 56 | if jj.Props.DoneFunc == nil { 57 | return ErrComponentPropsNotSet 58 | } 59 | 60 | if jj.slot == nil { 61 | return ErrComponentNotBound 62 | } 63 | 64 | return nil 65 | } 66 | 67 | func (jj *JumpToJob) Bind(slot *tview.Flex) { 68 | jj.slot = slot 69 | } 70 | 71 | func (jj *JumpToJob) find(text string) []string { 72 | result := []string{} 73 | if text == "" { 74 | return result 75 | } 76 | 77 | for _, j := range jj.Props.Jobs { 78 | ok := strings.Contains(j.ID, text) 79 | if ok { 80 | result = append(result, j.ID) 81 | } 82 | } 83 | 84 | return result 85 | } 86 | -------------------------------------------------------------------------------- /component/jump_test.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) HashiCorp, Inc. 2 | // SPDX-License-Identifier: MPL-2.0 3 | 4 | package component_test 5 | 6 | import ( 7 | "errors" 8 | "testing" 9 | 10 | "github.com/gdamore/tcell/v2" 11 | "github.com/rivo/tview" 12 | "github.com/stretchr/testify/require" 13 | 14 | "github.com/hcjulz/damon/component" 15 | "github.com/hcjulz/damon/component/componentfakes" 16 | ) 17 | 18 | func TestJump_Happy(t *testing.T) { 19 | r := require.New(t) 20 | 21 | input := &componentfakes.FakeInputField{} 22 | jump := component.NewJumpToJob() 23 | jump.InputField = input 24 | 25 | var doneCalled bool 26 | jump.Props.DoneFunc = func(key tcell.Key) { 27 | doneCalled = true 28 | } 29 | 30 | jump.Bind(tview.NewFlex()) 31 | 32 | err := jump.Render() 33 | r.NoError(err) 34 | 35 | actualDoneFunc := input.SetDoneFuncArgsForCall(0) 36 | 37 | actualDoneFunc(tcell.KeyACK) 38 | 39 | r.True(doneCalled) 40 | } 41 | 42 | func TestJump_Sad(t *testing.T) { 43 | r := require.New(t) 44 | 45 | t.Run("When the component isn't bound", func(t *testing.T) { 46 | jump := component.NewJumpToJob() 47 | 48 | jump.Props.DoneFunc = func(key tcell.Key) {} 49 | 50 | err := jump.Render() 51 | r.Error(err) 52 | 53 | // It provides the correct error message 54 | r.EqualError(err, "component not bound") 55 | 56 | // It is the correct error 57 | r.True(errors.Is(err, component.ErrComponentNotBound)) 58 | }) 59 | 60 | t.Run("When DoneFunc is not set", func(t *testing.T) { 61 | jump := component.NewJumpToJob() 62 | 63 | jump.Bind(tview.NewFlex()) 64 | 65 | err := jump.Render() 66 | r.Error(err) 67 | 68 | // It provides the correct error message 69 | r.EqualError(err, "component properties not set") 70 | 71 | // It is the correct error 72 | r.True(errors.Is(err, component.ErrComponentPropsNotSet)) 73 | }) 74 | 75 | } 76 | -------------------------------------------------------------------------------- /component/logo.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) HashiCorp, Inc. 2 | // SPDX-License-Identifier: MPL-2.0 3 | 4 | package component 5 | 6 | import ( 7 | "strings" 8 | 9 | "github.com/rivo/tview" 10 | 11 | primitive "github.com/hcjulz/damon/primitives" 12 | ) 13 | 14 | var LogoASCII = []string{ 15 | `[#00b57c] .___ `, 16 | ` __| _/____ _____ ____ ____ `, 17 | ` / __ |\__ \ / \ / _ \ / \ `, 18 | `/ /_/ | / __ \| Y Y ( <_> ) | \`, 19 | `\____ |(____ /__|_| /\____/|___| /`, 20 | ` \/ \/ \/ \/ `, 21 | `[#26ffe6]HashiCorp Nomad - Terminal Dashboard`, 22 | } 23 | 24 | type Logo struct { 25 | TextView TextView 26 | slot *tview.Flex 27 | } 28 | 29 | func NewLogo() *Logo { 30 | t := primitive.NewTextView(tview.AlignRight) 31 | return &Logo{ 32 | TextView: t, 33 | } 34 | } 35 | 36 | func (l *Logo) Render() error { 37 | if l.slot == nil { 38 | return ErrComponentNotBound 39 | } 40 | 41 | logo := strings.Join(LogoASCII, "\n") 42 | 43 | l.TextView.SetText(logo) 44 | l.slot.AddItem(l.TextView.Primitive(), 0, 1, false) 45 | return nil 46 | } 47 | 48 | func (l *Logo) Bind(slot *tview.Flex) { 49 | l.slot = slot 50 | } 51 | -------------------------------------------------------------------------------- /component/logo_test.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) HashiCorp, Inc. 2 | // SPDX-License-Identifier: MPL-2.0 3 | 4 | package component_test 5 | 6 | import ( 7 | "errors" 8 | "strings" 9 | "testing" 10 | 11 | "github.com/rivo/tview" 12 | "github.com/stretchr/testify/require" 13 | 14 | "github.com/hcjulz/damon/component" 15 | "github.com/hcjulz/damon/component/componentfakes" 16 | ) 17 | 18 | func TestLogo_Happy(t *testing.T) { 19 | r := require.New(t) 20 | 21 | textView := &componentfakes.FakeTextView{} 22 | logo := component.NewLogo() 23 | logo.TextView = textView 24 | 25 | logo.Bind(tview.NewFlex()) 26 | 27 | err := logo.Render() 28 | r.NoError(err) 29 | 30 | text := textView.SetTextArgsForCall(0) 31 | expectedLogo := strings.Join(component.LogoASCII, "\n") 32 | r.Equal(text, expectedLogo) 33 | } 34 | 35 | func TestLogo_Sad(t *testing.T) { 36 | r := require.New(t) 37 | logo := component.NewLogo() 38 | 39 | err := logo.Render() 40 | r.Error(err) 41 | 42 | r.True(errors.Is(err, component.ErrComponentNotBound)) 43 | r.EqualError(err, "component not bound") 44 | } 45 | -------------------------------------------------------------------------------- /component/logs.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) HashiCorp, Inc. 2 | // SPDX-License-Identifier: MPL-2.0 3 | 4 | package component 5 | 6 | import ( 7 | "bytes" 8 | "fmt" 9 | "regexp" 10 | "strings" 11 | "sync" 12 | 13 | "github.com/rivo/tview" 14 | 15 | "github.com/hcjulz/damon/models" 16 | primitive "github.com/hcjulz/damon/primitives" 17 | "github.com/hcjulz/damon/styles" 18 | ) 19 | 20 | type renderFunc func() error 21 | 22 | type Logger struct { 23 | TextView TextView 24 | Props *LogStreamProps 25 | slot *tview.Flex 26 | buf strings.Builder 27 | mutex sync.Mutex 28 | } 29 | 30 | type LogStreamProps struct { 31 | HandleNoResources models.HandlerFunc 32 | Filter string 33 | Highlight string 34 | Data []byte 35 | ChangedFunc func() 36 | TaskName string 37 | App *tview.Application 38 | } 39 | 40 | func NewLogger() *Logger { 41 | t := primitive.NewTextView(tview.AlignLeft) 42 | 43 | l := &Logger{ 44 | TextView: t, 45 | Props: &LogStreamProps{}, 46 | buf: strings.Builder{}, 47 | } 48 | 49 | t.ModifyPrimitive(l.applyLogModifiers) 50 | return l 51 | 52 | } 53 | 54 | func (l *Logger) Bind(slot *tview.Flex) { 55 | l.slot = slot 56 | } 57 | 58 | func (l *Logger) Render() error { 59 | if l.slot == nil { 60 | return ErrComponentNotBound 61 | } 62 | 63 | if l.Props.HandleNoResources == nil { 64 | return ErrComponentPropsNotSet 65 | } 66 | 67 | l.ClearDisplay() 68 | 69 | if len(l.Props.Data) == 0 { 70 | l.Props.HandleNoResources( 71 | "%sWHOOOPS, no Logs found", 72 | styles.HighlightSecondaryTag, 73 | ) 74 | return nil 75 | } 76 | 77 | lines := bytes.Split(l.Props.Data, []byte("\n")) 78 | if len(lines) > 1000 { 79 | rem := len(lines) % 1000 80 | lines = lines[rem:] 81 | l.Props.Data = bytes.Join(lines, []byte("\n")) 82 | } 83 | 84 | display := filter(l.Props.Data, l.Props.Filter) 85 | 86 | if l.Props.Filter == "" { 87 | display = highlight(display, l.Props.Highlight) 88 | } 89 | 90 | l.Clear() 91 | 92 | l.SetText(string(display)) 93 | 94 | l.Display() 95 | 96 | return nil 97 | } 98 | 99 | func (l *Logger) ClearDisplay() { 100 | l.slot.Clear() 101 | } 102 | 103 | func (l *Logger) Display() { 104 | l.slot.AddItem(l.TextView.Primitive(), 0, 1, true) 105 | } 106 | 107 | func (l *Logger) Clear() { 108 | l.mutex.Lock() 109 | defer l.mutex.Unlock() 110 | 111 | // l.Props.App.QueueUpdate(func() { 112 | l.TextView.Clear() 113 | // }) 114 | } 115 | 116 | func (l *Logger) SetText(log string) { 117 | l.mutex.Lock() 118 | defer l.mutex.Unlock() 119 | 120 | // l.Props.App.QueueUpdateDraw(func() { 121 | l.TextView.SetText(log) 122 | // }) 123 | } 124 | 125 | func filter(logs []byte, filter string) []byte { 126 | if filter == "" { 127 | return logs 128 | } 129 | 130 | buf := bytes.Buffer{} 131 | defer buf.Reset() 132 | 133 | rx := regexp.MustCompile(filter) 134 | 135 | logLines := bytes.Split(logs, []byte("\n")) 136 | var result []byte 137 | for _, log := range logLines { 138 | if rx.Match(log) { 139 | idx := rx.FindIndex([]byte(log)) 140 | fmt.Fprintf( 141 | &buf, 142 | "%s%s%s%s%s%s\n", 143 | []byte(styles.ColorLighGreyTag), 144 | log[:idx[0]], 145 | []byte(styles.HighlightSecondaryTag), 146 | log[idx[0]:idx[1]], 147 | []byte(styles.ColorLighGreyTag), 148 | log[idx[1]:], 149 | ) 150 | 151 | result = append(result, buf.Bytes()...) 152 | buf.Reset() 153 | } 154 | } 155 | return result 156 | } 157 | 158 | func highlight(logs []byte, highlight string) []byte { 159 | if highlight == "" { 160 | return logs 161 | } 162 | 163 | buf := bytes.Buffer{} 164 | defer buf.Reset() 165 | 166 | rx, _ := regexp.Compile(highlight) 167 | logLines := bytes.Split(logs, []byte("\n")) 168 | var result []byte 169 | for _, log := range logLines { 170 | if rx.Match(log) { 171 | fmt.Fprintf(&buf, "%s%s%s\n", 172 | []byte(styles.HighlightSecondaryTag), 173 | log, 174 | []byte(styles.ColorWhiteTag), 175 | ) 176 | } else { 177 | fmt.Fprintf(&buf, "%s\n", log) 178 | } 179 | 180 | result = append(result, buf.Bytes()...) 181 | buf.Reset() 182 | } 183 | 184 | return result 185 | } 186 | 187 | func (l *Logger) applyLogModifiers(t *tview.TextView) { 188 | t.SetScrollable(true) 189 | t.SetBorder(true) 190 | t.ScrollToEnd() 191 | t.SetTitle("Logs") 192 | // t.SetMaxLines(1000) 193 | } 194 | -------------------------------------------------------------------------------- /component/logs_test.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) HashiCorp, Inc. 2 | // SPDX-License-Identifier: MPL-2.0 3 | 4 | package component_test 5 | 6 | import ( 7 | "errors" 8 | "testing" 9 | 10 | "github.com/rivo/tview" 11 | "github.com/stretchr/testify/require" 12 | 13 | "github.com/hcjulz/damon/component" 14 | "github.com/hcjulz/damon/component/componentfakes" 15 | "github.com/hcjulz/damon/styles" 16 | ) 17 | 18 | func TestLogs_Happy(t *testing.T) { 19 | r := require.New(t) 20 | 21 | t.Run("When there is data to render", func(t *testing.T) { 22 | textView := &componentfakes.FakeTextView{} 23 | logs := component.NewLogger() 24 | logs.TextView = textView 25 | logs.Props.HandleNoResources = func(format string, args ...interface{}) {} 26 | logs.Props.Data = []byte("logs") 27 | 28 | logs.Bind(tview.NewFlex()) 29 | 30 | err := logs.Render() 31 | r.NoError(err) 32 | 33 | text := textView.SetTextArgsForCall(0) 34 | r.Equal(string(text), "logs") 35 | }) 36 | 37 | t.Run("When there is no data to render", func(t *testing.T) { 38 | textView := &componentfakes.FakeTextView{} 39 | logs := component.NewLogger() 40 | logs.TextView = textView 41 | 42 | var handleNoResourcesCalled bool 43 | logs.Props.HandleNoResources = func(format string, args ...interface{}) { 44 | handleNoResourcesCalled = true 45 | 46 | r.Equal("%sWHOOOPS, no Logs found", format) 47 | r.Len(args, 1) 48 | r.Equal(args[0], styles.HighlightSecondaryTag) 49 | } 50 | 51 | logs.Bind(tview.NewFlex()) 52 | 53 | err := logs.Render() 54 | r.NoError(err) 55 | 56 | r.True(handleNoResourcesCalled) 57 | }) 58 | } 59 | 60 | func TestLogs_Sad(t *testing.T) { 61 | r := require.New(t) 62 | 63 | t.Run("When the component is not bound", func(t *testing.T) { 64 | logs := component.NewLogger() 65 | logs.Props.HandleNoResources = func(format string, args ...interface{}) {} 66 | 67 | err := logs.Render() 68 | r.Error(err) 69 | 70 | r.True(errors.Is(err, component.ErrComponentNotBound)) 71 | r.EqualError(err, "component not bound") 72 | }) 73 | 74 | t.Run("When the component props are not set", func(t *testing.T) { 75 | logs := component.NewLogger() 76 | logs.Bind(tview.NewFlex()) 77 | 78 | err := logs.Render() 79 | r.Error(err) 80 | 81 | r.True(errors.Is(err, component.ErrComponentPropsNotSet)) 82 | r.EqualError(err, "component properties not set") 83 | }) 84 | } 85 | -------------------------------------------------------------------------------- /component/modal.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) HashiCorp, Inc. 2 | // SPDX-License-Identifier: MPL-2.0 3 | 4 | package component 5 | 6 | import ( 7 | "github.com/gdamore/tcell/v2" 8 | "github.com/rivo/tview" 9 | 10 | primitive "github.com/hcjulz/damon/primitives" 11 | ) 12 | 13 | type GenericModal struct { 14 | Modal Modal 15 | Props *ModalProps 16 | pages *tview.Pages 17 | } 18 | 19 | type ModalProps struct { 20 | ID string 21 | Done DoneModalFunc 22 | } 23 | 24 | func NewModal(id, title string, buttons []string, c tcell.Color) *GenericModal { 25 | modal := primitive.NewModal(title, buttons, c) 26 | 27 | return &GenericModal{ 28 | Modal: modal, 29 | Props: &ModalProps{ID: id}, 30 | } 31 | } 32 | 33 | func (m *GenericModal) Render(msg string) error { 34 | if m.Props.Done == nil { 35 | return ErrComponentPropsNotSet 36 | } 37 | 38 | if m.pages == nil { 39 | return ErrComponentNotBound 40 | } 41 | 42 | m.Modal.SetFocus(0) 43 | m.Modal.SetDoneFunc(m.Props.Done) 44 | m.Modal.SetText(msg) 45 | m.pages.AddPage(m.Props.ID, m.Modal.Container(), true, true) 46 | 47 | return nil 48 | } 49 | 50 | func (m *GenericModal) Bind(pages *tview.Pages) { 51 | m.pages = pages 52 | } 53 | -------------------------------------------------------------------------------- /component/modal_test.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) HashiCorp, Inc. 2 | // SPDX-License-Identifier: MPL-2.0 3 | 4 | package component_test 5 | 6 | import ( 7 | "errors" 8 | "testing" 9 | 10 | "github.com/gdamore/tcell/v2" 11 | "github.com/rivo/tview" 12 | "github.com/stretchr/testify/require" 13 | 14 | "github.com/hcjulz/damon/component" 15 | "github.com/hcjulz/damon/component/componentfakes" 16 | ) 17 | 18 | func TestModal_Happy(t *testing.T) { 19 | r := require.New(t) 20 | 21 | e := component.NewModal("test", "test", []string{"OK"}, tcell.ColorWhite) 22 | 23 | modal := &componentfakes.FakeModal{} 24 | e.Modal = modal 25 | 26 | var doneCalled bool 27 | e.Props.Done = func(buttonIndex int, buttonLabel string) { 28 | doneCalled = true 29 | } 30 | 31 | pages := tview.NewPages() 32 | e.Bind(pages) 33 | 34 | err := e.Render("test") 35 | r.NoError(err) 36 | 37 | actualDone := modal.SetDoneFuncArgsForCall(0) 38 | text := modal.SetTextArgsForCall(0) 39 | 40 | actualDone(0, "OK") 41 | 42 | r.True(doneCalled) 43 | r.Equal(text, "test") 44 | } 45 | 46 | func TestModal_Sad(t *testing.T) { 47 | r := require.New(t) 48 | 49 | t.Run("When the component isn't bound", func(t *testing.T) { 50 | e := component.NewInfo() 51 | 52 | e.Props.Done = func(buttonIndex int, buttonLabel string) {} 53 | 54 | err := e.Render("test") 55 | r.Error(err) 56 | 57 | // It provides the correct error message 58 | r.EqualError(err, "component not bound") 59 | 60 | // It is the correct error 61 | r.True(errors.Is(err, component.ErrComponentNotBound)) 62 | }) 63 | 64 | t.Run("When DoneFunc is not set", func(t *testing.T) { 65 | e := component.NewInfo() 66 | 67 | pages := tview.NewPages() 68 | e.Bind(pages) 69 | 70 | err := e.Render("error") 71 | r.Error(err) 72 | 73 | // It provides the correct error message 74 | r.EqualError(err, "component properties not set") 75 | 76 | // It is the correct error 77 | r.True(errors.Is(err, component.ErrComponentPropsNotSet)) 78 | }) 79 | } 80 | -------------------------------------------------------------------------------- /component/namespaces.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) HashiCorp, Inc. 2 | // SPDX-License-Identifier: MPL-2.0 3 | 4 | package component 5 | 6 | import ( 7 | "github.com/gdamore/tcell/v2" 8 | "github.com/rivo/tview" 9 | 10 | "github.com/hcjulz/damon/models" 11 | primitive "github.com/hcjulz/damon/primitives" 12 | "github.com/hcjulz/damon/styles" 13 | ) 14 | 15 | const TableTitleNamespaces = "Namespaces" 16 | 17 | var ( 18 | TableHeaderNamespaces = []string{ 19 | LabelName, 20 | LabelDescription, 21 | } 22 | ) 23 | 24 | type NamespaceTable struct { 25 | Table Table 26 | Props *NamespacesProps 27 | 28 | slot *tview.Flex 29 | } 30 | 31 | type NamespacesProps struct { 32 | HandleNoResources models.HandlerFunc 33 | Data []*models.Namespace 34 | } 35 | 36 | func NewNamespaceTable() *NamespaceTable { 37 | t := primitive.NewTable() 38 | 39 | return &NamespaceTable{ 40 | Table: t, 41 | Props: &NamespacesProps{}, 42 | } 43 | } 44 | 45 | func (n *NamespaceTable) Bind(slot *tview.Flex) { 46 | slot.SetTitle("Namespaces") 47 | n.slot = slot 48 | } 49 | 50 | func (n *NamespaceTable) Render() error { 51 | if n.slot == nil { 52 | return ErrComponentNotBound 53 | } 54 | 55 | if n.Props.HandleNoResources == nil { 56 | return ErrComponentPropsNotSet 57 | } 58 | 59 | n.reset() 60 | 61 | if len(n.Props.Data) == 0 { 62 | n.Props.HandleNoResources( 63 | "%sno namespaces available\n¯%s\\_( ͡• ͜ʖ ͡•)_/¯", 64 | styles.HighlightPrimaryTag, 65 | styles.HighlightSecondaryTag, 66 | ) 67 | 68 | return nil 69 | } 70 | 71 | n.Table.SetTitle(TableTitleNamespaces) 72 | 73 | n.Table.RenderHeader(TableHeaderNamespaces) 74 | n.renderRows() 75 | 76 | n.slot.AddItem(n.Table.Primitive(), 0, 1, false) 77 | return nil 78 | } 79 | 80 | func (n *NamespaceTable) reset() { 81 | n.Table.Clear() 82 | n.slot.Clear() 83 | } 84 | 85 | func (n *NamespaceTable) renderRows() { 86 | for i, ns := range n.Props.Data { 87 | row := []string{ 88 | ns.Name, 89 | ns.Description, 90 | } 91 | 92 | index := i + 1 93 | n.Table.RenderRow(row, index, tcell.ColorWhite) 94 | } 95 | } 96 | -------------------------------------------------------------------------------- /component/search.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) HashiCorp, Inc. 2 | // SPDX-License-Identifier: MPL-2.0 3 | 4 | package component 5 | 6 | import ( 7 | "fmt" 8 | 9 | "github.com/rivo/tview" 10 | 11 | primitive "github.com/hcjulz/damon/primitives" 12 | ) 13 | 14 | const searchPlaceholder = "(hit enter or esc to leave)" 15 | 16 | type SearchField struct { 17 | InputField InputField 18 | Props *SearchFieldProps 19 | slot *tview.Flex 20 | } 21 | 22 | type SearchFieldProps struct { 23 | DoneFunc SetDoneFunc 24 | ChangedFunc func(text string) 25 | } 26 | 27 | func NewSearchField(label string) *SearchField { 28 | sf := &SearchField{} 29 | sf.Props = &SearchFieldProps{} 30 | label = fmt.Sprintf("%s ", label) 31 | sf.InputField = primitive.NewInputField(label, searchPlaceholder) 32 | return sf 33 | } 34 | 35 | func (s *SearchField) Render() error { 36 | if s.Props.DoneFunc == nil || s.Props.ChangedFunc == nil { 37 | return ErrComponentPropsNotSet 38 | } 39 | 40 | if s.slot == nil { 41 | return ErrComponentNotBound 42 | } 43 | 44 | s.InputField.SetDoneFunc(s.Props.DoneFunc) 45 | s.InputField.SetChangedFunc(s.Props.ChangedFunc) 46 | s.slot.AddItem(s.InputField.Primitive(), 0, 2, false) 47 | 48 | return nil 49 | } 50 | 51 | func (s *SearchField) Bind(slot *tview.Flex) { 52 | s.slot = slot 53 | } 54 | -------------------------------------------------------------------------------- /component/search_test.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) HashiCorp, Inc. 2 | // SPDX-License-Identifier: MPL-2.0 3 | 4 | package component_test 5 | 6 | import ( 7 | "errors" 8 | "testing" 9 | 10 | "github.com/gdamore/tcell/v2" 11 | "github.com/rivo/tview" 12 | "github.com/stretchr/testify/require" 13 | 14 | "github.com/hcjulz/damon/component" 15 | "github.com/hcjulz/damon/component/componentfakes" 16 | ) 17 | 18 | func TestSearch_Happy(t *testing.T) { 19 | r := require.New(t) 20 | 21 | input := &componentfakes.FakeInputField{} 22 | search := component.NewSearchField("test") 23 | search.InputField = input 24 | 25 | var changedCalled bool 26 | search.Props.ChangedFunc = func(text string) { 27 | changedCalled = true 28 | } 29 | 30 | var doneCalled bool 31 | search.Props.DoneFunc = func(key tcell.Key) { 32 | doneCalled = true 33 | } 34 | search.Bind(tview.NewFlex()) 35 | 36 | err := search.Render() 37 | r.NoError(err) 38 | 39 | actualDoneFunc := input.SetDoneFuncArgsForCall(0) 40 | actualChangedFunc := input.SetChangedFuncArgsForCall(0) 41 | 42 | actualChangedFunc("") 43 | actualDoneFunc(tcell.KeyACK) 44 | 45 | r.True(changedCalled) 46 | r.True(doneCalled) 47 | } 48 | 49 | func TestSearch_Sad(t *testing.T) { 50 | r := require.New(t) 51 | 52 | t.Run("When the component isn't bound", func(t *testing.T) { 53 | input := &componentfakes.FakeInputField{} 54 | search := component.NewSearchField("test") 55 | search.InputField = input 56 | search.Props.ChangedFunc = func(text string) {} 57 | search.Props.DoneFunc = func(key tcell.Key) {} 58 | 59 | err := search.Render() 60 | r.Error(err) 61 | 62 | // It provides the correct error message 63 | r.EqualError(err, "component not bound") 64 | 65 | // It is the correct error 66 | r.True(errors.Is(err, component.ErrComponentNotBound)) 67 | }) 68 | 69 | t.Run("When DoneFunc is not set", func(t *testing.T) { 70 | input := &componentfakes.FakeInputField{} 71 | search := component.NewSearchField("test") 72 | search.InputField = input 73 | search.Props.ChangedFunc = func(text string) {} 74 | search.Bind(tview.NewFlex()) 75 | 76 | err := search.Render() 77 | r.Error(err) 78 | 79 | // It provides the correct error message 80 | r.EqualError(err, "component properties not set") 81 | 82 | // It is the correct error 83 | r.True(errors.Is(err, component.ErrComponentPropsNotSet)) 84 | }) 85 | 86 | t.Run("When ChangedFunc is not set", func(t *testing.T) { 87 | input := &componentfakes.FakeInputField{} 88 | search := component.NewSearchField("test") 89 | search.InputField = input 90 | search.Props.DoneFunc = func(key tcell.Key) {} 91 | search.Bind(tview.NewFlex()) 92 | 93 | err := search.Render() 94 | r.Error(err) 95 | 96 | // It provides the correct error message 97 | r.EqualError(err, "component properties not set") 98 | 99 | // It is the correct error 100 | r.True(errors.Is(err, component.ErrComponentPropsNotSet)) 101 | }) 102 | } 103 | -------------------------------------------------------------------------------- /component/selections.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) HashiCorp, Inc. 2 | // SPDX-License-Identifier: MPL-2.0 3 | 4 | package component 5 | 6 | import ( 7 | "fmt" 8 | 9 | "github.com/rivo/tview" 10 | 11 | "github.com/hcjulz/damon/primitives" 12 | "github.com/hcjulz/damon/state" 13 | "github.com/hcjulz/damon/styles" 14 | 15 | "github.com/hcjulz/damon/models" 16 | ) 17 | 18 | var ( 19 | labelNamespaceDropdown = fmt.Sprintf("%sNamespace : ▾ %s", 20 | styles.HighlightSecondaryTag, 21 | styles.StandardColorTag, 22 | ) 23 | ) 24 | 25 | type Selections struct { 26 | Namespace DropDown 27 | 28 | state *state.State 29 | slot *tview.Flex 30 | } 31 | 32 | func NewSelections(state *state.State) *Selections { 33 | return &Selections{ 34 | Namespace: primitives.NewDropDown(labelNamespaceDropdown), 35 | state: state, 36 | } 37 | } 38 | 39 | func (s *Selections) Render() error { 40 | if s.slot == nil { 41 | return ErrComponentNotBound 42 | } 43 | 44 | s.Namespace.SetOptions(convert(s.state.Namespaces), s.selected) 45 | s.Namespace.SetCurrentOption(len(s.state.Namespaces) - 1) 46 | s.Namespace.SetSelectedFunc(s.rerender) 47 | 48 | s.state.Elements.DropDownNamespace = s.Namespace.Primitive().(*tview.DropDown) 49 | s.slot.AddItem(s.Namespace.Primitive(), 0, 1, true) 50 | 51 | return nil 52 | } 53 | 54 | func (s *Selections) Bind(slot *tview.Flex) { 55 | s.slot = slot 56 | } 57 | 58 | func (s *Selections) selected(text string, index int) { 59 | s.state.SelectedNamespace = text 60 | } 61 | 62 | func (s *Selections) rerender(text string, index int) { 63 | s.state.SelectedNamespace = text 64 | } 65 | 66 | func convert(list []*models.Namespace) []string { 67 | var ns []string 68 | for _, n := range list { 69 | ns = append(ns, n.Name) 70 | } 71 | return ns 72 | } 73 | -------------------------------------------------------------------------------- /component/selections_test.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) HashiCorp, Inc. 2 | // SPDX-License-Identifier: MPL-2.0 3 | 4 | package component_test 5 | 6 | import ( 7 | "errors" 8 | "testing" 9 | 10 | "github.com/rivo/tview" 11 | "github.com/stretchr/testify/require" 12 | 13 | "github.com/hcjulz/damon/component" 14 | "github.com/hcjulz/damon/component/componentfakes" 15 | "github.com/hcjulz/damon/models" 16 | "github.com/hcjulz/damon/state" 17 | ) 18 | 19 | func TestSelections_Happy(t *testing.T) { 20 | r := require.New(t) 21 | 22 | state := state.New() 23 | state.Namespaces = []*models.Namespace{ 24 | { 25 | Name: "test", 26 | Description: "test-space", 27 | }, 28 | { 29 | Name: "space", 30 | Description: "ship", 31 | }, 32 | } 33 | dropdown := &componentfakes.FakeDropDown{} 34 | 35 | selections := component.NewSelections(state) 36 | selections.Namespace = dropdown 37 | 38 | selections.Bind(tview.NewFlex()) 39 | 40 | dropdown.PrimitiveReturns(tview.NewDropDown()) 41 | 42 | err := selections.Render() 43 | r.NoError(err) 44 | 45 | ns, _ := dropdown.SetOptionsArgsForCall(0) 46 | optIndex := dropdown.SetCurrentOptionArgsForCall(0) 47 | actualRerender := dropdown.SetSelectedFuncArgsForCall(0) 48 | 49 | r.Equal(ns, []string{"test", "space"}) 50 | r.Equal(optIndex, 1) 51 | 52 | actualRerender("text", 0) 53 | } 54 | 55 | func TestSelections_Sad(t *testing.T) { 56 | t.Run("When the component isn't bound", func(t *testing.T) { 57 | r := require.New(t) 58 | 59 | state := state.New() 60 | state.Namespaces = []*models.Namespace{} 61 | dropdown := &componentfakes.FakeDropDown{} 62 | 63 | selections := component.NewSelections(state) 64 | selections.Namespace = dropdown 65 | 66 | dropdown.PrimitiveReturns(tview.NewDropDown()) 67 | 68 | err := selections.Render() 69 | r.Error(err) 70 | 71 | // It provides the correct error message 72 | r.EqualError(err, "component not bound") 73 | 74 | // It is the correct error 75 | r.True(errors.Is(err, component.ErrComponentNotBound)) 76 | }) 77 | } 78 | -------------------------------------------------------------------------------- /component/selector_modal.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) HashiCorp, Inc. 2 | // SPDX-License-Identifier: MPL-2.0 3 | 4 | package component 5 | 6 | import ( 7 | "github.com/gdamore/tcell/v2" 8 | "github.com/rivo/tview" 9 | 10 | "github.com/hcjulz/damon/primitives" 11 | ) 12 | 13 | const pageNameSelector = "selector" 14 | 15 | type SelectorModal struct { 16 | Modal Selector 17 | Props *SelectorProps 18 | pages *tview.Pages 19 | keyBindings map[tcell.Key]func() 20 | } 21 | 22 | type SelectorProps struct { 23 | Items []string 24 | AllocationID string 25 | } 26 | 27 | func NewSelectorModal() *SelectorModal { 28 | s := &SelectorModal{ 29 | Modal: primitives.NewSelectionModal(), 30 | Props: &SelectorProps{}, 31 | keyBindings: map[tcell.Key]func(){}, 32 | } 33 | 34 | s.Modal.GetTable().SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey { 35 | if fn, ok := s.keyBindings[event.Key()]; ok { 36 | fn() 37 | } 38 | 39 | return event 40 | }) 41 | 42 | return s 43 | } 44 | 45 | func (s *SelectorModal) Render() error { 46 | if s.pages == nil { 47 | return ErrComponentNotBound 48 | } 49 | 50 | if s.Props.Items == nil { 51 | return ErrComponentPropsNotSet 52 | } 53 | 54 | table := s.Modal.GetTable() 55 | table.Clear() 56 | 57 | for i, v := range s.Props.Items { 58 | table.RenderRow([]string{v}, i, tcell.ColorWhite) 59 | } 60 | 61 | s.Modal.GetTable().SetTitle("Select a Task (alloc: %s)", s.Props.AllocationID) 62 | 63 | s.pages.AddPage(pageNameSelector, s.Modal.Container(), true, true) 64 | 65 | return nil 66 | } 67 | 68 | func (s *SelectorModal) Bind(pages *tview.Pages) { 69 | s.pages = pages 70 | } 71 | 72 | func (s *SelectorModal) SetSelectedFunc(fn func(task string)) { 73 | s.Modal.GetTable().SetSelectedFunc(func(row, column int) { 74 | task := s.Modal.GetTable().GetCellContent(row, 0) 75 | fn(task) 76 | s.Close() 77 | }) 78 | } 79 | 80 | func (s *SelectorModal) Close() { 81 | s.pages.RemovePage(pageNameSelector) 82 | } 83 | 84 | func (s *SelectorModal) BindKey(key tcell.Key, fn func()) { 85 | s.keyBindings[key] = fn 86 | } 87 | -------------------------------------------------------------------------------- /component/selector_modal_test.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) HashiCorp, Inc. 2 | // SPDX-License-Identifier: MPL-2.0 3 | 4 | package component_test 5 | 6 | import ( 7 | "testing" 8 | 9 | "github.com/rivo/tview" 10 | "github.com/stretchr/testify/require" 11 | 12 | "github.com/hcjulz/damon/component" 13 | "github.com/hcjulz/damon/component/componentfakes" 14 | "github.com/hcjulz/damon/primitives" 15 | ) 16 | 17 | func TestSelectorModal_Happy(t *testing.T) { 18 | r := require.New(t) 19 | 20 | fakeSelectorModal := &componentfakes.FakeSelector{} 21 | table := primitives.NewTable() 22 | 23 | pages := tview.NewPages() 24 | 25 | m := component.NewSelectorModal() 26 | m.Modal = fakeSelectorModal 27 | m.Bind(pages) 28 | m.Props.Items = []string{ 29 | "task-1", 30 | "task-2", 31 | } 32 | 33 | fakeSelectorModal.GetTableReturns(table) 34 | err := m.Render() 35 | r.NoError(err) 36 | 37 | r.Equal(fakeSelectorModal.GetTableCallCount(), 2) 38 | 39 | item1 := table.GetCellContent(0, 0) 40 | item2 := table.GetCellContent(1, 0) 41 | 42 | r.Equal(item1, "task-1") 43 | r.Equal(item2, "task-2") 44 | 45 | r.Equal(pages.GetPageCount(), 1) 46 | } 47 | 48 | func TestSelectorModal_Sad(t *testing.T) { 49 | t.Run("When the component is not bound", func(t *testing.T) { 50 | r := require.New(t) 51 | 52 | fakeSelectorModal := &componentfakes.FakeSelector{} 53 | table := primitives.NewTable() 54 | 55 | m := component.NewSelectorModal() 56 | m.Modal = fakeSelectorModal 57 | m.Props.Items = []string{ 58 | "task-1", 59 | "task-2", 60 | } 61 | 62 | fakeSelectorModal.GetTableReturns(table) 63 | err := m.Render() 64 | r.Error(err) 65 | 66 | r.ErrorIs(err, component.ErrComponentNotBound) 67 | }) 68 | 69 | t.Run("When component properites are not set", func(t *testing.T) { 70 | r := require.New(t) 71 | 72 | fakeSelectorModal := &componentfakes.FakeSelector{} 73 | table := primitives.NewTable() 74 | 75 | pages := tview.NewPages() 76 | 77 | m := component.NewSelectorModal() 78 | m.Modal = fakeSelectorModal 79 | 80 | m.Bind(pages) 81 | 82 | fakeSelectorModal.GetTableReturns(table) 83 | err := m.Render() 84 | r.Error(err) 85 | 86 | r.ErrorIs(err, component.ErrComponentPropsNotSet) 87 | }) 88 | } 89 | -------------------------------------------------------------------------------- /component/task_events.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) HashiCorp, Inc. 2 | // SPDX-License-Identifier: MPL-2.0 3 | 4 | package component 5 | 6 | import ( 7 | "fmt" 8 | "time" 9 | 10 | "github.com/gdamore/tcell/v2" 11 | "github.com/hashicorp/nomad/api" 12 | "github.com/rivo/tview" 13 | 14 | "github.com/hcjulz/damon/models" 15 | primitive "github.com/hcjulz/damon/primitives" 16 | "github.com/hcjulz/damon/styles" 17 | ) 18 | 19 | const ( 20 | TableTitleTaskEvents = "TaskEvents" 21 | ) 22 | 23 | var ( 24 | TableHeaderTaskEvents = []string{ 25 | LabelTime, 26 | LabelType, 27 | LabelMessage, 28 | } 29 | ) 30 | 31 | type TaskEventsTable struct { 32 | Table Table 33 | Props *TaskEventsTableProps 34 | 35 | slot *tview.Flex 36 | } 37 | 38 | type TaskEventsTableProps struct { 39 | HandleNoResources models.HandlerFunc 40 | Data []*api.TaskEvent 41 | AllocID string 42 | } 43 | 44 | func NewTaskEventsTable() *TaskEventsTable { 45 | return &TaskEventsTable{ 46 | Table: primitive.NewTable(), 47 | Props: &TaskEventsTableProps{}, 48 | } 49 | } 50 | 51 | func (t *TaskEventsTable) Bind(slot *tview.Flex) { 52 | slot.SetTitle("Events") 53 | t.slot = slot 54 | } 55 | 56 | func (t *TaskEventsTable) Render() error { 57 | if t.Props.HandleNoResources == nil { 58 | return ErrComponentPropsNotSet 59 | } 60 | 61 | if t.slot == nil { 62 | return ErrComponentNotBound 63 | } 64 | 65 | t.slot.Clear() 66 | t.Table.Clear() 67 | 68 | if len(t.Props.Data) == 0 { 69 | t.Props.HandleNoResources( 70 | "%sno task events available\n¯%s\\_( ͡• ͜ʖ ͡•)_/¯", 71 | styles.HighlightPrimaryTag, 72 | styles.HighlightSecondaryTag, 73 | ) 74 | 75 | return nil 76 | } 77 | 78 | t.Table.SetTitle(fmt.Sprintf("%s (%s)", TableTitleTaskEvents, t.Props.AllocID)) 79 | 80 | t.Table.RenderHeader(TableHeaderTaskEvents) 81 | t.renderRows() 82 | 83 | t.slot.AddItem(t.Table.Primitive(), 0, 1, false) 84 | return nil 85 | } 86 | 87 | func (t *TaskEventsTable) renderRows() { 88 | for i, e := range t.Props.Data { 89 | row := []string{ 90 | time.Unix(0, e.Time).Format(time.RFC3339), 91 | e.Type, 92 | e.DisplayMessage, 93 | } 94 | 95 | index := i + 1 96 | 97 | t.Table.RenderRow(row, index, tcell.ColorWhite) 98 | } 99 | } 100 | 101 | func (t *TaskEventsTable) GetIDForSelection() string { 102 | row, _ := t.Table.GetSelection() 103 | return t.Table.GetCellContent(row, 0) 104 | } 105 | -------------------------------------------------------------------------------- /component/task_group.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) HashiCorp, Inc. 2 | // SPDX-License-Identifier: MPL-2.0 3 | 4 | package component 5 | 6 | import ( 7 | "fmt" 8 | 9 | "github.com/gdamore/tcell/v2" 10 | "github.com/rivo/tview" 11 | 12 | "github.com/hcjulz/damon/models" 13 | primitive "github.com/hcjulz/damon/primitives" 14 | "github.com/hcjulz/damon/styles" 15 | ) 16 | 17 | const ( 18 | TableTitleTaskGroups = "TaskGroups" 19 | ) 20 | 21 | var ( 22 | TableHeaderTaskGroups = []string{ 23 | LabelName, 24 | LabelJobID, 25 | LabelStarting, 26 | LabelQueued, 27 | LabelRunning, 28 | LabelComplete, 29 | LabelFailed, 30 | LabelLost, 31 | } 32 | ) 33 | 34 | type SelectTaskGroupFunc func(ID string) 35 | 36 | type TaskGroupTable struct { 37 | Table Table 38 | Props *TaskGroupTableProps 39 | 40 | slot *tview.Flex 41 | } 42 | 43 | type TaskGroupTableProps struct { 44 | SelectTaskGroup SelectTaskGroupFunc 45 | HandleNoResources models.HandlerFunc 46 | Data []*models.TaskGroup 47 | JobID string 48 | } 49 | 50 | func NewTaskGroupTable() *TaskGroupTable { 51 | t := primitive.NewTable() 52 | 53 | return &TaskGroupTable{ 54 | Table: t, 55 | Props: &TaskGroupTableProps{}, 56 | } 57 | } 58 | 59 | func (t *TaskGroupTable) Bind(slot *tview.Flex) { 60 | t.slot = slot 61 | } 62 | 63 | func (t *TaskGroupTable) Render() error { 64 | if t.Props.SelectTaskGroup == nil || t.Props.HandleNoResources == nil { 65 | return ErrComponentPropsNotSet 66 | } 67 | 68 | if t.slot == nil { 69 | return ErrComponentNotBound 70 | } 71 | 72 | t.slot.Clear() 73 | t.Table.Clear() 74 | 75 | if len(t.Props.Data) == 0 { 76 | t.Props.HandleNoResources( 77 | "%sno TaskGroups available\n¯%s\\_( ͡• ͜ʖ ͡•)_/¯", 78 | styles.HighlightPrimaryTag, 79 | styles.HighlightSecondaryTag, 80 | ) 81 | 82 | return nil 83 | } 84 | 85 | t.Table.SetSelectedFunc(t.taskGroupSelected) 86 | t.Table.SetTitle(fmt.Sprintf("%s (%s)", TableTitleTaskGroups, t.Props.JobID)) 87 | 88 | t.Table.RenderHeader(TableHeaderTaskGroups) 89 | t.renderRows() 90 | 91 | t.slot.AddItem(t.Table.Primitive(), 0, 1, false) 92 | return nil 93 | } 94 | 95 | func (t *TaskGroupTable) renderRows() { 96 | for i, tg := range t.Props.Data { 97 | row := []string{ 98 | tg.Name, 99 | tg.JobID, 100 | fmt.Sprint(tg.Starting), 101 | fmt.Sprint(tg.Queued), 102 | fmt.Sprint(tg.Running), 103 | fmt.Sprint(tg.Complete), 104 | fmt.Sprint(tg.Failed), 105 | fmt.Sprint(tg.Lost), 106 | } 107 | 108 | index := i + 1 109 | 110 | t.Table.RenderRow(row, index, tcell.ColorWhite) 111 | } 112 | } 113 | 114 | func (t *TaskGroupTable) taskGroupSelected(row, column int) { 115 | jobID := t.Table.GetCellContent(row, 0) 116 | t.Props.SelectTaskGroup(jobID) 117 | } 118 | -------------------------------------------------------------------------------- /component/tasks.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) HashiCorp, Inc. 2 | // SPDX-License-Identifier: MPL-2.0 3 | 4 | package component 5 | 6 | import ( 7 | "fmt" 8 | 9 | "github.com/gdamore/tcell/v2" 10 | "github.com/rivo/tview" 11 | 12 | "github.com/hcjulz/damon/models" 13 | primitive "github.com/hcjulz/damon/primitives" 14 | "github.com/hcjulz/damon/styles" 15 | ) 16 | 17 | const ( 18 | TableTitleTasks = "Tasks" 19 | ) 20 | 21 | var ( 22 | TableHeaderTasks = []string{ 23 | LabelName, 24 | LabelState, 25 | LabelDriver, 26 | LabelImage, 27 | LabelLastEvent, 28 | } 29 | ) 30 | 31 | type SelectTaskFunc func(allocID, taskID string) 32 | 33 | type TaskTable struct { 34 | Table Table 35 | Props *TaskTableProps 36 | 37 | slot *tview.Flex 38 | keyBindings map[tcell.Key]func(event *tcell.EventKey) 39 | } 40 | 41 | type TaskTableProps struct { 42 | SelectTask SelectTaskFunc 43 | HandleNoResources models.HandlerFunc 44 | 45 | AllocationID string 46 | 47 | Data []*models.Task 48 | } 49 | 50 | func NewTaskTable() *TaskTable { 51 | t := primitive.NewTable() 52 | 53 | tt := &TaskTable{ 54 | Table: t, 55 | Props: &TaskTableProps{}, 56 | keyBindings: map[tcell.Key]func(event *tcell.EventKey){}, 57 | } 58 | 59 | tt.Table.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey { 60 | if fn, ok := tt.keyBindings[event.Key()]; ok { 61 | fn(event) 62 | } 63 | 64 | return event 65 | }) 66 | 67 | return tt 68 | } 69 | 70 | func (t *TaskTable) Bind(slot *tview.Flex) { 71 | t.slot = slot 72 | } 73 | 74 | func (t *TaskTable) Render() error { 75 | if t.Props.SelectTask == nil { 76 | return ErrComponentPropsNotSet 77 | } 78 | 79 | if t.Props.HandleNoResources == nil { 80 | return ErrComponentPropsNotSet 81 | } 82 | 83 | if t.slot == nil { 84 | return ErrComponentNotBound 85 | } 86 | 87 | t.reset() 88 | 89 | if len(t.Props.Data) == 0 { 90 | t.Props.HandleNoResources( 91 | "%sno tasks available\n¯%s\\_( ͡• ͜ʖ ͡•)_/¯", 92 | styles.HighlightPrimaryTag, 93 | styles.HighlightSecondaryTag, 94 | ) 95 | 96 | return nil 97 | } 98 | 99 | t.Table.SetSelectedFunc(t.taskSelected) 100 | 101 | t.Table.RenderHeader(TableHeaderTasks) 102 | t.renderRows() 103 | 104 | t.Table.SetTitle(fmt.Sprintf("%s (Allocation: %s)", TableTitleTasks, t.Props.AllocationID)) 105 | t.slot.AddItem(t.Table.Primitive(), 0, 1, false) 106 | 107 | return nil 108 | } 109 | 110 | func (t *TaskTable) reset() { 111 | t.slot.Clear() 112 | t.Table.Clear() 113 | } 114 | 115 | func (t *TaskTable) renderRows() { 116 | for i, task := range t.Props.Data { 117 | row := []string{ 118 | task.Name, 119 | task.State, 120 | task.Driver, 121 | } 122 | 123 | if image, ok := task.Config["image"]; ok { 124 | row = append(row, image.(string)) 125 | } 126 | 127 | row = append(row, task.Events[len(task.Events)-1].DisplayMessage) 128 | // row = append(row, strconv.Itoa(task.CPU)) 129 | // row = append(row, strconv.Itoa(task.MemoryMB)) 130 | 131 | index := i + 1 132 | 133 | c := t.getCellColor(task.State) 134 | t.Table.RenderRow(row, index, c) 135 | } 136 | } 137 | 138 | func (t *TaskTable) getCellColor(status string) tcell.Color { 139 | c := tcell.ColorWhite 140 | 141 | switch status { 142 | case models.StatusDead: 143 | c = tcell.ColorGray 144 | case models.StatusFailed: 145 | c = tcell.ColorRed 146 | case models.StatusPending: 147 | c = tcell.ColorYellow 148 | } 149 | 150 | return c 151 | } 152 | 153 | func (t *TaskTable) taskSelected(row, column int) { 154 | taskName := t.Table.GetCellContent(row, 0) 155 | t.Props.SelectTask(taskName, t.Props.AllocationID) 156 | } 157 | 158 | func (t *TaskTable) GetNameForSelection() string { 159 | row, _ := t.Table.GetSelection() 160 | return t.Table.GetCellContent(row, 0) 161 | } 162 | 163 | func (t *TaskTable) BindKey(key tcell.Key, fn func(event *tcell.EventKey)) { 164 | t.keyBindings[key] = fn 165 | } 166 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/hcjulz/damon 2 | 3 | go 1.19 4 | 5 | require ( 6 | github.com/gdamore/tcell/v2 v2.5.4 7 | github.com/hashicorp/nomad/api v0.0.0-20210804153318-c67b69bd0cc6 8 | github.com/jessevdk/go-flags v1.5.0 9 | github.com/olekukonko/tablewriter v0.0.5 10 | github.com/rivo/tview v0.0.0-20220911190240-55965cf21d8e 11 | github.com/stretchr/testify v1.7.0 12 | ) 13 | 14 | require ( 15 | github.com/davecgh/go-spew v1.1.1 // indirect 16 | github.com/gdamore/encoding v1.0.0 // indirect 17 | github.com/gorilla/websocket v1.4.2 // indirect 18 | github.com/hashicorp/cronexpr v1.1.1 // indirect 19 | github.com/hashicorp/go-cleanhttp v0.5.2 // indirect 20 | github.com/hashicorp/go-rootcerts v1.0.2 // indirect 21 | github.com/lucasb-eyer/go-colorful v1.2.0 // indirect 22 | github.com/mattn/go-runewidth v0.0.14 // indirect 23 | github.com/mitchellh/go-homedir v1.1.0 // indirect 24 | github.com/mitchellh/mapstructure v1.4.1 // indirect 25 | github.com/pmezard/go-difflib v1.0.0 // indirect 26 | github.com/rivo/uniseg v0.4.2 // indirect 27 | golang.org/x/sys v0.0.0-20220909162455-aba9fc2a8ff2 // indirect 28 | golang.org/x/term v0.0.0-20220722155259-a9ba230a4035 // indirect 29 | golang.org/x/text v0.5.0 // indirect 30 | gopkg.in/yaml.v3 v3.0.0 // indirect 31 | ) 32 | -------------------------------------------------------------------------------- /layout/layout.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) HashiCorp, Inc. 2 | // SPDX-License-Identifier: MPL-2.0 3 | 4 | package layout 5 | 6 | import ( 7 | "github.com/rivo/tview" 8 | ) 9 | 10 | const NameMainPage = "main" 11 | const NameErrorPage = "error" 12 | 13 | type Layout struct { 14 | Container *tview.Application 15 | 16 | Pages *tview.Pages 17 | MainPage *tview.Flex 18 | 19 | Header *Header 20 | Body *tview.Flex 21 | Footer *tview.Flex 22 | 23 | Elements *Elements 24 | } 25 | 26 | type Elements struct { 27 | ClusterInfo *tview.Flex 28 | Dropdowns *tview.Flex 29 | } 30 | 31 | type Header struct { 32 | SlotInfo *tview.Flex 33 | SlotCmd *tview.Flex 34 | SlotLogo *tview.Flex 35 | } 36 | 37 | func EnableMouse(l *Layout) { 38 | l.Container.EnableMouse(true) 39 | } 40 | 41 | func New(options ...func(*Layout)) *Layout { 42 | v := &Layout{} 43 | 44 | for _, opt := range options { 45 | opt(v) 46 | } 47 | 48 | return v 49 | } 50 | 51 | func Default(l *Layout) { 52 | l.Header = &Header{} 53 | l.Elements = &Elements{} 54 | 55 | l.Elements.ClusterInfo = tview.NewFlex() 56 | l.Elements.Dropdowns = tview.NewFlex().SetDirection(tview.FlexRow) 57 | 58 | l.Header.SlotInfo = tview.NewFlex().SetDirection(tview.FlexRow) 59 | l.Header.SlotInfo.AddItem(l.Elements.ClusterInfo, 0, 1, false) 60 | l.Header.SlotInfo.AddItem(l.Elements.Dropdowns, 0, 1, false) 61 | 62 | l.Header.SlotCmd = tview.NewFlex() 63 | l.Header.SlotLogo = tview.NewFlex() 64 | 65 | header := tview.NewFlex(). 66 | AddItem(l.Header.SlotInfo, 0, 1, false). 67 | AddItem(l.Header.SlotCmd, 0, 1, false). 68 | AddItem(l.Header.SlotLogo, 0, 1, false) 69 | 70 | header.SetBorderPadding(1, 1, 2, 2) 71 | 72 | footer := tview.NewFlex() 73 | body := tview.NewFlex() 74 | 75 | mainPage := tview.NewFlex().SetDirection(tview.FlexRow) 76 | mainPage. 77 | AddItem(header, 0, 4, false). 78 | AddItem(body, 0, 12, false). 79 | AddItem(footer, 0, 0, false) 80 | 81 | pages := tview.NewPages() 82 | pages.AddPage(NameMainPage, mainPage, true, true) 83 | 84 | l.Body = body 85 | l.Footer = footer 86 | 87 | l.MainPage = mainPage 88 | l.Pages = pages 89 | 90 | l.Container = tview.NewApplication(). 91 | SetRoot(pages, true). 92 | SetFocus(pages) 93 | 94 | } 95 | -------------------------------------------------------------------------------- /layout/layout_test.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) HashiCorp, Inc. 2 | // SPDX-License-Identifier: MPL-2.0 3 | 4 | package layout_test 5 | 6 | import ( 7 | "testing" 8 | 9 | "github.com/rivo/tview" 10 | "github.com/stretchr/testify/require" 11 | 12 | "github.com/hcjulz/damon/layout" 13 | ) 14 | 15 | func TestDefaultLayout(t *testing.T) { 16 | r := require.New(t) 17 | 18 | l := layout.New(layout.Default) 19 | 20 | r.NotNil(l.Container) 21 | r.IsType(l.Container, &tview.Application{}) 22 | 23 | r.NotNil(l.Pages) 24 | r.IsType(l.Pages, &tview.Pages{}) 25 | r.Equal(l.Pages.GetPageCount(), 1) 26 | r.True(l.Pages.HasPage("main")) 27 | 28 | r.NotNil(l.Header) 29 | r.NotNil(l.Header.SlotInfo) 30 | r.IsType(l.Header.SlotInfo, &tview.Flex{}) 31 | 32 | r.NotNil(l.Header.SlotCmd) 33 | r.IsType(l.Header.SlotCmd, &tview.Flex{}) 34 | 35 | r.NotNil(l.Header.SlotLogo) 36 | r.IsType(l.Header.SlotLogo, &tview.Flex{}) 37 | 38 | r.NotNil(l.Elements) 39 | r.NotNil(l.Elements.ClusterInfo) 40 | r.IsType(l.Elements.ClusterInfo, &tview.Flex{}) 41 | 42 | r.NotNil(l.Elements.Dropdowns) 43 | r.IsType(l.Elements.Dropdowns, &tview.Flex{}) 44 | 45 | r.NotNil(l.Body) 46 | r.IsType(l.Body, &tview.Flex{}) 47 | 48 | r.NotNil(l.Footer) 49 | r.IsType(l.Footer, &tview.Flex{}) 50 | 51 | r.NotNil(l.MainPage) 52 | r.IsType(l.MainPage, &tview.Flex{}) 53 | } 54 | -------------------------------------------------------------------------------- /models/models.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) HashiCorp, Inc. 2 | // SPDX-License-Identifier: MPL-2.0 3 | 4 | package models 5 | 6 | import ( 7 | "time" 8 | 9 | "github.com/hashicorp/nomad/api" 10 | ) 11 | 12 | type HandlerFunc func(format string, args ...interface{}) 13 | 14 | type Handler string 15 | 16 | const ( 17 | HandleError Handler = Handler("Error") 18 | HandleFatal Handler = Handler("Fatal") 19 | HandleInfo Handler = Handler("Info") 20 | 21 | TopicNamespace api.Topic = api.Topic("Namespace") 22 | TopicTaskGroup api.Topic = api.Topic("TaskGroup") 23 | TopicJobStatus api.Topic = api.Topic("JobStatus") 24 | TopicLog api.Topic = api.Topic("Log") 25 | ) 26 | 27 | type Job struct { 28 | ID string 29 | Name string 30 | Namespace string 31 | Type string 32 | Status string 33 | StatusDescription string 34 | StatusSummary Summary 35 | SubmitTime time.Time 36 | } 37 | 38 | type JobStatus struct { 39 | ID string 40 | Name string 41 | Namespace string 42 | Type string 43 | Status string 44 | StatusDescription string 45 | SubmitDate time.Time 46 | Priority int 47 | Datacenters string 48 | Periodic bool 49 | Parameterized bool 50 | TaskGroups []*TaskGroup 51 | TaskGroupStatus []*TaskGroupStatus 52 | Allocations []*Alloc 53 | } 54 | 55 | type Summary struct { 56 | Total int 57 | Running int 58 | } 59 | 60 | type TaskGroup struct { 61 | Name string 62 | JobID string 63 | Queued int 64 | Complete int 65 | Failed int 66 | Running int 67 | Starting int 68 | Lost int 69 | } 70 | 71 | type TaskGroupStatus struct { 72 | ID string 73 | Desired int 74 | Placed int 75 | Healthy int 76 | Unhealthy int 77 | ProgressDeadline time.Duration 78 | Status string 79 | StatusDescription string 80 | } 81 | 82 | type Alloc struct { 83 | ID string 84 | Name string 85 | Namespace string 86 | HostIP string 87 | HostAddresses []string 88 | TaskGroup string 89 | Tasks []AllocTask 90 | TaskList []*Task 91 | TaskNames []string 92 | JobID string 93 | JobType string 94 | NodeID string 95 | NodeName string 96 | DesiredStatus string 97 | Version uint64 98 | Status string 99 | Created time.Time 100 | Modified time.Time 101 | } 102 | 103 | type AllocTask struct { 104 | Name string 105 | Events []*api.TaskEvent 106 | } 107 | 108 | type Task struct { 109 | Name string 110 | Driver string 111 | State string 112 | Events []*api.TaskEvent 113 | Config map[string]interface{} 114 | Env map[string]string 115 | Image string 116 | CPU int 117 | MemoryMB int 118 | DiskMB int 119 | } 120 | 121 | type Namespace struct { 122 | Name string 123 | Description string 124 | } 125 | 126 | type Deployment struct { 127 | ID string 128 | JobID string 129 | Namespace string 130 | Status string 131 | StatusDescription string 132 | } 133 | 134 | type SearchResult struct { 135 | } 136 | 137 | type Status string 138 | 139 | const ( 140 | DesiredStatusRun = "run" 141 | DesiredStatusStop = "stop" 142 | StatusRunning = "running" 143 | StatusPending = "pending" 144 | StatusDead = "dead" 145 | StatusFailed = "failed" 146 | StatusSuccessful = "successful" 147 | 148 | TypeBatch = "batch" 149 | TypeService = "service" 150 | ) 151 | 152 | type Sentinel string 153 | 154 | func (s Sentinel) Error() string { 155 | return string(s) 156 | } 157 | 158 | /* 159 | Task Group Desired Placed Healthy Unhealthy Progress Deadline 160 | cadence 1 1 1 0 2021-05-12T14:27:03+02:00 161 | iam 1 1 1 0 2021-05-12T14:28:37+02:00 162 | */ 163 | -------------------------------------------------------------------------------- /nomad/alloc.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) HashiCorp, Inc. 2 | // SPDX-License-Identifier: MPL-2.0 3 | 4 | package nomad 5 | 6 | import ( 7 | "fmt" 8 | "time" 9 | 10 | "github.com/hashicorp/nomad/api" 11 | 12 | "github.com/hcjulz/damon/models" 13 | ) 14 | 15 | func (n *Nomad) JobAllocs(jobID string, so *SearchOptions) ([]*models.Alloc, error) { 16 | if so == nil { 17 | so = &SearchOptions{} 18 | } 19 | 20 | list, _, err := n.JobClient.Allocations(jobID, false, &api.QueryOptions{ 21 | Namespace: so.Namespace, 22 | Region: so.Region, 23 | }) 24 | 25 | if err != nil { 26 | return nil, err 27 | } 28 | 29 | allocs, err := n.toAllocs(list) 30 | if err != nil { 31 | return nil, err 32 | } 33 | 34 | return allocs, nil 35 | } 36 | 37 | func (n *Nomad) Allocations(so *SearchOptions) ([]*models.Alloc, error) { 38 | if so == nil { 39 | so = &SearchOptions{} 40 | } 41 | 42 | list, _, err := n.AllocClient.List(&api.QueryOptions{ 43 | Namespace: so.Namespace, 44 | Region: so.Region, 45 | }) 46 | if err != nil { 47 | return nil, err 48 | } 49 | 50 | allocs, err := n.toAllocs(list) 51 | if err != nil { 52 | return nil, err 53 | } 54 | 55 | return allocs, nil 56 | } 57 | 58 | func getTasksFromAlloc(taskStates map[string]*api.TaskState, alloc *api.Allocation) []*models.Task { 59 | tasks := []*models.Task{} 60 | 61 | for _, t := range alloc.GetTaskGroup().Tasks { 62 | task := &models.Task{ 63 | Name: t.Name, 64 | Driver: t.Driver, 65 | Env: t.Env, 66 | Config: t.Config, 67 | CPU: *t.Resources.CPU, 68 | MemoryMB: *t.Resources.MemoryMB, 69 | } 70 | 71 | if taskStates[t.Name] != nil { 72 | task.State = taskStates[t.Name].State 73 | task.Events = taskStates[t.Name].Events 74 | } 75 | 76 | tasks = append(tasks, task) 77 | 78 | } 79 | 80 | return tasks 81 | } 82 | 83 | func (n *Nomad) toAllocs(list []*api.AllocationListStub) ([]*models.Alloc, error) { 84 | result := make([]*models.Alloc, 0, len(list)) 85 | for _, el := range list { 86 | a, _, err := n.AllocClient.Info(el.ID, &api.QueryOptions{ 87 | Namespace: "*", 88 | }) 89 | if err != nil { 90 | return nil, err 91 | } 92 | 93 | tasks := getTasksFromAlloc(el.TaskStates, a) 94 | 95 | alloc := &models.Alloc{ 96 | ID: el.ID, 97 | Namespace: el.Namespace, 98 | TaskGroup: el.TaskGroup, 99 | TaskList: tasks, 100 | JobID: el.JobID, 101 | JobType: el.JobType, 102 | NodeID: el.NodeID, 103 | NodeName: el.NodeName, 104 | DesiredStatus: el.DesiredStatus, 105 | Version: el.JobVersion, 106 | Status: el.ClientStatus, 107 | Created: time.Unix(0, el.CreateTime), 108 | Modified: time.Unix(0, el.ModifyTime), 109 | } 110 | 111 | for _, t := range tasks { 112 | alloc.TaskNames = append(alloc.TaskNames, t.Name) 113 | alloc.Tasks = append(alloc.Tasks, models.AllocTask{ 114 | Name: t.Name, 115 | Events: t.Events, 116 | }) 117 | } 118 | 119 | if a.AllocatedResources != nil { 120 | for _, net := range a.AllocatedResources.Shared.Ports { 121 | alloc.HostAddresses = append( 122 | alloc.HostAddresses, 123 | fmt.Sprintf("%s/%s:%d", net.Label, net.HostIP, net.Value), 124 | ) 125 | } 126 | 127 | } 128 | 129 | result = append(result, alloc) 130 | } 131 | 132 | return result, nil 133 | } 134 | -------------------------------------------------------------------------------- /nomad/client.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) HashiCorp, Inc. 2 | // SPDX-License-Identifier: MPL-2.0 3 | 4 | package nomad 5 | 6 | import ( 7 | "context" 8 | 9 | "github.com/hashicorp/nomad/api" 10 | ) 11 | 12 | //go:generate counterfeiter . Client 13 | type Client interface { 14 | Address() string 15 | } 16 | 17 | //go:generate counterfeiter . JobClient 18 | type JobClient interface { 19 | List(*api.QueryOptions) ([]*api.JobListStub, *api.QueryMeta, error) 20 | Info(string, *api.QueryOptions) (*api.Job, *api.QueryMeta, error) 21 | Summary(string, *api.QueryOptions) (*api.JobSummary, *api.QueryMeta, error) 22 | Allocations(string, bool, *api.QueryOptions) ([]*api.AllocationListStub, *api.QueryMeta, error) 23 | Deregister(jobID string, purge bool, q *api.WriteOptions) (string, *api.WriteMeta, error) 24 | Register(job *api.Job, q *api.WriteOptions) (*api.JobRegisterResponse, *api.WriteMeta, error) 25 | } 26 | 27 | //go:generate counterfeiter . AllocationsClient 28 | type AllocationsClient interface { 29 | List(*api.QueryOptions) ([]*api.AllocationListStub, *api.QueryMeta, error) 30 | Info(string, *api.QueryOptions) (*api.Allocation, *api.QueryMeta, error) 31 | } 32 | 33 | //go:generate counterfeiter . AllocFSClient 34 | type AllocFSClient interface { 35 | Logs(alloc *api.Allocation, follow bool, task string, logType string, origin string, offset int64, cancel <-chan struct{}, q *api.QueryOptions) (<-chan *api.StreamFrame, <-chan error) 36 | } 37 | 38 | //go:generate counterfeiter . NamespaceClient 39 | type NamespaceClient interface { 40 | List(*api.QueryOptions) ([]*api.Namespace, *api.QueryMeta, error) 41 | } 42 | 43 | //go:generate counterfeiter . DeploymentClient 44 | type DeploymentClient interface { 45 | List(*api.QueryOptions) ([]*api.Deployment, *api.QueryMeta, error) 46 | } 47 | 48 | //go:generate counterfeiter . EventsClient 49 | type EventsClient interface { 50 | Stream(ctx context.Context, topics map[api.Topic][]string, index uint64, q *api.QueryOptions) (<-chan *api.Events, error) 51 | } 52 | 53 | type SearchOptions struct { 54 | Namespace string 55 | Region string 56 | DC string 57 | } 58 | 59 | type Nomad struct { 60 | nomad *api.Client 61 | Client Client 62 | EventsClient EventsClient 63 | JobClient JobClient 64 | NsClient NamespaceClient 65 | AllocClient AllocationsClient 66 | AllocFSClient AllocFSClient 67 | DpClient DeploymentClient 68 | } 69 | 70 | func New(opts ...func(*Nomad) error) (*Nomad, error) { 71 | nomad := Nomad{} 72 | for _, opt := range opts { 73 | err := opt(&nomad) 74 | if err != nil { 75 | return nil, err 76 | } 77 | } 78 | 79 | return &nomad, nil 80 | } 81 | 82 | func Default(n *Nomad) error { 83 | client, err := api.NewClient(api.DefaultConfig()) 84 | if err != nil { 85 | return err 86 | } 87 | 88 | n.nomad = client 89 | n.Client = client 90 | n.EventsClient = client.EventStream() 91 | n.JobClient = client.Jobs() 92 | n.NsClient = client.Namespaces() 93 | n.AllocClient = client.Allocations() 94 | n.AllocFSClient = client.AllocFS() 95 | n.DpClient = client.Deployments() 96 | 97 | return nil 98 | } 99 | 100 | func (n *Nomad) Address() string { 101 | return n.Client.Address() 102 | } 103 | -------------------------------------------------------------------------------- /nomad/client_test.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) HashiCorp, Inc. 2 | // SPDX-License-Identifier: MPL-2.0 3 | 4 | package nomad_test 5 | 6 | import ( 7 | "testing" 8 | 9 | "github.com/stretchr/testify/require" 10 | 11 | . "github.com/hcjulz/damon/nomad" 12 | "github.com/hcjulz/damon/nomad/nomadfakes" 13 | ) 14 | 15 | func TestAddress(t *testing.T) { 16 | r := require.New(t) 17 | fakeClient := &nomadfakes.FakeClient{} 18 | nomad := &Nomad{Client: fakeClient} 19 | 20 | fakeClient.AddressReturns("127.0.0.1") 21 | 22 | addr := nomad.Address() 23 | 24 | r.Equal(addr, "127.0.0.1") 25 | } 26 | -------------------------------------------------------------------------------- /nomad/deployments.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) HashiCorp, Inc. 2 | // SPDX-License-Identifier: MPL-2.0 3 | 4 | package nomad 5 | 6 | import ( 7 | "github.com/hashicorp/nomad/api" 8 | 9 | "github.com/hcjulz/damon/models" 10 | ) 11 | 12 | func (n *Nomad) Deployments(so *SearchOptions) ([]*models.Deployment, error) { 13 | if so == nil { 14 | so = &SearchOptions{} 15 | } 16 | 17 | d, _, err := n.DpClient.List(&api.QueryOptions{ 18 | Namespace: so.Namespace, 19 | Region: so.Region, 20 | }) 21 | 22 | deps := toDeployments(d) 23 | 24 | return deps, err 25 | } 26 | 27 | func toDeployments(dep []*api.Deployment) []*models.Deployment { 28 | result := make([]*models.Deployment, 0., len(dep)) 29 | for _, d := range dep { 30 | result = append(result, &models.Deployment{ 31 | ID: d.ID, 32 | JobID: d.JobID, 33 | Namespace: d.Namespace, 34 | Status: d.Status, 35 | StatusDescription: d.StatusDescription, 36 | }) 37 | 38 | } 39 | return result 40 | } 41 | -------------------------------------------------------------------------------- /nomad/deployments_test.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) HashiCorp, Inc. 2 | // SPDX-License-Identifier: MPL-2.0 3 | 4 | package nomad_test 5 | 6 | import ( 7 | "errors" 8 | "testing" 9 | 10 | "github.com/hashicorp/nomad/api" 11 | "github.com/stretchr/testify/require" 12 | 13 | "github.com/hcjulz/damon/models" 14 | "github.com/hcjulz/damon/nomad" 15 | "github.com/hcjulz/damon/nomad/nomadfakes" 16 | ) 17 | 18 | func TestDeployments(t *testing.T) { 19 | r := require.New(t) 20 | 21 | fakeClient := &nomadfakes.FakeDeploymentClient{} 22 | client := nomad.Nomad{DpClient: fakeClient} 23 | 24 | t.Run("When there are no issues", func(t *testing.T) { 25 | expectedDeps := []*models.Deployment{ 26 | { 27 | ID: "42", 28 | JobID: "bumblebee", 29 | Namespace: "transformers", 30 | Status: "transformed", 31 | StatusDescription: "car", 32 | }, 33 | { 34 | ID: "23", 35 | JobID: "optimus", 36 | Namespace: "transformers", 37 | Status: "transformed", 38 | StatusDescription: "truck", 39 | }, 40 | } 41 | 42 | fakeClient.ListReturns([]*api.Deployment{ 43 | { 44 | ID: "42", 45 | JobID: "bumblebee", 46 | Namespace: "transformers", 47 | Status: "transformed", 48 | StatusDescription: "car", 49 | }, 50 | { 51 | ID: "23", 52 | JobID: "optimus", 53 | Namespace: "transformers", 54 | Status: "transformed", 55 | StatusDescription: "truck", 56 | }, 57 | }, &api.QueryMeta{}, nil) 58 | 59 | dps, err := client.Deployments(&nomad.SearchOptions{ 60 | Namespace: "transformers", 61 | }) 62 | r.NoError(err) 63 | 64 | r.Equal(expectedDeps, dps) 65 | }) 66 | 67 | t.Run("When there are no search options provided, it doesn't error", func(t *testing.T) { 68 | fakeClient.ListReturns([]*api.Deployment{}, &api.QueryMeta{}, nil) 69 | _, err := client.Deployments(nil) 70 | r.NoError(err) 71 | }) 72 | 73 | t.Run("When there are issues with the client", func(t *testing.T) { 74 | fakeClient.ListReturns(nil, nil, errors.New("fatal")) 75 | _, err := client.Deployments(&nomad.SearchOptions{ 76 | Namespace: "transformers", 77 | }) 78 | r.Error(err) 79 | r.EqualError(err, "fatal") 80 | }) 81 | } 82 | -------------------------------------------------------------------------------- /nomad/events.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) HashiCorp, Inc. 2 | // SPDX-License-Identifier: MPL-2.0 3 | 4 | package nomad 5 | 6 | import ( 7 | "context" 8 | 9 | "github.com/hashicorp/nomad/api" 10 | ) 11 | 12 | type Topics map[api.Topic][]string 13 | 14 | func (n *Nomad) Stream(topics Topics, index uint64) (<-chan *api.Events, error) { 15 | return n.EventsClient.Stream(context.Background(), topics, index, nil) 16 | } 17 | -------------------------------------------------------------------------------- /nomad/events_test.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) HashiCorp, Inc. 2 | // SPDX-License-Identifier: MPL-2.0 3 | 4 | package nomad_test 5 | 6 | import ( 7 | "errors" 8 | "testing" 9 | 10 | "github.com/hashicorp/nomad/api" 11 | "github.com/stretchr/testify/require" 12 | 13 | "github.com/hcjulz/damon/nomad" 14 | "github.com/hcjulz/damon/nomad/nomadfakes" 15 | ) 16 | 17 | func TestStream(t *testing.T) { 18 | r := require.New(t) 19 | 20 | client := &nomadfakes.FakeEventsClient{} 21 | nmd := &nomad.Nomad{EventsClient: client} 22 | 23 | topics := map[api.Topic][]string{ 24 | "Job": {"*"}, 25 | } 26 | 27 | t.Run("It provides the correct params", func(t *testing.T) { 28 | topics := map[api.Topic][]string{ 29 | "Job": {"*"}, 30 | } 31 | 32 | _, err := nmd.Stream(topics, 0) 33 | r.NoError(err) 34 | 35 | }) 36 | 37 | t.Run("It returns a channel and an error", func(t *testing.T) { 38 | streamChan := make(<-chan *api.Events) 39 | err := errors.New("haha") 40 | 41 | client.StreamReturns(streamChan, err) 42 | actualStreamChan, actualError := nmd.Stream(topics, 0) 43 | 44 | r.Equal(actualStreamChan, streamChan) 45 | r.Equal(actualError, err) 46 | }) 47 | } 48 | -------------------------------------------------------------------------------- /nomad/job_status.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) HashiCorp, Inc. 2 | // SPDX-License-Identifier: MPL-2.0 3 | 4 | package nomad 5 | 6 | import ( 7 | "fmt" 8 | "strings" 9 | "time" 10 | 11 | "github.com/hashicorp/nomad/api" 12 | 13 | "github.com/hcjulz/damon/models" 14 | ) 15 | 16 | func (n *Nomad) JobStatus(jobID string, so *SearchOptions) (*models.JobStatus, error) { 17 | if so == nil { 18 | so = &SearchOptions{} 19 | } 20 | 21 | taskgroups, _ := n.TaskGroups(jobID, so) 22 | 23 | info, err := n.GetJob(jobID) 24 | 25 | if err != nil { 26 | return nil, fmt.Errorf("failed to retrieve job info: %w", err) 27 | } 28 | 29 | d, _, err := n.DpClient.List(&api.QueryOptions{ 30 | Namespace: so.Namespace, 31 | Region: so.Region, 32 | }) 33 | 34 | if err != nil { 35 | return nil, fmt.Errorf("failed to retrieve job deployments: %w", err) 36 | } 37 | 38 | taskGroupStatus := toTaskGroupStatus(jobID, d) 39 | 40 | allocations, _ := n.JobAllocs(jobID, so) 41 | 42 | if err != nil { 43 | return nil, err 44 | } 45 | 46 | jobStatus := toJobStatus(info, taskgroups, taskGroupStatus, allocations) 47 | 48 | return jobStatus, nil 49 | } 50 | 51 | func toJobStatus(job *api.Job, tasks []*models.TaskGroup, taskStatus []*models.TaskGroupStatus, allocs []*models.Alloc) *models.JobStatus { 52 | jobStatus := &models.JobStatus{ 53 | ID: *job.ID, 54 | Name: *job.Name, 55 | SubmitDate: time.Unix(0, *job.SubmitTime), 56 | Type: *job.Type, 57 | Priority: *job.Priority, 58 | Datacenters: strings.Join(job.Datacenters, ", "), 59 | Namespace: *job.Namespace, 60 | Status: *job.Status, 61 | Periodic: job.Periodic != nil, 62 | Parameterized: job.ParameterizedJob != nil, 63 | TaskGroups: tasks, 64 | TaskGroupStatus: taskStatus, 65 | Allocations: allocs, 66 | } 67 | 68 | return jobStatus 69 | } 70 | 71 | func toTaskGroupStatus(jobID string, dep []*api.Deployment) []*models.TaskGroupStatus { 72 | result := make([]*models.TaskGroupStatus, 0., len(dep)) 73 | for _, d := range dep { 74 | if d.JobID == jobID { 75 | for _, t := range d.TaskGroups { 76 | result = append(result, &models.TaskGroupStatus{ 77 | ID: d.JobID, 78 | Status: d.Status, 79 | StatusDescription: d.StatusDescription, 80 | Desired: t.DesiredTotal, 81 | Placed: t.PlacedAllocs, 82 | Healthy: t.HealthyAllocs, 83 | Unhealthy: t.UnhealthyAllocs, 84 | ProgressDeadline: t.ProgressDeadline, 85 | }) 86 | } 87 | break // * Deployments are already sorted. So break once you find your's. 88 | } 89 | 90 | } 91 | return result 92 | } 93 | -------------------------------------------------------------------------------- /nomad/job_status_test.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) HashiCorp, Inc. 2 | // SPDX-License-Identifier: MPL-2.0 3 | 4 | package nomad_test 5 | 6 | import ( 7 | "errors" 8 | "testing" 9 | "time" 10 | 11 | "github.com/hashicorp/nomad/api" 12 | "github.com/hcjulz/damon/models" 13 | "github.com/hcjulz/damon/nomad" 14 | "github.com/hcjulz/damon/nomad/nomadfakes" 15 | "github.com/stretchr/testify/require" 16 | ) 17 | 18 | func TestJobStatus(t *testing.T) { 19 | r := require.New(t) 20 | 21 | fakeJobClient := &nomadfakes.FakeJobClient{} 22 | fakeDpClient := &nomadfakes.FakeDeploymentClient{} 23 | client := &nomad.Nomad{JobClient: fakeJobClient, DpClient: fakeDpClient} 24 | 25 | t.Run("When there are no issues", func(t *testing.T) { 26 | now := time.Now().UnixNano() 27 | nowUnix := time.Unix(0, now) 28 | 29 | id := "fakeID" 30 | name := "fakeName" 31 | fakeType := "fakeType" 32 | status := "fakestatus" 33 | value := 100 34 | ns := "ns" 35 | fakeTaskGroup := "fakeTaskGroup" 36 | 37 | fakeJobClient.InfoReturns(&api.Job{ 38 | ID: &id, 39 | Name: &name, 40 | Type: &fakeType, 41 | Priority: &value, 42 | Status: &status, 43 | SubmitTime: &now, 44 | Namespace: &ns, 45 | TaskGroups: []*api.TaskGroup{ 46 | { 47 | Name: &fakeTaskGroup, 48 | }, 49 | }, 50 | }, nil, nil) 51 | 52 | fakeJobClient.SummaryReturns(&api.JobSummary{ 53 | JobID: "fakejobId", 54 | Summary: map[string]api.TaskGroupSummary{ 55 | "Mandalorian": { 56 | Running: 1, 57 | Queued: 1, 58 | }, 59 | "Grogu": { 60 | Failed: 1, 61 | Queued: 1, 62 | }, 63 | }, 64 | }, nil, nil) 65 | 66 | fakeDpClient.ListReturns([]*api.Deployment{ 67 | { 68 | JobID: "fakeID", 69 | TaskGroups: map[string]*api.DeploymentState{ 70 | "dp1": { 71 | HealthyAllocs: 1, 72 | UnhealthyAllocs: 0, 73 | }, 74 | }, 75 | }, 76 | }, nil, nil) 77 | 78 | so := nomad.SearchOptions{Namespace: "default"} 79 | 80 | jobStatus, err := client.JobStatus("fakeID", &so) 81 | 82 | jId, queryOptions := fakeJobClient.InfoArgsForCall(0) 83 | 84 | expectedJobStatus := &models.JobStatus{ 85 | ID: id, 86 | Name: name, 87 | Type: fakeType, 88 | Status: status, 89 | SubmitDate: nowUnix, 90 | Namespace: ns, 91 | Priority: value, 92 | TaskGroups: []*models.TaskGroup{ 93 | { 94 | Name: "Grogu", 95 | JobID: "fakejobId", 96 | Queued: 1, 97 | Complete: 0, 98 | Failed: 1, 99 | Running: 0, 100 | Starting: 0, 101 | Lost: 0, 102 | }, 103 | { 104 | Name: "Mandalorian", 105 | JobID: "fakejobId", 106 | Queued: 1, 107 | Complete: 0, 108 | Failed: 0, 109 | Running: 1, 110 | Starting: 0, 111 | Lost: 0, 112 | }, 113 | }, 114 | Allocations: []*models.Alloc{}, 115 | TaskGroupStatus: []*models.TaskGroupStatus{ 116 | { 117 | ID: "fakeID", 118 | Healthy: 1, 119 | Unhealthy: 0, 120 | Desired: 0, 121 | Placed: 0, 122 | ProgressDeadline: 0, 123 | Status: "", 124 | StatusDescription: "", 125 | }, 126 | }, 127 | } 128 | 129 | //check that no error occured 130 | r.NoError(err) 131 | 132 | r.Equal(jId, "fakeID") 133 | 134 | //check that List() was called once 135 | r.Equal(fakeJobClient.InfoCallCount(), 1) 136 | 137 | //check that the query params where passed correctly 138 | r.Nil(queryOptions) 139 | 140 | //check all fields are set 141 | r.Equal(expectedJobStatus, jobStatus) 142 | }) 143 | 144 | t.Run("When there is a problem with the client", func(t *testing.T) { 145 | fakeJobClient.InfoReturns(nil, nil, errors.New("aaah")) 146 | _, err := client.JobStatus("", nil) 147 | 148 | r.Error(err) 149 | r.Contains(err.Error(), "failed to retrieve job info") 150 | }) 151 | } 152 | -------------------------------------------------------------------------------- /nomad/jobs.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) HashiCorp, Inc. 2 | // SPDX-License-Identifier: MPL-2.0 3 | 4 | package nomad 5 | 6 | import ( 7 | "fmt" 8 | "time" 9 | 10 | "github.com/hashicorp/nomad/api" 11 | 12 | "github.com/hcjulz/damon/models" 13 | ) 14 | 15 | func (n *Nomad) Jobs(so *SearchOptions) ([]*models.Job, error) { 16 | if so == nil { 17 | so = &SearchOptions{} 18 | } 19 | 20 | jobList, _, err := n.JobClient.List(&api.QueryOptions{ 21 | Namespace: so.Namespace, 22 | Region: so.Region, 23 | }) 24 | if err != nil { 25 | return nil, fmt.Errorf("failed to retrieve job list: %w", err) 26 | } 27 | 28 | var jobs []*models.Job 29 | for _, j := range jobList { 30 | job := toJob(j) 31 | jobs = append(jobs, job) 32 | } 33 | 34 | return jobs, err 35 | } 36 | 37 | func toJob(j *api.JobListStub) *models.Job { 38 | t := time.Unix(0, j.SubmitTime) 39 | 40 | total := len(j.JobSummary.Summary) 41 | summary := models.Summary{ 42 | Total: total, 43 | } 44 | 45 | for _, job := range j.JobSummary.Summary { 46 | if job.Running > 0 { 47 | summary.Running++ 48 | } 49 | } 50 | 51 | return &models.Job{ 52 | ID: j.ID, 53 | Name: j.Name, 54 | Namespace: j.JobSummary.Namespace, 55 | Type: j.Type, 56 | Status: j.Status, 57 | StatusDescription: j.StatusDescription, 58 | StatusSummary: summary, 59 | SubmitTime: t, 60 | } 61 | } 62 | 63 | func (n *Nomad) GetJob(jobID string) (*api.Job, error) { 64 | job, _, err := n.JobClient.Info(jobID, nil) 65 | return job, err 66 | } 67 | 68 | func (n *Nomad) StartJob(job *api.Job) error { 69 | stop := false 70 | job.Stop = &stop 71 | 72 | _, _, err := n.JobClient.Register(job, nil) 73 | return err 74 | } 75 | 76 | func (n *Nomad) StopJob(jobID string) error { 77 | _, _, err := n.JobClient.Deregister(jobID, false, nil) 78 | return err 79 | } 80 | -------------------------------------------------------------------------------- /nomad/logs.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) HashiCorp, Inc. 2 | // SPDX-License-Identifier: MPL-2.0 3 | 4 | package nomad 5 | 6 | import "github.com/hashicorp/nomad/api" 7 | 8 | //TODO fix bug with task name 9 | func (n *Nomad) Logs(allocID, taskName, logType string, cancel <-chan struct{}) (<-chan *api.StreamFrame, <-chan error) { 10 | return n.AllocFSClient.Logs( 11 | &api.Allocation{ID: allocID}, 12 | true, 13 | taskName, 14 | logType, 15 | "end", 16 | 20000, 17 | cancel, 18 | &api.QueryOptions{}, 19 | ) 20 | } 21 | -------------------------------------------------------------------------------- /nomad/logs_test.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) HashiCorp, Inc. 2 | // SPDX-License-Identifier: MPL-2.0 3 | 4 | package nomad_test 5 | 6 | import ( 7 | "testing" 8 | 9 | "github.com/hashicorp/nomad/api" 10 | "github.com/stretchr/testify/require" 11 | 12 | "github.com/hcjulz/damon/nomad" 13 | "github.com/hcjulz/damon/nomad/nomadfakes" 14 | ) 15 | 16 | func TestLogs(t *testing.T) { 17 | r := require.New(t) 18 | 19 | fakeFSClient := &nomadfakes.FakeAllocFSClient{} 20 | client := &nomad.Nomad{AllocFSClient: fakeFSClient} 21 | 22 | t.Run("It provides the correct params", func(t *testing.T) { 23 | allocID := "moon" 24 | taskName := "solar-system" 25 | logType := "stderr" 26 | cancelCh := make(<-chan struct{}) 27 | 28 | client.Logs(allocID, taskName, logType, cancelCh) 29 | 30 | alloc, 31 | doFollow, 32 | actualTaskName, 33 | actualLogType, 34 | origin, offset, 35 | actualCancelCh, 36 | queryOptions := fakeFSClient.LogsArgsForCall(0) 37 | 38 | r.Equal(alloc, &api.Allocation{ID: "moon"}) 39 | r.Equal(doFollow, true) 40 | r.Equal(actualTaskName, "solar-system") 41 | r.Equal(actualLogType, "stderr") 42 | r.Equal(origin, "end") 43 | r.Equal(offset, int64(20000)) 44 | r.Equal(actualCancelCh, cancelCh) 45 | r.Equal(queryOptions, &api.QueryOptions{}) 46 | }) 47 | 48 | t.Run("It returns two channels", func(t *testing.T) { 49 | allocID := "moon" 50 | taskName := "solar-system" 51 | logType := "stderr" 52 | cancelCh := make(<-chan struct{}) 53 | 54 | streamChan := make(<-chan *api.StreamFrame) 55 | errorChan := make(<-chan error) 56 | 57 | fakeFSClient.LogsReturns(streamChan, errorChan) 58 | actualStreamChan, actualErrorChan := client.Logs(allocID, taskName, logType, cancelCh) 59 | 60 | r.Equal(actualStreamChan, streamChan) 61 | r.Equal(actualErrorChan, errorChan) 62 | }) 63 | } 64 | -------------------------------------------------------------------------------- /nomad/namespaces.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) HashiCorp, Inc. 2 | // SPDX-License-Identifier: MPL-2.0 3 | 4 | package nomad 5 | 6 | import ( 7 | "github.com/hashicorp/nomad/api" 8 | 9 | "github.com/hcjulz/damon/models" 10 | ) 11 | 12 | func (n *Nomad) Namespaces(_ *SearchOptions) ([]*models.Namespace, error) { 13 | ns, _, err := n.NsClient.List(nil) 14 | if err != nil { 15 | return nil, err 16 | } 17 | 18 | namespaces := []*models.Namespace{} 19 | for _, s := range ns { 20 | namespaces = append(namespaces, toNamespace(s)) 21 | } 22 | 23 | return namespaces, nil 24 | } 25 | 26 | func toNamespace(j *api.Namespace) *models.Namespace { 27 | return &models.Namespace{ 28 | Name: j.Name, 29 | Description: j.Description, 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /nomad/namespaces_test.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) HashiCorp, Inc. 2 | // SPDX-License-Identifier: MPL-2.0 3 | 4 | package nomad_test 5 | 6 | import ( 7 | "errors" 8 | "testing" 9 | 10 | "github.com/hashicorp/nomad/api" 11 | "github.com/stretchr/testify/require" 12 | 13 | . "github.com/hcjulz/damon/nomad" 14 | "github.com/hcjulz/damon/nomad/nomadfakes" 15 | ) 16 | 17 | func TestNamespaces(t *testing.T) { 18 | r := require.New(t) 19 | fakeNsClient := &nomadfakes.FakeNamespaceClient{} 20 | nomad := &Nomad{NsClient: fakeNsClient} 21 | 22 | t.Run("When everything is fine", func(t *testing.T) { 23 | fakeNsClient.ListReturns([]*api.Namespace{ 24 | { 25 | Name: "default", 26 | Description: "the default namespace", 27 | }, 28 | { 29 | Name: "test", 30 | Description: "the test namespace", 31 | }, 32 | }, nil, nil) 33 | 34 | ns, err := nomad.Namespaces(nil) 35 | 36 | r.NoError(err) 37 | r.Len(ns, 2) 38 | 39 | r.Equal(ns[0].Name, "default") 40 | r.Equal(ns[0].Description, "the default namespace") 41 | 42 | r.Equal(ns[1].Name, "test") 43 | r.Equal(ns[1].Description, "the test namespace") 44 | }) 45 | 46 | t.Run("When everything is fine", func(t *testing.T) { 47 | fakeNsClient.ListReturns(nil, nil, errors.New("fail!")) 48 | 49 | ns, err := nomad.Namespaces(nil) 50 | 51 | r.Nil(ns) 52 | r.Error(err) 53 | r.EqualError(err, "fail!") 54 | }) 55 | } 56 | -------------------------------------------------------------------------------- /nomad/nomadfakes/fake_client.go: -------------------------------------------------------------------------------- 1 | // Code generated by counterfeiter. DO NOT EDIT. 2 | package nomadfakes 3 | 4 | import ( 5 | "sync" 6 | 7 | "github.com/hcjulz/damon/nomad" 8 | ) 9 | 10 | type FakeClient struct { 11 | AddressStub func() string 12 | addressMutex sync.RWMutex 13 | addressArgsForCall []struct { 14 | } 15 | addressReturns struct { 16 | result1 string 17 | } 18 | addressReturnsOnCall map[int]struct { 19 | result1 string 20 | } 21 | invocations map[string][][]interface{} 22 | invocationsMutex sync.RWMutex 23 | } 24 | 25 | func (fake *FakeClient) Address() string { 26 | fake.addressMutex.Lock() 27 | ret, specificReturn := fake.addressReturnsOnCall[len(fake.addressArgsForCall)] 28 | fake.addressArgsForCall = append(fake.addressArgsForCall, struct { 29 | }{}) 30 | stub := fake.AddressStub 31 | fakeReturns := fake.addressReturns 32 | fake.recordInvocation("Address", []interface{}{}) 33 | fake.addressMutex.Unlock() 34 | if stub != nil { 35 | return stub() 36 | } 37 | if specificReturn { 38 | return ret.result1 39 | } 40 | return fakeReturns.result1 41 | } 42 | 43 | func (fake *FakeClient) AddressCallCount() int { 44 | fake.addressMutex.RLock() 45 | defer fake.addressMutex.RUnlock() 46 | return len(fake.addressArgsForCall) 47 | } 48 | 49 | func (fake *FakeClient) AddressCalls(stub func() string) { 50 | fake.addressMutex.Lock() 51 | defer fake.addressMutex.Unlock() 52 | fake.AddressStub = stub 53 | } 54 | 55 | func (fake *FakeClient) AddressReturns(result1 string) { 56 | fake.addressMutex.Lock() 57 | defer fake.addressMutex.Unlock() 58 | fake.AddressStub = nil 59 | fake.addressReturns = struct { 60 | result1 string 61 | }{result1} 62 | } 63 | 64 | func (fake *FakeClient) AddressReturnsOnCall(i int, result1 string) { 65 | fake.addressMutex.Lock() 66 | defer fake.addressMutex.Unlock() 67 | fake.AddressStub = nil 68 | if fake.addressReturnsOnCall == nil { 69 | fake.addressReturnsOnCall = make(map[int]struct { 70 | result1 string 71 | }) 72 | } 73 | fake.addressReturnsOnCall[i] = struct { 74 | result1 string 75 | }{result1} 76 | } 77 | 78 | func (fake *FakeClient) Invocations() map[string][][]interface{} { 79 | fake.invocationsMutex.RLock() 80 | defer fake.invocationsMutex.RUnlock() 81 | fake.addressMutex.RLock() 82 | defer fake.addressMutex.RUnlock() 83 | copiedInvocations := map[string][][]interface{}{} 84 | for key, value := range fake.invocations { 85 | copiedInvocations[key] = value 86 | } 87 | return copiedInvocations 88 | } 89 | 90 | func (fake *FakeClient) recordInvocation(key string, args []interface{}) { 91 | fake.invocationsMutex.Lock() 92 | defer fake.invocationsMutex.Unlock() 93 | if fake.invocations == nil { 94 | fake.invocations = map[string][][]interface{}{} 95 | } 96 | if fake.invocations[key] == nil { 97 | fake.invocations[key] = [][]interface{}{} 98 | } 99 | fake.invocations[key] = append(fake.invocations[key], args) 100 | } 101 | 102 | var _ nomad.Client = new(FakeClient) 103 | -------------------------------------------------------------------------------- /nomad/nomadfakes/fake_deployment_client.go: -------------------------------------------------------------------------------- 1 | // Code generated by counterfeiter. DO NOT EDIT. 2 | package nomadfakes 3 | 4 | import ( 5 | "sync" 6 | 7 | "github.com/hashicorp/nomad/api" 8 | "github.com/hcjulz/damon/nomad" 9 | ) 10 | 11 | type FakeDeploymentClient struct { 12 | ListStub func(*api.QueryOptions) ([]*api.Deployment, *api.QueryMeta, error) 13 | listMutex sync.RWMutex 14 | listArgsForCall []struct { 15 | arg1 *api.QueryOptions 16 | } 17 | listReturns struct { 18 | result1 []*api.Deployment 19 | result2 *api.QueryMeta 20 | result3 error 21 | } 22 | listReturnsOnCall map[int]struct { 23 | result1 []*api.Deployment 24 | result2 *api.QueryMeta 25 | result3 error 26 | } 27 | invocations map[string][][]interface{} 28 | invocationsMutex sync.RWMutex 29 | } 30 | 31 | func (fake *FakeDeploymentClient) List(arg1 *api.QueryOptions) ([]*api.Deployment, *api.QueryMeta, error) { 32 | fake.listMutex.Lock() 33 | ret, specificReturn := fake.listReturnsOnCall[len(fake.listArgsForCall)] 34 | fake.listArgsForCall = append(fake.listArgsForCall, struct { 35 | arg1 *api.QueryOptions 36 | }{arg1}) 37 | stub := fake.ListStub 38 | fakeReturns := fake.listReturns 39 | fake.recordInvocation("List", []interface{}{arg1}) 40 | fake.listMutex.Unlock() 41 | if stub != nil { 42 | return stub(arg1) 43 | } 44 | if specificReturn { 45 | return ret.result1, ret.result2, ret.result3 46 | } 47 | return fakeReturns.result1, fakeReturns.result2, fakeReturns.result3 48 | } 49 | 50 | func (fake *FakeDeploymentClient) ListCallCount() int { 51 | fake.listMutex.RLock() 52 | defer fake.listMutex.RUnlock() 53 | return len(fake.listArgsForCall) 54 | } 55 | 56 | func (fake *FakeDeploymentClient) ListCalls(stub func(*api.QueryOptions) ([]*api.Deployment, *api.QueryMeta, error)) { 57 | fake.listMutex.Lock() 58 | defer fake.listMutex.Unlock() 59 | fake.ListStub = stub 60 | } 61 | 62 | func (fake *FakeDeploymentClient) ListArgsForCall(i int) *api.QueryOptions { 63 | fake.listMutex.RLock() 64 | defer fake.listMutex.RUnlock() 65 | argsForCall := fake.listArgsForCall[i] 66 | return argsForCall.arg1 67 | } 68 | 69 | func (fake *FakeDeploymentClient) ListReturns(result1 []*api.Deployment, result2 *api.QueryMeta, result3 error) { 70 | fake.listMutex.Lock() 71 | defer fake.listMutex.Unlock() 72 | fake.ListStub = nil 73 | fake.listReturns = struct { 74 | result1 []*api.Deployment 75 | result2 *api.QueryMeta 76 | result3 error 77 | }{result1, result2, result3} 78 | } 79 | 80 | func (fake *FakeDeploymentClient) ListReturnsOnCall(i int, result1 []*api.Deployment, result2 *api.QueryMeta, result3 error) { 81 | fake.listMutex.Lock() 82 | defer fake.listMutex.Unlock() 83 | fake.ListStub = nil 84 | if fake.listReturnsOnCall == nil { 85 | fake.listReturnsOnCall = make(map[int]struct { 86 | result1 []*api.Deployment 87 | result2 *api.QueryMeta 88 | result3 error 89 | }) 90 | } 91 | fake.listReturnsOnCall[i] = struct { 92 | result1 []*api.Deployment 93 | result2 *api.QueryMeta 94 | result3 error 95 | }{result1, result2, result3} 96 | } 97 | 98 | func (fake *FakeDeploymentClient) Invocations() map[string][][]interface{} { 99 | fake.invocationsMutex.RLock() 100 | defer fake.invocationsMutex.RUnlock() 101 | fake.listMutex.RLock() 102 | defer fake.listMutex.RUnlock() 103 | copiedInvocations := map[string][][]interface{}{} 104 | for key, value := range fake.invocations { 105 | copiedInvocations[key] = value 106 | } 107 | return copiedInvocations 108 | } 109 | 110 | func (fake *FakeDeploymentClient) recordInvocation(key string, args []interface{}) { 111 | fake.invocationsMutex.Lock() 112 | defer fake.invocationsMutex.Unlock() 113 | if fake.invocations == nil { 114 | fake.invocations = map[string][][]interface{}{} 115 | } 116 | if fake.invocations[key] == nil { 117 | fake.invocations[key] = [][]interface{}{} 118 | } 119 | fake.invocations[key] = append(fake.invocations[key], args) 120 | } 121 | 122 | var _ nomad.DeploymentClient = new(FakeDeploymentClient) 123 | -------------------------------------------------------------------------------- /nomad/nomadfakes/fake_events_client.go: -------------------------------------------------------------------------------- 1 | // Code generated by counterfeiter. DO NOT EDIT. 2 | package nomadfakes 3 | 4 | import ( 5 | "context" 6 | "sync" 7 | 8 | "github.com/hashicorp/nomad/api" 9 | "github.com/hcjulz/damon/nomad" 10 | ) 11 | 12 | type FakeEventsClient struct { 13 | StreamStub func(context.Context, map[api.Topic][]string, uint64, *api.QueryOptions) (<-chan *api.Events, error) 14 | streamMutex sync.RWMutex 15 | streamArgsForCall []struct { 16 | arg1 context.Context 17 | arg2 map[api.Topic][]string 18 | arg3 uint64 19 | arg4 *api.QueryOptions 20 | } 21 | streamReturns struct { 22 | result1 <-chan *api.Events 23 | result2 error 24 | } 25 | streamReturnsOnCall map[int]struct { 26 | result1 <-chan *api.Events 27 | result2 error 28 | } 29 | invocations map[string][][]interface{} 30 | invocationsMutex sync.RWMutex 31 | } 32 | 33 | func (fake *FakeEventsClient) Stream(arg1 context.Context, arg2 map[api.Topic][]string, arg3 uint64, arg4 *api.QueryOptions) (<-chan *api.Events, error) { 34 | fake.streamMutex.Lock() 35 | ret, specificReturn := fake.streamReturnsOnCall[len(fake.streamArgsForCall)] 36 | fake.streamArgsForCall = append(fake.streamArgsForCall, struct { 37 | arg1 context.Context 38 | arg2 map[api.Topic][]string 39 | arg3 uint64 40 | arg4 *api.QueryOptions 41 | }{arg1, arg2, arg3, arg4}) 42 | stub := fake.StreamStub 43 | fakeReturns := fake.streamReturns 44 | fake.recordInvocation("Stream", []interface{}{arg1, arg2, arg3, arg4}) 45 | fake.streamMutex.Unlock() 46 | if stub != nil { 47 | return stub(arg1, arg2, arg3, arg4) 48 | } 49 | if specificReturn { 50 | return ret.result1, ret.result2 51 | } 52 | return fakeReturns.result1, fakeReturns.result2 53 | } 54 | 55 | func (fake *FakeEventsClient) StreamCallCount() int { 56 | fake.streamMutex.RLock() 57 | defer fake.streamMutex.RUnlock() 58 | return len(fake.streamArgsForCall) 59 | } 60 | 61 | func (fake *FakeEventsClient) StreamCalls(stub func(context.Context, map[api.Topic][]string, uint64, *api.QueryOptions) (<-chan *api.Events, error)) { 62 | fake.streamMutex.Lock() 63 | defer fake.streamMutex.Unlock() 64 | fake.StreamStub = stub 65 | } 66 | 67 | func (fake *FakeEventsClient) StreamArgsForCall(i int) (context.Context, map[api.Topic][]string, uint64, *api.QueryOptions) { 68 | fake.streamMutex.RLock() 69 | defer fake.streamMutex.RUnlock() 70 | argsForCall := fake.streamArgsForCall[i] 71 | return argsForCall.arg1, argsForCall.arg2, argsForCall.arg3, argsForCall.arg4 72 | } 73 | 74 | func (fake *FakeEventsClient) StreamReturns(result1 <-chan *api.Events, result2 error) { 75 | fake.streamMutex.Lock() 76 | defer fake.streamMutex.Unlock() 77 | fake.StreamStub = nil 78 | fake.streamReturns = struct { 79 | result1 <-chan *api.Events 80 | result2 error 81 | }{result1, result2} 82 | } 83 | 84 | func (fake *FakeEventsClient) StreamReturnsOnCall(i int, result1 <-chan *api.Events, result2 error) { 85 | fake.streamMutex.Lock() 86 | defer fake.streamMutex.Unlock() 87 | fake.StreamStub = nil 88 | if fake.streamReturnsOnCall == nil { 89 | fake.streamReturnsOnCall = make(map[int]struct { 90 | result1 <-chan *api.Events 91 | result2 error 92 | }) 93 | } 94 | fake.streamReturnsOnCall[i] = struct { 95 | result1 <-chan *api.Events 96 | result2 error 97 | }{result1, result2} 98 | } 99 | 100 | func (fake *FakeEventsClient) Invocations() map[string][][]interface{} { 101 | fake.invocationsMutex.RLock() 102 | defer fake.invocationsMutex.RUnlock() 103 | fake.streamMutex.RLock() 104 | defer fake.streamMutex.RUnlock() 105 | copiedInvocations := map[string][][]interface{}{} 106 | for key, value := range fake.invocations { 107 | copiedInvocations[key] = value 108 | } 109 | return copiedInvocations 110 | } 111 | 112 | func (fake *FakeEventsClient) recordInvocation(key string, args []interface{}) { 113 | fake.invocationsMutex.Lock() 114 | defer fake.invocationsMutex.Unlock() 115 | if fake.invocations == nil { 116 | fake.invocations = map[string][][]interface{}{} 117 | } 118 | if fake.invocations[key] == nil { 119 | fake.invocations[key] = [][]interface{}{} 120 | } 121 | fake.invocations[key] = append(fake.invocations[key], args) 122 | } 123 | 124 | var _ nomad.EventsClient = new(FakeEventsClient) 125 | -------------------------------------------------------------------------------- /nomad/nomadfakes/fake_namespace_client.go: -------------------------------------------------------------------------------- 1 | // Code generated by counterfeiter. DO NOT EDIT. 2 | package nomadfakes 3 | 4 | import ( 5 | "sync" 6 | 7 | "github.com/hashicorp/nomad/api" 8 | "github.com/hcjulz/damon/nomad" 9 | ) 10 | 11 | type FakeNamespaceClient struct { 12 | ListStub func(*api.QueryOptions) ([]*api.Namespace, *api.QueryMeta, error) 13 | listMutex sync.RWMutex 14 | listArgsForCall []struct { 15 | arg1 *api.QueryOptions 16 | } 17 | listReturns struct { 18 | result1 []*api.Namespace 19 | result2 *api.QueryMeta 20 | result3 error 21 | } 22 | listReturnsOnCall map[int]struct { 23 | result1 []*api.Namespace 24 | result2 *api.QueryMeta 25 | result3 error 26 | } 27 | invocations map[string][][]interface{} 28 | invocationsMutex sync.RWMutex 29 | } 30 | 31 | func (fake *FakeNamespaceClient) List(arg1 *api.QueryOptions) ([]*api.Namespace, *api.QueryMeta, error) { 32 | fake.listMutex.Lock() 33 | ret, specificReturn := fake.listReturnsOnCall[len(fake.listArgsForCall)] 34 | fake.listArgsForCall = append(fake.listArgsForCall, struct { 35 | arg1 *api.QueryOptions 36 | }{arg1}) 37 | stub := fake.ListStub 38 | fakeReturns := fake.listReturns 39 | fake.recordInvocation("List", []interface{}{arg1}) 40 | fake.listMutex.Unlock() 41 | if stub != nil { 42 | return stub(arg1) 43 | } 44 | if specificReturn { 45 | return ret.result1, ret.result2, ret.result3 46 | } 47 | return fakeReturns.result1, fakeReturns.result2, fakeReturns.result3 48 | } 49 | 50 | func (fake *FakeNamespaceClient) ListCallCount() int { 51 | fake.listMutex.RLock() 52 | defer fake.listMutex.RUnlock() 53 | return len(fake.listArgsForCall) 54 | } 55 | 56 | func (fake *FakeNamespaceClient) ListCalls(stub func(*api.QueryOptions) ([]*api.Namespace, *api.QueryMeta, error)) { 57 | fake.listMutex.Lock() 58 | defer fake.listMutex.Unlock() 59 | fake.ListStub = stub 60 | } 61 | 62 | func (fake *FakeNamespaceClient) ListArgsForCall(i int) *api.QueryOptions { 63 | fake.listMutex.RLock() 64 | defer fake.listMutex.RUnlock() 65 | argsForCall := fake.listArgsForCall[i] 66 | return argsForCall.arg1 67 | } 68 | 69 | func (fake *FakeNamespaceClient) ListReturns(result1 []*api.Namespace, result2 *api.QueryMeta, result3 error) { 70 | fake.listMutex.Lock() 71 | defer fake.listMutex.Unlock() 72 | fake.ListStub = nil 73 | fake.listReturns = struct { 74 | result1 []*api.Namespace 75 | result2 *api.QueryMeta 76 | result3 error 77 | }{result1, result2, result3} 78 | } 79 | 80 | func (fake *FakeNamespaceClient) ListReturnsOnCall(i int, result1 []*api.Namespace, result2 *api.QueryMeta, result3 error) { 81 | fake.listMutex.Lock() 82 | defer fake.listMutex.Unlock() 83 | fake.ListStub = nil 84 | if fake.listReturnsOnCall == nil { 85 | fake.listReturnsOnCall = make(map[int]struct { 86 | result1 []*api.Namespace 87 | result2 *api.QueryMeta 88 | result3 error 89 | }) 90 | } 91 | fake.listReturnsOnCall[i] = struct { 92 | result1 []*api.Namespace 93 | result2 *api.QueryMeta 94 | result3 error 95 | }{result1, result2, result3} 96 | } 97 | 98 | func (fake *FakeNamespaceClient) Invocations() map[string][][]interface{} { 99 | fake.invocationsMutex.RLock() 100 | defer fake.invocationsMutex.RUnlock() 101 | fake.listMutex.RLock() 102 | defer fake.listMutex.RUnlock() 103 | copiedInvocations := map[string][][]interface{}{} 104 | for key, value := range fake.invocations { 105 | copiedInvocations[key] = value 106 | } 107 | return copiedInvocations 108 | } 109 | 110 | func (fake *FakeNamespaceClient) recordInvocation(key string, args []interface{}) { 111 | fake.invocationsMutex.Lock() 112 | defer fake.invocationsMutex.Unlock() 113 | if fake.invocations == nil { 114 | fake.invocations = map[string][][]interface{}{} 115 | } 116 | if fake.invocations[key] == nil { 117 | fake.invocations[key] = [][]interface{}{} 118 | } 119 | fake.invocations[key] = append(fake.invocations[key], args) 120 | } 121 | 122 | var _ nomad.NamespaceClient = new(FakeNamespaceClient) 123 | -------------------------------------------------------------------------------- /nomad/taskgroups.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) HashiCorp, Inc. 2 | // SPDX-License-Identifier: MPL-2.0 3 | 4 | package nomad 5 | 6 | import ( 7 | "sort" 8 | 9 | "github.com/hashicorp/nomad/api" 10 | 11 | "github.com/hcjulz/damon/models" 12 | ) 13 | 14 | func (n *Nomad) TaskGroups(jobID string, so *SearchOptions) ([]*models.TaskGroup, error) { 15 | if so == nil { 16 | so = &SearchOptions{} 17 | } 18 | 19 | summary, _, err := n.JobClient.Summary(jobID, &api.QueryOptions{ 20 | Namespace: so.Namespace, 21 | Region: so.Region, 22 | }) 23 | 24 | taskGroups := toTaskGroups(summary) 25 | 26 | return taskGroups, err 27 | } 28 | 29 | func toTaskGroups(js *api.JobSummary) []*models.TaskGroup { 30 | if js == nil { 31 | return nil 32 | } 33 | 34 | keys := sortTaskGroupSummaryByKey(js.Summary) 35 | 36 | var result []*models.TaskGroup 37 | for _, key := range keys { 38 | tgs := js.Summary[key] 39 | taskGroup := &models.TaskGroup{ 40 | Name: key, 41 | JobID: js.JobID, 42 | Queued: tgs.Queued, 43 | Complete: tgs.Complete, 44 | Failed: tgs.Failed, 45 | Running: tgs.Running, 46 | Starting: tgs.Starting, 47 | Lost: tgs.Lost, 48 | } 49 | 50 | result = append(result, taskGroup) 51 | } 52 | 53 | return result 54 | } 55 | 56 | func sortTaskGroupSummaryByKey(tgs map[string]api.TaskGroupSummary) []string { 57 | keys := make([]string, 0, len(tgs)) 58 | for k := range tgs { 59 | keys = append(keys, k) 60 | } 61 | 62 | sort.Strings(keys) 63 | 64 | return keys 65 | } 66 | -------------------------------------------------------------------------------- /nomad/taskgroups_test.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) HashiCorp, Inc. 2 | // SPDX-License-Identifier: MPL-2.0 3 | 4 | package nomad_test 5 | 6 | import ( 7 | "errors" 8 | "testing" 9 | 10 | "github.com/hashicorp/nomad/api" 11 | "github.com/stretchr/testify/require" 12 | 13 | "github.com/hcjulz/damon/models" 14 | "github.com/hcjulz/damon/nomad" 15 | "github.com/hcjulz/damon/nomad/nomadfakes" 16 | ) 17 | 18 | func TestTaskGroups_Happy(t *testing.T) { 19 | r := require.New(t) 20 | 21 | fakeJobClient := &nomadfakes.FakeJobClient{} 22 | client := &nomad.Nomad{JobClient: fakeJobClient} 23 | 24 | jobSummary := &api.JobSummary{ 25 | JobID: "StarWars", 26 | Summary: map[string]api.TaskGroupSummary{ 27 | "Mandalorian": { 28 | Running: 1, 29 | Queued: 1, 30 | }, 31 | "Grogu": { 32 | Failed: 1, 33 | Queued: 1, 34 | }, 35 | }, 36 | } 37 | 38 | fakeJobClient.SummaryReturns(jobSummary, nil, nil) 39 | 40 | t.Run("It doesn't fail", func(t *testing.T) { 41 | _, err := client.TaskGroups("ID", nil) 42 | r.NoError(err) 43 | }) 44 | 45 | t.Run("It returns the correct number of tasks", func(t *testing.T) { 46 | tg, _ := client.TaskGroups("ID", nil) 47 | r.Len(tg, 2) 48 | }) 49 | 50 | t.Run("It returns the expected task groups", func(t *testing.T) { 51 | actualTaskGroups, _ := client.TaskGroups("ID", nil) 52 | 53 | expectedTaskGroups := []*models.TaskGroup{ 54 | { 55 | Name: "Grogu", 56 | JobID: "StarWars", 57 | Failed: 1, 58 | Queued: 1, 59 | }, 60 | { 61 | Name: "Mandalorian", 62 | JobID: "StarWars", 63 | Running: 1, 64 | Queued: 1, 65 | }, 66 | } 67 | 68 | r.Equal(expectedTaskGroups, actualTaskGroups) 69 | }) 70 | } 71 | 72 | func TestTaskGroups_Sad(t *testing.T) { 73 | r := require.New(t) 74 | 75 | fakeJobClient := &nomadfakes.FakeJobClient{} 76 | nomad := &nomad.Nomad{JobClient: fakeJobClient} 77 | fakeJobClient.SummaryReturns(nil, nil, errors.New("aaah")) 78 | 79 | t.Run("It fails when the client returns an error", func(t *testing.T) { 80 | _, err := nomad.TaskGroups("ID", nil) 81 | r.Error(err) 82 | }) 83 | } 84 | -------------------------------------------------------------------------------- /primitives/dropdown.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) HashiCorp, Inc. 2 | // SPDX-License-Identifier: MPL-2.0 3 | 4 | package primitives 5 | 6 | import ( 7 | "github.com/rivo/tview" 8 | 9 | "github.com/hcjulz/damon/styles" 10 | ) 11 | 12 | type DropDown struct { 13 | primitive *tview.DropDown 14 | } 15 | 16 | func NewDropDown(label string) *DropDown { 17 | dd := tview.NewDropDown() 18 | dd.SetLabel(label) 19 | dd.SetBackgroundColor(styles.TcellBackgroundColor) 20 | dd.SetCurrentOption(0) 21 | dd.SetFieldBackgroundColor(styles.TcellBackgroundColor) 22 | dd.SetFieldTextColor(styles.TcellColorStandard) 23 | 24 | return &DropDown{dd} 25 | } 26 | 27 | func (d *DropDown) SetOptions(options []string, selected func(text string, index int)) { 28 | d.primitive.SetOptions(options, selected) 29 | } 30 | 31 | func (d *DropDown) SetCurrentOption(index int) { 32 | d.primitive.SetCurrentOption(index) 33 | } 34 | 35 | func (d *DropDown) SetSelectedFunc(selected func(text string, index int)) { 36 | d.primitive.SetSelectedFunc(selected) 37 | } 38 | 39 | func (d *DropDown) Primitive() tview.Primitive { 40 | return d.primitive 41 | } 42 | -------------------------------------------------------------------------------- /primitives/dropdown_test.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) HashiCorp, Inc. 2 | // SPDX-License-Identifier: MPL-2.0 3 | 4 | package primitives_test 5 | 6 | import ( 7 | "testing" 8 | 9 | "github.com/rivo/tview" 10 | "github.com/stretchr/testify/require" 11 | 12 | "github.com/hcjulz/damon/primitives" 13 | "github.com/hcjulz/damon/styles" 14 | ) 15 | 16 | func TestDropDown(t *testing.T) { 17 | r := require.New(t) 18 | 19 | dd := primitives.NewDropDown("test") 20 | p := dd.Primitive().(*tview.DropDown) 21 | 22 | r.Equal(p.GetBackgroundColor(), styles.TcellBackgroundColor) 23 | r.Equal(p.GetLabel(), "test") 24 | } 25 | 26 | func TestDropDown_Options(t *testing.T) { 27 | r := require.New(t) 28 | 29 | dd := primitives.NewDropDown("test") 30 | p := dd.Primitive().(*tview.DropDown) 31 | 32 | dd.SetOptions([]string{"opt", "opt2"}, nil) 33 | 34 | dd.SetCurrentOption(0) 35 | index, text := p.GetCurrentOption() 36 | r.Equal(text, "opt") 37 | r.Equal(index, 0) 38 | 39 | dd.SetCurrentOption(1) 40 | index, text = p.GetCurrentOption() 41 | r.Equal(text, "opt2") 42 | r.Equal(index, 1) 43 | } 44 | 45 | func TestDropDown_SetSelectedFunc(t *testing.T) { 46 | r := require.New(t) 47 | 48 | dd := primitives.NewDropDown("test") 49 | p := dd.Primitive().(*tview.DropDown) 50 | 51 | dd.SetOptions([]string{"opt", "opt2"}, nil) 52 | 53 | var selected bool 54 | dd.SetSelectedFunc(func(text string, index int) { 55 | selected = true 56 | }) 57 | 58 | dd.SetCurrentOption(0) 59 | index, text := p.GetCurrentOption() 60 | r.Equal(text, "opt") 61 | r.Equal(index, 0) 62 | r.True(selected) 63 | } 64 | -------------------------------------------------------------------------------- /primitives/input.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) HashiCorp, Inc. 2 | // SPDX-License-Identifier: MPL-2.0 3 | 4 | package primitives 5 | 6 | import ( 7 | "github.com/gdamore/tcell/v2" 8 | "github.com/rivo/tview" 9 | 10 | "github.com/hcjulz/damon/styles" 11 | ) 12 | 13 | type InputField struct { 14 | primitive *tview.InputField 15 | } 16 | 17 | func NewInputField(label, placeholder string) *InputField { 18 | i := tview.NewInputField() 19 | i.SetLabel(label) 20 | i.SetFieldWidth(0) 21 | i.SetAcceptanceFunc(tview.InputFieldMaxLength(40)) 22 | i.SetPlaceholder(placeholder) 23 | i.SetBorder(true) 24 | i.SetFieldBackgroundColor(styles.TcellBackgroundColor) 25 | i.SetBackgroundColor(styles.TcellBackgroundColor) 26 | i.SetBorderAttributes(tcell.AttrDim) 27 | 28 | return &InputField{i} 29 | } 30 | 31 | func (i *InputField) SetDoneFunc(handler func(k tcell.Key)) { 32 | i.primitive.SetDoneFunc(handler) 33 | } 34 | 35 | func (i *InputField) SetChangedFunc(handler func(text string)) { 36 | i.primitive.SetChangedFunc(handler) 37 | } 38 | 39 | func (i *InputField) SetText(text string) { 40 | i.primitive.SetText(text) 41 | } 42 | 43 | func (i *InputField) GetText() string { 44 | return i.primitive.GetText() 45 | } 46 | 47 | func (i *InputField) SetAutocompleteFunc(callback func(currentText string) (entries []string)) { 48 | i.primitive.SetAutocompleteFunc(callback) 49 | } 50 | 51 | func (i *InputField) Primitive() tview.Primitive { 52 | return i.primitive 53 | } 54 | -------------------------------------------------------------------------------- /primitives/input_test.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) HashiCorp, Inc. 2 | // SPDX-License-Identifier: MPL-2.0 3 | 4 | package primitives_test 5 | 6 | import ( 7 | "testing" 8 | 9 | "github.com/gdamore/tcell/v2" 10 | "github.com/rivo/tview" 11 | "github.com/stretchr/testify/require" 12 | 13 | "github.com/hcjulz/damon/primitives" 14 | "github.com/hcjulz/damon/styles" 15 | ) 16 | 17 | func TestInputField(t *testing.T) { 18 | r := require.New(t) 19 | 20 | i := primitives.NewInputField("test", "input-field") 21 | p := i.Primitive().(*tview.InputField) 22 | 23 | r.Equal(p.GetBackgroundColor(), styles.TcellBackgroundColor) 24 | r.Equal(p.GetLabel(), "test") 25 | r.Equal(p.GetBorderAttributes(), tcell.AttrDim) 26 | } 27 | 28 | func TestInputField_Set_GetText(t *testing.T) { 29 | r := require.New(t) 30 | 31 | i := primitives.NewInputField("test", "input-field") 32 | 33 | i.SetText("test") 34 | r.Equal(i.GetText(), "test") 35 | } 36 | 37 | func TestInputField_SetAutocompleteFunc(t *testing.T) { 38 | r := require.New(t) 39 | 40 | i := primitives.NewInputField("test", "input-field") 41 | 42 | i.SetText("test") 43 | 44 | var text string 45 | i.SetAutocompleteFunc(func(currentText string) (entries []string) { 46 | text = currentText 47 | return nil 48 | }) 49 | 50 | r.Equal(text, "test") 51 | } 52 | 53 | func TestInputField_SetChangedFunc(t *testing.T) { 54 | r := require.New(t) 55 | 56 | i := primitives.NewInputField("test", "input-field") 57 | 58 | i.SetText("test") 59 | 60 | var text string 61 | i.SetChangedFunc(func(currentText string) { 62 | text = currentText 63 | }) 64 | 65 | i.SetText("changed") 66 | 67 | r.Equal(text, "changed") 68 | } 69 | -------------------------------------------------------------------------------- /primitives/modal.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) HashiCorp, Inc. 2 | // SPDX-License-Identifier: MPL-2.0 3 | 4 | package primitives 5 | 6 | import ( 7 | "github.com/gdamore/tcell/v2" 8 | "github.com/rivo/tview" 9 | ) 10 | 11 | type Modal struct { 12 | primitive *tview.Modal 13 | container *tview.Flex 14 | } 15 | 16 | func NewModal(title string, buttons []string, c tcell.Color) *Modal { 17 | m := tview.NewModal() 18 | m.SetTitle(title) 19 | m.SetTitleAlign(tview.AlignCenter) 20 | m.SetBackgroundColor(c) 21 | m.SetTextColor(tcell.ColorBlack) 22 | m.AddButtons(buttons) 23 | 24 | f := tview.NewFlex(). 25 | AddItem(nil, 0, 1, false). 26 | AddItem(tview.NewFlex().SetDirection(tview.FlexRow). 27 | AddItem(nil, 0, 1, false). 28 | AddItem(m, 10, 1, true). 29 | AddItem(nil, 0, 1, false), 80, 1, false). 30 | AddItem(nil, 0, 1, false) 31 | 32 | return &Modal{ 33 | primitive: m, 34 | container: f, 35 | } 36 | } 37 | 38 | func (m *Modal) SetDoneFunc(handler func(buttonIndex int, buttonLabel string)) { 39 | m.primitive.SetDoneFunc(handler) 40 | } 41 | 42 | func (m *Modal) SetText(text string) { 43 | m.primitive.SetText(text) 44 | } 45 | 46 | func (m *Modal) SetFocus(index int) { 47 | m.primitive.SetFocus(index) 48 | } 49 | 50 | func (m *Modal) Container() tview.Primitive { 51 | return m.container 52 | } 53 | 54 | func (m *Modal) Primitive() tview.Primitive { 55 | return m.primitive 56 | } 57 | -------------------------------------------------------------------------------- /primitives/modal_test.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) HashiCorp, Inc. 2 | // SPDX-License-Identifier: MPL-2.0 3 | 4 | package primitives_test 5 | 6 | import ( 7 | "testing" 8 | 9 | "github.com/rivo/tview" 10 | "github.com/stretchr/testify/require" 11 | 12 | "github.com/hcjulz/damon/primitives" 13 | "github.com/hcjulz/damon/styles" 14 | ) 15 | 16 | func TestModal(t *testing.T) { 17 | r := require.New(t) 18 | 19 | m := primitives.NewModal( 20 | "test", 21 | []string{"OK", "Cancel"}, 22 | styles.TcellColorStandard, 23 | ) 24 | 25 | p := m.Primitive().(*tview.Modal) 26 | c := m.Container().(*tview.Flex) 27 | 28 | r.Equal(p.GetTitle(), "test") 29 | r.NotNil(c) 30 | } 31 | -------------------------------------------------------------------------------- /primitives/selector.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) HashiCorp, Inc. 2 | // SPDX-License-Identifier: MPL-2.0 3 | 4 | package primitives 5 | 6 | import ( 7 | "github.com/rivo/tview" 8 | ) 9 | 10 | type SelectionModal struct { 11 | Table *Table 12 | container *tview.Flex 13 | } 14 | 15 | func NewSelectionModal() *SelectionModal { 16 | t := NewTable() 17 | f := tview.NewFlex(). 18 | AddItem(nil, 0, 1, false). 19 | AddItem(tview.NewFlex().SetDirection(tview.FlexRow). 20 | AddItem(nil, 0, 1, false). 21 | AddItem(t.primitive, 10, 1, false). 22 | AddItem(nil, 0, 1, false), 80, 1, false). 23 | AddItem(nil, 0, 1, false) 24 | 25 | return &SelectionModal{ 26 | Table: t, 27 | container: f, 28 | } 29 | } 30 | 31 | func (s *SelectionModal) Container() tview.Primitive { 32 | return s.container 33 | } 34 | 35 | func (s *SelectionModal) Primitive() tview.Primitive { 36 | return s.Table.primitive 37 | } 38 | 39 | func (s *SelectionModal) GetTable() *Table { 40 | return s.Table 41 | } 42 | -------------------------------------------------------------------------------- /primitives/selector_test.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) HashiCorp, Inc. 2 | // SPDX-License-Identifier: MPL-2.0 3 | 4 | package primitives_test 5 | 6 | import ( 7 | "testing" 8 | 9 | "github.com/gdamore/tcell/v2" 10 | "github.com/rivo/tview" 11 | "github.com/stretchr/testify/require" 12 | 13 | "github.com/hcjulz/damon/primitives" 14 | ) 15 | 16 | func TestSelectionModal(t *testing.T) { 17 | r := require.New(t) 18 | 19 | m := primitives.NewSelectionModal() 20 | 21 | tb := m.Primitive().(*tview.Table) 22 | c := m.Container().(*tview.Flex) 23 | table := m.GetTable() 24 | 25 | table.RenderRow([]string{"item1", "item2"}, 0, tcell.ColorWhite) 26 | table.RenderRow([]string{"item3", "item4"}, 1, tcell.ColorWhite) 27 | 28 | r.NotNil(tb) 29 | r.NotNil(c) 30 | r.Equal(tb, table.Primitive()) 31 | 32 | item1 := table.GetCellContent(0, 0) 33 | item2 := table.GetCellContent(0, 1) 34 | item3 := table.GetCellContent(1, 0) 35 | item4 := table.GetCellContent(1, 1) 36 | 37 | r.Equal(item1, "item1") 38 | r.Equal(item2, "item2") 39 | r.Equal(item3, "item3") 40 | r.Equal(item4, "item4") 41 | } 42 | -------------------------------------------------------------------------------- /primitives/table.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) HashiCorp, Inc. 2 | // SPDX-License-Identifier: MPL-2.0 3 | 4 | package primitives 5 | 6 | import ( 7 | "fmt" 8 | 9 | "github.com/gdamore/tcell/v2" 10 | "github.com/rivo/tview" 11 | 12 | "github.com/hcjulz/damon/styles" 13 | ) 14 | 15 | // Table is a wrapper of a tview.Table primitive. 16 | // It applies the damon look to the tviw.Table. 17 | type Table struct { 18 | primitive *tview.Table 19 | color tcell.Color 20 | } 21 | 22 | func NewTable() *Table { 23 | t := tview.NewTable() 24 | t.SetBorder(true) 25 | t.SetTitleColor(styles.TcellColorHighlighPrimary) 26 | t.SetSelectable(true, false) 27 | t.SetFixed(1, 1) 28 | t.SetBorderPadding(0, 0, 1, 1) 29 | t.SetBorderColor(styles.TcellColorStandard) 30 | 31 | return &Table{ 32 | primitive: t, 33 | } 34 | } 35 | 36 | func (t *Table) RenderHeader(data []string) { 37 | for i, h := range data { 38 | c := tcell.GetColor(styles.StandardColorHex) 39 | t.primitive.SetCell(0, i, tview.NewTableCell(h). 40 | SetTextColor(c). 41 | SetSelectable(false), 42 | ) 43 | } 44 | } 45 | 46 | func (t *Table) SetTitle(format string, args ...interface{}) { 47 | t.primitive.SetTitle(fmt.Sprintf(format, args...)) 48 | } 49 | 50 | func (t *Table) GetCellContent(row, column int) string { 51 | cell := t.primitive.GetCell(row, column) 52 | return cell.Text 53 | } 54 | 55 | func (t *Table) GetSelection() (row, column int) { 56 | return t.primitive.GetSelection() 57 | } 58 | 59 | func (t *Table) Clear() { 60 | t.primitive.Clear() 61 | } 62 | 63 | func (t *Table) RenderRow(data []string, index int, c tcell.Color) { 64 | for i, r := range data { 65 | t.primitive.SetCell(index, i, 66 | tview.NewTableCell(r).SetTextColor(c).SetExpansion(1), 67 | ) 68 | } 69 | } 70 | 71 | func (t *Table) SetSelectedFunc(fn func(row, column int)) { 72 | t.primitive.SetSelectedFunc(fn) 73 | } 74 | 75 | func (t *Table) SetInputCapture(capture func(event *tcell.EventKey) *tcell.EventKey) { 76 | t.primitive.SetInputCapture(capture) 77 | } 78 | 79 | func (t *Table) Primitive() tview.Primitive { 80 | return t.primitive 81 | } 82 | -------------------------------------------------------------------------------- /primitives/table_test.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) HashiCorp, Inc. 2 | // SPDX-License-Identifier: MPL-2.0 3 | 4 | package primitives_test 5 | 6 | import ( 7 | "testing" 8 | 9 | "github.com/gdamore/tcell/v2" 10 | "github.com/rivo/tview" 11 | "github.com/stretchr/testify/require" 12 | 13 | "github.com/hcjulz/damon/primitives" 14 | "github.com/hcjulz/damon/styles" 15 | ) 16 | 17 | func TestTable(t *testing.T) { 18 | tview.Styles.PrimitiveBackgroundColor = styles.TcellBackgroundColor 19 | 20 | r := require.New(t) 21 | 22 | tb := primitives.NewTable() 23 | p := tb.Primitive().(*tview.Table) 24 | 25 | r.Equal(p.GetBackgroundColor(), styles.TcellBackgroundColor) 26 | } 27 | 28 | func TestTable_Render(t *testing.T) { 29 | r := require.New(t) 30 | 31 | tb := primitives.NewTable() 32 | 33 | header := []string{"col1", "col2", "col3"} 34 | row1 := []string{"row1-1", "row1-2", "row1-3"} 35 | row2 := []string{"row2-1", "row2-2", "row2-3"} 36 | 37 | tb.RenderHeader(header) 38 | tb.RenderRow(row1, 1, tcell.ColorWhite) 39 | tb.RenderRow(row2, 2, tcell.ColorWhite) 40 | 41 | h1 := tb.GetCellContent(0, 0) 42 | h2 := tb.GetCellContent(0, 1) 43 | h3 := tb.GetCellContent(0, 2) 44 | 45 | r11 := tb.GetCellContent(1, 0) 46 | r12 := tb.GetCellContent(1, 1) 47 | r13 := tb.GetCellContent(1, 2) 48 | 49 | r21 := tb.GetCellContent(2, 0) 50 | r22 := tb.GetCellContent(2, 1) 51 | r23 := tb.GetCellContent(2, 2) 52 | 53 | r.Equal(h1, "col1") 54 | r.Equal(h2, "col2") 55 | r.Equal(h3, "col3") 56 | 57 | r.Equal(r11, "row1-1") 58 | r.Equal(r12, "row1-2") 59 | r.Equal(r13, "row1-3") 60 | 61 | r.Equal(r21, "row2-1") 62 | r.Equal(r22, "row2-2") 63 | r.Equal(r23, "row2-3") 64 | 65 | } 66 | 67 | func TestTable_Clear(t *testing.T) { 68 | r := require.New(t) 69 | 70 | tb := primitives.NewTable() 71 | p := tb.Primitive().(*tview.Table) 72 | 73 | header := []string{"col1", "col2", "col3"} 74 | row1 := []string{"row1-1", "row1-2", "row1-3"} 75 | row2 := []string{"row2-1", "row2-2", "row2-3"} 76 | 77 | tb.RenderHeader(header) 78 | tb.RenderRow(row1, 1, tcell.ColorWhite) 79 | tb.RenderRow(row2, 2, tcell.ColorWhite) 80 | 81 | tb.Clear() 82 | 83 | r.Equal(p.GetColumnCount(), 0) 84 | r.Equal(p.GetRowCount(), 0) 85 | } 86 | 87 | func TestTable_GetSelection(t *testing.T) { 88 | r := require.New(t) 89 | 90 | tb := primitives.NewTable() 91 | p := tb.Primitive().(*tview.Table) 92 | 93 | header := []string{"col1", "col2", "col3"} 94 | row1 := []string{"row1-1", "row1-2", "row1-3"} 95 | row2 := []string{"row2-1", "row2-2", "row2-3"} 96 | 97 | tb.RenderHeader(header) 98 | tb.RenderRow(row1, 1, tcell.ColorWhite) 99 | tb.RenderRow(row2, 2, tcell.ColorWhite) 100 | 101 | p.Select(2, 2) 102 | 103 | row, col := tb.GetSelection() 104 | 105 | r.Equal(row, 2) 106 | r.Equal(col, 2) 107 | } 108 | 109 | func TestTable_SetTitle(t *testing.T) { 110 | r := require.New(t) 111 | 112 | tb := primitives.NewTable() 113 | p := tb.Primitive().(*tview.Table) 114 | 115 | tb.SetTitle("test") 116 | 117 | r.Equal(p.GetTitle(), "test") 118 | } 119 | -------------------------------------------------------------------------------- /primitives/text.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) HashiCorp, Inc. 2 | // SPDX-License-Identifier: MPL-2.0 3 | 4 | package primitives 5 | 6 | import ( 7 | "github.com/rivo/tview" 8 | ) 9 | 10 | type TextView struct { 11 | *tview.TextView 12 | } 13 | 14 | func NewTextView(align int) *TextView { 15 | t := tview.NewTextView(). 16 | SetDynamicColors(true). 17 | SetTextAlign(align) 18 | 19 | return &TextView{TextView: t} 20 | } 21 | 22 | func (t *TextView) Primitive() tview.Primitive { 23 | return t.TextView 24 | } 25 | 26 | func (t *TextView) ModifyPrimitive(f func(t *tview.TextView)) { 27 | f(t.TextView) 28 | } 29 | -------------------------------------------------------------------------------- /primitives/text_test.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) HashiCorp, Inc. 2 | // SPDX-License-Identifier: MPL-2.0 3 | 4 | package primitives_test 5 | 6 | import ( 7 | "testing" 8 | 9 | "github.com/gdamore/tcell/v2" 10 | "github.com/rivo/tview" 11 | "github.com/stretchr/testify/require" 12 | 13 | "github.com/hcjulz/damon/primitives" 14 | ) 15 | 16 | func TestTextView(t *testing.T) { 17 | r := require.New(t) 18 | 19 | tv := primitives.NewTextView(tview.AlignRight) 20 | p := tv.Primitive().(*tview.TextView) 21 | 22 | tv.SetText("test") 23 | r.Equal(tv.GetText(true), "test") 24 | 25 | tv.Clear() 26 | r.Equal(tv.GetText(true), "") 27 | 28 | tv.ModifyPrimitive(func(v *tview.TextView) { 29 | v.SetBackgroundColor(tcell.ColorWhite) 30 | }) 31 | 32 | r.Equal(p.GetBackgroundColor(), tcell.ColorWhite) 33 | } 34 | -------------------------------------------------------------------------------- /refresher/refresher.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) HashiCorp, Inc. 2 | // SPDX-License-Identifier: MPL-2.0 3 | 4 | package refresher 5 | 6 | import ( 7 | "time" 8 | 9 | "github.com/hcjulz/damon/watcher" 10 | ) 11 | 12 | const defaultRefreshInterval = time.Second * 2 13 | 14 | //go:generate counterfeiter . RefreshFunc 15 | type RefreshFunc func() 16 | 17 | //go:generate counterfeiter . Activities 18 | type Activities interface { 19 | Add(chan struct{}) 20 | DeactivateAll() 21 | } 22 | 23 | type Refresher struct { 24 | activities Activities 25 | RefreshInterval time.Duration 26 | } 27 | 28 | func New(d time.Duration) *Refresher { 29 | if d == 0 { 30 | d = defaultRefreshInterval 31 | } 32 | 33 | return &Refresher{ 34 | activities: &watcher.ActivityPool{}, 35 | RefreshInterval: d, 36 | } 37 | } 38 | 39 | func (w *Refresher) WithCustomActivityPool(a Activities) *Refresher { 40 | w.activities = a 41 | return w 42 | } 43 | 44 | func (w *Refresher) Refresh(refresh RefreshFunc) { 45 | stop := make(chan struct{}, 1) 46 | w.activities.DeactivateAll() 47 | w.activities.Add(stop) 48 | 49 | refresh() 50 | 51 | ticker := time.NewTicker(w.RefreshInterval) 52 | for range ticker.C { 53 | select { 54 | case <-stop: 55 | close(stop) 56 | return 57 | default: 58 | refresh() 59 | } 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /refresher/refresher_test.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) HashiCorp, Inc. 2 | // SPDX-License-Identifier: MPL-2.0 3 | 4 | package refresher_test 5 | 6 | import ( 7 | "testing" 8 | "time" 9 | 10 | "github.com/stretchr/testify/require" 11 | 12 | "github.com/hcjulz/damon/refresher" 13 | "github.com/hcjulz/damon/refresher/refresherfakes" 14 | ) 15 | 16 | func TestWatch(t *testing.T) { 17 | r := require.New(t) 18 | 19 | refreshFunc := &refresherfakes.FakeRefreshFunc{} 20 | 21 | fakeActivityPool := &refresherfakes.FakeActivities{} 22 | 23 | refresher := refresher.New(time.Second * 1).WithCustomActivityPool(fakeActivityPool) 24 | 25 | go refresher.Refresh(refreshFunc.Spy) 26 | 27 | r.Eventually(func() bool { 28 | return refreshFunc.CallCount() > 1 29 | }, time.Second*6, time.Second*1) 30 | 31 | stopChan := fakeActivityPool.AddArgsForCall(0) 32 | stopChan <- struct{}{} 33 | 34 | // check if the channel is eventually closed 35 | r.Eventually(func() bool { 36 | _, ok := <-stopChan 37 | return !ok 38 | }, time.Second*6, time.Second*2) 39 | 40 | r.Equal(fakeActivityPool.DeactivateAllCallCount(), 1) 41 | r.Equal(fakeActivityPool.AddCallCount(), 1) 42 | } 43 | -------------------------------------------------------------------------------- /refresher/refresherfakes/fake_activities.go: -------------------------------------------------------------------------------- 1 | // Code generated by counterfeiter. DO NOT EDIT. 2 | package refresherfakes 3 | 4 | import ( 5 | "sync" 6 | 7 | watcher "github.com/hcjulz/damon/refresher" 8 | ) 9 | 10 | type FakeActivities struct { 11 | AddStub func(chan struct{}) 12 | addMutex sync.RWMutex 13 | addArgsForCall []struct { 14 | arg1 chan struct{} 15 | } 16 | DeactivateAllStub func() 17 | deactivateAllMutex sync.RWMutex 18 | deactivateAllArgsForCall []struct { 19 | } 20 | invocations map[string][][]interface{} 21 | invocationsMutex sync.RWMutex 22 | } 23 | 24 | func (fake *FakeActivities) Add(arg1 chan struct{}) { 25 | fake.addMutex.Lock() 26 | fake.addArgsForCall = append(fake.addArgsForCall, struct { 27 | arg1 chan struct{} 28 | }{arg1}) 29 | stub := fake.AddStub 30 | fake.recordInvocation("Add", []interface{}{arg1}) 31 | fake.addMutex.Unlock() 32 | if stub != nil { 33 | fake.AddStub(arg1) 34 | } 35 | } 36 | 37 | func (fake *FakeActivities) AddCallCount() int { 38 | fake.addMutex.RLock() 39 | defer fake.addMutex.RUnlock() 40 | return len(fake.addArgsForCall) 41 | } 42 | 43 | func (fake *FakeActivities) AddCalls(stub func(chan struct{})) { 44 | fake.addMutex.Lock() 45 | defer fake.addMutex.Unlock() 46 | fake.AddStub = stub 47 | } 48 | 49 | func (fake *FakeActivities) AddArgsForCall(i int) chan struct{} { 50 | fake.addMutex.RLock() 51 | defer fake.addMutex.RUnlock() 52 | argsForCall := fake.addArgsForCall[i] 53 | return argsForCall.arg1 54 | } 55 | 56 | func (fake *FakeActivities) DeactivateAll() { 57 | fake.deactivateAllMutex.Lock() 58 | fake.deactivateAllArgsForCall = append(fake.deactivateAllArgsForCall, struct { 59 | }{}) 60 | stub := fake.DeactivateAllStub 61 | fake.recordInvocation("DeactivateAll", []interface{}{}) 62 | fake.deactivateAllMutex.Unlock() 63 | if stub != nil { 64 | fake.DeactivateAllStub() 65 | } 66 | } 67 | 68 | func (fake *FakeActivities) DeactivateAllCallCount() int { 69 | fake.deactivateAllMutex.RLock() 70 | defer fake.deactivateAllMutex.RUnlock() 71 | return len(fake.deactivateAllArgsForCall) 72 | } 73 | 74 | func (fake *FakeActivities) DeactivateAllCalls(stub func()) { 75 | fake.deactivateAllMutex.Lock() 76 | defer fake.deactivateAllMutex.Unlock() 77 | fake.DeactivateAllStub = stub 78 | } 79 | 80 | func (fake *FakeActivities) Invocations() map[string][][]interface{} { 81 | fake.invocationsMutex.RLock() 82 | defer fake.invocationsMutex.RUnlock() 83 | fake.addMutex.RLock() 84 | defer fake.addMutex.RUnlock() 85 | fake.deactivateAllMutex.RLock() 86 | defer fake.deactivateAllMutex.RUnlock() 87 | copiedInvocations := map[string][][]interface{}{} 88 | for key, value := range fake.invocations { 89 | copiedInvocations[key] = value 90 | } 91 | return copiedInvocations 92 | } 93 | 94 | func (fake *FakeActivities) recordInvocation(key string, args []interface{}) { 95 | fake.invocationsMutex.Lock() 96 | defer fake.invocationsMutex.Unlock() 97 | if fake.invocations == nil { 98 | fake.invocations = map[string][][]interface{}{} 99 | } 100 | if fake.invocations[key] == nil { 101 | fake.invocations[key] = [][]interface{}{} 102 | } 103 | fake.invocations[key] = append(fake.invocations[key], args) 104 | } 105 | 106 | var _ watcher.Activities = new(FakeActivities) 107 | -------------------------------------------------------------------------------- /refresher/refresherfakes/fake_refresh_func.go: -------------------------------------------------------------------------------- 1 | // Code generated by counterfeiter. DO NOT EDIT. 2 | package refresherfakes 3 | 4 | import ( 5 | "sync" 6 | 7 | watcher "github.com/hcjulz/damon/refresher" 8 | ) 9 | 10 | type FakeRefreshFunc struct { 11 | Stub func() 12 | mutex sync.RWMutex 13 | argsForCall []struct { 14 | } 15 | invocations map[string][][]interface{} 16 | invocationsMutex sync.RWMutex 17 | } 18 | 19 | func (fake *FakeRefreshFunc) Spy() { 20 | fake.mutex.Lock() 21 | fake.argsForCall = append(fake.argsForCall, struct { 22 | }{}) 23 | stub := fake.Stub 24 | fake.recordInvocation("RefreshFunc", []interface{}{}) 25 | fake.mutex.Unlock() 26 | if stub != nil { 27 | fake.Stub() 28 | } 29 | } 30 | 31 | func (fake *FakeRefreshFunc) CallCount() int { 32 | fake.mutex.RLock() 33 | defer fake.mutex.RUnlock() 34 | return len(fake.argsForCall) 35 | } 36 | 37 | func (fake *FakeRefreshFunc) Calls(stub func()) { 38 | fake.mutex.Lock() 39 | defer fake.mutex.Unlock() 40 | fake.Stub = stub 41 | } 42 | 43 | func (fake *FakeRefreshFunc) Invocations() map[string][][]interface{} { 44 | fake.invocationsMutex.RLock() 45 | defer fake.invocationsMutex.RUnlock() 46 | fake.mutex.RLock() 47 | defer fake.mutex.RUnlock() 48 | copiedInvocations := map[string][][]interface{}{} 49 | for key, value := range fake.invocations { 50 | copiedInvocations[key] = value 51 | } 52 | return copiedInvocations 53 | } 54 | 55 | func (fake *FakeRefreshFunc) recordInvocation(key string, args []interface{}) { 56 | fake.invocationsMutex.Lock() 57 | defer fake.invocationsMutex.Unlock() 58 | if fake.invocations == nil { 59 | fake.invocations = map[string][][]interface{}{} 60 | } 61 | if fake.invocations[key] == nil { 62 | fake.invocations[key] = [][]interface{}{} 63 | } 64 | fake.invocations[key] = append(fake.invocations[key], args) 65 | } 66 | 67 | var _ watcher.RefreshFunc = new(FakeRefreshFunc).Spy 68 | -------------------------------------------------------------------------------- /scripts/docker-entrypoint.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env sh 2 | # Copyright (c) HashiCorp, Inc. 3 | # SPDX-License-Identifier: MPL-2.0 4 | 5 | 6 | set -e 7 | 8 | if [ "$1" = 'daemon' ]; then 9 | shift 10 | fi 11 | 12 | exec damon "$@" 13 | -------------------------------------------------------------------------------- /scripts/version.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | # Copyright (c) HashiCorp, Inc. 3 | # SPDX-License-Identifier: MPL-2.0 4 | 5 | 6 | version_file=$1 7 | version_metadata_file=$2 8 | version=$(awk '$1 == "Version" && $2 == "=" { gsub(/"/, "", $3); print $3 }' <"${version_file}") 9 | prerelease=$(awk '$1 == "VersionPrerelease" && $2 == "=" { gsub(/"/, "", $3); print $3 }' <"${version_file}") 10 | metadata=$(awk '$1 == "VersionMetadata" && $2 == "=" { gsub(/"/, "", $3); print $3 }' <"${version_metadata_file}") 11 | 12 | if [ -n "$metadata" ] && [ -n "$prerelease" ]; then 13 | echo "${version}-${prerelease}+${metadata}" 14 | elif [ -n "$metadata" ]; then 15 | echo "${version}+${metadata}" 16 | elif [ -n "$prerelease" ]; then 17 | echo "${version}-${prerelease}" 18 | else 19 | echo "${version}" 20 | fi 21 | -------------------------------------------------------------------------------- /state/state.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) HashiCorp, Inc. 2 | // SPDX-License-Identifier: MPL-2.0 3 | 4 | package state 5 | 6 | import ( 7 | "github.com/hashicorp/nomad/api" 8 | "github.com/rivo/tview" 9 | 10 | "github.com/hcjulz/damon/models" 11 | ) 12 | 13 | type State struct { 14 | NomadAddress string 15 | CurrentSubscriber api.Topic 16 | 17 | Jobs []*models.Job 18 | Deployments []*models.Deployment 19 | TaskGroups []*models.TaskGroup 20 | Allocations []*models.Alloc 21 | Namespaces []*models.Namespace 22 | Logs []byte 23 | JobStatus *models.JobStatus 24 | 25 | SelectedNamespace string 26 | SelectedRegion string 27 | 28 | Filter *Filter 29 | 30 | Elements *Elements 31 | 32 | Toggle *Toggle 33 | } 34 | 35 | type Filter struct { 36 | Running bool 37 | Pending bool 38 | Dead bool 39 | 40 | Logs string 41 | Jobs string 42 | Deployments string 43 | Namespaces string 44 | Allocations string 45 | TaskGroups string 46 | } 47 | 48 | type Toggle struct { 49 | JumpToJob bool 50 | Search bool 51 | LogSearch bool 52 | LogHighlight bool 53 | } 54 | 55 | type Elements struct { 56 | DropDownNamespace *tview.DropDown 57 | TableMain *tview.Table 58 | } 59 | 60 | func New() *State { 61 | return &State{ 62 | Filter: &Filter{}, 63 | Elements: &Elements{}, 64 | Toggle: &Toggle{}, 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /styles/styles.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) HashiCorp, Inc. 2 | // SPDX-License-Identifier: MPL-2.0 3 | 4 | package styles 5 | 6 | import ( 7 | "fmt" 8 | 9 | "github.com/gdamore/tcell/v2" 10 | ) 11 | 12 | func GetBackgroundColor() tcell.Color { 13 | return tcell.NewRGBColor(40, 44, 48) 14 | } 15 | 16 | var ( 17 | TcellBackgroundColor = tcell.NewRGBColor(40, 44, 48) 18 | 19 | HighlightPrimaryHex = "#26ffe6" 20 | HighlightSecondaryHex = "#baff26" 21 | StandardColorHex = "#00b57c" 22 | ColorActiveHex = "#b3f1ff" 23 | ColorWhiteHex = "#ffffff" 24 | ColorLightGreyHex = "#cccccc" 25 | ColorModalInfoHex = "#61877f" 26 | ColorAttentionHex = "#d98b6a" 27 | 28 | StandardColorTag = fmt.Sprintf("[%s]", StandardColorHex) 29 | HighlightPrimaryTag = fmt.Sprintf("[%s]", HighlightPrimaryHex) 30 | HighlightSecondaryTag = fmt.Sprintf("[%s]", HighlightSecondaryHex) 31 | ColorActiveTag = fmt.Sprintf("[%s]", ColorActiveHex) 32 | ColorWhiteTag = fmt.Sprintf("[%s]", ColorWhiteHex) 33 | ColorLighGreyTag = fmt.Sprintf("[%s]", ColorLightGreyHex) 34 | 35 | TcellColorHighlighPrimary = tcell.GetColor(HighlightPrimaryHex) 36 | TcellColorHighlighSecondary = tcell.GetColor(HighlightSecondaryHex) 37 | TcellColorStandard = tcell.GetColor(StandardColorHex) 38 | TcellColorActive = tcell.GetColor(ColorActiveHex) 39 | TcellColorModalInfo = tcell.GetColor(ColorModalInfoHex) 40 | TcellColorAttention = tcell.GetColor(ColorAttentionHex) 41 | ) 42 | -------------------------------------------------------------------------------- /version/version.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) HashiCorp, Inc. 2 | // SPDX-License-Identifier: MPL-2.0 3 | 4 | package version 5 | 6 | import ( 7 | "fmt" 8 | "strings" 9 | ) 10 | 11 | var ( 12 | // The git commit that was compiled. These will be filled in by the compiler. 13 | GitCommit string 14 | GitDescribe string 15 | 16 | // The main version number that is being run at the moment. 17 | // 18 | // Version must conform to the format expected by 19 | // github.com/hashicorp/go-version for tests to work. 20 | Version = "0.1.0" 21 | 22 | // A pre-release marker for the version. If this is "" (empty string) 23 | // then it means that it is a final release. Otherwise, this is a pre-release 24 | // such as "dev" (in development), "beta.1", "rc.1", etc. 25 | VersionPrerelease = "dev" 26 | 27 | // VersionMetadata is metadata further describing the build type. 28 | VersionMetadata = "" 29 | ) 30 | 31 | // GetHumanVersion composes the parts of the version in a way that's suitable 32 | // for displaying to humans. 33 | func GetHumanVersion() string { 34 | version := Version 35 | release := VersionPrerelease 36 | 37 | if GitDescribe != "" { 38 | version = GitDescribe 39 | } else { 40 | if release == "" { 41 | release = "dev" 42 | } 43 | 44 | if release != "" && !strings.HasSuffix(version, "-"+release) { 45 | // if we tagged a prerelease version then the release is in the version 46 | // already. 47 | version += fmt.Sprintf("-%s", release) 48 | } 49 | 50 | if VersionMetadata != "" { 51 | version += fmt.Sprintf("+%s", VersionMetadata) 52 | } 53 | } 54 | 55 | // Add the commit hash at the very end of the version. 56 | if GitCommit != "" { 57 | version += fmt.Sprintf(" (%s)", GitCommit) 58 | } 59 | 60 | // Add v as prefix if not present 61 | if !strings.HasPrefix(version, "v") { 62 | version = fmt.Sprintf("v%s", version) 63 | } 64 | 65 | // Strip off any single quotes added by the git information. 66 | return strings.Replace(version, "'", "", -1) 67 | } 68 | -------------------------------------------------------------------------------- /view/allocations.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) HashiCorp, Inc. 2 | // SPDX-License-Identifier: MPL-2.0 3 | 4 | package view 5 | 6 | import ( 7 | "fmt" 8 | "regexp" 9 | 10 | "github.com/hashicorp/nomad/api" 11 | "github.com/rivo/tview" 12 | 13 | "github.com/hcjulz/damon/component" 14 | "github.com/hcjulz/damon/models" 15 | ) 16 | 17 | func (v *View) Allocations(jobID string) { 18 | v.viewSwitch() 19 | 20 | v.Layout.Body.SetTitle(titleAllocations) 21 | 22 | v.components.Commands.Update(component.AllocCommands) 23 | v.Layout.Container.SetInputCapture(v.InputAllocations) 24 | 25 | search := v.components.Search 26 | table := v.components.AllocationTable 27 | 28 | update := func() { 29 | table.Props.Data = v.filterAllocs(jobID) 30 | table.Render() 31 | v.Draw() 32 | } 33 | 34 | // Overwrite the search change function to filter allocations 35 | search.Props.ChangedFunc = func(text string) { 36 | v.state.Filter.Allocations = text 37 | update() 38 | } 39 | 40 | v.components.AllocationTable.Props.JobID = jobID 41 | 42 | v.Watcher.Subscribe(update, api.TopicAllocation) 43 | 44 | update() 45 | 46 | v.components.Selections.Namespace.SetSelectedFunc(func(text string, index int) { 47 | v.state.SelectedNamespace = text 48 | v.Allocations(jobID) 49 | }) 50 | 51 | // Add this view to the history 52 | v.addToHistory(v.state.SelectedNamespace, api.TopicAllocation, func() { 53 | v.Allocations(jobID) 54 | }) 55 | 56 | // Set the current visible table, such that it can be focused when needed 57 | v.state.Elements.TableMain = v.components.AllocationTable.Table.Primitive().(*tview.Table) 58 | 59 | // focus the current table when loaded 60 | v.Layout.Container.SetFocus(v.components.AllocationTable.Table.Primitive()) 61 | } 62 | 63 | func (v *View) filterAllocs(jobID string) []*models.Alloc { 64 | data := v.filterAllocsForJob(jobID) 65 | data = v.namespaceFilterAllocs(data) 66 | filter := v.state.Filter.Allocations 67 | if filter != "" { 68 | rx, _ := regexp.Compile(filter) 69 | result := []*models.Alloc{} 70 | for _, alloc := range v.state.Allocations { 71 | switch true { 72 | case rx.MatchString(alloc.ID), 73 | rx.MatchString(alloc.TaskGroup), 74 | rx.MatchString(alloc.JobID), 75 | rx.MatchString(alloc.DesiredStatus), 76 | rx.MatchString(alloc.NodeID), 77 | rx.MatchString(alloc.NodeName): 78 | result = append(result, alloc) 79 | } 80 | } 81 | 82 | return result 83 | } 84 | 85 | return data 86 | } 87 | 88 | func (v *View) filterAllocsForJob(jobID string) []*models.Alloc { 89 | rx, _ := regexp.Compile(fmt.Sprintf("^%s$", jobID)) 90 | result := []*models.Alloc{} 91 | for _, job := range v.state.Allocations { 92 | switch true { 93 | case rx.MatchString(job.JobID): 94 | result = append(result, job) 95 | } 96 | } 97 | return result 98 | } 99 | 100 | func (v *View) namespaceFilterAllocs(allocs []*models.Alloc) []*models.Alloc { 101 | rx, _ := regexp.Compile(v.state.SelectedNamespace) 102 | result := []*models.Alloc{} 103 | for _, a := range allocs { 104 | switch true { 105 | case rx.MatchString(a.Namespace): 106 | result = append(result, a) 107 | } 108 | } 109 | return result 110 | } 111 | -------------------------------------------------------------------------------- /view/deployments.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) HashiCorp, Inc. 2 | // SPDX-License-Identifier: MPL-2.0 3 | 4 | package view 5 | 6 | import ( 7 | "regexp" 8 | 9 | "github.com/hashicorp/nomad/api" 10 | "github.com/rivo/tview" 11 | 12 | "github.com/hcjulz/damon/component" 13 | "github.com/hcjulz/damon/models" 14 | ) 15 | 16 | func (v *View) Deployments() { 17 | v.viewSwitch() 18 | v.Layout.Body.SetTitle(titleDeployments) 19 | 20 | v.components.Commands.Update(component.DeploymentCommands) 21 | 22 | v.Layout.Container.SetInputCapture(v.InputDeployments) 23 | 24 | v.state.Elements.TableMain = v.components.DeploymentTable.Table.Primitive().(*tview.Table) 25 | 26 | update := func() { 27 | v.components.DeploymentTable.Props.Data = v.filterDeployments(v.state.Deployments) 28 | v.components.DeploymentTable.Props.Namespace = v.state.SelectedNamespace 29 | v.components.DeploymentTable.Render() 30 | v.Draw() 31 | } 32 | 33 | v.components.Search.InputField.SetText("") 34 | v.components.Search.Props.ChangedFunc = func(text string) { 35 | v.state.Filter.Deployments = text 36 | update() 37 | } 38 | 39 | v.Watcher.Subscribe(update, api.TopicDeployment) 40 | 41 | update() 42 | 43 | v.components.Selections.Namespace.SetSelectedFunc(func(text string, index int) { 44 | v.state.SelectedNamespace = text 45 | v.Deployments() 46 | }) 47 | 48 | v.addToHistory(v.state.SelectedNamespace, api.TopicDeployment, v.Deployments) 49 | v.Layout.Container.SetFocus(v.components.DeploymentTable.Table.Primitive()) 50 | } 51 | 52 | func (v *View) filterDeployments(data []*models.Deployment) []*models.Deployment { 53 | filter := v.state.Filter.Deployments 54 | if filter != "" { 55 | rx, _ := regexp.Compile(filter) 56 | result := []*models.Deployment{} 57 | for _, dep := range v.state.Deployments { 58 | switch true { 59 | case rx.MatchString(dep.ID), 60 | rx.MatchString(dep.JobID), 61 | rx.MatchString(dep.Namespace), 62 | rx.MatchString(dep.Status), 63 | rx.MatchString(dep.StatusDescription): 64 | result = append(result, dep) 65 | } 66 | } 67 | 68 | return result 69 | } 70 | 71 | return data 72 | } 73 | -------------------------------------------------------------------------------- /view/handler.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) HashiCorp, Inc. 2 | // SPDX-License-Identifier: MPL-2.0 3 | 4 | package view 5 | 6 | import ( 7 | "fmt" 8 | 9 | "github.com/rivo/tview" 10 | ) 11 | 12 | func (v *View) handleNoResources(text string, args ...interface{}) { 13 | msg := fmt.Sprintf(text, args...) 14 | info := tview. 15 | NewTextView(). 16 | SetDynamicColors(true). 17 | SetText(msg). 18 | SetTextAlign(tview.AlignCenter) 19 | info.SetBorder(true) 20 | 21 | v.Layout.Body.AddItem(info, 0, 1, false) 22 | } 23 | 24 | func (v *View) err(err error, msg string) { 25 | if err != nil { 26 | fmt.Sprintf("%s: %s", msg, err.Error()) 27 | v.handleError(msg) 28 | } 29 | } 30 | 31 | func (v *View) handleError(format string, args ...interface{}) { 32 | msg := fmt.Sprintf(format, args...) 33 | v.components.Failure.Render(msg) 34 | v.Layout.Container.SetFocus(v.components.Failure.Modal.Primitive()) 35 | } 36 | 37 | func (v *View) handleInfo(format string, args ...interface{}) { 38 | msg := fmt.Sprintf(format, args...) 39 | v.components.Info.Render(msg) 40 | v.Layout.Container.SetFocus(v.components.Info.Modal.Primitive()) 41 | } 42 | 43 | func (v *View) handleFatal(format string, args ...interface{}) { 44 | msg := fmt.Sprintf(format, args...) 45 | v.components.Error.Render(msg) 46 | v.Layout.Container.SetFocus(v.components.Error.Modal.Primitive()) 47 | } 48 | -------------------------------------------------------------------------------- /view/history.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) HashiCorp, Inc. 2 | // SPDX-License-Identifier: MPL-2.0 3 | 4 | package view 5 | 6 | type History struct { 7 | stack []func() 8 | HistorySize int 9 | } 10 | 11 | func (h *History) push(back func()) { 12 | h.stack = append(h.stack, back) 13 | if len(h.stack) > h.HistorySize { 14 | h.stack = h.stack[1:] 15 | } 16 | } 17 | 18 | func (h *History) pop() { 19 | if len(h.stack) > 1 { 20 | last := h.stack[len(h.stack)-2] 21 | last() 22 | h.stack = h.stack[:len(h.stack)-2] 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /view/inputs.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) HashiCorp, Inc. 2 | // SPDX-License-Identifier: MPL-2.0 3 | 4 | package view 5 | 6 | import ( 7 | "github.com/gdamore/tcell/v2" 8 | ) 9 | 10 | func (v *View) InputJobs(event *tcell.EventKey) *tcell.EventKey { 11 | event = v.InputMainCommands(event) 12 | return v.inputJobs(event) 13 | } 14 | 15 | func (v *View) InputDeployments(event *tcell.EventKey) *tcell.EventKey { 16 | return v.InputMainCommands(event) 17 | } 18 | 19 | func (v *View) InputNamespaces(event *tcell.EventKey) *tcell.EventKey { 20 | return v.InputMainCommands(event) 21 | } 22 | 23 | func (v *View) InputTaskGroups(event *tcell.EventKey) *tcell.EventKey { 24 | return v.InputMainCommands(event) 25 | } 26 | 27 | func (v *View) InputAllocations(event *tcell.EventKey) *tcell.EventKey { 28 | v.InputMainCommands(event) 29 | return v.inputAllocs(event) 30 | } 31 | 32 | func (v *View) InputMainCommands(event *tcell.EventKey) *tcell.EventKey { 33 | if event == nil { 34 | return event 35 | } 36 | 37 | switch event.Key() { 38 | case tcell.KeyCtrlJ: 39 | v.Jobs() 40 | 41 | case tcell.KeyCtrlN: 42 | v.Namespaces() 43 | 44 | case tcell.KeyCtrlD: 45 | v.Deployments() 46 | 47 | case tcell.KeyCtrlO, tcell.KeyEsc: 48 | v.GoBack() 49 | 50 | case tcell.KeyCtrlP: 51 | if !v.Layout.Footer.HasFocus() { 52 | v.Layout.Container.SetFocus(v.components.LogSearch.InputField.Primitive()) 53 | if !v.state.Toggle.JumpToJob { 54 | v.viewSwitch() 55 | v.JumpToJob() 56 | v.state.Toggle.JumpToJob = true 57 | } else { 58 | v.Layout.Container.SetFocus(v.components.JumpToJob.InputField.Primitive()) 59 | } 60 | } 61 | case tcell.KeyRune: 62 | switch event.Rune() { 63 | 64 | case 's': 65 | if !v.Layout.Footer.HasFocus() { 66 | v.Layout.Container.SetFocus(v.state.Elements.DropDownNamespace) 67 | } 68 | } 69 | } 70 | 71 | return event 72 | } 73 | 74 | func (v *View) inputAllocs(event *tcell.EventKey) *tcell.EventKey { 75 | return event 76 | } 77 | 78 | func (v *View) InputLogs(event *tcell.EventKey) *tcell.EventKey { 79 | switch event.Key() { 80 | case tcell.KeyEsc, tcell.KeyCtrlO, tcell.KeyEnter: 81 | if v.components.LogStream.TextView.Primitive().HasFocus() { 82 | v.GoBack() 83 | return nil 84 | } 85 | case tcell.KeyRune: 86 | switch event.Rune() { 87 | case '/': 88 | if !v.Layout.Footer.HasFocus() { 89 | if !v.state.Toggle.LogSearch { 90 | v.state.Toggle.LogSearch = true 91 | v.LogSearch() 92 | return nil 93 | } else { 94 | v.Layout.Container.SetFocus(v.components.LogSearch.InputField.Primitive()) 95 | } 96 | 97 | } 98 | case 'h': 99 | if !v.Layout.Footer.HasFocus() { 100 | if !v.state.Toggle.LogHighlight { 101 | v.state.Toggle.LogHighlight = true 102 | v.LogHighlight() 103 | return nil 104 | } else { 105 | v.Layout.Container.SetFocus(v.components.LogHighlight.InputField.Primitive()) 106 | } 107 | } 108 | case 's': 109 | if !v.Layout.Footer.HasFocus() { 110 | v.Watcher.Unsubscribe() 111 | } 112 | case 'r': 113 | if !v.Layout.Footer.HasFocus() { 114 | v.Watcher.ResumeLogs() 115 | 116 | } 117 | } 118 | } 119 | 120 | return event 121 | } 122 | -------------------------------------------------------------------------------- /view/job_status.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) HashiCorp, Inc. 2 | // SPDX-License-Identifier: MPL-2.0 3 | 4 | package view 5 | 6 | import ( 7 | "github.com/hcjulz/damon/models" 8 | ) 9 | 10 | func (v *View) JobStatus(jobID string) { 11 | v.Layout.Body.SetTitle(titleJobStatus) 12 | v.Layout.Body.Clear() 13 | 14 | v.Layout.Container.SetInputCapture(v.InputMainCommands) 15 | v.Layout.Container.SetFocus(v.components.JobStatus.TextView.Primitive()) 16 | 17 | jobStatus := v.components.JobStatus 18 | 19 | update := func() { 20 | jobStatus.Props.Data = v.state.JobStatus 21 | 22 | jobStatus.Render() 23 | v.Draw() 24 | } 25 | 26 | v.Watcher.SubscribeToJobStatus(jobID, update) 27 | update() 28 | 29 | v.addToHistory(v.state.SelectedNamespace, models.TopicLog, update) 30 | v.Layout.Container.SetInputCapture(v.InputMainCommands) 31 | } 32 | -------------------------------------------------------------------------------- /view/logs.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) HashiCorp, Inc. 2 | // SPDX-License-Identifier: MPL-2.0 3 | 4 | package view 5 | 6 | import ( 7 | "github.com/hcjulz/damon/component" 8 | "github.com/hcjulz/damon/models" 9 | ) 10 | 11 | func (v *View) Logs(taskName string, allocID, source string) { 12 | v.Layout.Body.SetTitle(titleLogs) 13 | 14 | v.components.LogSearch.InputField.SetText("") 15 | v.Layout.Body.Clear() 16 | v.components.LogStream.Clear() 17 | 18 | logStreamProps := v.components.LogStream.Props 19 | logStreamProps.TaskName = taskName 20 | 21 | // If the logstream contains data from a previous log stream 22 | // remove it! 23 | if len(logStreamProps.Data) > 0 { 24 | logStreamProps.Data = []byte{} 25 | } 26 | 27 | v.Layout.Container.SetInputCapture(v.InputLogs) 28 | v.components.Commands.Update(component.LogCommands) 29 | 30 | update := func() { 31 | logStreamProps.Data = append(logStreamProps.Data, v.state.Logs...) 32 | v.components.LogStream.Render() 33 | v.Draw() 34 | } 35 | 36 | v.Watcher.SubscribeToLogs(allocID, taskName, source, update) 37 | 38 | v.components.LogStream.ClearDisplay() 39 | v.components.LogStream.Display() 40 | 41 | update() 42 | 43 | v.Layout.Container.SetFocus(v.components.LogStream.TextView.Primitive()) 44 | 45 | v.addToHistory(v.state.SelectedNamespace, models.TopicLog, func() { 46 | update() 47 | }) 48 | 49 | v.Layout.Container.SetInputCapture(v.InputLogs) 50 | } 51 | -------------------------------------------------------------------------------- /view/namespace.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) HashiCorp, Inc. 2 | // SPDX-License-Identifier: MPL-2.0 3 | 4 | package view 5 | 6 | import ( 7 | "regexp" 8 | 9 | "github.com/rivo/tview" 10 | 11 | "github.com/hcjulz/damon/component" 12 | "github.com/hcjulz/damon/models" 13 | ) 14 | 15 | func (v *View) Namespaces() { 16 | v.viewSwitch() 17 | 18 | v.Layout.Body.SetTitle(titleNamespaces) 19 | 20 | v.state.Elements.TableMain = v.components.NamespaceTable.Table.Primitive().(*tview.Table) 21 | v.components.Commands.Update(component.NoViewCommands) 22 | v.Layout.Container.SetInputCapture(v.InputNamespaces) 23 | 24 | update := func() { 25 | v.components.NamespaceTable.Props.Data = v.filterNamespaces(v.state.Namespaces) 26 | v.components.NamespaceTable.Render() 27 | v.Draw() 28 | } 29 | 30 | v.components.Search.Props.ChangedFunc = func(text string) { 31 | v.state.Filter.Namespaces = text 32 | update() 33 | } 34 | 35 | v.Watcher.SubscribeToNamespaces(update) 36 | 37 | update() 38 | 39 | v.components.Selections.Namespace.SetSelectedFunc(func(text string, index int) { 40 | v.state.SelectedNamespace = text 41 | v.Namespaces() 42 | }) 43 | 44 | v.addToHistory(v.state.SelectedNamespace, models.TopicNamespace, v.Namespaces) 45 | v.Layout.Container.SetFocus(v.components.NamespaceTable.Table.Primitive()) 46 | } 47 | 48 | func getNamespaceNameIndex(name string, ns []*models.Namespace) int { 49 | var index int 50 | for i, n := range ns { 51 | if n.Name == name { 52 | index = i 53 | } 54 | } 55 | 56 | return index 57 | } 58 | 59 | func (v *View) filterNamespaces(data []*models.Namespace) []*models.Namespace { 60 | filter := v.state.Filter.Namespaces 61 | if filter != "" { 62 | rx, _ := regexp.Compile(filter) 63 | result := []*models.Namespace{} 64 | for _, ns := range v.state.Namespaces { 65 | switch true { 66 | case rx.MatchString(ns.Name), 67 | rx.MatchString(ns.Description): 68 | result = append(result, ns) 69 | } 70 | } 71 | 72 | return result 73 | } 74 | 75 | return data 76 | } 77 | 78 | func (v *View) resetSearch() { 79 | if v.state.Toggle.Search { 80 | v.Layout.Container.SetFocus(v.state.Elements.TableMain) 81 | v.Layout.Footer.RemoveItem(v.components.Search.InputField.Primitive()) 82 | v.Layout.MainPage.ResizeItem(v.Layout.Footer, 0, 0) 83 | v.state.Toggle.Search = false 84 | } 85 | } 86 | -------------------------------------------------------------------------------- /view/task_events.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) HashiCorp, Inc. 2 | // SPDX-License-Identifier: MPL-2.0 3 | 4 | package view 5 | 6 | import ( 7 | "github.com/hashicorp/nomad/api" 8 | "github.com/rivo/tview" 9 | 10 | "github.com/hcjulz/damon/component" 11 | "github.com/hcjulz/damon/models" 12 | ) 13 | 14 | func (v *View) TaskEvents(allocID, taskName string) { 15 | v.Layout.Body.SetTitle(titleTaskEvents) 16 | v.state.Elements.TableMain = v.components.TaskEventsTable.Table.Primitive().(*tview.Table) 17 | 18 | v.components.Commands.Update(component.NoViewCommands) 19 | v.Layout.Container.SetInputCapture(v.InputMainCommands) 20 | 21 | alloc, ok := v.getAllocation(allocID) 22 | if !ok { 23 | v.handleError("allocation with ID %s doesn't exist", allocID) 24 | return 25 | } 26 | 27 | task := getTaskFromAlloc(alloc, taskName) 28 | 29 | // reverse the events array to show latest event on top. 30 | reverseEvents(task.Events) 31 | 32 | update := func() { 33 | v.components.TaskEventsTable.Props.Data = task.Events 34 | v.components.TaskEventsTable.Props.AllocID = allocID 35 | v.components.TaskEventsTable.Props.HandleNoResources = v.handleNoResources 36 | v.components.TaskEventsTable.Render() 37 | v.Draw() 38 | } 39 | 40 | v.Watcher.Subscribe(update, api.TopicAllocation) 41 | 42 | update() 43 | 44 | v.components.Selections.Namespace.SetSelectedFunc(func(text string, index int) { 45 | v.state.SelectedNamespace = text 46 | v.TaskEvents(allocID, taskName) 47 | }) 48 | 49 | v.addToHistory(v.state.SelectedNamespace, api.TopicAllocation, func() { 50 | v.TaskEvents(allocID, taskName) 51 | }) 52 | 53 | v.Layout.Container.SetFocus(v.components.TaskEventsTable.Table.Primitive()) 54 | } 55 | 56 | func getTaskFromAlloc(alloc *models.Alloc, taskName string) *models.Task { 57 | for _, t := range alloc.TaskList { 58 | if t.Name == taskName { 59 | return t 60 | } 61 | } 62 | 63 | return nil 64 | } 65 | 66 | func reverseEvents(e []*api.TaskEvent) { 67 | for i, j := 0, len(e)-1; i < j; i, j = i+1, j-1 { 68 | e[i], e[j] = e[j], e[i] 69 | } 70 | } 71 | 72 | func (v *View) getAllocation(id string) (*models.Alloc, bool) { 73 | for _, a := range v.state.Allocations { 74 | if a.ID == id { 75 | return a, true 76 | } 77 | } 78 | 79 | return nil, false 80 | } 81 | -------------------------------------------------------------------------------- /view/task_groups.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) HashiCorp, Inc. 2 | // SPDX-License-Identifier: MPL-2.0 3 | 4 | package view 5 | 6 | import ( 7 | "github.com/rivo/tview" 8 | 9 | "github.com/hcjulz/damon/component" 10 | "github.com/hcjulz/damon/models" 11 | ) 12 | 13 | func (v *View) TaskGroups(jobID string) { 14 | v.Layout.Body.SetTitle(titleTaskGroups) 15 | v.state.Elements.TableMain = v.components.TaskGroupTable.Table.Primitive().(*tview.Table) 16 | 17 | v.components.Commands.Update(component.NoViewCommands) 18 | v.Layout.Container.SetInputCapture(v.InputTaskGroups) 19 | 20 | search := v.components.Search 21 | 22 | update := func() { 23 | v.components.TaskGroupTable.Props.Data = v.state.TaskGroups 24 | v.components.TaskGroupTable.Props.JobID = jobID 25 | v.components.TaskGroupTable.Props.HandleNoResources = v.handleNoResources 26 | v.components.TaskGroupTable.Render() 27 | v.Draw() 28 | } 29 | 30 | search.Props.ChangedFunc = func(text string) { 31 | v.state.Filter.TaskGroups = text 32 | update() 33 | } 34 | 35 | v.Watcher.SubscribeToTaskGroups(jobID, update) 36 | 37 | update() 38 | 39 | v.components.Selections.Namespace.SetSelectedFunc(func(text string, index int) { 40 | v.state.SelectedNamespace = text 41 | v.TaskGroups(jobID) 42 | }) 43 | 44 | v.addToHistory(v.state.SelectedNamespace, models.TopicTaskGroup, func() { 45 | v.TaskGroups(jobID) 46 | }) 47 | 48 | v.Layout.Container.SetFocus(v.components.TaskGroupTable.Table.Primitive()) 49 | } 50 | -------------------------------------------------------------------------------- /view/tasks.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) HashiCorp, Inc. 2 | // SPDX-License-Identifier: MPL-2.0 3 | 4 | package view 5 | 6 | import ( 7 | "github.com/hashicorp/nomad/api" 8 | "github.com/rivo/tview" 9 | 10 | "github.com/hcjulz/damon/component" 11 | "github.com/hcjulz/damon/models" 12 | ) 13 | 14 | func (v *View) Tasks(alloc *models.Alloc) { 15 | v.viewSwitch() 16 | v.Layout.Body.SetTitle(titleTasks) 17 | 18 | v.Layout.Container.SetInputCapture(v.InputMainCommands) 19 | v.components.Commands.Update(component.TaskCommands) 20 | 21 | table := v.components.TaskTable 22 | table.Props.Data = alloc.TaskList 23 | table.Props.AllocationID = alloc.ID 24 | 25 | v.state.Elements.TableMain = table.Table.Primitive().(*tview.Table) 26 | 27 | update := func() { 28 | table.Render() 29 | 30 | v.Draw() 31 | } 32 | 33 | if table.Props.SelectTask == nil { 34 | table.Props.SelectTask = func(taskName, allocID string) { 35 | v.Logs(taskName, allocID, "stdout") 36 | } 37 | } 38 | 39 | v.Watcher.Subscribe(update, api.TopicAllocation) 40 | 41 | update() 42 | 43 | v.components.Selections.Namespace.SetSelectedFunc(func(text string, index int) { 44 | v.state.SelectedNamespace = text 45 | v.Tasks(alloc) 46 | }) 47 | 48 | v.addToHistory(v.state.SelectedNamespace, api.TopicAllocation, func() { 49 | v.Tasks(alloc) 50 | }) 51 | 52 | v.Layout.Container.SetFocus(v.components.TaskTable.Table.Primitive()) 53 | } 54 | -------------------------------------------------------------------------------- /watcher/activity.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) HashiCorp, Inc. 2 | // SPDX-License-Identifier: MPL-2.0 3 | 4 | package watcher 5 | 6 | type ActivityPool struct { 7 | Activities []chan struct{} 8 | } 9 | 10 | func (a *ActivityPool) Add(act chan struct{}) { 11 | a.Activities = append(a.Activities, act) 12 | } 13 | 14 | func (a *ActivityPool) DeactivateAll() { 15 | for a.hasActivities() { 16 | a.deactivate() 17 | } 18 | } 19 | 20 | func (a *ActivityPool) deactivate() { 21 | if a.hasActivities() { 22 | ch := a.Activities[0] 23 | ch <- struct{}{} 24 | a.Activities = a.Activities[1:] 25 | } 26 | } 27 | 28 | func (a *ActivityPool) hasActivities() bool { 29 | return len(a.Activities) > 0 30 | } 31 | -------------------------------------------------------------------------------- /watcher/activity_test.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) HashiCorp, Inc. 2 | // SPDX-License-Identifier: MPL-2.0 3 | 4 | package watcher_test 5 | 6 | import ( 7 | "testing" 8 | 9 | "github.com/stretchr/testify/require" 10 | 11 | "github.com/hcjulz/damon/watcher" 12 | ) 13 | 14 | func TestAdd(t *testing.T) { 15 | r := require.New(t) 16 | 17 | activity := &watcher.ActivityPool{} 18 | 19 | activity.Add(make(chan struct{})) 20 | r.Equal(len(activity.Activities), 1) 21 | 22 | activity.Add(make(chan struct{})) 23 | r.Equal(len(activity.Activities), 2) 24 | } 25 | 26 | func TestDeactivateAll(t *testing.T) { 27 | r := require.New(t) 28 | 29 | activity := &watcher.ActivityPool{} 30 | activity.Activities = []chan struct{}{ 31 | make(chan struct{}, 1), 32 | make(chan struct{}, 1), 33 | } 34 | 35 | activity.DeactivateAll() 36 | 37 | r.Empty(activity.Activities) 38 | } 39 | -------------------------------------------------------------------------------- /watcher/jobStatus.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) HashiCorp, Inc. 2 | // SPDX-License-Identifier: MPL-2.0 3 | 4 | package watcher 5 | 6 | import ( 7 | "time" 8 | 9 | "github.com/hcjulz/damon/models" 10 | ) 11 | 12 | // SubscribeToJobStatus starts a goroutine to polls JobStatus every two 13 | // seconds to update the state. The goroutine will be stopped whenever 14 | // a new subscription happens. 15 | func (w *Watcher) SubscribeToJobStatus(jobID string, notify func()) error { 16 | w.updateJobStatus(jobID) 17 | w.Subscribe(notify, models.TopicJobStatus) 18 | w.Notify(models.TopicJobStatus) 19 | 20 | stop := make(chan struct{}) 21 | w.activities.Add(stop) 22 | 23 | ticker := time.NewTicker(time.Second * 2) 24 | go func() { 25 | for { 26 | select { 27 | case <-ticker.C: 28 | w.updateJobStatus(jobID) 29 | w.Notify(models.TopicJobStatus) 30 | case <-stop: 31 | return 32 | } 33 | } 34 | }() 35 | 36 | return nil 37 | } 38 | 39 | func (w *Watcher) updateJobStatus(jobID string) { 40 | js, err := w.nomad.JobStatus(jobID, nil) 41 | if err != nil { 42 | w.NotifyHandler(models.HandleError, err.Error()) 43 | } 44 | 45 | w.state.JobStatus = js 46 | } 47 | -------------------------------------------------------------------------------- /watcher/jobStatus_test.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) HashiCorp, Inc. 2 | // SPDX-License-Identifier: MPL-2.0 3 | 4 | package watcher_test 5 | 6 | import ( 7 | "errors" 8 | "testing" 9 | "time" 10 | 11 | "github.com/stretchr/testify/require" 12 | 13 | "github.com/hcjulz/damon/models" 14 | "github.com/hcjulz/damon/state" 15 | "github.com/hcjulz/damon/watcher" 16 | "github.com/hcjulz/damon/watcher/watcherfakes" 17 | ) 18 | 19 | func TestSubscribeToJobStatus_Happy(t *testing.T) { 20 | r := require.New(t) 21 | 22 | t.Run("It notifies the subscriber initially", func(t *testing.T) { 23 | // SubscribeToJobStatus runs a goroutine that polls Nomad based on the given 24 | // interval to fetch JobStatus and notify the subscriber (if subscribed to JobStatus). 25 | // Before the goroutine starts it performs an initial fetch to avoid a delay in the 26 | // size of the interval duration. 27 | // In this case we test if this initial call happens. 28 | 29 | nomad := &watcherfakes.FakeNomad{} 30 | state := state.New() 31 | watcher := watcher.NewWatcher(state, nomad, time.Millisecond*250) 32 | 33 | expectedNSFirstCall := &models.JobStatus{Name: "foo"} 34 | 35 | done := make(chan struct{}) 36 | 37 | var callCount int 38 | notifier := func() { 39 | callCount++ 40 | switch callCount { 41 | case 1: 42 | r.Equal(expectedNSFirstCall, state.JobStatus) 43 | case 2: 44 | // wait for the goroutine to do his first call 45 | // and finish the test. 46 | done <- struct{}{} 47 | } 48 | } 49 | 50 | nomad.JobStatusReturnsOnCall(0, &models.JobStatus{ 51 | Name: "foo", 52 | }, nil) 53 | 54 | nomad.JobStatusReturnsOnCall(1, &models.JobStatus{ 55 | Name: "bar", 56 | }, nil) 57 | 58 | watcher.SubscribeToJobStatus("myJob", notifier) 59 | 60 | <-done 61 | }) 62 | 63 | t.Run("It continues to notify the subscriber after the initial notification", func(t *testing.T) { 64 | // SubscribeToJobStatus runs a goroutine that polls Nomad based on the given 65 | // interval to fetch JobStatus and notify the subscriber (if subscribed to JobStatus). 66 | // In this case we test if fetch happes. 67 | 68 | nomad := &watcherfakes.FakeNomad{} 69 | state := state.New() 70 | watcher := watcher.NewWatcher(state, nomad, time.Millisecond*250) 71 | 72 | expectedNSSecondCall := &models.JobStatus{Name: "foo"} 73 | 74 | done := make(chan struct{}) 75 | 76 | var callCount int 77 | notifier := func() { 78 | callCount++ 79 | switch callCount { 80 | case 3: 81 | // make sure that the test finishes 82 | // and avoid blocking if the assertion 83 | // fails. 84 | defer func() { done <- struct{}{} }() 85 | 86 | r.Equal(expectedNSSecondCall, state.JobStatus) 87 | } 88 | } 89 | 90 | nomad.JobStatusReturnsOnCall(2, &models.JobStatus{ 91 | Name: "foo", 92 | }, nil) 93 | 94 | watcher.SubscribeToJobStatus("myJob", notifier) 95 | 96 | <-done 97 | 98 | r.Equal(callCount, 3) 99 | }) 100 | 101 | } 102 | 103 | func TestSubscribeToJobStatus_Sad(t *testing.T) { 104 | // In this case we test that the Error handler 105 | // is called when nomad returns an error. 106 | 107 | r := require.New(t) 108 | 109 | nomad := &watcherfakes.FakeNomad{} 110 | state := state.New() 111 | watcher := watcher.NewWatcher(state, nomad, time.Millisecond*250) 112 | 113 | var called bool 114 | watcher.SubscribeHandler(models.HandleError, func(_ string, _ ...interface{}) { 115 | called = true 116 | }) 117 | 118 | nomad.JobStatusReturns(nil, errors.New("argh")) 119 | 120 | watcher.SubscribeToJobStatus("myJob", func() {}) 121 | 122 | r.True(called) 123 | } 124 | -------------------------------------------------------------------------------- /watcher/logs.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) HashiCorp, Inc. 2 | // SPDX-License-Identifier: MPL-2.0 3 | 4 | package watcher 5 | 6 | import "github.com/hcjulz/damon/models" 7 | 8 | // SubscribeToLogs starts an event stream for Logs 9 | // which updates the state whenever a new log is written. 10 | // The stream will be stopped whenever a new subscription happens. 11 | func (w *Watcher) SubscribeToLogs(allocID, taskName, source string, notify func()) { 12 | // wipe any previous logs 13 | w.state.Logs = nil 14 | w.logResumer = &logResumer{ 15 | allocID: allocID, 16 | taskName: taskName, 17 | source: source, 18 | notify: notify, 19 | } 20 | 21 | alloc, ok := w.getAllocation(allocID) 22 | if !ok { 23 | w.NotifyHandler(models.HandleError, "allocation not found: %s", allocID) 24 | return 25 | } 26 | 27 | if len(alloc.TaskNames) == 0 { 28 | w.NotifyHandler(models.HandleError, "no tasks for allocation: %s", allocID) 29 | return 30 | } 31 | 32 | w.Subscribe(notify, models.TopicLog) 33 | w.Notify(models.TopicLog) 34 | 35 | cancel := make(chan struct{}) 36 | streamCh, errorCh := w.nomad.Logs(allocID, taskName, source, cancel) 37 | 38 | w.activities.Add(cancel) 39 | 40 | go func() { 41 | for { 42 | select { 43 | case frame := <-streamCh: 44 | if frame.Data != nil { 45 | w.state.Logs = frame.Data 46 | w.Notify(models.TopicLog) 47 | } 48 | case err := <-errorCh: 49 | w.NotifyHandler(models.HandleError, err.Error()) 50 | case <-cancel: 51 | return 52 | } 53 | } 54 | }() 55 | } 56 | 57 | func (w *Watcher) ResumeLogs() { 58 | w.SubscribeToLogs(w.logResumer.allocID, w.logResumer.taskName, w.logResumer.source, w.logResumer.notify) 59 | } 60 | 61 | func (w *Watcher) getAllocation(id string) (*models.Alloc, bool) { 62 | for _, a := range w.state.Allocations { 63 | if a.ID == id { 64 | return a, true 65 | } 66 | } 67 | 68 | return nil, false 69 | } 70 | -------------------------------------------------------------------------------- /watcher/namespaces.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) HashiCorp, Inc. 2 | // SPDX-License-Identifier: MPL-2.0 3 | 4 | package watcher 5 | 6 | import ( 7 | "time" 8 | 9 | "github.com/hcjulz/damon/models" 10 | ) 11 | 12 | // SubscribeToNamespaces starts a goroutine to poll Namespaces based 13 | // on the provided interval. It updates the state accordingly. 14 | // The goroutine will be stopped whenever a new subscription happens. 15 | func (w *Watcher) SubscribeToNamespaces(notify func()) { 16 | w.updateNamespaces() 17 | w.Subscribe(notify, models.TopicNamespace) 18 | w.Notify(models.TopicNamespace) 19 | 20 | stop := make(chan struct{}) 21 | w.activities.Add(stop) 22 | 23 | ticker := time.NewTicker(w.interval) 24 | go func() { 25 | for { 26 | select { 27 | case <-ticker.C: 28 | w.updateNamespaces() 29 | w.Notify(models.TopicNamespace) 30 | case <-stop: 31 | return 32 | } 33 | } 34 | }() 35 | } 36 | 37 | func (w *Watcher) updateNamespaces() { 38 | ns, err := w.nomad.Namespaces(nil) 39 | if err != nil { 40 | w.NotifyHandler(models.HandleError, err.Error()) 41 | } 42 | 43 | w.state.Namespaces = ns 44 | } 45 | -------------------------------------------------------------------------------- /watcher/namespaces_test.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) HashiCorp, Inc. 2 | // SPDX-License-Identifier: MPL-2.0 3 | 4 | package watcher_test 5 | 6 | import ( 7 | "errors" 8 | "testing" 9 | "time" 10 | 11 | "github.com/stretchr/testify/require" 12 | 13 | "github.com/hcjulz/damon/models" 14 | "github.com/hcjulz/damon/state" 15 | "github.com/hcjulz/damon/watcher" 16 | "github.com/hcjulz/damon/watcher/watcherfakes" 17 | ) 18 | 19 | func TestSubscribeToNamespaces_Happy(t *testing.T) { 20 | r := require.New(t) 21 | 22 | t.Run("It notifies the subscriber initially", func(t *testing.T) { 23 | // SubscribeToNamespace runs a goroutine that polls Nomad based on the given 24 | // interval to fetch Namespaces and notify the subscriber (if subscribed to namespaces). 25 | // Before the goroutine starts it performs an initial fetch to avoid a delay in the 26 | // size of the interval duration. 27 | // In this case we test if this initial call happens. 28 | 29 | nomad := &watcherfakes.FakeNomad{} 30 | state := state.New() 31 | watcher := watcher.NewWatcher(state, nomad, time.Millisecond*250) 32 | 33 | expectedNSFirstCall := []*models.Namespace{{Name: "foo"}} 34 | 35 | done := make(chan struct{}) 36 | 37 | var callCount int 38 | notifier := func() { 39 | callCount++ 40 | switch callCount { 41 | case 1: 42 | r.Equal(expectedNSFirstCall, state.Namespaces) 43 | case 2: 44 | // wait for the goroutine to do his first call 45 | // and finish the test. 46 | done <- struct{}{} 47 | } 48 | } 49 | 50 | nomad.NamespacesReturnsOnCall(0, []*models.Namespace{ 51 | {Name: "foo"}, 52 | }, nil) 53 | 54 | nomad.NamespacesReturnsOnCall(1, []*models.Namespace{ 55 | {Name: "foo"}, 56 | {Name: "bar"}, 57 | }, nil) 58 | 59 | watcher.SubscribeToNamespaces(notifier) 60 | 61 | <-done 62 | }) 63 | 64 | t.Run("It continues to notify the subscriber after the initial notification", func(t *testing.T) { 65 | // SubscribeToNamespace runs a goroutine that polls Nomad based on the given 66 | // interval to fetch Namespaces and notify the subscriber (if subscribed to namespaces). 67 | // In this case we test if fetch happes. 68 | 69 | nomad := &watcherfakes.FakeNomad{} 70 | state := state.New() 71 | watcher := watcher.NewWatcher(state, nomad, time.Millisecond*250) 72 | 73 | expectedNSSecondCall := []*models.Namespace{{Name: "foo"}, {Name: "bar"}} 74 | 75 | done := make(chan struct{}) 76 | 77 | var callCount int 78 | notifier := func() { 79 | callCount++ 80 | switch callCount { 81 | case 3: 82 | // make sure that the test finishes 83 | // and avoid blocking if the assertion 84 | // fails. 85 | defer func() { done <- struct{}{} }() 86 | 87 | r.Equal(expectedNSSecondCall, state.Namespaces) 88 | } 89 | } 90 | 91 | nomad.NamespacesReturnsOnCall(2, []*models.Namespace{ 92 | {Name: "foo"}, 93 | {Name: "bar"}, 94 | }, nil) 95 | 96 | watcher.SubscribeToNamespaces(notifier) 97 | 98 | <-done 99 | 100 | r.Equal(callCount, 3) 101 | }) 102 | 103 | } 104 | 105 | func TestSubscribeToNamespaces_Sad(t *testing.T) { 106 | // In this case we test that the Error handler 107 | // is called when nomad returns an error. 108 | 109 | r := require.New(t) 110 | 111 | nomad := &watcherfakes.FakeNomad{} 112 | state := state.New() 113 | watcher := watcher.NewWatcher(state, nomad, time.Millisecond*250) 114 | 115 | var called bool 116 | watcher.SubscribeHandler(models.HandleError, func(_ string, _ ...interface{}) { 117 | called = true 118 | }) 119 | 120 | nomad.NamespacesReturns(nil, errors.New("argh")) 121 | 122 | watcher.SubscribeToNamespaces(func() {}) 123 | 124 | r.True(called) 125 | } 126 | -------------------------------------------------------------------------------- /watcher/taskgroups.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) HashiCorp, Inc. 2 | // SPDX-License-Identifier: MPL-2.0 3 | 4 | package watcher 5 | 6 | import ( 7 | "time" 8 | 9 | "github.com/hcjulz/damon/models" 10 | ) 11 | 12 | // SubscribeToTaskGroups starts a goroutine to polls TaskGroups every two 13 | // seconds to update the state. The goroutine will be stopped whenever 14 | // a new subscription happens. 15 | func (w *Watcher) SubscribeToTaskGroups(jobID string, notify func()) error { 16 | w.updateTaskGroups(jobID) 17 | w.Subscribe(notify, models.TopicTaskGroup) 18 | w.Notify(models.TopicTaskGroup) 19 | 20 | stop := make(chan struct{}) 21 | w.activities.Add(stop) 22 | 23 | ticker := time.NewTicker(time.Second * 2) 24 | go func() { 25 | for { 26 | select { 27 | case <-ticker.C: 28 | w.updateTaskGroups(jobID) 29 | w.Notify(models.TopicTaskGroup) 30 | case <-stop: 31 | return 32 | } 33 | } 34 | }() 35 | 36 | return nil 37 | } 38 | 39 | func (w *Watcher) updateTaskGroups(jobID string) { 40 | tg, err := w.nomad.TaskGroups(jobID, nil) 41 | if err != nil { 42 | w.NotifyHandler(models.HandleError, err.Error()) 43 | } 44 | 45 | w.state.TaskGroups = tg 46 | } 47 | -------------------------------------------------------------------------------- /watcher/taskgroups_test.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) HashiCorp, Inc. 2 | // SPDX-License-Identifier: MPL-2.0 3 | 4 | package watcher_test 5 | 6 | import ( 7 | "errors" 8 | "testing" 9 | "time" 10 | 11 | "github.com/stretchr/testify/require" 12 | 13 | "github.com/hcjulz/damon/models" 14 | "github.com/hcjulz/damon/state" 15 | "github.com/hcjulz/damon/watcher" 16 | "github.com/hcjulz/damon/watcher/watcherfakes" 17 | ) 18 | 19 | func TestSubscribeToTaskGroups_Happy(t *testing.T) { 20 | r := require.New(t) 21 | 22 | t.Run("It notifies the subscriber initially", func(t *testing.T) { 23 | // SubscribeToTaskGroups runs a goroutine that polls Nomad based on the given 24 | // interval to fetch TaskGroups and notify the subscriber. 25 | // Before the goroutine starts it performs an initial fetch to avoid a delay in the 26 | // size of the interval duration. 27 | // In this case we test if this initial call happens. 28 | 29 | nomad := &watcherfakes.FakeNomad{} 30 | state := state.New() 31 | watcher := watcher.NewWatcher(state, nomad, time.Millisecond*250) 32 | 33 | expectedNSFirstCall := []*models.TaskGroup{{Name: "foo"}} 34 | 35 | done := make(chan struct{}) 36 | 37 | var callCount int 38 | notifier := func() { 39 | callCount++ 40 | switch callCount { 41 | case 1: 42 | r.Equal(expectedNSFirstCall, state.TaskGroups) 43 | case 2: 44 | // wait for the goroutine to do his first call 45 | // and finish the test. 46 | done <- struct{}{} 47 | } 48 | } 49 | 50 | nomad.TaskGroupsReturnsOnCall(0, []*models.TaskGroup{ 51 | {Name: "foo"}, 52 | }, nil) 53 | 54 | nomad.TaskGroupsReturnsOnCall(1, []*models.TaskGroup{ 55 | {Name: "foo"}, 56 | {Name: "bar"}, 57 | }, nil) 58 | 59 | watcher.SubscribeToTaskGroups("myJob", notifier) 60 | 61 | <-done 62 | }) 63 | 64 | t.Run("It continues to notify the subscriber after the initial notification", func(t *testing.T) { 65 | // SubscribeToTaskGroups runs a goroutine that polls Nomad based on the given 66 | // interval to fetch TaskGroups and notify the subscriber. 67 | // In this case we test if fetch happes. 68 | 69 | nomad := &watcherfakes.FakeNomad{} 70 | state := state.New() 71 | watcher := watcher.NewWatcher(state, nomad, time.Millisecond*250) 72 | 73 | expectedNSSecondCall := []*models.TaskGroup{{Name: "foo"}, {Name: "bar"}} 74 | 75 | done := make(chan struct{}) 76 | 77 | var callCount int 78 | notifier := func() { 79 | callCount++ 80 | switch callCount { 81 | case 3: 82 | // make sure that the test finishes 83 | // and avoid blocking if the assertion 84 | // fails. 85 | defer func() { done <- struct{}{} }() 86 | 87 | r.Equal(expectedNSSecondCall, state.TaskGroups) 88 | } 89 | } 90 | 91 | nomad.TaskGroupsReturnsOnCall(2, []*models.TaskGroup{ 92 | {Name: "foo"}, 93 | {Name: "bar"}, 94 | }, nil) 95 | 96 | watcher.SubscribeToTaskGroups("jobID", notifier) 97 | 98 | <-done 99 | 100 | r.Equal(callCount, 3) 101 | }) 102 | 103 | } 104 | 105 | func TestSubscribeToTaskGroups_Sad(t *testing.T) { 106 | // In this case we test that the Error handler 107 | // is called when nomad returns an error. 108 | 109 | r := require.New(t) 110 | 111 | nomad := &watcherfakes.FakeNomad{} 112 | state := state.New() 113 | watcher := watcher.NewWatcher(state, nomad, time.Millisecond*250) 114 | 115 | var called bool 116 | watcher.SubscribeHandler(models.HandleError, func(_ string, _ ...interface{}) { 117 | called = true 118 | }) 119 | 120 | nomad.TaskGroupsReturns(nil, errors.New("argh")) 121 | 122 | watcher.SubscribeToTaskGroups("jobID", func() {}) 123 | 124 | r.True(called) 125 | } 126 | -------------------------------------------------------------------------------- /watcher/watcherfakes/fake_activities.go: -------------------------------------------------------------------------------- 1 | // Code generated by counterfeiter. DO NOT EDIT. 2 | package watcherfakes 3 | 4 | import ( 5 | "sync" 6 | 7 | "github.com/hcjulz/damon/watcher" 8 | ) 9 | 10 | type FakeActivities struct { 11 | AddStub func(chan struct{}) 12 | addMutex sync.RWMutex 13 | addArgsForCall []struct { 14 | arg1 chan struct{} 15 | } 16 | DeactivateAllStub func() 17 | deactivateAllMutex sync.RWMutex 18 | deactivateAllArgsForCall []struct { 19 | } 20 | invocations map[string][][]interface{} 21 | invocationsMutex sync.RWMutex 22 | } 23 | 24 | func (fake *FakeActivities) Add(arg1 chan struct{}) { 25 | fake.addMutex.Lock() 26 | fake.addArgsForCall = append(fake.addArgsForCall, struct { 27 | arg1 chan struct{} 28 | }{arg1}) 29 | stub := fake.AddStub 30 | fake.recordInvocation("Add", []interface{}{arg1}) 31 | fake.addMutex.Unlock() 32 | if stub != nil { 33 | fake.AddStub(arg1) 34 | } 35 | } 36 | 37 | func (fake *FakeActivities) AddCallCount() int { 38 | fake.addMutex.RLock() 39 | defer fake.addMutex.RUnlock() 40 | return len(fake.addArgsForCall) 41 | } 42 | 43 | func (fake *FakeActivities) AddCalls(stub func(chan struct{})) { 44 | fake.addMutex.Lock() 45 | defer fake.addMutex.Unlock() 46 | fake.AddStub = stub 47 | } 48 | 49 | func (fake *FakeActivities) AddArgsForCall(i int) chan struct{} { 50 | fake.addMutex.RLock() 51 | defer fake.addMutex.RUnlock() 52 | argsForCall := fake.addArgsForCall[i] 53 | return argsForCall.arg1 54 | } 55 | 56 | func (fake *FakeActivities) DeactivateAll() { 57 | fake.deactivateAllMutex.Lock() 58 | fake.deactivateAllArgsForCall = append(fake.deactivateAllArgsForCall, struct { 59 | }{}) 60 | stub := fake.DeactivateAllStub 61 | fake.recordInvocation("DeactivateAll", []interface{}{}) 62 | fake.deactivateAllMutex.Unlock() 63 | if stub != nil { 64 | fake.DeactivateAllStub() 65 | } 66 | } 67 | 68 | func (fake *FakeActivities) DeactivateAllCallCount() int { 69 | fake.deactivateAllMutex.RLock() 70 | defer fake.deactivateAllMutex.RUnlock() 71 | return len(fake.deactivateAllArgsForCall) 72 | } 73 | 74 | func (fake *FakeActivities) DeactivateAllCalls(stub func()) { 75 | fake.deactivateAllMutex.Lock() 76 | defer fake.deactivateAllMutex.Unlock() 77 | fake.DeactivateAllStub = stub 78 | } 79 | 80 | func (fake *FakeActivities) Invocations() map[string][][]interface{} { 81 | fake.invocationsMutex.RLock() 82 | defer fake.invocationsMutex.RUnlock() 83 | fake.addMutex.RLock() 84 | defer fake.addMutex.RUnlock() 85 | fake.deactivateAllMutex.RLock() 86 | defer fake.deactivateAllMutex.RUnlock() 87 | copiedInvocations := map[string][][]interface{}{} 88 | for key, value := range fake.invocations { 89 | copiedInvocations[key] = value 90 | } 91 | return copiedInvocations 92 | } 93 | 94 | func (fake *FakeActivities) recordInvocation(key string, args []interface{}) { 95 | fake.invocationsMutex.Lock() 96 | defer fake.invocationsMutex.Unlock() 97 | if fake.invocations == nil { 98 | fake.invocations = map[string][][]interface{}{} 99 | } 100 | if fake.invocations[key] == nil { 101 | fake.invocations[key] = [][]interface{}{} 102 | } 103 | fake.invocations[key] = append(fake.invocations[key], args) 104 | } 105 | 106 | var _ watcher.Activities = new(FakeActivities) 107 | --------------------------------------------------------------------------------