├── .circleci └── config.yml ├── .github └── CODEOWNERS ├── .gitignore ├── CHANGELOG.md ├── LICENSE ├── Makefile ├── Readme.md ├── cmd └── grafterm │ ├── flags.go │ └── main.go ├── dashboard-examples ├── Readme.md ├── gitlab.json ├── go.json ├── kubernetes-cluster-status.json ├── red.json ├── test-grid-adaptive.json ├── test-grid-fixed.json └── wikimedia.json ├── docker └── dev │ └── Dockerfile ├── docs └── cfg.md ├── go.mod ├── go.sum ├── hack └── scripts │ ├── build-image.sh │ ├── build.sh │ ├── ci-release.sh │ ├── integration-test.sh │ ├── mockgen.sh │ └── unit-test.sh ├── img └── grafterm-red-compressed.gif └── internal ├── controller ├── controller.go └── controller_test.go ├── mocks ├── controller │ └── Controller.go ├── doc.go ├── github.com │ └── prometheus │ │ └── client_golang │ │ └── api │ │ └── prometheus │ │ └── v1 │ │ └── API.go ├── service │ └── metric │ │ └── Gatherer.go ├── thirdparty │ └── github.com │ │ └── prometheus │ │ └── client_golang │ │ └── api │ │ └── prometheus │ │ └── v1 │ │ └── v1.go └── view │ └── render │ ├── GaugeWidget.go │ ├── GraphWidget.go │ ├── Renderer.go │ └── SinglestatWidget.go ├── model ├── dashboard.go ├── dashboard_test.go ├── datasource.go ├── datasource_test.go └── metric.go ├── service ├── configuration │ ├── configuration.go │ ├── loader.go │ ├── loader_test.go │ ├── meta │ │ └── meta.go │ └── v1 │ │ ├── v1.go │ │ └── v1_test.go ├── log │ └── log.go ├── metric │ ├── datasource │ │ ├── datasource.go │ │ └── datasource_test.go │ ├── fake │ │ └── fake.go │ ├── gather.go │ ├── graphite │ │ ├── graphite.go │ │ └── graphite_test.go │ ├── influxdb │ │ ├── influxdb.go │ │ └── influxdb_test.go │ ├── middleware │ │ └── log.go │ └── prometheus │ │ ├── prometheus.go │ │ └── prometheus_test.go └── unit │ ├── formatter.go │ ├── formatter_test.go │ ├── time.go │ └── time_test.go └── view ├── app.go ├── grid ├── grid.go └── grid_test.go ├── page ├── dashboard.go ├── middleware.go ├── middleware_test.go └── widget │ ├── gauge.go │ ├── gauge_test.go │ ├── graph.go │ ├── graph_test.go │ ├── misc.go │ ├── misc_test.go │ ├── singlestat.go │ └── singlestat_test.go ├── render ├── api.go └── termdash │ ├── gauge.go │ ├── graph.go │ ├── misc.go │ ├── singlestat.go │ └── view.go ├── sync └── sync.go ├── template ├── template.go └── template_test.go └── variable ├── const.go ├── interval.go └── variable.go /.circleci/config.yml: -------------------------------------------------------------------------------- 1 | version: 2.1 2 | jobs: 3 | test: 4 | docker: 5 | - image: circleci/golang:1.12 6 | environment: 7 | GO111MODULE: "on" 8 | GOPROXY: https://gocenter.io 9 | working_directory: /go/src/github.com/slok/grafterm 10 | steps: 11 | - checkout 12 | - run: make ci 13 | 14 | release: 15 | docker: 16 | - image: circleci/golang:1.12 17 | environment: 18 | GO111MODULE: "on" 19 | GOPROXY: https://gocenter.io 20 | working_directory: /go/src/github.com/slok/grafterm 21 | steps: 22 | - checkout 23 | - run: make ci-release 24 | - run: 25 | name: "Publish Release on GitHub" 26 | command: | 27 | go get github.com/tcnksm/ghr 28 | ghr -t ${GITHUB_TOKEN} -u ${CIRCLE_PROJECT_USERNAME} -r ${CIRCLE_PROJECT_REPONAME} -c ${CIRCLE_SHA1} -delete ${CIRCLE_TAG} ./bin/ 29 | 30 | workflows: 31 | version: 2 32 | main: 33 | jobs: 34 | - test: 35 | filters: 36 | tags: 37 | only: /.*/ 38 | - release: 39 | requires: 40 | - test 41 | filters: 42 | branches: 43 | ignore: /.*/ 44 | tags: 45 | only: /^v.*/ 46 | -------------------------------------------------------------------------------- /.github/CODEOWNERS: -------------------------------------------------------------------------------- 1 | * @slok 2 | 3 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Binaries for programs and plugins 2 | *.exe 3 | *.dll 4 | *.so 5 | *.dylib 6 | 7 | # Test binary, build with `go test -c` 8 | *.test 9 | 10 | # Output of the go coverage tool, specifically when used with LiteIDE 11 | *.out 12 | 13 | # Project-local glide cache, RE: https://github.com/Masterminds/glide/issues/736 14 | .glide/ 15 | 16 | # binary 17 | bin/ 18 | 19 | # vendor 20 | vendor/ 21 | 22 | # App specific 23 | dashboards/ 24 | grafterm.log -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | ## [0.2.0] - 2019-07-26 4 | 5 | ### Added 6 | 7 | - ARM release builds. 8 | - Add `nullPointMode` series override setting (with `zero` and `connected` strategies). 9 | - Graphite datasource. 10 | - Milliseconds unit conversion. 11 | - Quit grafterm with `Esc` key. 12 | - User defined datasources via flag and/or env var. 13 | - Alias flag to override dashboard datasource ID using user datasource IDs. 14 | - Fallback dashboard referenced datasources to user datasources. 15 | 16 | ### Fixed 17 | 18 | - Gauges that had color thresholds not being show. 19 | 20 | ## [0.1.0] - 2019-05-13 21 | 22 | ### Added 23 | 24 | - `start` and `end` flags to visualize fixed time range graphs. 25 | - `var` repeatable flag to override dashboard variables. 26 | - Unit formatters for time, RPS, percent and ratios. 27 | - Unit and decimals on the graph widget Y-axis. 28 | - Unit and decimals on the singlestat widget. 29 | - MaxWidth option that sets the horizontal scale of the grid. 30 | - Widget grid fixed mode. 31 | - Widget grid adaptive mode. 32 | - Grid implementation for widgets. 33 | - Dynamic X-axis time labels based on time range and steps. 34 | - Templated queries using variables. 35 | - Const and autointerval variables. 36 | - Color override on graph series based on legend regex. 37 | - Templated legends on graph widget. 38 | - Legend on graph widget. 39 | - Graph widget. 40 | - Single metric gather. 41 | - Metric range gather. 42 | - Allow multiple datasources in the same dashboard. 43 | - Debug flag that will set a verbose logger (will break UI rendering but prints errors and infos). 44 | - Termdash render engine implementation for widgets. 45 | - Singlestat widget. 46 | - Gauge widget. 47 | - Main term application. 48 | - Fake metrics gatherer. 49 | 50 | [unreleased]: https://github.com/slok/grafterm/compare/v0.2.0...HEAD 51 | [0.2.0]: https://github.com/slok/grafterm/compare/v0.1.0...0.2.0 52 | [0.1.0]: https://github.com/slok/grafterm/releases/tag/v0.1.0 53 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | 2 | 3 | # Name of this service/application 4 | SERVICE_NAME := grafterm 5 | 6 | # Path of the go service inside docker 7 | DOCKER_GO_SERVICE_PATH := /src 8 | 9 | # Shell to use for running scripts 10 | SHELL := $(shell which bash) 11 | 12 | # Get OS 13 | OSTYPE := $(shell uname) 14 | 15 | # Get docker path or an empty string 16 | DOCKER := $(shell command -v docker) 17 | 18 | # Get the main unix group for the user running make (to be used by docker-compose later) 19 | GID := $(shell id -g) 20 | 21 | # Get the unix user id for the user running make (to be used by docker-compose later) 22 | UID := $(shell id -u) 23 | 24 | # Version from Git. 25 | VERSION=$(shell git describe --tags --always) 26 | 27 | # cmds 28 | UNIT_TEST_CMD := ./hack/scripts/unit-test.sh 29 | INTEGRATION_TEST_CMD := ./hack/scripts/integration-test.sh 30 | MOCKS_CMD := ./hack/scripts/mockgen.sh 31 | DOCKER_RUN_CMD := docker run --env ostype=$(OSTYPE) -v ${PWD}:$(DOCKER_GO_SERVICE_PATH) --rm -it $(SERVICE_NAME) 32 | BUILD_BINARY_CMD := VERSION=${VERSION} ./hack/scripts/build.sh 33 | BUILD_IMAGE_CMD := IMAGE_VERSION=${VERSION} ./hack/scripts/build-image.sh 34 | CI_RELEASE_CMD := ./hack/scripts/ci-release.sh 35 | DEPS_CMD := GO111MODULE=on go mod tidy && GO111MODULE=on go mod vendor 36 | 37 | 38 | # The default action of this Makefile is to build the development docker image 39 | .PHONY: default 40 | default: build 41 | 42 | # Test if the dependencies we need to run this Makefile are installed 43 | .PHONY: deps-development 44 | deps-development: 45 | ifndef DOCKER 46 | @echo "Docker is not available. Please install docker" 47 | @exit 1 48 | endif 49 | ifndef IMAGE 50 | @echo "Docker Image not available, Building..." 51 | docker build -t $(SERVICE_NAME) \ 52 | --build-arg uid=$(UID) \ 53 | --build-arg gid=$(GID) \ 54 | --build-arg ostype=$(OSTYPE) -f ./docker/dev/Dockerfile . 55 | endif 56 | 57 | # Build the development docker image 58 | .PHONY: build 59 | build: 60 | docker build -t $(SERVICE_NAME) \ 61 | --build-arg uid=$(UID) \ 62 | --build-arg gid=$(GID) \ 63 | --build-arg ostype=$(OSTYPE) -f ./docker/dev/Dockerfile . 64 | 65 | # Shell the development docker image 66 | .PHONY: build 67 | shell: build 68 | $(DOCKER_RUN_CMD) /bin/bash 69 | 70 | # Build production stuff. 71 | build-binary: deps-development 72 | $(DOCKER_RUN_CMD) /bin/sh -c '$(BUILD_BINARY_CMD)' 73 | 74 | .PHONY: build-image 75 | build-image: 76 | $(BUILD_IMAGE_CMD) 77 | 78 | # Test stuff in dev 79 | .PHONY: unit-test 80 | unit-test: build 81 | $(DOCKER_RUN_CMD) /bin/sh -c '$(UNIT_TEST_CMD)' 82 | .PHONY: integration-test 83 | integration-test: build 84 | $(DOCKER_RUN_CMD) /bin/sh -c '$(INTEGRATION_TEST_CMD)' 85 | .PHONY: test 86 | test: integration-test 87 | 88 | # Test stuff in ci 89 | .PHONY: ci-unit-test 90 | ci-unit-test: 91 | $(UNIT_TEST_CMD) 92 | .PHONY: ci-integration-test 93 | ci-integration-test: 94 | $(INTEGRATION_TEST_CMD) 95 | .PHONY: ci 96 | ci: ci-integration-test 97 | 98 | .PHONY: ci-release 99 | ci-release: 100 | $(CI_RELEASE_CMD) 101 | 102 | # Mocks stuff in dev 103 | .PHONY: mocks 104 | mocks: build 105 | #$(DOCKER_RUN_CMD) /bin/sh -c '$(MOCKS_CMD)' 106 | $(MOCKS_CMD) 107 | 108 | # Dependencies stuff. 109 | .PHONY: deps 110 | deps: 111 | $(DEPS_CMD) 112 | -------------------------------------------------------------------------------- /Readme.md: -------------------------------------------------------------------------------- 1 | # Grafterm [![CircleCI][circleci-image]][circleci-url] [![Go Report Card][go-reportcard-image]][go-reportcard-url] 2 | 3 | Visualize metrics dashboards on the terminal, like a simplified and minimalist version of [Grafana] for terminal. 4 | 5 | ![grafterm red dashboard](/img/grafterm-red-compressed.gif) 6 | 7 | ## Features 8 | 9 | - Multiple widgets (graph, singlestat, gauge). 10 | - Multiple datasources usage. 11 | - User stored datasources. 12 | - Override dashboard datasource ID to different datasource ID configured by the user. 13 | - Custom dashboards based on JSON configuration files. 14 | - Extensible metrics datasource implementation (Prometheus and Graphite included). 15 | - Templating of variables. 16 | - Auto time interval adjustment for queries. 17 | - Auto unit formatting on widgets. 18 | - Fixed and adaptive grid. 19 | - Color customization on widgets. 20 | - Configurable autorefresh. 21 | - Single binary and easy usage/deployment. 22 | 23 | ## Installation 24 | 25 | Download the binaries from [releases] 26 | 27 | ## Running options 28 | 29 | Exit with `q` or `Esc` 30 | 31 | ### Simple 32 | 33 | ```bash 34 | grafterm -c ./mydashboard.json 35 | ``` 36 | 37 | ### Relative time 38 | 39 | ```bash 40 | grafterm -c ./mydashboard.json -d 48h 41 | ``` 42 | 43 | ### Refresh interval 44 | 45 | ```bash 46 | grafterm -c ./mydashboard.json -r 2s 47 | ``` 48 | 49 | ### Debugging 50 | 51 | When grafterm doesn't show anything may be that has errors getting metrics or similar. There is available a `--debug` flag that will write a log on `grafterm.log` (this path can be override with `--log-path` flag) 52 | 53 | Read the log 54 | 55 | ```bash 56 | tail -f ./grafterm.log 57 | ``` 58 | 59 | And run grafterm in debug mode. 60 | 61 | ```bash 62 | grafterm -c ./mydashboard.json -d 48h -r 2s --debug 63 | ``` 64 | 65 | ### Fixed time 66 | 67 | Setting a fixed time range to visualize the metrics using duration notation. In this example is start at `now-22h` and end at `now-20h` 68 | 69 | ```bash 70 | grafterm -c ./mydashboard.json -s 22h -e 20h 71 | ``` 72 | 73 | Setting a fixed time range to visualize the metrics using timestamp [ISO 8601] notation. 74 | 75 | ```bash 76 | grafterm -c ./mydashboard.json -s 2019-05-12T12:32:11+02:00 -e 2019-05-12T12:35:11+02:00 77 | ``` 78 | 79 | ### Replacing dashboard variables 80 | 81 | ```bash 82 | grafterm -c ./mydashboard.json -v env=prod -v job=envoy 83 | ``` 84 | 85 | ### Replacing dashboard datasource configuration 86 | 87 | Replace dashbaord `prometheus` datasource with user datasource `thanos-prometheus` (check [Datasources](#datasources) section): 88 | 89 | ```bash 90 | grafterm -c ./mydashboard.json -a "prometheus=thanos-prometheus" 91 | ``` 92 | 93 | Replace dashboard `prometheus` datasource with user datasource `thanos-prometheus` available on `/tmp/my-datasources.json` user datasource configuration file: 94 | 95 | ```bash 96 | grafterm -c ./mydashboard.json -a "prometheus=thanos-prometheus" -u /tmp/my-datasources.json 97 | ``` 98 | 99 | ## Dashboard 100 | 101 | Check [this][cfg-md] section that explains how a dashboard is configured. Also check [dashboard examples][dashboard-examples] 102 | 103 | ## Datasources 104 | 105 | Datasources are the way grafterm knows how to retrieve the metrics for the dashboard. 106 | 107 | check available types and how to configure in [this][cfg-md] section. 108 | 109 | **If you want support for a new datasource type, open an issue or send a PR** 110 | 111 | ### Overriding dashboard datasources 112 | 113 | Dashboard referenced datasources on the queries can be override. 114 | 115 | #### User datasource 116 | 117 | Grafterm dashboards can have default datasources but the user can override these datasources using a datasources config file. This file has the same format as the dashboard configuration file but will ignore anything other than the `datasources` block. Example: 118 | 119 | ```json 120 | { 121 | "version": "v1", 122 | "datasources": { 123 | "prometheus": { 124 | "prometheus": { "address": "http://127.0.0.1:9090" } 125 | }, 126 | "localprom": { 127 | "prometheus": { "address": "http://127.0.0.1:9091" } 128 | }, 129 | "thanos": { 130 | "prometheus": { "address": "http://127.0.0.1:9092" } 131 | }, 132 | "m3db": { 133 | "prometheus": { "address": "http://127.0.0.1:9093" } 134 | }, 135 | "victoriametrics": { 136 | "prometheus": { "address": "http://127.0.0.1:8428" } 137 | }, 138 | "wikimedia": { 139 | "graphite": { "address": "https://graphite.wikimedia.org" } 140 | } 141 | } 142 | } 143 | ``` 144 | 145 | If the dashboard has defined a datasource configuration with the ID `my-ds` reference, and the user datasources has this same datasource ID, grafterm will use the user defined one when the queries in the dashboard reference this ID. 146 | 147 | The user datasources location can be configured with this priority (from highest to lowest): 148 | 149 | - If `--user-datasources` explicit flag is used, it will use this. 150 | - If `GRAFTERM_USER_DATASOURCES` env var is set, it will use this. 151 | - As a fallback location will check `{USER_HOME}/grafterm/datasources.json` exists. 152 | 153 | #### Alias 154 | 155 | Apart from overriding the dashboard datasources IDs that match with the user datasources, the user can force an alias with the form `dashboard-ds-id=user-ds-id`. 156 | 157 | For example, the dashboard uses a datasource named `prometheus-2b`, and we want to use our local prometheus configured on the user datasources as `localprom`, we could use the alias flag like this: `-a "prometheus-2b=localprom"`, now every query the dashboard widgets make to `prometheus-2b` will be made to `localprom`. 158 | 159 | ## Kudos 160 | 161 | This project would not be possible without the effort of many people and projects but specially [Grafana] for the inspiration, ideas and the project itself, and [Termdash] for the rendering of all those fancy graphs on the terminal. 162 | 163 | [circleci-image]: https://img.shields.io/circleci/project/github/slok/grafterm/master.svg 164 | [circleci-url]: https://circleci.com/gh/slok/grafterm 165 | [go-reportcard-image]: https://goreportcard.com/badge/github.com/slok/grafterm 166 | [go-reportcard-url]: https://goreportcard.com/report/github.com/slok/grafterm 167 | [grafana]: https://grafana.com/ 168 | [termdash]: https://github.com/mum4k/termdash 169 | [releases]: https://github.com/slok/grafterm/releases 170 | [cfg-md]: /docs/cfg.md 171 | [dashboard-examples]: /dashboard-examples 172 | [iso 8601]: https://en.wikipedia.org/wiki/ISO_8601 173 | [prometheus]: http://prometheus.io 174 | -------------------------------------------------------------------------------- /cmd/grafterm/flags.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | "path" 7 | "time" 8 | 9 | "github.com/alecthomas/kingpin" 10 | ) 11 | 12 | // Version is the application version. 13 | var Version = "dev" 14 | 15 | // Defaults settings required by flags.. 16 | const ( 17 | defConfig = "dashboard.json" 18 | defRefreshInterval = "10s" 19 | defLogPath = "grafterm.log" 20 | defGraftermDir = "grafterm" 21 | ) 22 | 23 | var defUserDatasourcePath = []string{defGraftermDir, "datasources.json"} 24 | 25 | // Env vars. 26 | const ( 27 | envPrefix = "GRAFTERM" 28 | envUserDatasources = envPrefix + "_USER_DATASOURCES" 29 | ) 30 | 31 | // flag descriptions. 32 | const ( 33 | descCfg = "the path to the configuration file" 34 | descRefreshInterval = "the interval to refresh the dashboard" 35 | descLogPath = "the path where the log output will be written" 36 | descRelativeDur = "the relative duration from now to load the graph." 37 | descStart = "the time the dashboard will start in time. Accepts 2 formats, relative time from now based on duration(e.g.: 24h, 15m), or fixed duration in ISO 8601 (e.g.: 2019-05-12T09:35:11+00:00). If set it disables relative duration flag." 38 | descEnd = "the time the dashboard will end in time. Accepts 2 formats, relative time from now based on duration(e.g.: 24h, 15m), or fixed duration in ISO 8601 (e.g.: 2019-05-12T09:35:11+00:00)." 39 | descDebug = "enable debug mode, on debug mode it will print logs to the desired output" 40 | descVar = "repeatable flag that will override the variable defined on the dashboard (in 'key=value' form)" 41 | descDSAlias = "repeatable flag that maps dashboard ID datasources to user defined datasources in the form of 'dashboard=user' (in 'key=value' form)" 42 | ) 43 | 44 | var descUserDS = fmt.Sprintf("path to a configuration file with user defined datasources, these datasources can override the dashboard datasources with the same ID and also can be used to alias them using datasource alias flags. It fallbacks to %s env var", envUserDatasources) 45 | 46 | type flags struct { 47 | variables map[string]string 48 | aliases map[string]string 49 | cfg string 50 | userDSPath string 51 | debug bool 52 | version bool 53 | refreshInterval time.Duration 54 | logPath string 55 | start string 56 | relativeDur time.Duration 57 | end string 58 | } 59 | 60 | func newFlags() (*flags, error) { 61 | flags := &flags{ 62 | variables: map[string]string{}, 63 | aliases: map[string]string{}, 64 | } 65 | 66 | // Get default datasource path. 67 | userHome, _ := os.UserHomeDir() 68 | userDsPath := "" 69 | if userHome != "" { 70 | dsPath := []string{userHome} 71 | dsPath = append(dsPath, defUserDatasourcePath...) 72 | userDsPath = path.Join(dsPath...) 73 | } 74 | 75 | // Create app. 76 | app := kingpin.New("grafterm", "graph metrics on the terminal") 77 | app.Version(Version) 78 | 79 | // Register flags. 80 | app.Flag("cfg", descCfg).Default(defConfig).Short('c').StringVar(&flags.cfg) 81 | app.Flag("refresh-interval", descRefreshInterval).Default(defRefreshInterval).Short('r').DurationVar(&flags.refreshInterval) 82 | app.Flag("log-path", descLogPath).Default(defLogPath).StringVar(&flags.logPath) 83 | app.Flag("relative-duration", descRelativeDur).Short('d').DurationVar(&flags.relativeDur) 84 | app.Flag("start", descStart).Short('s').StringVar(&flags.start) 85 | app.Flag("end", descEnd).Short('e').StringVar(&flags.end) 86 | app.Flag("var", descVar).Short('v').StringMapVar(&flags.variables) 87 | app.Flag("ds-alias", descDSAlias).Short('a').StringMapVar(&flags.aliases) 88 | app.Flag("user-datasources", descUserDS).Default(userDsPath).Short('u').Envar(envUserDatasources).StringVar(&flags.userDSPath) 89 | app.Flag("debug", descDebug).BoolVar(&flags.debug) 90 | app.Parse(os.Args[1:]) 91 | 92 | if err := flags.validate(); err != nil { 93 | return nil, err 94 | } 95 | 96 | return flags, nil 97 | } 98 | 99 | func (f *flags) validate() error { 100 | return nil 101 | } 102 | -------------------------------------------------------------------------------- /cmd/grafterm/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "os" 7 | "os/signal" 8 | "syscall" 9 | "time" 10 | 11 | "github.com/oklog/run" 12 | 13 | "github.com/slok/grafterm/internal/controller" 14 | "github.com/slok/grafterm/internal/model" 15 | "github.com/slok/grafterm/internal/service/configuration" 16 | "github.com/slok/grafterm/internal/service/log" 17 | "github.com/slok/grafterm/internal/service/metric" 18 | metricdatasource "github.com/slok/grafterm/internal/service/metric/datasource" 19 | metricmiddleware "github.com/slok/grafterm/internal/service/metric/middleware" 20 | "github.com/slok/grafterm/internal/view" 21 | "github.com/slok/grafterm/internal/view/page" 22 | "github.com/slok/grafterm/internal/view/render" 23 | "github.com/slok/grafterm/internal/view/render/termdash" 24 | ) 25 | 26 | // Main is the main application. 27 | type Main struct { 28 | flags *flags 29 | logger log.Logger 30 | } 31 | 32 | // Run runs the main app. 33 | func (m *Main) Run() error { 34 | if m.flags.version { 35 | fmt.Fprint(os.Stdout, Version) 36 | return nil 37 | } 38 | 39 | // If debug mode then use a verbose logger. 40 | m.logger = log.Dummy 41 | if m.flags.debug { 42 | f, err := os.OpenFile(m.flags.logPath, os.O_CREATE|os.O_RDWR|os.O_APPEND, 0660) 43 | if err != nil { 44 | return err 45 | } 46 | defer f.Close() 47 | 48 | m.logger = log.New(log.Config{ 49 | Output: f, 50 | }) 51 | } 52 | 53 | // Load Dashboard. 54 | cfg, err := loadConfiguration(m.flags.cfg) 55 | if err != nil { 56 | return err 57 | } 58 | 59 | ddss, err := cfg.Datasources() 60 | if err != nil { 61 | return err 62 | } 63 | 64 | udss, err := m.loadUserDatasources() 65 | if err != nil { 66 | return err 67 | } 68 | 69 | gatherer, err := m.createGatherer(ddss, udss) 70 | if err != nil { 71 | return err 72 | } 73 | 74 | // Create controller. 75 | ctrl := controller.NewController(gatherer) 76 | 77 | // Create renderer. 78 | ctx, cancel := context.WithCancel(context.Background()) 79 | defer cancel() 80 | renderer, err := termdash.NewTermDashboard(cancel, m.logger) 81 | if err != nil { 82 | return err 83 | } 84 | defer renderer.Close() 85 | 86 | // Prepare app for running. 87 | var g run.Group 88 | 89 | // Capture signals. 90 | { 91 | sigC := make(chan os.Signal, 1) 92 | exitC := make(chan struct{}) 93 | signal.Notify(sigC, syscall.SIGTERM, syscall.SIGINT) 94 | 95 | g.Add( 96 | func() error { 97 | select { 98 | case <-sigC: 99 | case <-exitC: 100 | } 101 | 102 | return nil 103 | }, 104 | func(e error) { 105 | close(exitC) 106 | }) 107 | } 108 | 109 | // Run application. 110 | { 111 | appcfg := view.AppConfig{ 112 | RefreshInterval: m.flags.refreshInterval, 113 | RelativeTimeRange: m.flags.relativeDur, 114 | } 115 | 116 | // Only set fixed time if start set. 117 | if m.flags.start != "" { 118 | start, err := timeFromFlag(m.flags.start) 119 | if err != nil { 120 | return fmt.Errorf("error parsing start flag: %s", err) 121 | } 122 | end, err := timeFromFlag(m.flags.end) 123 | if err != nil { 124 | return fmt.Errorf("error parsing end flag: %s", err) 125 | } 126 | 127 | appcfg.TimeRangeStart = start 128 | appcfg.TimeRangeEnd = end 129 | 130 | // Check times are correct. 131 | if !appcfg.TimeRangeEnd.IsZero() && appcfg.TimeRangeEnd.Before(appcfg.TimeRangeStart) { 132 | return fmt.Errorf("end timestamp can't be before start timestamp") 133 | } 134 | } 135 | 136 | ds, err := cfg.Dashboard() 137 | if err != nil { 138 | return err 139 | } 140 | 141 | app, err := m.createApp(ctx, appcfg, ds, ctrl, renderer) 142 | if err != nil { 143 | return err 144 | } 145 | 146 | g.Add( 147 | func() error { 148 | err := app.Run(ctx) 149 | if err != nil { 150 | return err 151 | } 152 | defer cancel() 153 | return nil 154 | }, 155 | func(e error) { 156 | cancel() 157 | }) 158 | } 159 | 160 | return g.Run() 161 | } 162 | 163 | func loadConfiguration(cfgPath string) (configuration.Configuration, error) { 164 | // Load dashboard file. 165 | f, err := os.Open(cfgPath) 166 | if err != nil { 167 | return nil, err 168 | } 169 | defer f.Close() 170 | 171 | cfg, err := configuration.JSONLoader{}.Load(f) 172 | if err != nil { 173 | return nil, err 174 | } 175 | 176 | return cfg, nil 177 | } 178 | 179 | func (m *Main) loadUserDatasources() ([]model.Datasource, error) { 180 | // If we could not load user datasources do not fail. 181 | f, err := os.Open(m.flags.userDSPath) 182 | if err != nil { 183 | m.logger.Warnf("could not load '%s' user datasources file: %s", m.flags.userDSPath, err) 184 | return []model.Datasource{}, nil 185 | } 186 | defer f.Close() 187 | 188 | cfg, err := configuration.JSONLoader{}.Load(f) 189 | if err != nil { 190 | return nil, err 191 | } 192 | 193 | return cfg.Datasources() 194 | } 195 | 196 | func (m *Main) createGatherer(dashboardDss, userDss []model.Datasource) (metric.Gatherer, error) { 197 | gatherer, err := metricdatasource.NewGatherer(metricdatasource.ConfigGatherer{ 198 | DashboardDatasources: dashboardDss, 199 | UserDatasources: userDss, 200 | Aliases: m.flags.aliases, 201 | }) 202 | if err != nil { 203 | return nil, err 204 | } 205 | gatherer = metricmiddleware.Logger(m.logger, gatherer) 206 | 207 | return gatherer, nil 208 | } 209 | 210 | func (m *Main) createApp(ctx context.Context, appCfg view.AppConfig, dashboard model.Dashboard, ctrl controller.Controller, renderer render.Renderer) (*view.App, error) { 211 | dashCfg := page.DashboardCfg{ 212 | AppRelativeTimeRange: m.flags.relativeDur, 213 | AppOverrideVariables: m.flags.variables, 214 | Controller: ctrl, 215 | Dashboard: dashboard, 216 | Renderer: renderer, 217 | } 218 | 219 | syncer, err := page.NewDashboard(ctx, dashCfg, m.logger) 220 | if err != nil { 221 | return nil, err 222 | } 223 | app := view.NewApp(appCfg, syncer, m.logger) 224 | return app, nil 225 | } 226 | 227 | // timeFromFlag gets the time from a flag based on a duration or on a 228 | // fixed time stamp. 229 | func timeFromFlag(v string) (time.Time, error) { 230 | var t time.Time 231 | 232 | // Try parsing using duration. 233 | d, err := time.ParseDuration(v) 234 | if err == nil { 235 | t = time.Now().UTC().Add(-1 * d) 236 | } else { 237 | // Try parsing as ISO 8601. 238 | parsedTime, err := time.Parse(time.RFC3339, v) 239 | if err != nil { 240 | return t, fmt.Errorf("'%s' is not a valid timestamp or duration string", v) 241 | } 242 | t = parsedTime 243 | } 244 | 245 | return t, nil 246 | } 247 | 248 | func main() { 249 | flags, err := newFlags() 250 | if err != nil { 251 | fmt.Fprintf(os.Stderr, "error parsing flags: %s\n", err) 252 | os.Exit(1) 253 | } 254 | 255 | m := Main{ 256 | flags: flags, 257 | } 258 | 259 | if err := m.Run(); err != nil { 260 | fmt.Fprintf(os.Stderr, "error executing program: %s\n", err) 261 | os.Exit(1) 262 | } 263 | 264 | os.Exit(0) 265 | } 266 | -------------------------------------------------------------------------------- /dashboard-examples/Readme.md: -------------------------------------------------------------------------------- 1 | # Dashboard examples 2 | 3 | ## RED metrics 4 | 5 | This dashboards shows [RED] metrics. 6 | 7 | Useful override variables: 8 | 9 | - `prefix`: Will be used as a prefix on all the metrics queries to Prometheus. 10 | - `job`: Will be used as the `job` label to filter on all the Prometheus queries. 11 | 12 | ![](https://i.imgur.com/DOPeiWI.png) 13 | 14 | ## Go stats 15 | 16 | This is a useful dashboard for go application. 17 | 18 | Useful override variables: 19 | 20 | - `job`: Will be used as the `job` label to filter on all the Prometheus queries. 21 | 22 | ![](https://i.imgur.com/dyiR7J6.png) 23 | 24 | ![](https://i.imgur.com/qeXRmOl.png) 25 | 26 | ## Gitlab 27 | 28 | This is a gitlab based dashboard example. 29 | 30 | ![](https://i.imgur.com/RGlygHF.png) 31 | 32 | ## Kubernetes status 33 | 34 | This is a port from [this](https://grafana.com/dashboards/5315) Grafana dashboard (with very small changes). Mainly shows the usage of the gauges. 35 | 36 | ![](https://i.imgur.com/N5jtCFT.png) 37 | 38 | ## Wikimedia 39 | 40 | This is a port from [this](https://grafana.wikimedia.org/d/000000002/api-backend-summary) dashboard (with very small changes). It uses Graphite backend. 41 | 42 | ![](https://i.imgur.com/bJjGtyF.png) 43 | 44 | [red]: https://www.weave.works/blog/the-red-method-key-metrics-for-microservices-architecture/ 45 | -------------------------------------------------------------------------------- /dashboard-examples/go.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "v1", 3 | "datasources": { 4 | "prometheus": { 5 | "prometheus": { 6 | "address": "http://127.0.0.1:9090" 7 | } 8 | } 9 | }, 10 | "dashboard": { 11 | "variables": { 12 | "job": { 13 | "constant": { "value": "prometheus" } 14 | }, 15 | "interval": { 16 | "interval": { "steps": 50 } 17 | } 18 | }, 19 | "widgets": [ 20 | { 21 | "title": "Goroutines", 22 | "gridPos": { "w": 20 }, 23 | "singlestat": { 24 | "thresholds": [{ "color": "#47D038" }], 25 | "query": { 26 | "datasourceID": "prometheus", 27 | "expr": "sum(go_goroutines{job=\"{{.job}}\"})" 28 | } 29 | } 30 | }, 31 | { 32 | "title": "GC duration", 33 | "gridPos": { "w": 20 }, 34 | "singlestat": { 35 | "unit": "second", 36 | "query": { 37 | "datasourceID": "prometheus", 38 | "expr": "max(go_gc_duration_seconds{job=\"{{.job}}\"})" 39 | } 40 | } 41 | }, 42 | { 43 | "title": "Stack", 44 | "gridPos": { "w": 20 }, 45 | "singlestat": { 46 | "unit": "bytes", 47 | "thresholds": [{ "color": "#22F1F1" }], 48 | "query": { 49 | "datasourceID": "prometheus", 50 | "expr": "sum(go_memstats_stack_inuse_bytes{job=\"{{.job}}\"})" 51 | } 52 | } 53 | }, 54 | { 55 | "title": "Heap", 56 | "gridPos": { "w": 20 }, 57 | "singlestat": { 58 | "unit": "bytes", 59 | "thresholds": [{ "color": "#22F1F1" }], 60 | "query": { 61 | "datasourceID": "prometheus", 62 | "expr": "sum(go_memstats_heap_inuse_bytes{job=\"{{.job}}\"})" 63 | } 64 | } 65 | }, 66 | { 67 | "title": "Alloc", 68 | "gridPos": { "w": 20 }, 69 | "singlestat": { 70 | "unit": "bytes", 71 | "thresholds": [{ "color": "#22F1F1" }], 72 | "query": { 73 | "datasourceID": "prometheus", 74 | "expr": "sum(go_memstats_alloc_bytes{job=\"{{.job}}\"})" 75 | } 76 | } 77 | }, 78 | { 79 | "title": "Goroutines", 80 | "gridPos": { "w": 50 }, 81 | "graph": { 82 | "visualization": { 83 | "legend": { "disable": true }, 84 | "yAxis": { "unit": "", "decimals": 2 } 85 | }, 86 | "queries": [ 87 | { 88 | "datasourceID": "prometheus", 89 | "expr": "sum(go_goroutines{job=\"{{.job}}\"})" 90 | } 91 | ] 92 | } 93 | }, 94 | { 95 | "title": "GC duration", 96 | "gridPos": { "w": 50 }, 97 | "graph": { 98 | "queries": [ 99 | { 100 | "datasourceID": "prometheus", 101 | "expr": "max(go_gc_duration_seconds{job=\"{{.job}}\"}) by (quantile)", 102 | "legend": "Q{{.quantile}}" 103 | } 104 | ], 105 | "visualization": { 106 | "yAxis": { "unit": "second" }, 107 | "seriesOverride": [ 108 | { "regex": "^Q0$", "color": "#F9E2D2" }, 109 | { "regex": "^Q0.25$", "color": "#F2C96D" }, 110 | { "regex": "^Q0.5(0)?$", "color": "#EAB839" }, 111 | { "regex": "^Q0.75$", "color": "#EF843C" }, 112 | { "regex": "^Q1(.0)?$", "color": "#E24D42" } 113 | ] 114 | } 115 | } 116 | }, 117 | { 118 | "title": "Memory", 119 | "gridPos": { "w": 50 }, 120 | "graph": { 121 | "visualization": { 122 | "yAxis": { "unit": "byte", "decimals": 0 } 123 | }, 124 | "queries": [ 125 | { 126 | "datasourceID": "prometheus", 127 | "expr": "sum(go_memstats_stack_inuse_bytes{job=\"{{.job}}\"})", 128 | "legend": "stack inuse" 129 | }, 130 | { 131 | "datasourceID": "prometheus", 132 | "expr": "sum(go_memstats_heap_inuse_bytes{job=\"{{.job}}\"})", 133 | "legend": "heap inuse" 134 | }, 135 | { 136 | "datasourceID": "prometheus", 137 | "expr": "sum(go_memstats_alloc_bytes{job=\"{{.job}}\"})", 138 | "legend": "alloc" 139 | } 140 | ] 141 | } 142 | }, 143 | { 144 | "title": "Memory ops rate", 145 | "gridPos": { 146 | "w": 50 147 | }, 148 | "graph": { 149 | "queries": [ 150 | { 151 | "datasourceID": "prometheus", 152 | "expr": "sum(rate(go_memstats_frees_total{job=\"{{.job}}\"}[{{.interval}}]))", 153 | "legend": "frees/s" 154 | }, 155 | { 156 | "datasourceID": "prometheus", 157 | "expr": "sum(rate(go_memstats_mallocs_total{job=\"{{.job}}\"}[{{.interval}}]))", 158 | "legend": "mallocs/s" 159 | }, 160 | { 161 | "datasourceID": "prometheus", 162 | "expr": "sum(rate(go_memstats_lookups_total{job=\"{{.job}}\"}[{{.interval}}]))", 163 | "legend": "lookups/s" 164 | } 165 | ] 166 | } 167 | } 168 | ] 169 | } 170 | } 171 | -------------------------------------------------------------------------------- /dashboard-examples/kubernetes-cluster-status.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "v1", 3 | "datasources": { 4 | "prometheus": { 5 | "prometheus": { 6 | "address": "http://127.0.0.1:9090" 7 | } 8 | } 9 | }, 10 | "dashboard": { 11 | "variables": { 12 | "interval": { 13 | "interval": { "steps": 50 } 14 | } 15 | }, 16 | "widgets": [ 17 | { 18 | "title": "Control plane UP", 19 | "gridPos": { "w": 50 }, 20 | "singlestat": { 21 | "query": { 22 | "datasourceID": "prometheus", 23 | "expr": "sum(up{job=~\"apiserver|kube-scheduler|kube-controller-manager\"} == 0) or vector(0)" 24 | }, 25 | "valueText": "{{ if (gt .value 0.0) }}DOWN{{else}}UP{{end}}", 26 | "thresholds": [ 27 | { "color": "#299c46" }, 28 | { "color": "#d44a3a", "startValue": 1 } 29 | ] 30 | } 31 | }, 32 | { 33 | "title": "Alerts firing", 34 | "gridPos": { "w": 50 }, 35 | "singlestat": { 36 | "query": { 37 | "datasourceID": "prometheus", 38 | "expr": "sum(ALERTS{alertstate=\"firing\",alertname!=\"DeadMansSwitch\"})" 39 | }, 40 | "unit": "none", 41 | "thresholds": [ 42 | { "color": "#299c46" }, 43 | { "startValue": 3, "color": "#FF780A" }, 44 | { "startValue": 5, "color": "#d44a3a" } 45 | ] 46 | } 47 | }, 48 | { 49 | "title": "APIservers UP", 50 | "gridPos": { "w": 25 }, 51 | "gauge": { 52 | "percentValue": true, 53 | "max": 100, 54 | "query": { 55 | "datasourceID": "prometheus", 56 | "expr": "(sum(up{job=\"apiserver\"} == 1) / count(up{job=\"apiserver\"})) * 100" 57 | }, 58 | "thresholds": [ 59 | { "color": "#d44a3a" }, 60 | { "startValue": 50, "color": "#FF780A" }, 61 | { "startValue": 80, "color": "#299c46" } 62 | ] 63 | } 64 | }, 65 | { 66 | "title": "Kubelets UP", 67 | "gridPos": { "w": 25 }, 68 | "gauge": { 69 | "percentValue": true, 70 | "max": 100, 71 | "query": { 72 | "datasourceID": "prometheus", 73 | "expr": "(sum(up{job=\"kubelet\"} == 1) / count(up{job=\"kubelet\"})) * 100" 74 | }, 75 | "thresholds": [ 76 | { "color": "#d44a3a" }, 77 | { "startValue": 50, "color": "#FF780A" }, 78 | { "startValue": 80, "color": "#299c46" } 79 | ] 80 | } 81 | }, 82 | { 83 | "title": "Schedulers UP", 84 | "gridPos": { "w": 25 }, 85 | "gauge": { 86 | "percentValue": true, 87 | "max": 100, 88 | "query": { 89 | "datasourceID": "prometheus", 90 | "expr": "(sum(up{job=\"kube-scheduler\"} == 1) / count(up{job=\"kube-scheduler\"})) * 100" 91 | }, 92 | "thresholds": [ 93 | { "color": "#d44a3a" }, 94 | { "startValue": 50, "color": "#FF780A" }, 95 | { "startValue": 80, "color": "#299c46" } 96 | ] 97 | } 98 | }, 99 | { 100 | "title": "Crashlooping control-plane pods", 101 | "gridPos": { "w": 25 }, 102 | "singlestat": { 103 | "query": { 104 | "datasourceID": "prometheus", 105 | "expr": "count(increase(kube_pod_container_status_restarts{namespace=~\"kube-system|tectonic-system\"}[1h])) or vector(0)" 106 | }, 107 | "thresholds": [ 108 | { "color": "#299c46" }, 109 | { "startValue": 1, "color": "#FF780A" }, 110 | { "startValue": 3, "color": "#d44a3a" } 111 | ] 112 | } 113 | }, 114 | { 115 | "title": "CPU utilization", 116 | "gridPos": { "w": 25 }, 117 | "gauge": { 118 | "percentValue": true, 119 | "max": 100, 120 | "query": { 121 | "datasourceID": "prometheus", 122 | "expr": "sum(100 - (avg by (instance) (rate(node_cpu_seconds_total{job=\"node-exporter\",mode=\"idle\"}[5m])) * 100)) / count(node_cpu_seconds_total{job=\"node-exporter\",mode=\"idle\"})" 123 | }, 124 | "thresholds": [ 125 | { "color": "#299c46" }, 126 | { "startValue": 80, "color": "#FF780A" }, 127 | { "startValue": 90, "color": "#d44a3a" } 128 | ] 129 | } 130 | }, 131 | { 132 | "title": "Memory utilization", 133 | "gridPos": { "w": 25 }, 134 | "gauge": { 135 | "percentValue": true, 136 | "max": 100, 137 | "query": { 138 | "datasourceID": "prometheus", 139 | "expr": "((sum(node_memory_MemTotal_bytes) - sum(node_memory_MemFree_bytes) - sum(node_memory_Buffers_bytes) - sum(node_memory_Cached_bytes)) / sum(node_memory_MemTotal_bytes)) * 100" 140 | }, 141 | "thresholds": [ 142 | { "color": "#299c46" }, 143 | { "startValue": 80, "color": "#FF780A" }, 144 | { "startValue": 90, "color": "#d44a3a" } 145 | ] 146 | } 147 | }, 148 | { 149 | "title": "Filesystem utilization", 150 | "gridPos": { "w": 25 }, 151 | "gauge": { 152 | "percentValue": true, 153 | "max": 100, 154 | "query": { 155 | "datasourceID": "prometheus", 156 | "expr": "(sum(node_filesystem_size_bytes{device!=\"rootfs\"}) - sum(node_filesystem_free_bytes{device!=\"rootfs\"})) / sum(node_filesystem_size_bytes{device!=\"rootfs\"})" 157 | }, 158 | "thresholds": [ 159 | { "color": "#299c46" }, 160 | { "startValue": 80, "color": "#FF780A" }, 161 | { "startValue": 90, "color": "#d44a3a" } 162 | ] 163 | } 164 | }, 165 | { 166 | "title": "Pod utilization", 167 | "gridPos": { "w": 25 }, 168 | "gauge": { 169 | "percentValue": true, 170 | "max": 100, 171 | "query": { 172 | "datasourceID": "prometheus", 173 | "expr": "100 - (sum(kube_node_status_capacity_pods) - sum(kube_pod_info)) / sum(kube_node_status_capacity_pods) * 100" 174 | }, 175 | "thresholds": [ 176 | { "color": "#299c46" }, 177 | { "startValue": 80, "color": "#FF780A" }, 178 | { "startValue": 90, "color": "#d44a3a" } 179 | ] 180 | } 181 | } 182 | ] 183 | } 184 | } 185 | -------------------------------------------------------------------------------- /dashboard-examples/red.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "v1", 3 | "datasources": { 4 | "ds": { 5 | "prometheus": { 6 | "address": "http://127.0.0.1:9090" 7 | } 8 | } 9 | }, 10 | "dashboard": { 11 | "grid": { 12 | "maxWidth": 100 13 | }, 14 | "variables": { 15 | "prefix": { 16 | "constant": { "value": " " } 17 | }, 18 | "job": { 19 | "constant": { "value": ".*" } 20 | }, 21 | "interval": { 22 | "interval": { "steps": 50 } 23 | } 24 | }, 25 | "widgets": [ 26 | { 27 | "title": "RPS", 28 | "gridpos": { 29 | "w": 33 30 | }, 31 | "singlestat": { 32 | "query": { 33 | "expr": "sum(rate({{.prefix}}http_request_duration_seconds_count{job=~\"{{.job}}\"}[{{.interval}}]))", 34 | "datasourceID": "ds" 35 | }, 36 | "decimals": 2, 37 | "thresholds": [{ "color": "#1f78c1" }] 38 | } 39 | }, 40 | { 41 | "title": "Errors(5xx)", 42 | "gridpos": { 43 | "w": 33 44 | }, 45 | "singlestat": { 46 | "query": { 47 | "expr": "(sum(rate({{.prefix}}http_request_duration_seconds_count{job=~\"{{.job}}\",code=~\"5..\"}[{{.interval}}])) / sum(rate({{.prefix}}http_request_duration_seconds_count[{{.interval}}])) ) * 100 OR vector(0)", 48 | "datasourceID": "ds" 49 | }, 50 | "unit": "percent", 51 | "decimals": 2, 52 | "thresholds": [ 53 | { 54 | "color": "#299c46" 55 | }, 56 | { 57 | "color": "#FF780A", 58 | "startValue": 0.01 59 | }, 60 | { 61 | "color": "#d44a3a", 62 | "startValue": 2 63 | } 64 | ] 65 | } 66 | }, 67 | { 68 | "title": "Latency", 69 | "gridpos": { "w": 34 }, 70 | "singlestat": { 71 | "query": { 72 | "expr": "histogram_quantile(0.99, sum(rate({{.prefix}}http_request_duration_seconds_bucket{job=~\"{{.job}}\"}[{{.interval}}])) by (le))", 73 | "datasourceID": "ds" 74 | }, 75 | "unit": "seconds", 76 | "thresholds": [ 77 | { "color": "#299c46" }, 78 | { "color": "#FF780A", "startValue": 0.35 }, 79 | { "color": "#d44a3a", "startValue": 0.6 } 80 | ] 81 | } 82 | }, 83 | { 84 | "title": "RPS", 85 | "gridpos": { "w": 100 }, 86 | "graph": { 87 | "visualization": { 88 | "yAxis": { 89 | "unit": "reqps", 90 | "decimals": 2 91 | } 92 | }, 93 | "queries": [ 94 | { 95 | "datasourceID": "ds", 96 | "expr": "sum(rate({{.prefix}}http_request_duration_seconds_count{job=~\"{{.job}}\"}[{{.interval}}])) by (code)", 97 | "legend": "{{ .code }}" 98 | } 99 | ] 100 | } 101 | }, 102 | { 103 | "title": "Latency", 104 | "gridpos": { "w": 100 }, 105 | "graph": { 106 | "visualization": { 107 | "seriesOverride": [ 108 | { "regex": "^p50$", "color": "#EAB839" }, 109 | { "regex": "^p95$", "color": "#EF843C" }, 110 | { "regex": "^p99$", "color": "#E24D42" } 111 | ], 112 | "yAxis": { 113 | "unit": "seconds" 114 | } 115 | }, 116 | "queries": [ 117 | { 118 | "datasourceID": "ds", 119 | "expr": "histogram_quantile(0.99, sum(rate({{.prefix}}http_request_duration_seconds_bucket{job=~\"{{.job}}\"}[{{.interval}}])) by (le))", 120 | "legend": "p99" 121 | }, 122 | { 123 | "datasourceID": "ds", 124 | "expr": "histogram_quantile(0.95, sum(rate({{.prefix}}http_request_duration_seconds_bucket{job=~\"{{.job}}\"}[{{.interval}}])) by (le)) ", 125 | "legend": "p95" 126 | }, 127 | { 128 | "datasourceID": "ds", 129 | "expr": "histogram_quantile(0.50, sum(rate({{.prefix}}http_request_duration_seconds_bucket{job=~\"{{.job}}\"}[{{.interval}}])) by (le)) ", 130 | "legend": "p50" 131 | } 132 | ] 133 | } 134 | } 135 | ] 136 | } 137 | } 138 | -------------------------------------------------------------------------------- /dashboard-examples/test-grid-adaptive.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "v1", 3 | "datasources": { 4 | "ds": { "fake": {} } 5 | }, 6 | "dashboard": { 7 | "widgets": [ 8 | { 9 | "title": "", 10 | "gridpos": { "x": 0, "y": 0, "w": 100, "h": 0 }, 11 | "graph": { "queries": [{ "datasourceID": "ds", "expr": "test" }] } 12 | }, 13 | { 14 | "title": "", 15 | "gridpos": { "x": 0, "y": 0, "w": 50, "h": 0 }, 16 | "graph": { "queries": [{ "datasourceID": "ds", "expr": "test" }] } 17 | }, 18 | { 19 | "title": "", 20 | "gridpos": { "x": 0, "y": 0, "w": 75, "h": 0 }, 21 | "graph": { "queries": [{ "datasourceID": "ds", "expr": "test" }] } 22 | }, 23 | { 24 | "title": "", 25 | "gridpos": { "x": 0, "y": 0, "w": 10, "h": 0 }, 26 | "graph": { "queries": [{ "datasourceID": "ds", "expr": "test" }] } 27 | }, 28 | { 29 | "title": "", 30 | "gridpos": { "x": 0, "y": 0, "w": 10, "h": 0 }, 31 | "graph": { "queries": [{ "datasourceID": "ds", "expr": "test" }] } 32 | }, 33 | { 34 | "title": "", 35 | "gridpos": { "x": 0, "y": 0, "w": 50, "h": 0 }, 36 | "graph": { "queries": [{ "datasourceID": "ds", "expr": "test" }] } 37 | }, 38 | { 39 | "title": "", 40 | "gridpos": { "x": 0, "y": 0, "w": 100, "h": 0 }, 41 | "graph": { "queries": [{ "datasourceID": "ds", "expr": "test" }] } 42 | } 43 | ] 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /dashboard-examples/test-grid-fixed.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "v1", 3 | "datasources": { 4 | "ds": { "fake": {} } 5 | }, 6 | "dashboard": { 7 | "grid": { 8 | "fixedWidgets": true 9 | }, 10 | "widgets": [ 11 | { 12 | "gridpos": { "x": 0, "y": 0, "w": 50, "h": 0 }, 13 | "graph": { "queries": [{ "datasourceID": "ds", "expr": "test" }] } 14 | }, 15 | { 16 | "gridpos": { "x": 50, "y": 0, "w": 50, "h": 0 }, 17 | "graph": { "queries": [{ "datasourceID": "ds", "expr": "test" }] } 18 | }, 19 | { 20 | "gridpos": { "x": 0, "y": 1, "w": 15, "h": 0 }, 21 | "graph": { "queries": [{ "datasourceID": "ds", "expr": "test" }] } 22 | }, 23 | { 24 | "gridpos": { "x": 25, "y": 1, "w": 15, "h": 0 }, 25 | "graph": { "queries": [{ "datasourceID": "ds", "expr": "test" }] } 26 | }, 27 | { 28 | "gridpos": { "x": 50, "y": 1, "w": 15, "h": 0 }, 29 | "graph": { "queries": [{ "datasourceID": "ds", "expr": "test" }] } 30 | }, 31 | { 32 | "gridpos": { "x": 75, "y": 1, "w": 15, "h": 0 }, 33 | "graph": { "queries": [{ "datasourceID": "ds", "expr": "test" }] } 34 | }, 35 | 36 | { 37 | "gridpos": { "x": 15, "y": 2, "w": 10, "h": 0 }, 38 | "graph": { "queries": [{ "datasourceID": "ds", "expr": "test" }] } 39 | }, 40 | { 41 | "gridpos": { "x": 40, "y": 2, "w": 10, "h": 0 }, 42 | "graph": { "queries": [{ "datasourceID": "ds", "expr": "test" }] } 43 | }, 44 | { 45 | "gridpos": { "x": 65, "y": 2, "w": 10, "h": 0 }, 46 | "graph": { "queries": [{ "datasourceID": "ds", "expr": "test" }] } 47 | }, 48 | { 49 | "gridpos": { "x": 90, "y": 2, "w": 10, "h": 0 }, 50 | "graph": { "queries": [{ "datasourceID": "ds", "expr": "test" }] } 51 | }, 52 | 53 | { 54 | "gridpos": { "x": 0, "y": 3, "w": 100, "h": 0 }, 55 | "graph": { "queries": [{ "datasourceID": "ds", "expr": "test" }] } 56 | }, 57 | 58 | { 59 | "gridpos": { "x": 0, "y": 4, "w": 5, "h": 0 }, 60 | "graph": { "queries": [{ "datasourceID": "ds", "expr": "test" }] } 61 | }, 62 | { 63 | "gridpos": { "x": 10, "y": 4, "w": 5, "h": 0 }, 64 | "graph": { "queries": [{ "datasourceID": "ds", "expr": "test" }] } 65 | }, 66 | { 67 | "gridpos": { "x": 20, "y": 4, "w": 5, "h": 0 }, 68 | "graph": { "queries": [{ "datasourceID": "ds", "expr": "test" }] } 69 | }, 70 | { 71 | "gridpos": { "x": 30, "y": 4, "w": 5, "h": 0 }, 72 | "graph": { "queries": [{ "datasourceID": "ds", "expr": "test" }] } 73 | }, 74 | { 75 | "gridpos": { "x": 40, "y": 4, "w": 5, "h": 0 }, 76 | "graph": { "queries": [{ "datasourceID": "ds", "expr": "test" }] } 77 | }, 78 | { 79 | "gridpos": { "x": 50, "y": 4, "w": 5, "h": 0 }, 80 | "graph": { "queries": [{ "datasourceID": "ds", "expr": "test" }] } 81 | }, 82 | { 83 | "gridpos": { "x": 60, "y": 4, "w": 5, "h": 0 }, 84 | "graph": { "queries": [{ "datasourceID": "ds", "expr": "test" }] } 85 | }, 86 | { 87 | "gridpos": { "x": 70, "y": 4, "w": 5, "h": 0 }, 88 | "graph": { "queries": [{ "datasourceID": "ds", "expr": "test" }] } 89 | }, 90 | { 91 | "gridpos": { "x": 80, "y": 4, "w": 5, "h": 0 }, 92 | "graph": { "queries": [{ "datasourceID": "ds", "expr": "test" }] } 93 | }, 94 | { 95 | "gridpos": { "x": 90, "y": 4, "w": 5, "h": 0 }, 96 | "graph": { "queries": [{ "datasourceID": "ds", "expr": "test" }] } 97 | } 98 | ] 99 | } 100 | } 101 | -------------------------------------------------------------------------------- /dashboard-examples/wikimedia.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "v1", 3 | "datasources": { 4 | "graphite": { 5 | "graphite": { 6 | "address": "https://graphite.wikimedia.org" 7 | } 8 | } 9 | }, 10 | "dashboard": { 11 | "widgets": [ 12 | { 13 | "title": "API request rate", 14 | "gridPos": { "w": 50 }, 15 | "graph": { 16 | "visualization": { 17 | "yAxis": { "unit": "reqps", "decimals": 1 }, 18 | "seriesOverride": [{ "regex": ".*", "nullPointMode": "connected" }] 19 | }, 20 | "queries": [ 21 | { 22 | "datasourceID": "graphite", 23 | "expr": "alias(sumSeries(MediaWiki.api.*.executeTiming.sample_rate), 'rate')" 24 | }, 25 | { 26 | "datasourceID": "graphite", 27 | "expr": "alias(sumSeries(timeShift(MediaWiki.api.*.executeTiming.sample_rate, '2d')), 'last week')" 28 | } 29 | ] 30 | } 31 | }, 32 | { 33 | "title": "API request rate", 34 | "gridPos": { "w": 50 }, 35 | "graph": { 36 | "visualization": { 37 | "yAxis": { "unit": "reqps", "decimals": 1 }, 38 | "seriesOverride": [{ "regex": ".*", "nullPointMode": "connected" }] 39 | }, 40 | "queries": [ 41 | { 42 | "datasourceID": "graphite", 43 | "expr": "aliasByNode(highestAverage(MediaWiki.api.*.executeTiming.sample_rate, 10), 2)" 44 | } 45 | ] 46 | } 47 | }, 48 | { 49 | "title": "Mean latency", 50 | "gridPos": { "w": 70 }, 51 | "graph": { 52 | "visualization": { 53 | "yAxis": { "unit": "milliseconds" }, 54 | "legend": { "disable": true }, 55 | "seriesOverride": [{ "regex": ".*", "nullPointMode": "connected" }] 56 | }, 57 | "queries": [ 58 | { 59 | "datasourceID": "graphite", 60 | "expr": "divideSeries(sumSeries(MediaWiki.api.*.executeTiming.sum),sumSeries(MediaWiki.api.*.executeTiming.count))" 61 | } 62 | ] 63 | } 64 | }, 65 | { 66 | "title": "Mean latency now", 67 | "gridPos": { "w": 30 }, 68 | "singlestat": { 69 | "unit": "milliseconds", 70 | "query": { 71 | "datasourceID": "graphite", 72 | "expr": "divideSeries(sumSeries(MediaWiki.api.*.executeTiming.sum),sumSeries(MediaWiki.api.*.executeTiming.count))" 73 | }, 74 | "thresholds": [ 75 | { "color": "#299c46" }, 76 | { "color": "#FF780A", "startValue": 350 }, 77 | { "color": "#d44a3a", "startValue": 600 } 78 | ] 79 | } 80 | }, 81 | { 82 | "title": "Top 10 load breakdown", 83 | "gridPos": { "w": 50 }, 84 | "graph": { 85 | "visualization": { 86 | "seriesOverride": [{ "regex": ".*", "nullPointMode": "connected" }] 87 | }, 88 | "queries": [ 89 | { 90 | "datasourceID": "graphite", 91 | "expr": "aliasByNode(highestAverage(scaleToSeconds(MediaWiki.api.*.executeTiming.sum,0.001), 10), 2)" 92 | } 93 | ] 94 | } 95 | }, 96 | { 97 | "title": "Top 10 load (percentage)", 98 | "gridPos": { "w": 50 }, 99 | "graph": { 100 | "visualization": { 101 | "yAxis": { "unit": "percent" }, 102 | "seriesOverride": [{ "regex": ".*", "nullPointMode": "connected" }] 103 | }, 104 | "queries": [ 105 | { 106 | "datasourceID": "graphite", 107 | "expr": "aliasByNode(asPercent(highestAverage(MediaWiki.api.*.executeTiming.sum, 10), alias(sumSeries(MediaWiki.api.*.executeTiming.sum), 'Total')), 2)" 108 | } 109 | ] 110 | } 111 | } 112 | ] 113 | } 114 | } 115 | -------------------------------------------------------------------------------- /docker/dev/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM golang:1.12-alpine 2 | 3 | ARG ostype=Linux 4 | 5 | RUN apk --no-cache add \ 6 | g++ \ 7 | git \ 8 | bash 9 | 10 | ENV GOPROXY=https://gocenter.io 11 | 12 | # Mock creator 13 | RUN go get -u github.com/vektra/mockery/.../ 14 | 15 | # Create user 16 | ARG uid=1000 17 | ARG gid=1000 18 | 19 | RUN bash -c 'if [ ${ostype} == Linux ]; then addgroup -g $gid app; else addgroup app; fi && \ 20 | adduser -D -u $uid -G app app && \ 21 | chown app:app -R /go' 22 | 23 | # Fill go mod cache. 24 | RUN mkdir /tmp/cache 25 | COPY go.mod /tmp/cache 26 | COPY go.sum /tmp/cache 27 | RUN chown app:app -R /tmp/cache 28 | USER app 29 | RUN cd /tmp/cache && \ 30 | go mod download 31 | 32 | 33 | WORKDIR /src 34 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/slok/grafterm 2 | 3 | require ( 4 | github.com/DATA-DOG/go-sqlmock v1.3.3 // indirect 5 | github.com/JensRantil/graphite-client v0.0.0-20151206234601-d93bf4b72f5a 6 | github.com/alecthomas/kingpin v2.2.6+incompatible 7 | github.com/alecthomas/template v0.0.0-20190718012654-fb15b899a751 // indirect 8 | github.com/alecthomas/units v0.0.0-20190910110746-680d30ca3117 // indirect 9 | github.com/influxdata/influxdb1-client v0.0.0-20190809212627-fc22c7df067e 10 | github.com/kylelemons/godebug v1.1.0 // indirect 11 | github.com/lucasb-eyer/go-colorful v1.0.1 12 | github.com/mattn/go-runewidth v0.0.4 // indirect 13 | github.com/mum4k/termdash v0.10.0 14 | github.com/nsf/termbox-go v0.0.0-20190624072549-eeb6cd0a1762 // indirect 15 | github.com/oklog/run v1.0.0 16 | github.com/prometheus/client_golang v1.0.0 17 | github.com/prometheus/common v0.6.0 18 | github.com/rs/zerolog v1.13.0 19 | github.com/stretchr/testify v1.4.0 20 | ) 21 | -------------------------------------------------------------------------------- /hack/scripts/build-image.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env sh 2 | 3 | set -e 4 | 5 | if [ -z ${IMAGE_VERSION} ]; then 6 | echo "IMAGE_VERSION env var needs to be set" 7 | exit 1 8 | fi 9 | 10 | REPOSITORY="slok/" 11 | IMAGE="grafterm" 12 | 13 | 14 | docker build \ 15 | --build-arg operator=${OPERATOR} \ 16 | -t ${REPOSITORY}${IMAGE}:${IMAGE_VERSION} \ 17 | -f ./docker/prod/Dockerfile . -------------------------------------------------------------------------------- /hack/scripts/build.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | set -o errexit 4 | set -o nounset 5 | 6 | src=./cmd/grafterm 7 | out=./bin/grafterm 8 | 9 | goarch=amd64 10 | goos=linux 11 | goarm=7 12 | 13 | if [ $ostype == 'Linux' ]; then 14 | echo "Building linux release..." 15 | goos=linux 16 | binary_ext=-linux-amd64 17 | elif [ $ostype == 'Darwin' ]; then 18 | echo "Building darwin release..." 19 | goos=darwin 20 | binary_ext=-darwin-amd64 21 | elif [ $ostype == 'Windows' ]; then 22 | echo "Building windows release..." 23 | goos=windows 24 | binary_ext=-windows-amd64.exe 25 | elif [ $ostype == 'ARM64' ]; then 26 | echo "Building ARM64 release..." 27 | goos=linux 28 | goarch=arm64 29 | binary_ext=-linux-arm64 30 | elif [ $ostype == 'ARM' ]; then 31 | echo "Building ARM release..." 32 | goos=linux 33 | goarch=arm 34 | goarm=7 35 | binary_ext=-linux-arm-v7 36 | else 37 | echo "ostype env var required" 38 | exit 1 39 | fi 40 | 41 | final_out=${out}${binary_ext} 42 | ldf_cmp="-w -extldflags '-static'" 43 | f_ver="-X main.Version=${VERSION:-dev}" 44 | 45 | echo "Building binary at ${final_out}" 46 | GOOS=${goos} GOARCH=${goarch} GOARM=${goarm} CGO_ENABLED=0 go build -o ${final_out} --ldflags "${ldf_cmp} ${f_ver}" ${src} -------------------------------------------------------------------------------- /hack/scripts/ci-release.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | current_dir="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )" 4 | 5 | if [[ -n ${CIRCLE_TAG} ]]; then 6 | echo "Tag ${CIRCLE_TAG}. building releases..." 7 | 8 | archs=( Linux Darwin Windows ARM64 ARM ) 9 | for arch in "${archs[@]}" 10 | do 11 | VERSION=${CIRCLE_TAG} ostype=${arch} ${current_dir}/build.sh 12 | done 13 | else 14 | echo "no tag, skipping release..." 15 | fi -------------------------------------------------------------------------------- /hack/scripts/integration-test.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env sh 2 | 3 | set -o errexit 4 | set -o nounset 5 | 6 | go test `go list ./... | grep -v vendor` -v -tags='integration' -------------------------------------------------------------------------------- /hack/scripts/mockgen.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env sh 2 | 3 | set -o errexit 4 | set -o nounset 5 | 6 | go generate ./internal/mocks -------------------------------------------------------------------------------- /hack/scripts/unit-test.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env sh 2 | 3 | set -o errexit 4 | set -o nounset 5 | 6 | go test `go list ./... | grep -v vendor` -v -------------------------------------------------------------------------------- /img/grafterm-red-compressed.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/slok/grafterm/b4f1144ede2255f8b4eeab8e079db34af177fb69/img/grafterm-red-compressed.gif -------------------------------------------------------------------------------- /internal/controller/controller.go: -------------------------------------------------------------------------------- 1 | package controller 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "time" 7 | 8 | "github.com/slok/grafterm/internal/model" 9 | "github.com/slok/grafterm/internal/service/metric" 10 | ) 11 | 12 | // Controller is what has the domain logic, the one that 13 | // can translate from the views to the models. 14 | type Controller interface { 15 | // GetSingleMetric will get one single metric value at a point in time. 16 | GetSingleMetric(ctx context.Context, query model.Query, t time.Time) (*model.Metric, error) 17 | // GetSingleInstantMetric will get one single metric value in real time. 18 | GetSingleInstantMetric(ctx context.Context, query model.Query) (*model.Metric, error) 19 | // GetRangeMetrics will get N metrics based in a time range. 20 | GetRangeMetrics(ctx context.Context, query model.Query, start, end time.Time, step time.Duration) ([]model.MetricSeries, error) 21 | } 22 | 23 | type controller struct { 24 | gatherer metric.Gatherer 25 | } 26 | 27 | // NewController returns a new controller. 28 | func NewController(gatherer metric.Gatherer) Controller { 29 | return &controller{ 30 | gatherer: gatherer, 31 | } 32 | } 33 | 34 | func (c controller) GetSingleMetric(ctx context.Context, query model.Query, t time.Time) (*model.Metric, error) { 35 | m, err := c.gatherer.GatherSingle(ctx, query, t) 36 | if err != nil { 37 | return nil, err 38 | } 39 | 40 | if len(m) != 1 { 41 | return nil, fmt.Errorf("wrong number of series returned, 1 expected, got: %d", len(m)) 42 | } 43 | 44 | if len(m[0].Metrics) != 1 { 45 | return nil, fmt.Errorf("wrong number of metric in series returned, 1 expected, got: %d", len(m[0].Metrics)) 46 | } 47 | 48 | return &m[0].Metrics[0], nil 49 | } 50 | 51 | func (c controller) GetSingleInstantMetric(ctx context.Context, query model.Query) (*model.Metric, error) { 52 | return c.GetSingleMetric(ctx, query, time.Now().UTC()) 53 | } 54 | 55 | func (c controller) GetRangeMetrics(ctx context.Context, query model.Query, start, end time.Time, step time.Duration) ([]model.MetricSeries, error) { 56 | if step <= 0 { 57 | return nil, fmt.Errorf("step must be positive") 58 | } 59 | 60 | if !start.Before(end) { 61 | return nil, fmt.Errorf("start timestamp must be before end timestamp") 62 | } 63 | 64 | // Get the metrics. 65 | s, err := c.gatherer.GatherRange(ctx, query, start, end, step) 66 | if err != nil { 67 | return []model.MetricSeries{}, err 68 | } 69 | 70 | return s, nil 71 | } 72 | -------------------------------------------------------------------------------- /internal/controller/controller_test.go: -------------------------------------------------------------------------------- 1 | package controller_test 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "testing" 7 | "time" 8 | 9 | "github.com/stretchr/testify/assert" 10 | "github.com/stretchr/testify/mock" 11 | 12 | "github.com/slok/grafterm/internal/controller" 13 | mmetric "github.com/slok/grafterm/internal/mocks/service/metric" 14 | "github.com/slok/grafterm/internal/model" 15 | ) 16 | 17 | func TestGetSingleMetric(t *testing.T) { 18 | tests := []struct { 19 | name string 20 | query model.Query 21 | serviceMetrics []model.MetricSeries 22 | serviceErr error 23 | ts time.Time 24 | expErr bool 25 | expMetric *model.Metric 26 | }{ 27 | { 28 | name: "Returning a correct metric the controller should handle the single metric correctly.", 29 | query: model.Query{Expr: "test"}, 30 | serviceMetrics: []model.MetricSeries{ 31 | model.MetricSeries{ 32 | Metrics: []model.Metric{ 33 | {Value: 17.9}, 34 | }, 35 | }, 36 | }, 37 | ts: time.Now(), 38 | expMetric: &model.Metric{Value: 17.9}, 39 | }, 40 | { 41 | name: "Returning multiple metrics should error.", 42 | query: model.Query{Expr: "test"}, 43 | serviceMetrics: []model.MetricSeries{ 44 | model.MetricSeries{ 45 | Metrics: []model.Metric{ 46 | {Value: 17.9}, 47 | {Value: 28.1}, 48 | }, 49 | }, 50 | }, 51 | ts: time.Now(), 52 | expErr: true, 53 | }, 54 | { 55 | name: "Returning no metrics should error.", 56 | query: model.Query{Expr: "test"}, 57 | serviceMetrics: []model.MetricSeries{ 58 | model.MetricSeries{ 59 | Metrics: []model.Metric{}, 60 | }, 61 | }, 62 | ts: time.Now(), 63 | expErr: true, 64 | }, 65 | { 66 | name: "Returning no metric series should error.", 67 | query: model.Query{Expr: "test"}, 68 | serviceMetrics: []model.MetricSeries{}, 69 | ts: time.Now(), 70 | expErr: true, 71 | }, 72 | { 73 | name: "Returning multiple metric series should error.", 74 | query: model.Query{Expr: "test"}, 75 | serviceMetrics: []model.MetricSeries{ 76 | model.MetricSeries{}, 77 | model.MetricSeries{}, 78 | }, 79 | ts: time.Now(), 80 | expErr: true, 81 | }, 82 | { 83 | name: "Returning a error from the metrics service should error.", 84 | query: model.Query{Expr: "test"}, 85 | serviceErr: errors.New("wanted error"), 86 | ts: time.Now(), 87 | expErr: true, 88 | }, 89 | } 90 | 91 | for _, test := range tests { 92 | t.Run(test.name, func(t *testing.T) { 93 | assert := assert.New(t) 94 | 95 | // Mocks. 96 | mg := &mmetric.Gatherer{} 97 | mg.On("GatherSingle", mock.Anything, test.query, test.ts).Once().Return(test.serviceMetrics, test.serviceErr) 98 | 99 | c := controller.NewController(mg) 100 | gotm, err := c.GetSingleMetric(context.TODO(), test.query, test.ts) 101 | 102 | if test.expErr { 103 | assert.Error(err) 104 | } else if assert.NoError(err) { 105 | assert.Equal(test.expMetric, gotm) 106 | mg.AssertExpectations(t) 107 | } 108 | }) 109 | } 110 | } 111 | 112 | func TestGetRangeMetrics(t *testing.T) { 113 | start := time.Now() 114 | end := start.Add(5 * time.Hour) 115 | 116 | tests := []struct { 117 | name string 118 | query model.Query 119 | serviceMetrics []model.MetricSeries 120 | serviceErr error 121 | start time.Time 122 | end time.Time 123 | step time.Duration 124 | expErr bool 125 | expSeries []model.MetricSeries 126 | }{ 127 | { 128 | name: "Receiving a non positive step should return an error.", 129 | query: model.Query{Expr: "test"}, 130 | start: start, 131 | end: end, 132 | expErr: true, 133 | }, 134 | { 135 | name: "Receiving a start TS that is older than a end TS should return an error.", 136 | query: model.Query{Expr: "test"}, 137 | start: end, 138 | end: start, 139 | step: 1 * time.Hour, 140 | expErr: true, 141 | }, 142 | { 143 | name: "Receiving and error from the services should return an error.", 144 | query: model.Query{Expr: "test"}, 145 | serviceMetrics: []model.MetricSeries{ 146 | model.MetricSeries{ 147 | Metrics: []model.Metric{ 148 | {Value: 17.9}, 149 | }, 150 | }, 151 | }, 152 | serviceErr: errors.New("wanted error"), 153 | start: start, 154 | end: end, 155 | step: 1 * time.Hour, 156 | expErr: true, 157 | }, 158 | { 159 | name: "Receiving correct group of arguments should call the services and return the metrics.", 160 | query: model.Query{Expr: "test"}, 161 | serviceMetrics: []model.MetricSeries{ 162 | model.MetricSeries{ 163 | Metrics: []model.Metric{ 164 | {Value: 17.9}, 165 | }, 166 | }, 167 | }, 168 | start: start, 169 | end: end, 170 | step: 1 * time.Hour, 171 | expSeries: []model.MetricSeries{ 172 | model.MetricSeries{ 173 | Metrics: []model.Metric{ 174 | {Value: 17.9}, 175 | }, 176 | }, 177 | }, 178 | }, 179 | } 180 | 181 | for _, test := range tests { 182 | t.Run(test.name, func(t *testing.T) { 183 | assert := assert.New(t) 184 | 185 | // Mocks. 186 | mg := &mmetric.Gatherer{} 187 | mg.On("GatherRange", mock.Anything, test.query, test.start, test.end, test.step).Once().Return(test.serviceMetrics, test.serviceErr) 188 | 189 | c := controller.NewController(mg) 190 | gotSeries, err := c.GetRangeMetrics(context.TODO(), test.query, test.start, test.end, test.step) 191 | 192 | if test.expErr { 193 | assert.Error(err) 194 | } else if assert.NoError(err) { 195 | assert.Equal(test.expSeries, gotSeries) 196 | mg.AssertExpectations(t) 197 | } 198 | }) 199 | } 200 | } 201 | -------------------------------------------------------------------------------- /internal/mocks/controller/Controller.go: -------------------------------------------------------------------------------- 1 | // Code generated by mockery v1.0.0. DO NOT EDIT. 2 | 3 | package controller 4 | 5 | import context "context" 6 | 7 | import mock "github.com/stretchr/testify/mock" 8 | import model "github.com/slok/grafterm/internal/model" 9 | import time "time" 10 | 11 | // Controller is an autogenerated mock type for the Controller type 12 | type Controller struct { 13 | mock.Mock 14 | } 15 | 16 | // GetRangeMetrics provides a mock function with given fields: ctx, query, start, end, step 17 | func (_m *Controller) GetRangeMetrics(ctx context.Context, query model.Query, start time.Time, end time.Time, step time.Duration) ([]model.MetricSeries, error) { 18 | ret := _m.Called(ctx, query, start, end, step) 19 | 20 | var r0 []model.MetricSeries 21 | if rf, ok := ret.Get(0).(func(context.Context, model.Query, time.Time, time.Time, time.Duration) []model.MetricSeries); ok { 22 | r0 = rf(ctx, query, start, end, step) 23 | } else { 24 | if ret.Get(0) != nil { 25 | r0 = ret.Get(0).([]model.MetricSeries) 26 | } 27 | } 28 | 29 | var r1 error 30 | if rf, ok := ret.Get(1).(func(context.Context, model.Query, time.Time, time.Time, time.Duration) error); ok { 31 | r1 = rf(ctx, query, start, end, step) 32 | } else { 33 | r1 = ret.Error(1) 34 | } 35 | 36 | return r0, r1 37 | } 38 | 39 | // GetSingleInstantMetric provides a mock function with given fields: ctx, query 40 | func (_m *Controller) GetSingleInstantMetric(ctx context.Context, query model.Query) (*model.Metric, error) { 41 | ret := _m.Called(ctx, query) 42 | 43 | var r0 *model.Metric 44 | if rf, ok := ret.Get(0).(func(context.Context, model.Query) *model.Metric); ok { 45 | r0 = rf(ctx, query) 46 | } else { 47 | if ret.Get(0) != nil { 48 | r0 = ret.Get(0).(*model.Metric) 49 | } 50 | } 51 | 52 | var r1 error 53 | if rf, ok := ret.Get(1).(func(context.Context, model.Query) error); ok { 54 | r1 = rf(ctx, query) 55 | } else { 56 | r1 = ret.Error(1) 57 | } 58 | 59 | return r0, r1 60 | } 61 | 62 | // GetSingleMetric provides a mock function with given fields: ctx, query, t 63 | func (_m *Controller) GetSingleMetric(ctx context.Context, query model.Query, t time.Time) (*model.Metric, error) { 64 | ret := _m.Called(ctx, query, t) 65 | 66 | var r0 *model.Metric 67 | if rf, ok := ret.Get(0).(func(context.Context, model.Query, time.Time) *model.Metric); ok { 68 | r0 = rf(ctx, query, t) 69 | } else { 70 | if ret.Get(0) != nil { 71 | r0 = ret.Get(0).(*model.Metric) 72 | } 73 | } 74 | 75 | var r1 error 76 | if rf, ok := ret.Get(1).(func(context.Context, model.Query, time.Time) error); ok { 77 | r1 = rf(ctx, query, t) 78 | } else { 79 | r1 = ret.Error(1) 80 | } 81 | 82 | return r0, r1 83 | } 84 | -------------------------------------------------------------------------------- /internal/mocks/doc.go: -------------------------------------------------------------------------------- 1 | /* 2 | Package mocks will have all the mocks of the library. 3 | */ 4 | package mocks // import "github.com/slok/grafterm/internal/mocks" 5 | 6 | // Controller mocks. 7 | //go:generate mockery -output ./controller -outpkg controller -dir ../controller -name Controller 8 | 9 | // Render mocks. 10 | //go:generate mockery -output ./view/render -outpkg render -dir ../view/render -name Renderer 11 | //go:generate mockery -output ./view/render -outpkg render -dir ../view/render -name GaugeWidget 12 | //go:generate mockery -output ./view/render -outpkg render -dir ../view/render -name SinglestatWidget 13 | //go:generate mockery -output ./view/render -outpkg render -dir ../view/render -name GraphWidget 14 | 15 | // Services mocks. 16 | //go:generate mockery -output ./service/metric -outpkg metric -dir ../service/metric -name Gatherer 17 | 18 | // 3rd party 19 | //go:generate mockery -output ./github.com/prometheus/client_golang/api/prometheus/v1 -outpkg v1 -dir ./thirdparty/github.com/prometheus/client_golang/api/prometheus/v1 -name API 20 | -------------------------------------------------------------------------------- /internal/mocks/service/metric/Gatherer.go: -------------------------------------------------------------------------------- 1 | // Code generated by mockery v1.0.0. DO NOT EDIT. 2 | 3 | package metric 4 | 5 | import context "context" 6 | 7 | import mock "github.com/stretchr/testify/mock" 8 | import model "github.com/slok/grafterm/internal/model" 9 | import time "time" 10 | 11 | // Gatherer is an autogenerated mock type for the Gatherer type 12 | type Gatherer struct { 13 | mock.Mock 14 | } 15 | 16 | // GatherRange provides a mock function with given fields: ctx, query, start, end, step 17 | func (_m *Gatherer) GatherRange(ctx context.Context, query model.Query, start time.Time, end time.Time, step time.Duration) ([]model.MetricSeries, error) { 18 | ret := _m.Called(ctx, query, start, end, step) 19 | 20 | var r0 []model.MetricSeries 21 | if rf, ok := ret.Get(0).(func(context.Context, model.Query, time.Time, time.Time, time.Duration) []model.MetricSeries); ok { 22 | r0 = rf(ctx, query, start, end, step) 23 | } else { 24 | if ret.Get(0) != nil { 25 | r0 = ret.Get(0).([]model.MetricSeries) 26 | } 27 | } 28 | 29 | var r1 error 30 | if rf, ok := ret.Get(1).(func(context.Context, model.Query, time.Time, time.Time, time.Duration) error); ok { 31 | r1 = rf(ctx, query, start, end, step) 32 | } else { 33 | r1 = ret.Error(1) 34 | } 35 | 36 | return r0, r1 37 | } 38 | 39 | // GatherSingle provides a mock function with given fields: ctx, query, t 40 | func (_m *Gatherer) GatherSingle(ctx context.Context, query model.Query, t time.Time) ([]model.MetricSeries, error) { 41 | ret := _m.Called(ctx, query, t) 42 | 43 | var r0 []model.MetricSeries 44 | if rf, ok := ret.Get(0).(func(context.Context, model.Query, time.Time) []model.MetricSeries); ok { 45 | r0 = rf(ctx, query, t) 46 | } else { 47 | if ret.Get(0) != nil { 48 | r0 = ret.Get(0).([]model.MetricSeries) 49 | } 50 | } 51 | 52 | var r1 error 53 | if rf, ok := ret.Get(1).(func(context.Context, model.Query, time.Time) error); ok { 54 | r1 = rf(ctx, query, t) 55 | } else { 56 | r1 = ret.Error(1) 57 | } 58 | 59 | return r0, r1 60 | } 61 | -------------------------------------------------------------------------------- /internal/mocks/thirdparty/github.com/prometheus/client_golang/api/prometheus/v1/v1.go: -------------------------------------------------------------------------------- 1 | package v1 2 | 3 | import ( 4 | promv1 "github.com/prometheus/client_golang/api/prometheus/v1" 5 | ) 6 | 7 | // API is a wrapper of v1.API. 8 | type API interface { 9 | promv1.API 10 | } 11 | -------------------------------------------------------------------------------- /internal/mocks/view/render/GaugeWidget.go: -------------------------------------------------------------------------------- 1 | // Code generated by mockery v1.0.0. DO NOT EDIT. 2 | 3 | package render 4 | 5 | import mock "github.com/stretchr/testify/mock" 6 | import model "github.com/slok/grafterm/internal/model" 7 | 8 | // GaugeWidget is an autogenerated mock type for the GaugeWidget type 9 | type GaugeWidget struct { 10 | mock.Mock 11 | } 12 | 13 | // GetWidgetCfg provides a mock function with given fields: 14 | func (_m *GaugeWidget) GetWidgetCfg() model.Widget { 15 | ret := _m.Called() 16 | 17 | var r0 model.Widget 18 | if rf, ok := ret.Get(0).(func() model.Widget); ok { 19 | r0 = rf() 20 | } else { 21 | r0 = ret.Get(0).(model.Widget) 22 | } 23 | 24 | return r0 25 | } 26 | 27 | // SetColor provides a mock function with given fields: hexColor 28 | func (_m *GaugeWidget) SetColor(hexColor string) error { 29 | ret := _m.Called(hexColor) 30 | 31 | var r0 error 32 | if rf, ok := ret.Get(0).(func(string) error); ok { 33 | r0 = rf(hexColor) 34 | } else { 35 | r0 = ret.Error(0) 36 | } 37 | 38 | return r0 39 | } 40 | 41 | // Sync provides a mock function with given fields: isPercent, value 42 | func (_m *GaugeWidget) Sync(isPercent bool, value float64) error { 43 | ret := _m.Called(isPercent, value) 44 | 45 | var r0 error 46 | if rf, ok := ret.Get(0).(func(bool, float64) error); ok { 47 | r0 = rf(isPercent, value) 48 | } else { 49 | r0 = ret.Error(0) 50 | } 51 | 52 | return r0 53 | } 54 | -------------------------------------------------------------------------------- /internal/mocks/view/render/GraphWidget.go: -------------------------------------------------------------------------------- 1 | // Code generated by mockery v1.0.0. DO NOT EDIT. 2 | 3 | package render 4 | 5 | import mock "github.com/stretchr/testify/mock" 6 | import model "github.com/slok/grafterm/internal/model" 7 | import render "github.com/slok/grafterm/internal/view/render" 8 | 9 | // GraphWidget is an autogenerated mock type for the GraphWidget type 10 | type GraphWidget struct { 11 | mock.Mock 12 | } 13 | 14 | // GetGraphPointQuantity provides a mock function with given fields: 15 | func (_m *GraphWidget) GetGraphPointQuantity() int { 16 | ret := _m.Called() 17 | 18 | var r0 int 19 | if rf, ok := ret.Get(0).(func() int); ok { 20 | r0 = rf() 21 | } else { 22 | r0 = ret.Get(0).(int) 23 | } 24 | 25 | return r0 26 | } 27 | 28 | // GetWidgetCfg provides a mock function with given fields: 29 | func (_m *GraphWidget) GetWidgetCfg() model.Widget { 30 | ret := _m.Called() 31 | 32 | var r0 model.Widget 33 | if rf, ok := ret.Get(0).(func() model.Widget); ok { 34 | r0 = rf() 35 | } else { 36 | r0 = ret.Get(0).(model.Widget) 37 | } 38 | 39 | return r0 40 | } 41 | 42 | // Sync provides a mock function with given fields: series 43 | func (_m *GraphWidget) Sync(series []render.Series) error { 44 | ret := _m.Called(series) 45 | 46 | var r0 error 47 | if rf, ok := ret.Get(0).(func([]render.Series) error); ok { 48 | r0 = rf(series) 49 | } else { 50 | r0 = ret.Error(0) 51 | } 52 | 53 | return r0 54 | } 55 | -------------------------------------------------------------------------------- /internal/mocks/view/render/Renderer.go: -------------------------------------------------------------------------------- 1 | // Code generated by mockery v1.0.0. DO NOT EDIT. 2 | 3 | package render 4 | 5 | import context "context" 6 | import grid "github.com/slok/grafterm/internal/view/grid" 7 | import mock "github.com/stretchr/testify/mock" 8 | import render "github.com/slok/grafterm/internal/view/render" 9 | 10 | // Renderer is an autogenerated mock type for the Renderer type 11 | type Renderer struct { 12 | mock.Mock 13 | } 14 | 15 | // Close provides a mock function with given fields: 16 | func (_m *Renderer) Close() { 17 | _m.Called() 18 | } 19 | 20 | // LoadDashboard provides a mock function with given fields: ctx, _a1 21 | func (_m *Renderer) LoadDashboard(ctx context.Context, _a1 *grid.Grid) ([]render.Widget, error) { 22 | ret := _m.Called(ctx, _a1) 23 | 24 | var r0 []render.Widget 25 | if rf, ok := ret.Get(0).(func(context.Context, *grid.Grid) []render.Widget); ok { 26 | r0 = rf(ctx, _a1) 27 | } else { 28 | if ret.Get(0) != nil { 29 | r0 = ret.Get(0).([]render.Widget) 30 | } 31 | } 32 | 33 | var r1 error 34 | if rf, ok := ret.Get(1).(func(context.Context, *grid.Grid) error); ok { 35 | r1 = rf(ctx, _a1) 36 | } else { 37 | r1 = ret.Error(1) 38 | } 39 | 40 | return r0, r1 41 | } 42 | -------------------------------------------------------------------------------- /internal/mocks/view/render/SinglestatWidget.go: -------------------------------------------------------------------------------- 1 | // Code generated by mockery v1.0.0. DO NOT EDIT. 2 | 3 | package render 4 | 5 | import mock "github.com/stretchr/testify/mock" 6 | import model "github.com/slok/grafterm/internal/model" 7 | 8 | // SinglestatWidget is an autogenerated mock type for the SinglestatWidget type 9 | type SinglestatWidget struct { 10 | mock.Mock 11 | } 12 | 13 | // GetWidgetCfg provides a mock function with given fields: 14 | func (_m *SinglestatWidget) GetWidgetCfg() model.Widget { 15 | ret := _m.Called() 16 | 17 | var r0 model.Widget 18 | if rf, ok := ret.Get(0).(func() model.Widget); ok { 19 | r0 = rf() 20 | } else { 21 | r0 = ret.Get(0).(model.Widget) 22 | } 23 | 24 | return r0 25 | } 26 | 27 | // SetColor provides a mock function with given fields: hexColor 28 | func (_m *SinglestatWidget) SetColor(hexColor string) error { 29 | ret := _m.Called(hexColor) 30 | 31 | var r0 error 32 | if rf, ok := ret.Get(0).(func(string) error); ok { 33 | r0 = rf(hexColor) 34 | } else { 35 | r0 = ret.Error(0) 36 | } 37 | 38 | return r0 39 | } 40 | 41 | // Sync provides a mock function with given fields: text 42 | func (_m *SinglestatWidget) Sync(text string) error { 43 | ret := _m.Called(text) 44 | 45 | var r0 error 46 | if rf, ok := ret.Get(0).(func(string) error); ok { 47 | r0 = rf(text) 48 | } else { 49 | r0 = ret.Error(0) 50 | } 51 | 52 | return r0 53 | } 54 | -------------------------------------------------------------------------------- /internal/model/datasource.go: -------------------------------------------------------------------------------- 1 | package model 2 | 3 | import "fmt" 4 | 5 | // Datasource is where the data will be retrieved. 6 | type Datasource struct { 7 | ID string 8 | DatasourceSource `json:",inline"` 9 | } 10 | 11 | // DatasourceSource represents the datasource. 12 | type DatasourceSource struct { 13 | Fake *FakeDatasource `json:"fake,omitempty"` 14 | Prometheus *PrometheusDatasource `json:"prometheus,omitempty"` 15 | Graphite *GraphiteDatasource `json:"graphite,omitempty"` 16 | InfluxDB *InfluxDBDatasource `json:"influxdb,omitempty"` 17 | } 18 | 19 | // FakeDatasource is the fake datasource. 20 | type FakeDatasource struct{} 21 | 22 | // PrometheusDatasource is the Prometheus kind datasource. 23 | type PrometheusDatasource struct { 24 | Address string `json:"address,omitempty"` 25 | } 26 | 27 | // GraphiteDatasource is the Graphite kind datasource. 28 | type GraphiteDatasource struct { 29 | Address string `json:"address,omitempty"` 30 | } 31 | 32 | // InfluxDBDatasource is the Graphite kind datasource. 33 | type InfluxDBDatasource struct { 34 | Address string `json:"address,omitempty"` 35 | Insecure bool `json:"insecure,omitempty"` 36 | Database string `json:"database,omitempty"` 37 | Username string `json:"username,omitempty"` 38 | Password string `json:"password,omitempty"` 39 | } 40 | 41 | // Validate validates the object model is correct. 42 | func (d Datasource) Validate() error { 43 | if d.ID == "" { 44 | return fmt.Errorf("datasource ID is required") 45 | } 46 | 47 | // Check sources. 48 | var err error 49 | switch { 50 | case d.Prometheus != nil: 51 | err = d.Prometheus.validate() 52 | case d.Graphite != nil: 53 | err = d.Graphite.validate() 54 | case d.InfluxDB != nil: 55 | err = d.InfluxDB.validate() 56 | case d.Fake != nil: 57 | default: 58 | err = fmt.Errorf("declared datasource %s can't be empty", d.ID) 59 | } 60 | if err != nil { 61 | return err 62 | } 63 | 64 | return nil 65 | } 66 | 67 | func (p PrometheusDatasource) validate() error { 68 | if p.Address == "" { 69 | return fmt.Errorf("prometheus address can't be empty") 70 | } 71 | 72 | return nil 73 | } 74 | 75 | func (g GraphiteDatasource) validate() error { 76 | if g.Address == "" { 77 | return fmt.Errorf("Graphite API address can't be empty") 78 | } 79 | 80 | return nil 81 | } 82 | 83 | func (g InfluxDBDatasource) validate() error { 84 | if g.Address == "" { 85 | return fmt.Errorf("InfluxDB API address can't be empty") 86 | } 87 | 88 | return nil 89 | } 90 | -------------------------------------------------------------------------------- /internal/model/datasource_test.go: -------------------------------------------------------------------------------- 1 | package model_test 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/stretchr/testify/assert" 7 | 8 | "github.com/slok/grafterm/internal/model" 9 | ) 10 | 11 | func getBaseDatasource() model.Datasource { 12 | return model.Datasource{ 13 | ID: "test", 14 | DatasourceSource: model.DatasourceSource{ 15 | Fake: &model.FakeDatasource{}, 16 | }, 17 | } 18 | } 19 | 20 | func TestValidateDatasource(t *testing.T) { 21 | tests := []struct { 22 | name string 23 | ds func() model.Datasource 24 | expErr bool 25 | }{ 26 | { 27 | name: "All ok.", 28 | ds: func() model.Datasource { 29 | return getBaseDatasource() 30 | }, 31 | expErr: false, 32 | }, 33 | { 34 | name: "A datasources without ID should error.", 35 | ds: func() model.Datasource { 36 | d := getBaseDatasource() 37 | d.ID = "" 38 | return d 39 | }, 40 | expErr: true, 41 | }, 42 | { 43 | name: "A declared datasource without datasource should error.", 44 | ds: func() model.Datasource { 45 | d := getBaseDatasource() 46 | d.Fake = nil 47 | return d 48 | }, 49 | expErr: true, 50 | }, 51 | { 52 | name: "A Prometheus datasource without address should error.", 53 | ds: func() model.Datasource { 54 | d := getBaseDatasource() 55 | d.Prometheus = &model.PrometheusDatasource{ 56 | Address: "", 57 | } 58 | return d 59 | }, 60 | expErr: true, 61 | }, 62 | { 63 | name: "A Graphite datasource without address should error.", 64 | ds: func() model.Datasource { 65 | d := getBaseDatasource() 66 | d.Graphite = &model.GraphiteDatasource{ 67 | Address: "", 68 | } 69 | return d 70 | }, 71 | expErr: true, 72 | }, 73 | { 74 | name: "A InfluxDB datasource without address should error.", 75 | ds: func() model.Datasource { 76 | d := getBaseDatasource() 77 | d.InfluxDB = &model.InfluxDBDatasource{ 78 | Address: "", 79 | } 80 | return d 81 | }, 82 | expErr: true, 83 | }, 84 | } 85 | 86 | for _, test := range tests { 87 | t.Run(test.name, func(t *testing.T) { 88 | assert := assert.New(t) 89 | err := test.ds().Validate() 90 | if test.expErr { 91 | assert.Error(err) 92 | } else { 93 | assert.NoError(err) 94 | } 95 | }) 96 | } 97 | } 98 | -------------------------------------------------------------------------------- /internal/model/metric.go: -------------------------------------------------------------------------------- 1 | package model 2 | 3 | import ( 4 | "time" 5 | ) 6 | 7 | // Metric represents a measured value in time. 8 | type Metric struct { 9 | Value float64 10 | TS time.Time 11 | } 12 | 13 | // MetricSeries is a group of metrics identified by an ID and a context 14 | // information. 15 | type MetricSeries struct { 16 | ID string 17 | Labels map[string]string 18 | Metrics []Metric 19 | } 20 | -------------------------------------------------------------------------------- /internal/service/configuration/configuration.go: -------------------------------------------------------------------------------- 1 | package configuration 2 | 3 | import ( 4 | "io" 5 | 6 | "github.com/slok/grafterm/internal/model" 7 | ) 8 | 9 | // Configuration is the interface that the different configurations need to implement. 10 | type Configuration interface { 11 | // Version gets the version of the configuration. 12 | Version() string 13 | // Dashboard gets the domain model dashboard from the configuration. 14 | Dashboard() (model.Dashboard, error) 15 | // Dashboard gets the domain model datasources from the configuration. 16 | Datasources() ([]model.Datasource, error) 17 | } 18 | 19 | // Loader knows how to load different configuration versions. 20 | type Loader interface { 21 | // Load loads a configuration. 22 | Load(r io.Reader) (Configuration, error) 23 | } 24 | -------------------------------------------------------------------------------- /internal/service/configuration/loader.go: -------------------------------------------------------------------------------- 1 | package configuration 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "io" 7 | "io/ioutil" 8 | 9 | "github.com/slok/grafterm/internal/service/configuration/meta" 10 | v1 "github.com/slok/grafterm/internal/service/configuration/v1" 11 | ) 12 | 13 | // JSONLoader will load configuration in JSON format. 14 | // It autodetects the version configuration so the user 15 | // doesn't know what version of configuration is loading. 16 | type JSONLoader struct{} 17 | 18 | // Load satisfies configuration.Loader interface. 19 | func (j JSONLoader) Load(r io.Reader) (Configuration, error) { 20 | bs, err := ioutil.ReadAll(r) 21 | if err != nil { 22 | return nil, err 23 | } 24 | 25 | cfg, err := newConfig(bs) 26 | if err != nil { 27 | return nil, err 28 | } 29 | 30 | err = json.Unmarshal(bs, cfg) 31 | if err != nil { 32 | return nil, fmt.Errorf("error unmarshalling json: %s", err) 33 | } 34 | 35 | return cfg, nil 36 | } 37 | 38 | // newConfig will get the correct object configuration 39 | // based on the version of the configuration file. 40 | func newConfig(cfgData []byte) (Configuration, error) { 41 | cfgVersion := &struct { 42 | meta.Meta 43 | }{} 44 | err := json.Unmarshal(cfgData, cfgVersion) 45 | if err != nil { 46 | return nil, fmt.Errorf("error unmarshalling json: %s", err) 47 | } 48 | 49 | var cfg Configuration 50 | switch cfgVersion.Version { 51 | case v1.Version: 52 | cfg = &v1.Configuration{} 53 | default: 54 | return nil, fmt.Errorf("%s is not a valid configuration version", cfgVersion.Version) 55 | } 56 | 57 | return cfg, nil 58 | } 59 | -------------------------------------------------------------------------------- /internal/service/configuration/loader_test.go: -------------------------------------------------------------------------------- 1 | package configuration_test 2 | 3 | import ( 4 | "io" 5 | "strings" 6 | "testing" 7 | 8 | "github.com/stretchr/testify/assert" 9 | 10 | "github.com/slok/grafterm/internal/service/configuration" 11 | ) 12 | 13 | func TestLoadJSON(t *testing.T) { 14 | tests := []struct { 15 | name string 16 | config func() io.Reader 17 | loader func() configuration.Loader 18 | expVersion string 19 | expErr bool 20 | }{ 21 | { 22 | name: "Invalid JSON should return an error.", 23 | loader: func() configuration.Loader { 24 | return &configuration.JSONLoader{} 25 | }, 26 | config: func() io.Reader { 27 | return strings.NewReader(`{"version": "v1",}`) 28 | }, 29 | expErr: true, 30 | }, 31 | { 32 | name: "Unknown JSON version should error.", 33 | loader: func() configuration.Loader { 34 | return &configuration.JSONLoader{} 35 | }, 36 | config: func() io.Reader { 37 | return strings.NewReader(`{"version": "v0.987654321"}`) 38 | }, 39 | expErr: true, 40 | }, 41 | { 42 | name: "Valid JSON V1 load.", 43 | loader: func() configuration.Loader { 44 | return &configuration.JSONLoader{} 45 | }, 46 | config: func() io.Reader { 47 | return strings.NewReader(`{"version": "v1"}`) 48 | }, 49 | expVersion: "v1", 50 | }, 51 | } 52 | 53 | for _, test := range tests { 54 | t.Run(test.name, func(t *testing.T) { 55 | assert := assert.New(t) 56 | 57 | loader := test.loader() 58 | gotcfg, err := loader.Load(test.config()) 59 | 60 | if test.expErr { 61 | assert.Error(err) 62 | } else if assert.NoError(err) { 63 | assert.Equal(test.expVersion, gotcfg.Version()) 64 | } 65 | }) 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /internal/service/configuration/meta/meta.go: -------------------------------------------------------------------------------- 1 | package meta 2 | 3 | // Meta is the configuration metadata compatible with all the versions. 4 | type Meta struct { 5 | Version string `json:"version,omitempty"` 6 | } 7 | -------------------------------------------------------------------------------- /internal/service/configuration/v1/v1.go: -------------------------------------------------------------------------------- 1 | package v1 2 | 3 | import ( 4 | "github.com/slok/grafterm/internal/model" 5 | "github.com/slok/grafterm/internal/service/configuration/meta" 6 | ) 7 | 8 | const ( 9 | // Version is the that represents the configuration version. 10 | Version = "v1" 11 | ) 12 | 13 | // Datasource represents a configuration v1 datasource. 14 | type Datasource = model.Datasource 15 | 16 | // Dashboard represents a configuration v1 dashboard. 17 | type Dashboard struct { 18 | Grid model.Grid `json:"grid,omitempty"` 19 | Variables map[string]*model.Variable `json:"variables,omitempty"` 20 | Widgets []model.Widget `json:"widgets,omitempty"` 21 | } 22 | 23 | // Configuration is the v1 configuration.Satisfies configuration.Configuration interface. 24 | type Configuration struct { 25 | meta.Meta `json:",inline"` 26 | V1Datasources map[string]*Datasource `json:"datasources,omitempty"` 27 | V1Dashboard Dashboard `json:"dashboard,omitempty"` 28 | } 29 | 30 | // Version satisfies Configuration interface. 31 | func (c *Configuration) Version() string { 32 | return Version 33 | } 34 | 35 | // Dashboard satisfies Configuration interface. 36 | func (c *Configuration) Dashboard() (model.Dashboard, error) { 37 | // Transform to model. 38 | vars := []model.Variable{} 39 | for name, v := range c.V1Dashboard.Variables { 40 | v.Name = name 41 | vars = append(vars, *v) 42 | } 43 | dashboard := model.Dashboard{ 44 | Grid: c.V1Dashboard.Grid, 45 | Variables: vars, 46 | Widgets: c.V1Dashboard.Widgets, 47 | } 48 | 49 | err := dashboard.Validate() 50 | if err != nil { 51 | return dashboard, err 52 | } 53 | 54 | return dashboard, nil 55 | } 56 | 57 | // Datasources satisfies Configuration interface. 58 | func (c *Configuration) Datasources() ([]model.Datasource, error) { 59 | // Transform to model. 60 | dss := []model.Datasource{} 61 | for id, ds := range c.V1Datasources { 62 | ds.ID = id 63 | err := ds.Validate() 64 | if err != nil { 65 | return dss, err 66 | } 67 | 68 | dss = append(dss, *ds) 69 | } 70 | 71 | return dss, nil 72 | } 73 | -------------------------------------------------------------------------------- /internal/service/log/log.go: -------------------------------------------------------------------------------- 1 | package log 2 | 3 | import ( 4 | "io" 5 | 6 | "github.com/rs/zerolog" 7 | ) 8 | 9 | // Logger knows how to log. 10 | type Logger interface { 11 | Infof(format string, args ...interface{}) 12 | Warnf(format string, args ...interface{}) 13 | Errorf(format string, args ...interface{}) 14 | } 15 | 16 | // Dummy is a dummy logger that doesn't log anything. 17 | var Dummy = &dummy{} 18 | 19 | type dummy struct{} 20 | 21 | func (d dummy) Infof(format string, args ...interface{}) {} 22 | func (d dummy) Warnf(format string, args ...interface{}) {} 23 | func (d dummy) Errorf(format string, args ...interface{}) {} 24 | 25 | // Config is the Logger configuration 26 | type Config struct { 27 | Output io.Writer 28 | } 29 | 30 | // New returns a new logger. 31 | func New(cfg Config) Logger { 32 | return newZero(cfg) 33 | } 34 | 35 | func newZero(cfg Config) Logger { 36 | return &zero{ 37 | logger: zerolog.New(cfg.Output).With(). 38 | Timestamp(). 39 | Logger(), 40 | } 41 | } 42 | 43 | type zero struct { 44 | logger zerolog.Logger 45 | } 46 | 47 | func (z zero) Infof(format string, args ...interface{}) { 48 | z.logger.Info().Msgf(format, args...) 49 | } 50 | func (z zero) Warnf(format string, args ...interface{}) { 51 | z.logger.Warn().Msgf(format, args...) 52 | } 53 | func (z zero) Errorf(format string, args ...interface{}) { 54 | z.logger.Error().Msgf(format, args...) 55 | } 56 | -------------------------------------------------------------------------------- /internal/service/metric/datasource/datasource.go: -------------------------------------------------------------------------------- 1 | package datasource 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "fmt" 7 | "net/http" 8 | "time" 9 | 10 | prometheusapi "github.com/prometheus/client_golang/api" 11 | prometheusv1 "github.com/prometheus/client_golang/api/prometheus/v1" 12 | 13 | _ "github.com/influxdata/influxdb1-client" // needed due to go mod bug 14 | influxdbv2 "github.com/influxdata/influxdb1-client/v2" 15 | 16 | "github.com/slok/grafterm/internal/model" 17 | "github.com/slok/grafterm/internal/service/metric" 18 | "github.com/slok/grafterm/internal/service/metric/fake" 19 | "github.com/slok/grafterm/internal/service/metric/graphite" 20 | "github.com/slok/grafterm/internal/service/metric/influxdb" 21 | "github.com/slok/grafterm/internal/service/metric/prometheus" 22 | ) 23 | 24 | const ( 25 | defGraphiteTimeout = 7 * time.Second 26 | ) 27 | 28 | // ConfigGatherer is the configuration of the multi Gatherer. 29 | type ConfigGatherer struct { 30 | // DashboardDatasources are the datasources that are on the dashboards and 31 | // will be reference, these datasources are the ones with the lowest priority. 32 | DashboardDatasources []model.Datasource 33 | // UserDatasources are the datasources outside the dashboard and defined by the suer 34 | // the ones that have priority over dashboards, also are the ones that will be used as 35 | // replacement for the aliased datasources. 36 | UserDatasources []model.Datasource 37 | // Aliases are the aliases of the dashboard datasources. 38 | // The key of the map is the referenced ID on the dashboard, and the 39 | // value of the map is the ID of the datasource that will be used. 40 | Aliases map[string]string 41 | // CreateFakeFunc is the function that will be called to create fake gatherers. 42 | CreateFakeFunc func(ds model.FakeDatasource) (metric.Gatherer, error) 43 | // CreatePrometheusFunc is the function that will be called to create Prometheus gatherers. 44 | CreatePrometheusFunc func(ds model.PrometheusDatasource) (metric.Gatherer, error) 45 | // CreateGraphiteFunc is the function that will be called to create Graphite gatherers. 46 | CreateGraphiteFunc func(ds model.GraphiteDatasource) (metric.Gatherer, error) 47 | // CreateInfluxDBFunc is the function that will be called to create InfluxDB gatherers. 48 | CreateInfluxDBFunc func(ds model.InfluxDBDatasource) (metric.Gatherer, error) 49 | } 50 | 51 | func (c *ConfigGatherer) defaults() { 52 | // Set default creator function for fake. 53 | if c.CreateFakeFunc == nil { 54 | c.CreateFakeFunc = func(_ model.FakeDatasource) (metric.Gatherer, error) { 55 | return &fake.Gatherer{}, nil 56 | } 57 | } 58 | 59 | // Set default creator function for prometheus. 60 | if c.CreatePrometheusFunc == nil { 61 | c.CreatePrometheusFunc = func(ds model.PrometheusDatasource) (metric.Gatherer, error) { 62 | cli, err := prometheusapi.NewClient(prometheusapi.Config{ 63 | Address: ds.Address, 64 | }) 65 | if err != nil { 66 | return nil, err 67 | } 68 | g := prometheus.NewGatherer(prometheus.ConfigGatherer{ 69 | Client: prometheusv1.NewAPI(cli), 70 | }) 71 | 72 | return g, nil 73 | } 74 | } 75 | 76 | // Set default creator function for Graphite. 77 | if c.CreateGraphiteFunc == nil { 78 | c.CreateGraphiteFunc = func(ds model.GraphiteDatasource) (metric.Gatherer, error) { 79 | g, err := graphite.NewGatherer(graphite.ConfigGatherer{ 80 | GraphiteAPIURL: ds.Address, 81 | HTTPCli: &http.Client{ 82 | Timeout: defGraphiteTimeout, 83 | }, 84 | }) 85 | if err != nil { 86 | return nil, err 87 | } 88 | 89 | return g, nil 90 | } 91 | } 92 | 93 | // Set default creator function for InfluxDB. 94 | if c.CreateInfluxDBFunc == nil { 95 | c.CreateInfluxDBFunc = func(ds model.InfluxDBDatasource) (metric.Gatherer, error) { 96 | 97 | cli, err := influxdbv2.NewHTTPClient( 98 | influxdbv2.HTTPConfig{ 99 | Addr: ds.Address, InsecureSkipVerify: ds.Insecure, 100 | Username: ds.Username, Password: ds.Password, 101 | }, 102 | ) 103 | if err != nil { 104 | return nil, err 105 | } 106 | 107 | g, err := influxdb.NewGatherer(influxdb.ConfigGatherer{ 108 | Addr: ds.Address, 109 | Database: ds.Database, 110 | Client: cli, 111 | }) 112 | if err != nil { 113 | return nil, err 114 | } 115 | 116 | return g, nil 117 | } 118 | } 119 | 120 | if c.Aliases == nil { 121 | c.Aliases = map[string]string{} 122 | } 123 | } 124 | 125 | type gatherer struct { 126 | cfg ConfigGatherer 127 | gatherers map[string]metric.Gatherer 128 | } 129 | 130 | // NewGatherer returns a new gatherer that knows how to register different 131 | // gatherer types based on datasources and when calling the methods of this 132 | // gatherer will delegate to the correct gatherer based on the query's 133 | // datasource ID. 134 | func NewGatherer(cfg ConfigGatherer) (metric.Gatherer, error) { 135 | cfg.defaults() 136 | 137 | // Lowest priority (0). 138 | gs := map[string]metric.Gatherer{} 139 | for _, ds := range cfg.DashboardDatasources { 140 | g, err := createGatherer(cfg, ds) 141 | if err != nil { 142 | return nil, err 143 | } 144 | gs[ds.ID] = g 145 | } 146 | 147 | // Mid priority (1). 148 | ags := map[string]metric.Gatherer{} 149 | for _, ds := range cfg.UserDatasources { 150 | g, err := createGatherer(cfg, ds) 151 | if err != nil { 152 | return nil, err 153 | } 154 | ags[ds.ID] = g 155 | } 156 | 157 | // Use the IDs from the dashboard to use the user datasources. 158 | for id := range gs { 159 | g, ok := ags[id] 160 | if ok { 161 | gs[id] = g 162 | } 163 | } 164 | 165 | // Override dashboard datasource with the user datsources using the aliases. 166 | // Highest priority (2). 167 | for id, alias := range cfg.Aliases { 168 | ag, ok := ags[alias] 169 | if !ok { 170 | return nil, fmt.Errorf("alias %s for ID %s not found", alias, id) 171 | } 172 | gs[id] = ag 173 | } 174 | 175 | return &gatherer{ 176 | cfg: cfg, 177 | gatherers: gs, 178 | }, nil 179 | } 180 | 181 | func (g *gatherer) GatherSingle(ctx context.Context, query model.Query, t time.Time) ([]model.MetricSeries, error) { 182 | dsg, err := g.metricGatherer(query.DatasourceID) 183 | if err != nil { 184 | return nil, err 185 | } 186 | return dsg.GatherSingle(ctx, query, t) 187 | } 188 | 189 | func (g *gatherer) GatherRange(ctx context.Context, query model.Query, start, end time.Time, step time.Duration) ([]model.MetricSeries, error) { 190 | dsg, err := g.metricGatherer(query.DatasourceID) 191 | if err != nil { 192 | return nil, err 193 | } 194 | return dsg.GatherRange(ctx, query, start, end, step) 195 | } 196 | 197 | func (g *gatherer) metricGatherer(id string) (metric.Gatherer, error) { 198 | mg, ok := g.gatherers[id] 199 | if !ok { 200 | return nil, fmt.Errorf("datasource %s does not exists", id) 201 | } 202 | 203 | return mg, nil 204 | } 205 | 206 | func createGatherer(cfg ConfigGatherer, ds model.Datasource) (metric.Gatherer, error) { 207 | switch { 208 | case ds.Prometheus != nil: 209 | return cfg.CreatePrometheusFunc(*ds.Prometheus) 210 | case ds.Graphite != nil: 211 | return cfg.CreateGraphiteFunc(*ds.Graphite) 212 | case ds.InfluxDB != nil: 213 | return cfg.CreateInfluxDBFunc(*ds.InfluxDB) 214 | case ds.Fake != nil: 215 | return cfg.CreateFakeFunc(*ds.Fake) 216 | } 217 | 218 | return nil, errors.New("not a valid datasource") 219 | } 220 | -------------------------------------------------------------------------------- /internal/service/metric/datasource/datasource_test.go: -------------------------------------------------------------------------------- 1 | package datasource_test 2 | 3 | import ( 4 | "context" 5 | "testing" 6 | "time" 7 | 8 | "github.com/stretchr/testify/assert" 9 | "github.com/stretchr/testify/mock" 10 | "github.com/stretchr/testify/require" 11 | 12 | mmetric "github.com/slok/grafterm/internal/mocks/service/metric" 13 | "github.com/slok/grafterm/internal/model" 14 | "github.com/slok/grafterm/internal/service/metric" 15 | "github.com/slok/grafterm/internal/service/metric/datasource" 16 | ) 17 | 18 | func TestGathererGatherSingle(t *testing.T) { 19 | datasources1 := []model.Datasource{ 20 | model.Datasource{ 21 | ID: "ds0", 22 | DatasourceSource: model.DatasourceSource{Fake: &model.FakeDatasource{}}, 23 | }, 24 | model.Datasource{ 25 | ID: "ds1", 26 | DatasourceSource: model.DatasourceSource{Prometheus: &model.PrometheusDatasource{}}, 27 | }, 28 | } 29 | datasources2 := []model.Datasource{ 30 | model.Datasource{ 31 | ID: "ds2", 32 | DatasourceSource: model.DatasourceSource{Graphite: &model.GraphiteDatasource{}}, 33 | }, 34 | model.Datasource{ 35 | ID: "ds3", 36 | DatasourceSource: model.DatasourceSource{Prometheus: &model.PrometheusDatasource{}}, 37 | }, 38 | } 39 | datasources3 := []model.Datasource{ 40 | model.Datasource{ 41 | ID: "ds0", 42 | DatasourceSource: model.DatasourceSource{Fake: &model.FakeDatasource{}}, 43 | }, 44 | model.Datasource{ 45 | ID: "ds1", 46 | DatasourceSource: model.DatasourceSource{Prometheus: &model.PrometheusDatasource{}}, 47 | }, 48 | } 49 | tests := []struct { 50 | name string 51 | dashboardDatasources []model.Datasource 52 | userDatasources []model.Datasource 53 | aliases map[string]string 54 | query model.Query 55 | exp func(mgs []*mmetric.Gatherer) 56 | expErr bool 57 | }{ 58 | { 59 | name: "A query to an non existent gatherer should fail.", 60 | query: model.Query{ 61 | DatasourceID: "does-not-exists", 62 | Expr: "test", 63 | }, 64 | dashboardDatasources: datasources1, 65 | userDatasources: datasources2, 66 | exp: func(mgs []*mmetric.Gatherer) {}, 67 | expErr: true, 68 | }, 69 | { 70 | name: "A query using a datasource ID should use the correct datasource based on the query.", 71 | query: model.Query{ 72 | DatasourceID: "ds1", 73 | Expr: "test", 74 | }, 75 | dashboardDatasources: datasources1, 76 | userDatasources: datasources2, 77 | exp: func(mgs []*mmetric.Gatherer) { 78 | mgs[1].On("GatherSingle", mock.Anything, mock.Anything, mock.Anything).Once().Return([]model.MetricSeries{}, nil) 79 | }, 80 | }, 81 | { 82 | name: "A query using a datasource ID that isn't on the dashboard datasources but is on the user datasources should fail.", 83 | query: model.Query{ 84 | DatasourceID: "ds3", 85 | Expr: "test", 86 | }, 87 | dashboardDatasources: datasources1, 88 | userDatasources: datasources2, 89 | exp: func(mgs []*mmetric.Gatherer) {}, 90 | expErr: true, 91 | }, 92 | { 93 | name: "A query using a datasource ID that is aliased should use the alias replacement datasource.", 94 | query: model.Query{ 95 | DatasourceID: "ds1", 96 | Expr: "test", 97 | }, 98 | dashboardDatasources: datasources1, 99 | userDatasources: datasources2, 100 | aliases: map[string]string{ 101 | "ds1": "ds3", 102 | }, 103 | exp: func(mgs []*mmetric.Gatherer) { 104 | mgs[3].On("GatherSingle", mock.Anything, mock.Anything, mock.Anything).Once().Return([]model.MetricSeries{}, nil) 105 | }, 106 | }, 107 | { 108 | name: "A query using a datasource ID that has the same ID on the user defined datasources, should use the user one.", 109 | query: model.Query{ 110 | DatasourceID: "ds1", 111 | Expr: "test", 112 | }, 113 | dashboardDatasources: datasources1, 114 | userDatasources: datasources3, 115 | exp: func(mgs []*mmetric.Gatherer) { 116 | mgs[3].On("GatherSingle", mock.Anything, mock.Anything, mock.Anything).Once().Return([]model.MetricSeries{}, nil) 117 | }, 118 | }, 119 | } 120 | 121 | for _, test := range tests { 122 | t.Run(test.name, func(t *testing.T) { 123 | require := require.New(t) 124 | assert := assert.New(t) 125 | 126 | // Create mocks based on the datasources of the test. 127 | mgs := []*mmetric.Gatherer{} 128 | for i := 0; i < len(test.dashboardDatasources); i++ { 129 | mgs = append(mgs, &mmetric.Gatherer{}) 130 | } 131 | for i := 0; i < len(test.userDatasources); i++ { 132 | mgs = append(mgs, &mmetric.Gatherer{}) 133 | } 134 | test.exp(mgs) 135 | 136 | // Create the datasource based gatherer. 137 | // The creation funcs return the mocks in order. 138 | gCount := 0 139 | g, err := datasource.NewGatherer(datasource.ConfigGatherer{ 140 | DashboardDatasources: test.dashboardDatasources, 141 | UserDatasources: test.userDatasources, 142 | Aliases: test.aliases, 143 | CreateFakeFunc: func(_ model.FakeDatasource) (metric.Gatherer, error) { 144 | g := mgs[gCount] 145 | gCount++ 146 | return g, nil 147 | }, 148 | CreatePrometheusFunc: func(_ model.PrometheusDatasource) (metric.Gatherer, error) { 149 | g := mgs[gCount] 150 | gCount++ 151 | return g, nil 152 | }, 153 | CreateGraphiteFunc: func(_ model.GraphiteDatasource) (metric.Gatherer, error) { 154 | g := mgs[gCount] 155 | gCount++ 156 | return g, nil 157 | }, 158 | }) 159 | require.NoError(err) 160 | 161 | // Call to gatherer method and check the delegations to the correct 162 | // datasources (expected mock calls) have been made correctly. 163 | _, err = g.GatherSingle(context.TODO(), test.query, time.Now()) 164 | if test.expErr { 165 | assert.Error(err) 166 | } else if assert.NoError(err) { 167 | for _, mg := range mgs { 168 | mg.AssertExpectations(t) 169 | } 170 | } 171 | }) 172 | } 173 | } 174 | -------------------------------------------------------------------------------- /internal/service/metric/fake/fake.go: -------------------------------------------------------------------------------- 1 | package fake 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "time" 7 | 8 | "github.com/slok/grafterm/internal/model" 9 | ) 10 | 11 | // Gatherer is a fake Gatherer. 12 | type Gatherer struct{} 13 | 14 | // GatherSingle satisfies metric.Gatherer interface. 15 | func (g Gatherer) GatherSingle(_ context.Context, _ model.Query, t time.Time) ([]model.MetricSeries, error) { 16 | return []model.MetricSeries{ 17 | model.MetricSeries{ 18 | ID: "fake", 19 | Labels: map[string]string{ 20 | "faked": "true", 21 | "gatherer": "fake", 22 | "kind": "fixed", 23 | }, 24 | Metrics: []model.Metric{ 25 | model.Metric{ 26 | Value: float64(t.Second()), 27 | TS: t, 28 | }, 29 | }, 30 | }, 31 | }, nil 32 | } 33 | 34 | // GatherRange satisfies metric.Gatherer interface. 35 | func (g Gatherer) GatherRange(ctx context.Context, query model.Query, start, end time.Time, step time.Duration) ([]model.MetricSeries, error) { 36 | // Get some series. 37 | series := []model.MetricSeries{} 38 | seriesQ := 3 39 | for i := 0; i < seriesQ; i++ { 40 | id := fmt.Sprintf("fake-%d", i) 41 | s := model.MetricSeries{ 42 | ID: id, 43 | Labels: map[string]string{ 44 | "fake": "true", 45 | "name": id, 46 | }, 47 | Metrics: generateMetrics(i*11, start, end, step), 48 | } 49 | 50 | series = append(series, s) 51 | } 52 | 53 | return series, nil 54 | } 55 | 56 | func generateMetrics(offset int, start, end time.Time, step time.Duration) []model.Metric { 57 | metrics := []model.Metric{} 58 | for i := 1; ; i++ { 59 | t := start.Add(step * time.Duration(i)) 60 | val := float64(offset + t.Second()) 61 | 62 | // Add some noise. 63 | noise := t.Second() % 3 64 | if noise%2 == 0 { 65 | val = val + float64(noise) 66 | } else { 67 | val = val - float64(noise) 68 | } 69 | 70 | m := model.Metric{ 71 | TS: t, 72 | Value: val + float64(offset), 73 | } 74 | metrics = append(metrics, m) 75 | 76 | if t.After(end) { 77 | break 78 | } 79 | } 80 | 81 | return metrics 82 | } 83 | -------------------------------------------------------------------------------- /internal/service/metric/gather.go: -------------------------------------------------------------------------------- 1 | package metric 2 | 3 | import ( 4 | "context" 5 | "time" 6 | 7 | "github.com/slok/grafterm/internal/model" 8 | ) 9 | 10 | // Gatherer knows how to gather metrics from different backends. 11 | type Gatherer interface { 12 | // GatherSingle gathers one single metric at a point in time. 13 | GatherSingle(ctx context.Context, query model.Query, t time.Time) ([]model.MetricSeries, error) 14 | // GatherRange gathers multiple metrics based on a start and an end using a step duration 15 | // to know how many metrics needs to gather. 16 | // The returned metrics on the series should be ordered. 17 | GatherRange(ctx context.Context, query model.Query, start, end time.Time, step time.Duration) ([]model.MetricSeries, error) 18 | } 19 | -------------------------------------------------------------------------------- /internal/service/metric/graphite/graphite.go: -------------------------------------------------------------------------------- 1 | package graphite 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "net/http" 7 | "net/url" 8 | "time" 9 | 10 | graphite "github.com/JensRantil/graphite-client" 11 | 12 | "github.com/slok/grafterm/internal/model" 13 | "github.com/slok/grafterm/internal/service/metric" 14 | ) 15 | 16 | // ConfigGatherer is the configuration of the Graphite gatherer. 17 | type ConfigGatherer struct { 18 | GraphiteAPIURL string 19 | HTTPCli *http.Client 20 | } 21 | 22 | func (c *ConfigGatherer) defaults() { 23 | if c.HTTPCli == nil { 24 | c.HTTPCli = http.DefaultClient 25 | } 26 | } 27 | 28 | type gatherer struct { 29 | cli *graphite.Client 30 | cfg ConfigGatherer 31 | } 32 | 33 | // NewGatherer returns a new metric gatherer for graphite carbon backends. 34 | func NewGatherer(cfg ConfigGatherer) (metric.Gatherer, error) { 35 | cfg.defaults() 36 | 37 | url, err := url.Parse(cfg.GraphiteAPIURL) 38 | if err != nil { 39 | return nil, err 40 | } 41 | cli := &graphite.Client{ 42 | URL: *url, 43 | Client: cfg.HTTPCli, 44 | } 45 | 46 | return &gatherer{ 47 | cfg: cfg, 48 | cli: cli, 49 | }, nil 50 | } 51 | 52 | const ( 53 | instantRange = 5 * time.Minute 54 | targetLabelKey = "target" 55 | ) 56 | 57 | func (g *gatherer) GatherSingle(ctx context.Context, query model.Query, t time.Time) ([]model.MetricSeries, error) { 58 | res, err := g.GatherRange(ctx, query, t.Add(-1*instantRange), t, 0) 59 | if err != nil { 60 | return []model.MetricSeries{}, err 61 | } 62 | 63 | if len(res) < 1 { 64 | return []model.MetricSeries{}, fmt.Errorf("server didn't return any metric series") 65 | } 66 | if len(res) > 1 { 67 | return []model.MetricSeries{}, fmt.Errorf("server returned more than one metric series, got %d", len(res)) 68 | } 69 | 70 | // Get the latest datapoint. 71 | res[0].Metrics = res[0].Metrics[len(res[0].Metrics)-1:] 72 | 73 | return res, nil 74 | } 75 | 76 | func (g *gatherer) GatherRange(ctx context.Context, query model.Query, start, end time.Time, _ time.Duration) ([]model.MetricSeries, error) { 77 | // Get the data from the Graphite API. 78 | result, err := g.cli.QueryMulti([]string{query.Expr}, graphite.TimeInterval{ 79 | From: start, 80 | To: end, 81 | }) 82 | if err != nil { 83 | return []model.MetricSeries{}, err 84 | } 85 | 86 | // For every metric series. 87 | mss := []model.MetricSeries{} 88 | for _, resultDps := range result { 89 | dps, err := resultDps.AsFloats() 90 | if err != nil { 91 | continue 92 | } 93 | if len(dps) < 1 { 94 | continue 95 | } 96 | 97 | // Get all it's datapoints. 98 | m := []model.Metric{} 99 | for _, dp := range dps { 100 | if dp.Value != nil { 101 | m = append(m, model.Metric{ 102 | TS: dp.Time, 103 | Value: *dp.Value, 104 | }) 105 | } 106 | } 107 | ms := model.MetricSeries{ 108 | ID: resultDps.Target, 109 | Labels: map[string]string{ 110 | targetLabelKey: resultDps.Target, 111 | }, 112 | Metrics: m, 113 | } 114 | 115 | mss = append(mss, ms) 116 | } 117 | 118 | return mss, nil 119 | } 120 | -------------------------------------------------------------------------------- /internal/service/metric/graphite/graphite_test.go: -------------------------------------------------------------------------------- 1 | package graphite_test 2 | 3 | import ( 4 | "context" 5 | "net/http" 6 | "net/http/httptest" 7 | "testing" 8 | "time" 9 | 10 | "github.com/stretchr/testify/assert" 11 | 12 | "github.com/slok/grafterm/internal/model" 13 | "github.com/slok/grafterm/internal/service/metric/graphite" 14 | ) 15 | 16 | func TestGathererGatherSingle(t *testing.T) { 17 | tests := map[string]struct { 18 | graphiteResponse string 19 | cfg graphite.ConfigGatherer 20 | expMetricSeries []model.MetricSeries 21 | expErr bool 22 | }{ 23 | "Getting multiple metrics in a single metric series should return only the last metric.": { 24 | graphiteResponse: ` 25 | [{ 26 | "target": "batman", 27 | "datapoints": [ 28 | [612.54, 1558275625], 29 | [712.54, 1558275725], 30 | [812.54, 1558275825], 31 | [912.54, 1558275925] 32 | ] 33 | }]`, 34 | expMetricSeries: []model.MetricSeries{ 35 | { 36 | ID: "batman", 37 | Labels: map[string]string{"target": "batman"}, 38 | Metrics: []model.Metric{ 39 | { 40 | Value: 912.54, 41 | TS: time.Unix(1558275925, 0), 42 | }, 43 | }, 44 | }, 45 | }, 46 | }, 47 | "Getting 0 metric series should error.": { 48 | graphiteResponse: `[]`, 49 | expErr: true, 50 | }, 51 | "Getting more than one metric series should error.": { 52 | graphiteResponse: ` 53 | [ 54 | {"target": "batman","datapoints": [[612.54, 1558275625]]}, 55 | {"target2": "batman","datapoints": [[612.54, 1558275625]]}, 56 | {"target3": "batman","datapoints": [[612.54, 1558275625]]} 57 | ]`, 58 | expErr: true, 59 | }, 60 | } 61 | 62 | for name, test := range tests { 63 | t.Run(name, func(t *testing.T) { 64 | assert := assert.New(t) 65 | 66 | // Mock server response. 67 | srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 68 | w.Write([]byte(test.graphiteResponse)) 69 | })) 70 | defer srv.Close() 71 | test.cfg.GraphiteAPIURL = srv.URL 72 | 73 | g, _ := graphite.NewGatherer(test.cfg) 74 | gotms, err := g.GatherSingle(context.TODO(), model.Query{}, time.Now()) 75 | if test.expErr { 76 | assert.Error(err) 77 | } else if assert.NoError(err) { 78 | // We don't control the order of the MetricSeries and sorting is harder than checking in 79 | // two steps. 80 | assert.Len(gotms, len(test.expMetricSeries)) 81 | for _, gotm := range gotms { 82 | assert.Contains(test.expMetricSeries, gotm) 83 | } 84 | } 85 | }) 86 | } 87 | } 88 | 89 | func TestGathererGatherRange(t *testing.T) { 90 | tests := map[string]struct { 91 | graphiteResponse string 92 | cfg graphite.ConfigGatherer 93 | expMetricSeries []model.MetricSeries 94 | expErr bool 95 | }{ 96 | "When Graphite API returns multiple time series the gatherer should return the metrics translated to the model.": { 97 | graphiteResponse: ` 98 | [ 99 | { 100 | "target": "batman", 101 | "datapoints": [[612.54, 1558332722],[712.54, 1558332724],[812.54, 1558332726],[912.54, 1558332728]] 102 | }, 103 | { 104 | "target": "deadpool", 105 | "datapoints": [[10.15, 1558332822],[10.17, 1558332832],[13.7, 1558332843]] 106 | }, 107 | { 108 | "target": "wolverine", 109 | "datapoints": [[10012.8992, 1558352722],[60072.8992, 1558352726]] 110 | } 111 | ]`, 112 | expMetricSeries: []model.MetricSeries{ 113 | { 114 | ID: "batman", 115 | Labels: map[string]string{"target": "batman"}, 116 | Metrics: []model.Metric{ 117 | {Value: 612.54, TS: time.Unix(1558332722, 0)}, 118 | {Value: 712.54, TS: time.Unix(1558332724, 0)}, 119 | {Value: 812.54, TS: time.Unix(1558332726, 0)}, 120 | {Value: 912.54, TS: time.Unix(1558332728, 0)}, 121 | }, 122 | }, 123 | { 124 | ID: "deadpool", 125 | Labels: map[string]string{"target": "deadpool"}, 126 | Metrics: []model.Metric{ 127 | {Value: 10.15, TS: time.Unix(1558332822, 0)}, 128 | {Value: 10.17, TS: time.Unix(1558332832, 0)}, 129 | {Value: 13.7, TS: time.Unix(1558332843, 0)}, 130 | }, 131 | }, 132 | { 133 | ID: "wolverine", 134 | Labels: map[string]string{"target": "wolverine"}, 135 | Metrics: []model.Metric{ 136 | {Value: 10012.8992, TS: time.Unix(1558352722, 0)}, 137 | {Value: 60072.8992, TS: time.Unix(1558352726, 0)}, 138 | }, 139 | }, 140 | }, 141 | }, 142 | } 143 | 144 | for name, test := range tests { 145 | t.Run(name, func(t *testing.T) { 146 | assert := assert.New(t) 147 | 148 | // Mock server response. 149 | srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 150 | w.Write([]byte(test.graphiteResponse)) 151 | })) 152 | defer srv.Close() 153 | test.cfg.GraphiteAPIURL = srv.URL 154 | 155 | g, _ := graphite.NewGatherer(test.cfg) 156 | gotms, err := g.GatherRange(context.TODO(), model.Query{}, time.Now(), time.Now(), 0) 157 | if test.expErr { 158 | assert.Error(err) 159 | } else if assert.NoError(err) { 160 | // We don't control the order of the MetricSeries and sorting is harder than checking in 161 | // two steps. 162 | assert.Len(gotms, len(test.expMetricSeries)) 163 | for _, gotm := range gotms { 164 | assert.Contains(test.expMetricSeries, gotm) 165 | } 166 | } 167 | }) 168 | } 169 | } 170 | -------------------------------------------------------------------------------- /internal/service/metric/influxdb/influxdb.go: -------------------------------------------------------------------------------- 1 | package influxdb 2 | 3 | import ( 4 | "context" 5 | "encoding/json" 6 | "fmt" 7 | "time" 8 | 9 | _ "github.com/influxdata/influxdb1-client" // needed due to go mod bug 10 | influxdbv2 "github.com/influxdata/influxdb1-client/v2" 11 | 12 | "github.com/slok/grafterm/internal/model" 13 | "github.com/slok/grafterm/internal/service/metric" 14 | ) 15 | 16 | // ConfigGatherer is the configuration of the InfluxDB gatherer. 17 | type ConfigGatherer struct { 18 | Addr string 19 | Database string 20 | Client influxdbv2.Client 21 | } 22 | 23 | func (c *ConfigGatherer) defaults() error { 24 | var err error 25 | 26 | if c.Database == "" { 27 | return fmt.Errorf("no influxdb database given") 28 | } 29 | 30 | if c.Client == nil { 31 | c.Client, err = influxdbv2.NewHTTPClient( 32 | influxdbv2.HTTPConfig{ 33 | Addr: c.Addr, 34 | }) 35 | } 36 | return err 37 | } 38 | 39 | type gatherer struct { 40 | cli influxdbv2.Client 41 | cfg ConfigGatherer 42 | } 43 | 44 | // NewGatherer returns a new metric gatherer for influxdb backends. 45 | func NewGatherer(cfg ConfigGatherer) (metric.Gatherer, error) { 46 | err := cfg.defaults() 47 | if err != nil { 48 | return &gatherer{}, err 49 | } 50 | 51 | return &gatherer{ 52 | cli: cfg.Client, 53 | cfg: cfg, 54 | }, nil 55 | } 56 | 57 | func (g *gatherer) GatherSingle(ctx context.Context, query model.Query, t time.Time) ([]model.MetricSeries, error) { 58 | res, err := g.GatherRange(ctx, query, t.Add(-1*5), t, 0) 59 | if err != nil { 60 | return []model.MetricSeries{}, err 61 | } 62 | 63 | if len(res) < 1 { 64 | return []model.MetricSeries{}, fmt.Errorf("server didn't return any metric series") 65 | } 66 | if len(res) > 1 { 67 | return []model.MetricSeries{}, fmt.Errorf("server returned more than one metric series, got %d", len(res)) 68 | } 69 | 70 | // Get the latest datapoint 71 | res[0].Metrics = res[0].Metrics[len(res[0].Metrics)-1:] 72 | 73 | return res, nil 74 | } 75 | 76 | func (g *gatherer) GatherRange(ctx context.Context, query model.Query, start, end time.Time, _ time.Duration) ([]model.MetricSeries, error) { 77 | res := []model.MetricSeries{} 78 | 79 | // Get the data from the InfluxDB API 80 | q := influxdbv2.NewQuery(query.Expr, g.cfg.Database, "ms") 81 | resp, err := g.cli.Query(q) 82 | if err != nil { 83 | return res, err 84 | } 85 | if resp.Error() != nil { 86 | return res, resp.Error() 87 | } 88 | 89 | // Build the metric series 90 | for _, result := range resp.Results { 91 | for _, serie := range result.Series { 92 | metrics := []model.Metric{} 93 | for _, value := range serie.Values { 94 | v, err := value[1].(json.Number).Float64() 95 | if err != nil { 96 | return res, err 97 | } 98 | t := time.Time{} 99 | switch value[0].(type) { 100 | case string: 101 | t, err = time.Parse("2006-01-02T15:04:05Z", value[0].(string)) 102 | if err != nil { 103 | return res, err 104 | } 105 | case json.Number: 106 | c, err := value[0].(json.Number).Int64() 107 | if err != nil { 108 | return res, err 109 | } 110 | t = time.Unix(c/1000, c%1000) 111 | default: 112 | } 113 | m := model.Metric{ 114 | TS: t, 115 | Value: v, 116 | } 117 | metrics = append(metrics, m) 118 | } 119 | label := serie.Name 120 | //TODO(rochaporto): Allow alias based on a tag value 121 | //if serie.Tags != nil { 122 | // if v1, ok := serie.Tags["version"]; ok { 123 | // label = v1 124 | // } 125 | //} 126 | res = append(res, model.MetricSeries{ID: label, Metrics: metrics}) 127 | } 128 | } 129 | 130 | return res, nil 131 | } 132 | -------------------------------------------------------------------------------- /internal/service/metric/influxdb/influxdb_test.go: -------------------------------------------------------------------------------- 1 | package influxdb_test 2 | 3 | import ( 4 | "context" 5 | "net/http" 6 | "net/http/httptest" 7 | "testing" 8 | "time" 9 | 10 | _ "github.com/influxdata/influxdb1-client" // needed due to go mod bug 11 | influxdbv2 "github.com/influxdata/influxdb1-client/v2" 12 | 13 | "github.com/stretchr/testify/assert" 14 | 15 | "github.com/slok/grafterm/internal/model" 16 | "github.com/slok/grafterm/internal/service/metric/influxdb" 17 | ) 18 | 19 | func TestGathererGatherSingle(t *testing.T) { 20 | tests := map[string]struct { 21 | influxdbResponse string 22 | cfg influxdb.ConfigGatherer 23 | expMetricSeries []model.MetricSeries 24 | expErr bool 25 | }{ 26 | "Getting multiple metrics in a single metric series should return only the last metric": { 27 | influxdbResponse: ` 28 | {"results":[ 29 | { 30 | "series": [ 31 | {"name":"myseries","columns":["time","mean"],"values":[["2017-03-01T00:16:18Z",12.34],["2017-03-01T00:17:18Z",23.45],["2017-03-01T00:18:18Z",34.56],["2017-03-01T00:19:18Z",45.67]]} 32 | ] 33 | } 34 | ]}`, 35 | expMetricSeries: []model.MetricSeries{ 36 | { 37 | ID: "myseries", 38 | Metrics: []model.Metric{ 39 | { 40 | Value: 45.67, 41 | TS: time.Date(2017, 3, 1, 0, 19, 18, 0, time.UTC), 42 | }, 43 | }, 44 | }, 45 | }, 46 | }, 47 | "Getting 0 metric series should error": { 48 | influxdbResponse: `[]`, 49 | expErr: true, 50 | }, 51 | } 52 | 53 | for name, test := range tests { 54 | t.Run(name, func(t *testing.T) { 55 | assert := assert.New(t) 56 | 57 | // Mock server response. 58 | srv := httptest.NewServer( 59 | http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 60 | w.Header().Set("Content-Type", "application/json") 61 | w.Write([]byte(test.influxdbResponse)) 62 | })) 63 | defer srv.Close() 64 | 65 | test.cfg.Addr = srv.URL 66 | test.cfg.Client = influxdbClient(srv.URL) 67 | test.cfg.Database = "dummy" 68 | 69 | g, _ := influxdb.NewGatherer(test.cfg) 70 | gotms, err := g.GatherSingle(context.TODO(), model.Query{}, time.Now()) 71 | if test.expErr { 72 | assert.Error(err) 73 | } else if assert.NoError(err) { 74 | // We don't control the order of the MetricSeries and sorting is harder than checking in 75 | // two steps. 76 | assert.Len(gotms, len(test.expMetricSeries)) 77 | for _, gotm := range gotms { 78 | assert.Contains(test.expMetricSeries, gotm) 79 | } 80 | } 81 | }) 82 | } 83 | } 84 | 85 | func TestGathererGatherRange(t *testing.T) { 86 | tests := map[string]struct { 87 | influxdbResponse string 88 | cfg influxdb.ConfigGatherer 89 | expMetricSeries []model.MetricSeries 90 | expErr bool 91 | }{ 92 | "When influxdb returns multiple time series the gatherer should return the metrics translated to the model": { 93 | influxdbResponse: ` 94 | {"results":[ 95 | { 96 | "series": [ 97 | {"name":"myseries1","columns":["time","mean1"],"values":[["2017-03-01T00:16:18Z",12.34],["2017-03-01T00:17:18Z",23.45]]}, 98 | {"name":"myseries2","columns":["time","mean2"],"values":[["2017-03-01T00:18:18Z",34.56],["2017-03-01T00:19:18Z",45.67]]} 99 | ] 100 | } 101 | ]}`, 102 | expMetricSeries: []model.MetricSeries{ 103 | { 104 | ID: "myseries1", 105 | Metrics: []model.Metric{ 106 | {Value: 12.34, TS: time.Date(2017, 3, 1, 0, 16, 18, 0, time.UTC)}, 107 | {Value: 23.45, TS: time.Date(2017, 3, 1, 0, 17, 18, 0, time.UTC)}, 108 | }, 109 | }, 110 | { 111 | ID: "myseries2", 112 | Metrics: []model.Metric{ 113 | {Value: 34.56, TS: time.Date(2017, 3, 1, 0, 18, 18, 0, time.UTC)}, 114 | {Value: 45.67, TS: time.Date(2017, 3, 1, 0, 19, 18, 0, time.UTC)}, 115 | }, 116 | }, 117 | }, 118 | }, 119 | } 120 | 121 | for name, test := range tests { 122 | t.Run(name, func(t *testing.T) { 123 | assert := assert.New(t) 124 | 125 | // Mock server response. 126 | srv := httptest.NewServer( 127 | http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 128 | w.Header().Set("Content-Type", "application/json") 129 | w.Write([]byte(test.influxdbResponse)) 130 | })) 131 | defer srv.Close() 132 | 133 | test.cfg.Addr = srv.URL 134 | test.cfg.Client = influxdbClient(srv.URL) 135 | test.cfg.Database = "dummy" 136 | 137 | g, _ := influxdb.NewGatherer(test.cfg) 138 | gotms, err := g.GatherRange(context.TODO(), model.Query{}, time.Now(), time.Now(), 0) 139 | if test.expErr { 140 | assert.Error(err) 141 | } else if assert.NoError(err) { 142 | // We don't control the order of the MetricSeries and sorting is harder than checking in 143 | // two steps. 144 | assert.Len(gotms, len(test.expMetricSeries)) 145 | for _, gotm := range gotms { 146 | assert.Contains(test.expMetricSeries, gotm) 147 | } 148 | } 149 | }) 150 | } 151 | } 152 | 153 | func influxdbClient(addr string) influxdbv2.Client { 154 | cli, _ := influxdbv2.NewHTTPClient( 155 | influxdbv2.HTTPConfig{ 156 | Addr: addr, 157 | }, 158 | ) 159 | return cli 160 | } 161 | -------------------------------------------------------------------------------- /internal/service/metric/middleware/log.go: -------------------------------------------------------------------------------- 1 | package middleware 2 | 3 | import ( 4 | "context" 5 | "time" 6 | 7 | "github.com/slok/grafterm/internal/model" 8 | "github.com/slok/grafterm/internal/service/log" 9 | "github.com/slok/grafterm/internal/service/metric" 10 | ) 11 | 12 | type logger struct { 13 | next metric.Gatherer 14 | logger log.Logger 15 | } 16 | 17 | // Logger is a gatherer middleware that wraps the real gatherer and logs 18 | // the queries that it makes. 19 | func Logger(l log.Logger, next metric.Gatherer) metric.Gatherer { 20 | return &logger{ 21 | next: next, 22 | logger: l, 23 | } 24 | } 25 | 26 | func (l *logger) GatherSingle(ctx context.Context, query model.Query, t time.Time) ([]model.MetricSeries, error) { 27 | st := time.Now() 28 | defer func() { 29 | l.logger.Infof("(%s) gathering single metric on %s: %s", time.Since(st), query.DatasourceID, query.Expr) 30 | }() 31 | return l.next.GatherSingle(ctx, query, t) 32 | } 33 | func (l *logger) GatherRange(ctx context.Context, query model.Query, start, end time.Time, step time.Duration) ([]model.MetricSeries, error) { 34 | st := time.Now() 35 | defer func() { 36 | l.logger.Infof("(%s) gathering range metric [from %v to %v with %v step] on %s: %s", time.Since(st), start, end, step, query.DatasourceID, query.Expr) 37 | }() 38 | return l.next.GatherRange(ctx, query, start, end, step) 39 | } 40 | -------------------------------------------------------------------------------- /internal/service/metric/prometheus/prometheus.go: -------------------------------------------------------------------------------- 1 | package prometheus 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "strings" 7 | "time" 8 | 9 | promv1 "github.com/prometheus/client_golang/api/prometheus/v1" 10 | prommodel "github.com/prometheus/common/model" 11 | 12 | "github.com/slok/grafterm/internal/model" 13 | "github.com/slok/grafterm/internal/service/metric" 14 | ) 15 | 16 | // ConfigGatherer is the configuration of the Prometheus gatherer. 17 | type ConfigGatherer struct { 18 | // Client is the prometheus API client. 19 | Client promv1.API 20 | // FilterSpecialLabels will return the metrics with the special labels filtered. 21 | // The special labels start with `__`, examples: `__name__`, `__scheme__`. 22 | FilterSpecialLabels bool 23 | } 24 | 25 | type gatherer struct { 26 | cli promv1.API 27 | cfg ConfigGatherer 28 | } 29 | 30 | // NewGatherer returns a new metric gatherer for prometheus backends. 31 | func NewGatherer(cfg ConfigGatherer) metric.Gatherer { 32 | return &gatherer{ 33 | cli: cfg.Client, 34 | cfg: cfg, 35 | } 36 | } 37 | 38 | func (g *gatherer) GatherSingle(ctx context.Context, query model.Query, t time.Time) ([]model.MetricSeries, error) { 39 | // Get value from Prometheus. 40 | val, _, err := g.cli.Query(ctx, query.Expr, t) 41 | if err != nil { 42 | return []model.MetricSeries{}, err 43 | } 44 | 45 | // Translate prom values to domain. 46 | res, err := g.promToModel(val) 47 | if err != nil { 48 | return []model.MetricSeries{}, err 49 | } 50 | 51 | return res, nil 52 | } 53 | 54 | func (g *gatherer) GatherRange(ctx context.Context, query model.Query, start, end time.Time, step time.Duration) ([]model.MetricSeries, error) { 55 | // Get value from Prometheus. 56 | val, _, err := g.cli.QueryRange(ctx, query.Expr, promv1.Range{ 57 | Start: start, 58 | End: end, 59 | Step: step, 60 | }) 61 | if err != nil { 62 | return []model.MetricSeries{}, err 63 | } 64 | 65 | // Translate prom values to domain. 66 | res, err := g.promToModel(val) 67 | if err != nil { 68 | return []model.MetricSeries{}, err 69 | } 70 | 71 | return res, nil 72 | } 73 | 74 | // promToModel converts a prometheus result metric to a domain model one. 75 | func (g *gatherer) promToModel(pm prommodel.Value) ([]model.MetricSeries, error) { 76 | res := []model.MetricSeries{} 77 | 78 | switch pm.Type() { 79 | case prommodel.ValScalar: 80 | scalar := pm.(*prommodel.Scalar) 81 | res = g.transformScalar(scalar) 82 | case prommodel.ValVector: 83 | vector := pm.(prommodel.Vector) 84 | res = g.transformVector(vector) 85 | case prommodel.ValMatrix: 86 | matrix := pm.(prommodel.Matrix) 87 | res = g.transformMatrix(matrix) 88 | default: 89 | return res, errors.New("prometheus value type not supported") 90 | } 91 | 92 | return res, nil 93 | } 94 | 95 | // transformScalar will get a prometheus Scalar and transform to a domain model 96 | // MetricSeries slice. 97 | func (g *gatherer) transformScalar(scalar *prommodel.Scalar) []model.MetricSeries { 98 | res := []model.MetricSeries{} 99 | 100 | m := model.Metric{ 101 | TS: scalar.Timestamp.Time(), 102 | Value: float64(scalar.Value), 103 | } 104 | res = append(res, model.MetricSeries{ 105 | Metrics: []model.Metric{m}, 106 | }) 107 | 108 | return res 109 | } 110 | 111 | // transformVector will get a prometheus Vector and transform to a domain model 112 | // MetricSeries slice. 113 | // A Prometheus vector is an slice of metrics (group of labels) that have one 114 | // sample only (all samples from all metrics have the same timestamp) 115 | func (g *gatherer) transformVector(vector prommodel.Vector) []model.MetricSeries { 116 | res := []model.MetricSeries{} 117 | 118 | for _, sample := range vector { 119 | id := sample.Metric.String() 120 | labels := g.labelSetToMap(prommodel.LabelSet(sample.Metric)) 121 | series := model.MetricSeries{ 122 | ID: id, 123 | Labels: g.sanitizeLabels(labels), 124 | } 125 | 126 | // Add the metric to the series. 127 | series.Metrics = append(series.Metrics, model.Metric{ 128 | TS: sample.Timestamp.Time(), 129 | Value: float64(sample.Value), 130 | }) 131 | 132 | res = append(res, series) 133 | } 134 | 135 | return res 136 | } 137 | 138 | // transformMatrix will get a prometheus Matrix and transform to a domain model 139 | // MetricSeries slice. 140 | // A Prometheus Matrix is an slices of metrics (group of labels) that have multiple 141 | // samples (in a slice of samples). 142 | func (g *gatherer) transformMatrix(matrix prommodel.Matrix) []model.MetricSeries { 143 | res := []model.MetricSeries{} 144 | 145 | // Use a map to index the different series based on labels. 146 | for _, sampleStream := range matrix { 147 | id := sampleStream.Metric.String() 148 | labels := g.labelSetToMap(prommodel.LabelSet(sampleStream.Metric)) 149 | series := model.MetricSeries{ 150 | ID: id, 151 | Labels: g.sanitizeLabels(labels), 152 | } 153 | 154 | // Add the metric to the series. 155 | for _, sample := range sampleStream.Values { 156 | series.Metrics = append(series.Metrics, model.Metric{ 157 | TS: sample.Timestamp.Time(), 158 | Value: float64(sample.Value), 159 | }) 160 | } 161 | 162 | res = append(res, series) 163 | } 164 | 165 | return res 166 | } 167 | 168 | func (g *gatherer) labelSetToMap(ls prommodel.LabelSet) map[string]string { 169 | res := map[string]string{} 170 | for k, v := range ls { 171 | res[string(k)] = string(v) 172 | } 173 | 174 | return res 175 | } 176 | 177 | // sanitizeLabels will sanitize the map label values. 178 | // - Remove special labels if required (start with `__`). 179 | func (g *gatherer) sanitizeLabels(m map[string]string) map[string]string { 180 | 181 | // Filter if required. 182 | if !g.cfg.FilterSpecialLabels { 183 | return m 184 | } 185 | 186 | res := map[string]string{} 187 | for k, v := range m { 188 | if strings.HasPrefix(k, "__") { 189 | continue 190 | } 191 | res[k] = v 192 | } 193 | 194 | return res 195 | } 196 | -------------------------------------------------------------------------------- /internal/service/unit/formatter.go: -------------------------------------------------------------------------------- 1 | package unit 2 | 3 | import ( 4 | "fmt" 5 | "math" 6 | "strings" 7 | "time" 8 | ) 9 | 10 | // Formatter knows how to interact with different values 11 | // to give then a format and a representation. 12 | // They are based on units that can do the conversion to 13 | // other units and represent them in the returning string. 14 | type Formatter func(value float64, decimals int) string 15 | 16 | // NewUnitFormatter is a factory that selects the correct formatter 17 | // based on the unit. 18 | // If the unit does not exists it will return an error. 19 | func NewUnitFormatter(unit string) (Formatter, error) { 20 | unit = strings.ToLower(unit) 21 | 22 | var f Formatter 23 | switch unit { 24 | case "", "short": 25 | f = shortFormatter 26 | case "none": 27 | f = noneFormatter 28 | case "percent": 29 | f = percentFormatter 30 | case "ratio": 31 | f = ratioFormatter 32 | case "s", "second", "seconds": 33 | f = secondFormatter 34 | case "ms", "millisecond", "milliseconds": 35 | f = millisecondFormatter 36 | case "reqps": 37 | f = newSuffixFormatter(" reqps") 38 | case "byte", "bytes": 39 | f = bytesFormatter 40 | default: 41 | return nil, fmt.Errorf("%s is not a valid unit", unit) 42 | } 43 | 44 | return safeFormatter(f), nil 45 | } 46 | 47 | // noneFormatter returns the value as it is 48 | // in a float representation with decimal trim. 49 | func noneFormatter(value float64, decimals int) string { 50 | f := suffixDecimalFormat(decimals, "") 51 | return fmt.Sprintf(f, value) 52 | } 53 | 54 | // percentFormatter returns the value with the 55 | // percent suffix and assumes is a percent value. 56 | // Examples: 57 | // - 100: 100% 58 | // - 1029.12: 9876.12% 59 | var percentFormatter = newSuffixFormatter("%") 60 | 61 | // ratioFormatter returns the value with the 62 | // percent suffix and assumes is a ratio value 63 | // (0-1). 64 | // Examples: 65 | // - 1: 100% 66 | // - 0.412: 41.2% 67 | func ratioFormatter(value float64, decimals int) string { 68 | return percentFormatter(value*100, decimals) 69 | } 70 | 71 | // secondFormatter returns the value with the 72 | // seconds in a single unit pretty format time. 73 | // supports: ns, µs, ms, s, m, h, d. 74 | // Examples: 75 | // - 1: 1s 76 | // - 0.1: 100ms 77 | // - 300: 5m 78 | func secondFormatter(value float64, decimals int) string { 79 | t := time.Duration(value * float64(time.Second)) 80 | return durationSingleUnitPrettyFormat(t, decimals) 81 | } 82 | 83 | // millisecondFormatter is like secondFormatter but with 84 | // milliseconds as base. 85 | func millisecondFormatter(value float64, decimals int) string { 86 | t := time.Duration(value * float64(time.Millisecond)) 87 | return durationSingleUnitPrettyFormat(t, decimals) 88 | } 89 | 90 | // newSuffixFormatter returns a formatter that will apply a 91 | // suffix to the received value. 92 | func newSuffixFormatter(suffix string) Formatter { 93 | return func(value float64, decimals int) string { 94 | dFmt := suffixDecimalFormat(decimals, suffix) 95 | return fmt.Sprintf(dFmt, value) 96 | } 97 | } 98 | 99 | func suffixDecimalFormat(decimals int, suffix string) string { 100 | suffix = strings.ReplaceAll(suffix, "%", "%%") // Safe `%` character for fmt. 101 | return fmt.Sprintf("%%.%df%s", decimals, suffix) 102 | } 103 | 104 | // durationSingleUnitPrettyFormat returns the pretty format in one single 105 | // unit for a time.Duration, the different returned unit formats 106 | // are: nanoseconds, microseconds, milliseconds, seconds, minutes 107 | // hours, days. 108 | // Implementation obtained from: https://github.com/mum4k/termdash/blob/d34e18ab097be3ec6147767173db12f810f8dbbb/widgets/linechart/value_formatter.go#L31 109 | func durationSingleUnitPrettyFormat(d time.Duration, decimals int) string { 110 | // Check if the duration is less than 0. 111 | prefix := "" 112 | if d < 0 { 113 | prefix = "-" 114 | d = time.Duration(math.Abs(d.Seconds()) * float64(time.Second)) 115 | } 116 | 117 | switch { 118 | // Nanoseconds. 119 | case d.Nanoseconds() < 1000: 120 | dFmt := prefix + "%d ns" 121 | return fmt.Sprintf(dFmt, d.Nanoseconds()) 122 | // Microseconds. 123 | case d.Seconds()*1000*1000 < 1000: 124 | dFmt := prefix + suffixDecimalFormat(decimals, " µs") 125 | return fmt.Sprintf(dFmt, d.Seconds()*1000*1000) 126 | // Milliseconds. 127 | case d.Seconds()*1000 < 1000: 128 | dFmt := prefix + suffixDecimalFormat(decimals, " ms") 129 | return fmt.Sprintf(dFmt, d.Seconds()*1000) 130 | // Seconds. 131 | case d.Seconds() < 60: 132 | dFmt := prefix + suffixDecimalFormat(decimals, " s") 133 | return fmt.Sprintf(dFmt, d.Seconds()) 134 | // Minutes. 135 | case d.Minutes() < 60: 136 | dFmt := prefix + suffixDecimalFormat(decimals, " m") 137 | return fmt.Sprintf(dFmt, d.Minutes()) 138 | // Hours. 139 | case d.Hours() < 24: 140 | dFmt := prefix + suffixDecimalFormat(decimals, " h") 141 | return fmt.Sprintf(dFmt, d.Hours()) 142 | // Days. 143 | default: 144 | dFmt := prefix + suffixDecimalFormat(decimals, " d") 145 | return fmt.Sprintf(dFmt, d.Hours()/24) 146 | } 147 | } 148 | 149 | const ( 150 | kibibyte float64 = 1024 151 | mebibyte = kibibyte * 1024 152 | gibibyte = mebibyte * 1024 153 | tebibyte = gibibyte * 1024 154 | pebibyte = tebibyte * 1024 155 | exibyte = pebibyte * 1024 156 | zebibyte = exibyte * 1024 157 | yobibyte = zebibyte * 2014 158 | ) 159 | 160 | // bytesFormatter returns the value in bytes for a data 161 | // quantity in a pretty format style. 162 | // supports: B, KiB, MiB, GiB, TiB, PiB, EiB, ZiB, YiB. 163 | // Examples: 164 | // - 35: 35 B 165 | // - 1024: 1 KiB 166 | var bytesFormatter = newRangedFormatter([]rangeStep{ 167 | {max: kibibyte, base: 1, suffix: " B"}, 168 | {max: mebibyte, base: kibibyte, suffix: " KiB"}, 169 | {max: gibibyte, base: mebibyte, suffix: " MiB"}, 170 | {max: tebibyte, base: gibibyte, suffix: " GiB"}, 171 | {max: pebibyte, base: tebibyte, suffix: " TiB"}, 172 | {max: exibyte, base: pebibyte, suffix: " PiB"}, 173 | {max: zebibyte, base: exibyte, suffix: " EiB"}, 174 | {max: yobibyte, base: zebibyte, suffix: " ZiB"}, 175 | {base: yobibyte, suffix: " YiB"}, 176 | }) 177 | 178 | const ( 179 | shortK float64 = 1000 180 | shortMil = shortK * 1000 181 | shortBil = shortMil * 1000 182 | shortTri = shortBil * 1000 183 | shortQuadr = shortTri * 1000 184 | shortQuint = shortQuadr * 1000 185 | shortSext = shortQuint * 1000 186 | shortSept = shortSext * 1000 187 | ) 188 | 189 | // shortFormatter returns the value trimming the value on 190 | // high numbers adding a suffix. 191 | // supports: K, Mil, Bil, tri, Quadr, Quint, Sext, Sept. 192 | // Examples: 193 | // - 1000 = 1 k 194 | // - 2000000 = 2 Mil 195 | var shortFormatter = newRangedFormatter([]rangeStep{ 196 | {max: shortK, base: 1, suffix: ""}, 197 | {max: shortMil, base: shortK, suffix: " K"}, 198 | {max: shortBil, base: shortMil, suffix: " Mil"}, 199 | {max: shortTri, base: shortBil, suffix: " Bil"}, 200 | {max: shortQuadr, base: shortTri, suffix: " Tri"}, 201 | {max: shortQuint, base: shortQuadr, suffix: " Quadr"}, 202 | {max: shortSext, base: shortQuint, suffix: " Quint"}, 203 | {max: shortSept, base: shortSext, suffix: " Sext"}, 204 | {base: shortSept, suffix: " Sept"}, 205 | }) 206 | 207 | // safeFormatter wraps a formatter and wraps the received formatter 208 | // with some sanity checks to make it safe. 209 | func safeFormatter(f Formatter) Formatter { 210 | return func(value float64, decimals int) string { 211 | if decimals < 0 { 212 | decimals = 0 213 | } 214 | if math.IsNaN(value) { 215 | return "" 216 | } 217 | return f(value, decimals) 218 | } 219 | } 220 | 221 | type rangeStep struct { 222 | max float64 223 | suffix string 224 | base float64 225 | } 226 | 227 | // newRangeFormatter returns a formatter based on a stepped 228 | // range. 229 | func newRangedFormatter(r []rangeStep) Formatter { 230 | return func(value float64, decimals int) string { 231 | // Check if the duration is less than 0. 232 | prefix := "" 233 | if value < 0 { 234 | prefix = "-" 235 | value = math.Abs(value) 236 | } 237 | 238 | step := r[0] 239 | for _, s := range r { 240 | if value < step.max { 241 | break 242 | } 243 | step = s 244 | } 245 | 246 | dFmt := prefix + suffixDecimalFormat(decimals, step.suffix) 247 | return fmt.Sprintf(dFmt, value/step.base) 248 | } 249 | } 250 | -------------------------------------------------------------------------------- /internal/service/unit/time.go: -------------------------------------------------------------------------------- 1 | package unit 2 | 3 | import ( 4 | "fmt" 5 | "time" 6 | ) 7 | 8 | var ( 9 | defIntervals = []time.Duration{ 10 | 30 * time.Second, 11 | 1 * time.Minute, 12 | 2 * time.Minute, 13 | 10 * time.Minute, 14 | 30 * time.Minute, 15 | 1 * time.Hour, 16 | 3 * time.Hour, 17 | 6 * time.Hour, 18 | 12 * time.Hour, 19 | 24 * time.Hour, 20 | 168 * time.Hour, // 7d 21 | 336 * time.Hour, // 14d 22 | 720 * time.Hour, // 30d 23 | } 24 | ) 25 | 26 | // NearestDurationFromSteps returns the nearest interval based on the 27 | // steps in a time range. 28 | func NearestDurationFromSteps(timeRange time.Duration, steps int) time.Duration { 29 | rawInterval := timeRange / time.Duration(steps) 30 | 31 | switch { 32 | case rawInterval <= defIntervals[0]: 33 | return defIntervals[0] 34 | case rawInterval >= defIntervals[len(defIntervals)-1]: 35 | return defIntervals[len(defIntervals)-1] 36 | } 37 | 38 | return getNearestDuration(defIntervals, rawInterval) 39 | } 40 | 41 | func getNearestDuration(intervals []time.Duration, timeRange time.Duration) time.Duration { 42 | var bottom, top time.Duration 43 | 44 | // Get the top and bottom limits in the range. 45 | bottom = intervals[0] 46 | for _, limit := range intervals[1:] { 47 | if limit > timeRange { 48 | top = limit 49 | break 50 | } 51 | 52 | bottom = limit 53 | } 54 | 55 | // Get distance from both and return the shortest one. 56 | bottomDiff := timeRange - bottom 57 | topDiff := top - timeRange 58 | if bottomDiff < topDiff { 59 | return bottom 60 | } 61 | return top 62 | } 63 | 64 | // DurationToSimpleString will get a duration interval and get the string 65 | // with a simple format (e.g 14m instead of 14m0s). 66 | func DurationToSimpleString(dur time.Duration) string { 67 | res := "" 68 | switch { 69 | case int(dur.Minutes()) < 1, 70 | int(dur.Seconds())%60 != 0: 71 | res = fmt.Sprintf("%.0fs", dur.Seconds()) 72 | case int(dur.Hours()) < 1, 73 | int(dur.Minutes())%60 != 0: 74 | res = fmt.Sprintf("%.0fm", dur.Minutes()) 75 | default: 76 | res = fmt.Sprintf("%.0fh", dur.Hours()) 77 | } 78 | 79 | return res 80 | } 81 | 82 | // TimeRangeTimeStringFormat returns the best visual string format for a 83 | // time range. 84 | // TODO(slok): Use better the steps to get more accurate formats. 85 | func TimeRangeTimeStringFormat(timeRange time.Duration, steps int) string { 86 | const ( 87 | hourMinuteSeconds = "15:04:05" 88 | hourMinute = "15:04" 89 | monthDayHourMinute = "01/02 15:04" 90 | monthDay = "01/02" 91 | ) 92 | 93 | if steps == 0 { 94 | steps = 1 95 | } 96 | 97 | switch { 98 | // If greater than 15 day then always return month and day. 99 | case timeRange > 15*24*time.Hour: 100 | return monthDay 101 | // If greater than 1 day always return day and time. 102 | case timeRange > 24*time.Hour: 103 | return monthDayHourMinute 104 | // If always less than 1 minute return with seconds. 105 | case timeRange < time.Minute: 106 | return hourMinuteSeconds 107 | // If the minute based time has small duration steps we need to be 108 | // more accurate, so we use second base notation. 109 | case timeRange/time.Duration(steps) < 5*time.Second: 110 | return hourMinuteSeconds 111 | default: 112 | return hourMinute 113 | } 114 | } 115 | -------------------------------------------------------------------------------- /internal/service/unit/time_test.go: -------------------------------------------------------------------------------- 1 | package unit_test 2 | 3 | import ( 4 | "testing" 5 | "time" 6 | 7 | "github.com/slok/grafterm/internal/service/unit" 8 | "github.com/stretchr/testify/assert" 9 | ) 10 | 11 | func TestNearestDurationFromSteps(t *testing.T) { 12 | tests := []struct { 13 | name string 14 | timeRange time.Duration 15 | steps int 16 | expDur time.Duration 17 | }{ 18 | { 19 | name: "A low range with lots of steps should return the min interval", 20 | timeRange: 2 * time.Hour, 21 | steps: 1000, 22 | expDur: 30 * time.Second, 23 | }, 24 | { 25 | name: "Greater that the first available interval but not greater than the second one.", 26 | timeRange: 31 * time.Second, 27 | steps: 1, 28 | expDur: 30 * time.Second, 29 | }, 30 | { 31 | name: "A high range with few steps should return the max interval", 32 | timeRange: 5000 * time.Hour, 33 | steps: 2, 34 | expDur: 720 * time.Hour, 35 | }, 36 | { 37 | name: "calculates the nearest (12h, 50 steps).", 38 | timeRange: 12 * time.Hour, 39 | steps: 50, 40 | expDur: 10 * time.Minute, 41 | }, 42 | { 43 | name: "calculates the nearest (2h, 50 steps).", 44 | timeRange: 2 * time.Hour, 45 | steps: 50, 46 | expDur: 2 * time.Minute, 47 | }, 48 | { 49 | name: "calculates the nearest (30m, 50 steps).", 50 | timeRange: 30 * time.Minute, 51 | steps: 50, 52 | expDur: 30 * time.Second, 53 | }, 54 | { 55 | name: "calculates the nearest (6h, 30 steps).", 56 | timeRange: 6 * time.Hour, 57 | steps: 30, 58 | expDur: 10 * time.Minute, 59 | }, 60 | { 61 | name: "calculates the nearest (3d, 50 steps).", 62 | timeRange: 72 * time.Hour, 63 | steps: 50, 64 | expDur: 1 * time.Hour, 65 | }, 66 | { 67 | name: "calculates the nearest (7d, 50 steps).", 68 | timeRange: 168 * time.Hour, 69 | steps: 50, 70 | expDur: 3 * time.Hour, 71 | }, 72 | } 73 | 74 | for _, test := range tests { 75 | t.Run(test.name, func(t *testing.T) { 76 | gotDur := unit.NearestDurationFromSteps(test.timeRange, test.steps) 77 | assert.Equal(t, test.expDur, gotDur) 78 | }) 79 | } 80 | } 81 | 82 | func TestDurationToSimpleString(t *testing.T) { 83 | tests := []struct { 84 | name string 85 | timeRange time.Duration 86 | exp string 87 | }{ 88 | { 89 | name: "Seconds.", 90 | timeRange: 39 * time.Second, 91 | exp: "39s", 92 | }, 93 | { 94 | name: "Minutes.", 95 | timeRange: 39 * time.Minute, 96 | exp: "39m", 97 | }, 98 | { 99 | name: "Minutes in seconds.", 100 | timeRange: 61 * time.Second, 101 | exp: "61s", 102 | }, 103 | { 104 | name: "Hours.", 105 | timeRange: 39 * time.Hour, 106 | exp: "39h", 107 | }, 108 | { 109 | name: "Hours in minutes.", 110 | timeRange: 75 * time.Minute, 111 | exp: "75m", 112 | }, 113 | } 114 | 115 | for _, test := range tests { 116 | t.Run(test.name, func(t *testing.T) { 117 | got := unit.DurationToSimpleString(test.timeRange) 118 | assert.Equal(t, test.exp, got) 119 | }) 120 | } 121 | } 122 | 123 | func TestSteppedTimeRangeStringFormat(t *testing.T) { 124 | tests := []struct { 125 | name string 126 | timeRange time.Duration 127 | steps int 128 | exp string 129 | }{ 130 | { 131 | name: "Month based ranges should have the day and month.", 132 | timeRange: 38 * 24 * time.Hour, 133 | exp: "01/02", 134 | }, 135 | { 136 | name: "More than a half of a month ranges should have the day and month.", 137 | timeRange: 16 * 24 * time.Hour, 138 | exp: "01/02", 139 | }, 140 | { 141 | name: "More than one day ranges should have day and time.", 142 | timeRange: 72 * time.Hour, 143 | exp: "01/02 15:04", 144 | }, 145 | { 146 | name: "Less than a minute ranges should have seconds.", 147 | timeRange: 48 * time.Second, 148 | exp: "15:04:05", 149 | }, 150 | { 151 | name: "less than one day based ranges with few steps don't have seconds.", 152 | timeRange: 2 * time.Hour, 153 | steps: 50, 154 | exp: "15:04", 155 | }, 156 | { 157 | name: "less than one day based ranges with few steps don't have seconds.", 158 | timeRange: 15 * time.Minute, 159 | steps: 200, 160 | exp: "15:04:05", 161 | }, 162 | } 163 | for _, test := range tests { 164 | t.Run(test.name, func(t *testing.T) { 165 | got := unit.TimeRangeTimeStringFormat(test.timeRange, test.steps) 166 | assert.Equal(t, test.exp, got) 167 | }) 168 | } 169 | } 170 | -------------------------------------------------------------------------------- /internal/view/app.go: -------------------------------------------------------------------------------- 1 | package view 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "fmt" 7 | "sync" 8 | "time" 9 | 10 | "github.com/slok/grafterm/internal/service/log" 11 | viewsync "github.com/slok/grafterm/internal/view/sync" 12 | "github.com/slok/grafterm/internal/view/template" 13 | ) 14 | 15 | // AppConfig are the options to run the app. 16 | // this configuration has values at global app level. 17 | type AppConfig struct { 18 | RefreshInterval time.Duration 19 | TimeRangeStart time.Time // Fixed optional time. 20 | TimeRangeEnd time.Time // Fixed optional time. 21 | RelativeTimeRange time.Duration 22 | } 23 | 24 | func (a *AppConfig) defaults() { 25 | const ( 26 | defRelativeTimeRange = 1 * time.Hour 27 | defRefreshInterval = 10 * time.Second 28 | ) 29 | 30 | if a.RefreshInterval == 0 { 31 | a.RefreshInterval = defRefreshInterval 32 | } 33 | if a.RelativeTimeRange == 0 { 34 | a.RelativeTimeRange = defRelativeTimeRange 35 | } 36 | } 37 | 38 | // App represents the application that will render the metrics dashboard. 39 | type App struct { 40 | syncer viewsync.Syncer 41 | cfg AppConfig 42 | logger log.Logger 43 | 44 | running bool 45 | mu sync.Mutex 46 | } 47 | 48 | // NewApp Is the main application 49 | func NewApp(cfg AppConfig, syncer viewsync.Syncer, logger log.Logger) *App { 50 | cfg.defaults() 51 | 52 | return &App{ 53 | cfg: cfg, 54 | syncer: syncer, 55 | logger: logger, 56 | } 57 | } 58 | 59 | // Run will start running the application. 60 | func (a *App) Run(ctx context.Context) error { 61 | a.mu.Lock() 62 | defer a.mu.Unlock() 63 | if a.running { 64 | return errors.New("already running") 65 | } 66 | a.running = true 67 | 68 | // TODO(slok): Think if we should set running to false, for now we 69 | // don't want to reuse the app. 70 | return a.run(ctx) 71 | } 72 | 73 | func (a *App) run(ctx context.Context) error { 74 | // Start the sync loop. This operation blocks. 75 | a.sync() 76 | 77 | tk := time.NewTicker(a.cfg.RefreshInterval) 78 | defer tk.Stop() 79 | for { 80 | // Check if we already done. 81 | select { 82 | case <-ctx.Done(): 83 | return nil 84 | case <-tk.C: 85 | } 86 | 87 | a.sync() 88 | } 89 | } 90 | 91 | func (a *App) sync() { 92 | ctx := context.Background() 93 | r := a.syncRequest() 94 | err := a.syncer.Sync(ctx, r) 95 | if err != nil { 96 | a.logger.Errorf("app level error, syncer failed sync: %s", err) 97 | } 98 | } 99 | 100 | func (a *App) syncRequest() *viewsync.Request { 101 | r := &viewsync.Request{ 102 | TimeRangeStart: a.cfg.TimeRangeStart, 103 | TimeRangeEnd: a.cfg.TimeRangeEnd, 104 | } 105 | 106 | // If we don't have fixed time, make the time ranges work in relative mode 107 | // based on now timestamp. 108 | if r.TimeRangeEnd.IsZero() { 109 | r.TimeRangeEnd = time.Now().UTC() 110 | } 111 | if r.TimeRangeStart.IsZero() { 112 | r.TimeRangeStart = r.TimeRangeEnd.Add(-1 * a.cfg.RelativeTimeRange) 113 | } 114 | 115 | // Create the template data for each sync. 116 | r.TemplateData = a.syncData(r) 117 | 118 | return r 119 | } 120 | 121 | func (a *App) syncData(r *viewsync.Request) template.Data { 122 | data := map[string]interface{}{ 123 | "__start": fmt.Sprintf("%v", r.TimeRangeStart), 124 | "__end": fmt.Sprintf("%v", r.TimeRangeEnd), 125 | } 126 | return data 127 | } 128 | -------------------------------------------------------------------------------- /internal/view/grid/grid.go: -------------------------------------------------------------------------------- 1 | package grid 2 | 3 | import ( 4 | "math" 5 | "sort" 6 | 7 | "github.com/slok/grafterm/internal/model" 8 | ) 9 | 10 | const ( 11 | maxWidthPercent = 100 12 | ) 13 | 14 | // Element is a "placeable" element on the grid, depending on the 15 | // implementation of the renderer will be created in one way or another. 16 | type Element struct { 17 | // Percent size is the percent of the total in the horizontal axis. 18 | PercentSize int 19 | // Empty marks the element as an empty block that will not be used. 20 | Empty bool 21 | // Widget is the widget to be placed. 22 | Widget model.Widget 23 | } 24 | 25 | // Row is composed by multiple elements. 26 | type Row struct { 27 | // Elements are the elements that will be placed on the row. The 28 | // elements of a row are horizontally placed. also known as 29 | // the X axis. 30 | Elements []*Element 31 | // PercentSize is the size in percentage of the total vertical axis. 32 | PercentSize int 33 | } 34 | 35 | // Grid is the grid itself, it's composed by rows that inside of the rows 36 | // are the columns, the elements are the columns. 37 | // 38 | // ---------------------------------------------------- 39 | // [------element------] [--element--] [---element---] 40 | // ---------------------------------------------------- 41 | // [element] [element] [------------element----------] 42 | // ---------------------------------------------------- 43 | // [-element-] [----element----] [element] 44 | // ---------------------------------------------------- 45 | type Grid struct { 46 | // Is the max size of the X axis. This is equal to a 100 percentage. 47 | MaxWidth int 48 | // Is the max size of the y axis. This is equal to a 100 percentage. 49 | MaxHeight int 50 | // Rows are the rows the grid has (inside the rows are the columns). 51 | // the rows are vertically placed, also know as the Y axis. 52 | Rows []*Row 53 | } 54 | 55 | // NewAdaptiveGrid returns a grid that places the widgets in the received order 56 | // without checking its position (x, y) only using the size of the widgets. 57 | // It will adapt the rows dinamically so the widgets that are bigger than the empty 58 | // space on the row, will be placed in the next row an so own, on after the other 59 | // creating new rows until all the widgets have been placed. 60 | func NewAdaptiveGrid(maxWidth int, widgets []model.Widget) (*Grid, error) { 61 | d := &Grid{ 62 | MaxWidth: maxWidth, 63 | } 64 | 65 | d.fillAdaptiveGrid(widgets) 66 | return d, nil 67 | } 68 | 69 | func (g *Grid) fillAdaptiveGrid(widgets []model.Widget) { 70 | g.Rows = []*Row{} 71 | filledRow := 0 72 | currentRow := 0 73 | for _, cfg := range widgets { 74 | // Initial row check existence. 75 | if len(g.Rows) <= currentRow { 76 | g.Rows = append(g.Rows, &Row{}) 77 | } 78 | 79 | r := g.Rows[currentRow] 80 | 81 | // Create the widget element. 82 | e := &Element{ 83 | PercentSize: percent(cfg.GridPos.W, g.MaxWidth), 84 | Widget: cfg, 85 | } 86 | 87 | // To get he correct row of the widget then we need to see if 88 | // the widget is from this row or next row . 89 | // TODO(slok): check if widget is greater than grid totalX 90 | if filledRow+e.PercentSize > maxWidthPercent { 91 | // If there is spare space on the row, before creating a new row 92 | // create an empty widget to fill the row until the end. 93 | if filledRow < maxWidthPercent { 94 | r.Elements = append(r.Elements, &Element{ 95 | Empty: true, 96 | PercentSize: maxWidthPercent - filledRow, 97 | }) 98 | } 99 | 100 | // Next and new row. 101 | currentRow++ 102 | filledRow = 0 103 | g.Rows = append(g.Rows, &Row{}) 104 | r = g.Rows[currentRow] 105 | } 106 | 107 | // Add widget to row. 108 | filledRow += e.PercentSize 109 | r.Elements = append(r.Elements, e) 110 | } 111 | 112 | // Set the size of the rows, the rows have been dinamically created so until 113 | // we had all the rows we can't be sure what is the total of the vertical axis, 114 | // set the same vertical percent size of the rows to all the rows 115 | // (e.g 4 rows of 25% or 3 rows of 33% or 10 rows of 10% ). 116 | totalRows := len(g.Rows) // This is the 100%. 117 | for _, row := range g.Rows { 118 | row.PercentSize = percent(1, totalRows) 119 | } 120 | } 121 | 122 | // NewFixedGrid will place the widgets on the grid using the size and position 123 | // of the widgets letting empty elements between them if required. This kind 124 | // of grid needs the widgets to be exactly placed on the grid it doesn't adapt 125 | // horizontally nor vertically. 126 | func NewFixedGrid(maxWidth int, widgets []model.Widget) (*Grid, error) { 127 | maxHeight := 0 128 | for _, w := range widgets { 129 | if maxHeight < w.GridPos.Y+1 { 130 | maxHeight = w.GridPos.Y + 1 131 | } 132 | } 133 | 134 | g := &Grid{ 135 | MaxHeight: maxHeight, 136 | MaxWidth: maxWidth, 137 | Rows: []*Row{}, 138 | } 139 | 140 | g.fillFixedGrid(widgets) 141 | 142 | return g, nil 143 | } 144 | 145 | func (g *Grid) fillFixedGrid(widgets []model.Widget) { 146 | sortwidgets(widgets) 147 | g.initRows() 148 | // Create the widgets. 149 | for _, cfg := range widgets { 150 | row := g.Rows[cfg.GridPos.Y] 151 | row.Elements = append(row.Elements, &Element{ 152 | PercentSize: percent(cfg.GridPos.W, g.MaxWidth), 153 | Widget: cfg, 154 | }) 155 | } 156 | 157 | // Fill the blank spaces between widgets for each row. 158 | for _, row := range g.Rows { 159 | rowFilled := 0 160 | var rowElements []*Element 161 | for _, rowElement := range row.Elements { 162 | posperc := percent(rowElement.Widget.GridPos.X, g.MaxWidth) 163 | 164 | // If what we filled is not the start point of the current 165 | // widget it means that we have a blank space. 166 | if rowFilled < posperc { 167 | rowElements = append(rowElements, &Element{ 168 | Empty: true, 169 | PercentSize: posperc - rowFilled, 170 | }) 171 | } 172 | rowElements = append(rowElements, rowElement) 173 | rowFilled = posperc + rowElement.PercentSize 174 | } 175 | 176 | // Check if we need to fill with blank space until the end 177 | // of the row. 178 | if rowFilled < maxWidthPercent { 179 | rowElements = append(rowElements, &Element{ 180 | Empty: true, 181 | PercentSize: maxWidthPercent - rowFilled, 182 | }) 183 | } 184 | 185 | row.Elements = rowElements 186 | } 187 | } 188 | 189 | // initRows creates all the rows in empty state. 190 | func (g *Grid) initRows() { 191 | for i := 0; i < g.MaxHeight; i++ { 192 | g.Rows = append(g.Rows, &Row{ 193 | PercentSize: percent(1, g.MaxHeight), 194 | }) 195 | } 196 | } 197 | 198 | func percent(value, total int) int { 199 | perc := float64(value) * 100 / float64(total) 200 | return int(math.Round(perc)) 201 | } 202 | 203 | // sortwidgets sorts the widgets in left-right and top-down 204 | // order. 205 | func sortwidgets(widgets []model.Widget) { 206 | sort.Slice(widgets, func(i, j int) bool { 207 | gpi := widgets[i].GridPos 208 | gpj := widgets[j].GridPos 209 | 210 | switch { 211 | case gpi.Y > gpj.Y: 212 | return false 213 | case gpi.Y < gpj.Y: 214 | return true 215 | case gpi.X < gpj.X: 216 | return true 217 | case gpi.X > gpj.X: 218 | return false 219 | // If are the same then doesn't matter, we shouldn't reach here. 220 | default: 221 | return true 222 | } 223 | }) 224 | } 225 | -------------------------------------------------------------------------------- /internal/view/page/dashboard.go: -------------------------------------------------------------------------------- 1 | package page 2 | 3 | import ( 4 | "context" 5 | "time" 6 | 7 | "github.com/slok/grafterm/internal/controller" 8 | "github.com/slok/grafterm/internal/model" 9 | "github.com/slok/grafterm/internal/service/log" 10 | "github.com/slok/grafterm/internal/view/grid" 11 | "github.com/slok/grafterm/internal/view/page/widget" 12 | "github.com/slok/grafterm/internal/view/render" 13 | "github.com/slok/grafterm/internal/view/sync" 14 | "github.com/slok/grafterm/internal/view/template" 15 | "github.com/slok/grafterm/internal/view/variable" 16 | ) 17 | 18 | // DashboardCfg is the configuration required to create a Dashboard. 19 | type DashboardCfg struct { 20 | AppRelativeTimeRange time.Duration 21 | AppOverrideVariables map[string]string 22 | Controller controller.Controller 23 | Dashboard model.Dashboard 24 | Renderer render.Renderer 25 | } 26 | 27 | // NewDashboard returns a new syncer from a dashboard with all the required 28 | // widgets loaded. 29 | // The widgets the dashboard manages at the same time are syncers also. 30 | func NewDashboard(ctx context.Context, cfg DashboardCfg, logger log.Logger) (sync.Syncer, error) { 31 | // Create variablers. 32 | vs, err := variable.NewVariablers(variable.FactoryConfig{ 33 | TimeRange: cfg.AppRelativeTimeRange, 34 | Dashboard: cfg.Dashboard, 35 | }) 36 | 37 | // Create Grid. 38 | var gr *grid.Grid 39 | if cfg.Dashboard.Grid.FixedWidgets { 40 | gr, err = grid.NewFixedGrid(cfg.Dashboard.Grid.MaxWidth, cfg.Dashboard.Widgets) 41 | if err != nil { 42 | return nil, err 43 | } 44 | } else { 45 | gr, err = grid.NewAdaptiveGrid(cfg.Dashboard.Grid.MaxWidth, cfg.Dashboard.Widgets) 46 | if err != nil { 47 | return nil, err 48 | } 49 | } 50 | 51 | d := &dashboard{ 52 | cfg: cfg, 53 | variablers: vs, 54 | ctrl: cfg.Controller, 55 | logger: logger, 56 | } 57 | 58 | // Call the View to load the dashboard and return us the widgets that we will need to call. 59 | renderWidgets, err := cfg.Renderer.LoadDashboard(ctx, gr) 60 | if err != nil { 61 | return nil, err 62 | } 63 | 64 | d.widgets = d.createWidgets(renderWidgets) 65 | 66 | return d, nil 67 | } 68 | 69 | type dashboard struct { 70 | cfg DashboardCfg 71 | widgets []sync.Syncer 72 | ctrl controller.Controller 73 | variablers map[string]variable.Variabler 74 | logger log.Logger 75 | } 76 | 77 | func (d *dashboard) Sync(ctx context.Context, r *sync.Request) error { 78 | // Add dashboard sync data. 79 | r = d.syncData(r) 80 | 81 | // Sync all widgets. 82 | for _, w := range d.widgets { 83 | w := w 84 | go func() { 85 | // Don't wait to sync all at the same time, the widgets 86 | // should control multiple calls to sync and reject the sync 87 | // if already syncing. 88 | err := w.Sync(ctx, r) 89 | if err != nil { 90 | d.logger.Errorf("error syncing widget: %s", err) 91 | } 92 | }() 93 | } 94 | return nil 95 | } 96 | 97 | func (d *dashboard) createWidgets(rws []render.Widget) []sync.Syncer { 98 | widgets := []sync.Syncer{} 99 | 100 | // Create app widgets based on the render view widgets. 101 | for _, rw := range rws { 102 | var w sync.Syncer 103 | 104 | // Depending on the type create a widget kind or another. 105 | switch v := rw.(type) { 106 | case render.GaugeWidget: 107 | w = widget.NewGauge(d.ctrl, v) 108 | case render.SinglestatWidget: 109 | w = widget.NewSinglestat(d.ctrl, v) 110 | case render.GraphWidget: 111 | w = widget.NewGraph(d.ctrl, v, d.logger) 112 | default: 113 | continue 114 | } 115 | 116 | // Dashboard data. 117 | dashboardData := d.staticData() 118 | overrideData := d.overrideVariableData() 119 | 120 | // Widget middlewares. 121 | w = withWidgetDataMiddleware(dashboardData, overrideData, w) // Assign static data to widget. 122 | 123 | widgets = append(widgets, w) 124 | } 125 | 126 | return widgets 127 | } 128 | 129 | func (d *dashboard) overrideVariableData() template.Data { 130 | od := map[string]interface{}{} 131 | for k, v := range d.cfg.AppOverrideVariables { 132 | od[k] = v 133 | } 134 | return template.Data(od) 135 | } 136 | 137 | func (d *dashboard) staticData() template.Data { 138 | // Load variablers data from the dashboard scope. 139 | dashboardData := map[string]interface{}{} 140 | for vid, v := range d.variablers { 141 | if v.Scope() == variable.ScopeDashboard { 142 | dashboardData[vid] = v.GetValue() 143 | } 144 | } 145 | 146 | return dashboardData 147 | } 148 | 149 | func (d *dashboard) syncData(r *sync.Request) *sync.Request { 150 | // Load variablers data from the sync scope. 151 | data := map[string]interface{}{} 152 | for vid, v := range d.variablers { 153 | if v.Scope() == variable.ScopeSync { 154 | data[vid] = v.GetValue() 155 | } 156 | } 157 | r.TemplateData = r.TemplateData.WithData(data) 158 | 159 | return r 160 | } 161 | -------------------------------------------------------------------------------- /internal/view/page/middleware.go: -------------------------------------------------------------------------------- 1 | package page 2 | 3 | import ( 4 | "context" 5 | 6 | "github.com/slok/grafterm/internal/view/sync" 7 | "github.com/slok/grafterm/internal/view/template" 8 | ) 9 | 10 | // withWidgetDataMiddleware controls the variables data 11 | // the widget receives, it wraps any widget and will 12 | // mutate the variable data (updating, adding, deleting...) 13 | // the widget receives on every sync. 14 | // 15 | // It has the static data the widget will receive on all 16 | // the syncs, this way the widget doesn't need to store 17 | // the static data. 18 | // 19 | // It also controls the data that the user wants to override 20 | // (for example via cmd flags). 21 | // 22 | // Priority chain. 23 | // 1- OverrideData 24 | // 2- SyncData 25 | // 3- StaticData 26 | func withWidgetDataMiddleware(data template.Data, overrideData template.Data, next sync.Syncer) sync.Syncer { 27 | return &widgetDataMiddleware{ 28 | staticData: data, 29 | overrideData: overrideData, 30 | next: next, 31 | } 32 | } 33 | 34 | type widgetDataMiddleware struct { 35 | staticData template.Data 36 | overrideData template.Data 37 | next sync.Syncer 38 | } 39 | 40 | func (w widgetDataMiddleware) Sync(ctx context.Context, r *sync.Request) error { 41 | // Add the sync data to the static data and place it again on the cfg. 42 | data := w.staticData.WithData(r.TemplateData) 43 | // Override the data asked by the user. 44 | data = data.WithData(w.overrideData) 45 | 46 | r.TemplateData = data 47 | return w.next.Sync(ctx, r) 48 | } 49 | -------------------------------------------------------------------------------- /internal/view/page/middleware_test.go: -------------------------------------------------------------------------------- 1 | package page 2 | 3 | import ( 4 | "context" 5 | "testing" 6 | 7 | "github.com/stretchr/testify/assert" 8 | 9 | "github.com/slok/grafterm/internal/view/sync" 10 | "github.com/slok/grafterm/internal/view/template" 11 | ) 12 | 13 | type mockWidget struct { 14 | calledReq *sync.Request 15 | } 16 | 17 | func (m *mockWidget) Sync(_ context.Context, r *sync.Request) error { 18 | m.calledReq = r 19 | return nil 20 | } 21 | 22 | func TestWidgetDataMiddleware(t *testing.T) { 23 | tests := map[string]struct { 24 | data template.Data 25 | overrideData template.Data 26 | syncReq *sync.Request 27 | expData template.Data 28 | }{ 29 | "Storing static data should add that data on every call to the sync.": { 30 | data: map[string]interface{}{ 31 | "name": "Batman", 32 | "realName": "Bruce", 33 | "lastName": "Wayne", 34 | "location": "Gotham", 35 | }, 36 | syncReq: &sync.Request{ 37 | TemplateData: map[string]interface{}{ 38 | "location": "Arkham asylum", 39 | "transport": "batmobile", 40 | }, 41 | }, 42 | expData: map[string]interface{}{ 43 | "name": "Batman", 44 | "realName": "Bruce", 45 | "lastName": "Wayne", 46 | "location": "Arkham asylum", 47 | "transport": "batmobile", 48 | }, 49 | }, 50 | "Storing override data should add that data on every call to the sync.": { 51 | overrideData: map[string]interface{}{ 52 | "name": "Batman", 53 | "realName": "Bruce", 54 | "lastName": "Wayne", 55 | "location": "Gotham", 56 | }, 57 | syncReq: &sync.Request{ 58 | TemplateData: map[string]interface{}{ 59 | "location": "Arkham asylum", 60 | "transport": "batmobile", 61 | }, 62 | }, 63 | expData: map[string]interface{}{ 64 | "name": "Batman", 65 | "realName": "Bruce", 66 | "lastName": "Wayne", 67 | "location": "Gotham", 68 | "transport": "batmobile", 69 | }, 70 | }, 71 | "Override data should be merged and have priority.": { 72 | data: map[string]interface{}{ 73 | "name": "Batman", 74 | "realName": "Bruce", 75 | "lastName": "Wayne", 76 | "worstEnemy": "Joker", 77 | }, 78 | overrideData: map[string]interface{}{ 79 | "name": "Batman2", 80 | "realName": "Bruce", 81 | "lastName": "Wayne2", 82 | "location": "Gotham", 83 | }, 84 | syncReq: &sync.Request{ 85 | TemplateData: map[string]interface{}{ 86 | "location": "Arkham asylum", 87 | "transport": "batmobile", 88 | }, 89 | }, 90 | expData: map[string]interface{}{ 91 | "name": "Batman2", 92 | "realName": "Bruce", 93 | "lastName": "Wayne2", 94 | "location": "Gotham", 95 | "transport": "batmobile", 96 | "worstEnemy": "Joker", 97 | }, 98 | }, 99 | } 100 | 101 | for name, test := range tests { 102 | t.Run(name, func(t *testing.T) { 103 | mw := &mockWidget{} 104 | w := withWidgetDataMiddleware(test.data, test.overrideData, mw) 105 | w.Sync(context.TODO(), test.syncReq) 106 | 107 | assert.Equal(t, test.expData, mw.calledReq.TemplateData) 108 | }) 109 | } 110 | } 111 | -------------------------------------------------------------------------------- /internal/view/page/widget/gauge.go: -------------------------------------------------------------------------------- 1 | package widget 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "sort" 7 | 8 | "github.com/slok/grafterm/internal/controller" 9 | "github.com/slok/grafterm/internal/model" 10 | "github.com/slok/grafterm/internal/view/render" 11 | "github.com/slok/grafterm/internal/view/sync" 12 | ) 13 | 14 | // gauge is a widget that represents a metric in percent format. 15 | type gauge struct { 16 | controller controller.Controller 17 | rendererWidget render.GaugeWidget 18 | cfg model.Widget 19 | currentColor string 20 | syncLock syncingFlag 21 | } 22 | 23 | // NewGauge returns a new Gauge widget that is a syncer. 24 | func NewGauge(controller controller.Controller, rendererWidget render.GaugeWidget) sync.Syncer { 25 | cfg := rendererWidget.GetWidgetCfg() 26 | 27 | // Sort gauge thresholds. Optimization so we don't have to sort every time we calculate 28 | // a color. 29 | sort.Slice(cfg.Gauge.Thresholds, func(i, j int) bool { 30 | return cfg.Gauge.Thresholds[i].StartValue < cfg.Gauge.Thresholds[j].StartValue 31 | }) 32 | 33 | return &gauge{ 34 | controller: controller, 35 | rendererWidget: rendererWidget, 36 | cfg: cfg, 37 | } 38 | } 39 | 40 | func (g *gauge) Sync(ctx context.Context, r *sync.Request) error { 41 | // If already syncinc ignore call. 42 | if g.syncLock.Get() { 43 | return nil 44 | } 45 | // If didn't changed the value means some other sync process 46 | // already entered before us. 47 | if !g.syncLock.Set(true) { 48 | return nil 49 | } 50 | defer g.syncLock.Set(false) 51 | 52 | // Gather the gauge value. 53 | templatedQ := g.cfg.Gauge.Query 54 | templatedQ.Expr = r.TemplateData.Render(templatedQ.Expr) 55 | m, err := g.controller.GetSingleMetric(ctx, templatedQ, r.TimeRangeEnd) 56 | if err != nil { 57 | return fmt.Errorf("error getting single instant metric: %s", err) 58 | } 59 | 60 | // calculate percent value if required. 61 | val := m.Value 62 | if g.cfg.Gauge.PercentValue { 63 | val = g.getPercentValue(val) 64 | } 65 | 66 | // Change the widget color if required. 67 | err = g.changeWidgetColor(val) 68 | if err != nil { 69 | return err 70 | } 71 | 72 | // Update the render view value. 73 | err = g.rendererWidget.Sync(g.cfg.Gauge.PercentValue, val) 74 | if err != nil { 75 | return fmt.Errorf("error setting value on render view widget: %s", err) 76 | } 77 | 78 | return nil 79 | } 80 | 81 | func (g *gauge) getPercentValue(val float64) float64 { 82 | // Calculate percent, if not max assume is from 0 to 100. 83 | if g.cfg.Gauge.Max != 0 { 84 | val = val - float64(g.cfg.Gauge.Min) 85 | cap := g.cfg.Gauge.Max - g.cfg.Gauge.Min 86 | val = val / float64(cap) * 100 87 | } 88 | 89 | if val > 100 { 90 | val = 100 91 | } 92 | 93 | if val < 0 { 94 | val = 0 95 | } 96 | 97 | return val 98 | } 99 | 100 | func (g *gauge) changeWidgetColor(val float64) error { 101 | if len(g.cfg.Gauge.Thresholds) == 0 { 102 | return nil 103 | } 104 | 105 | color, err := widgetColorManager{}.GetColorFromThresholds(g.cfg.Gauge.Thresholds, val) 106 | if err != nil { 107 | return fmt.Errorf("error getting threshold color: %s", err) 108 | } 109 | 110 | // If is the same color then don't change the widget color. 111 | if color == g.currentColor { 112 | return nil 113 | } 114 | 115 | // Change the color of the gauge widget. 116 | err = g.rendererWidget.SetColor(color) 117 | if err != nil { 118 | return fmt.Errorf("error setting color on view widget: %s", err) 119 | } 120 | 121 | // Update state. 122 | g.currentColor = color 123 | 124 | return nil 125 | } 126 | -------------------------------------------------------------------------------- /internal/view/page/widget/gauge_test.go: -------------------------------------------------------------------------------- 1 | package widget_test 2 | 3 | import ( 4 | "context" 5 | "testing" 6 | 7 | "github.com/stretchr/testify/assert" 8 | "github.com/stretchr/testify/mock" 9 | 10 | mcontroller "github.com/slok/grafterm/internal/mocks/controller" 11 | mrender "github.com/slok/grafterm/internal/mocks/view/render" 12 | "github.com/slok/grafterm/internal/model" 13 | "github.com/slok/grafterm/internal/view/page/widget" 14 | "github.com/slok/grafterm/internal/view/sync" 15 | "github.com/slok/grafterm/internal/view/template" 16 | ) 17 | 18 | func TestGaugeWidget(t *testing.T) { 19 | tests := []struct { 20 | name string 21 | cfg model.Widget 22 | controllerMetric *model.Metric 23 | syncReq *sync.Request 24 | expQuery model.Query 25 | exp func(*mrender.GaugeWidget) 26 | expErr bool 27 | }{ 28 | { 29 | name: "A gauge without thresholds and in absolute value should render ok.", 30 | controllerMetric: &model.Metric{ 31 | Value: 19, 32 | }, 33 | syncReq: &sync.Request{}, 34 | cfg: model.Widget{ 35 | WidgetSource: model.WidgetSource{ 36 | Gauge: &model.GaugeWidgetSource{}, 37 | }, 38 | }, 39 | exp: func(mc *mrender.GaugeWidget) { 40 | mc.On("Sync", false, float64(19)).Return(nil) 41 | }, 42 | }, 43 | { 44 | name: "A gauge should make templated queries.", 45 | controllerMetric: &model.Metric{ 46 | Value: 19, 47 | }, 48 | syncReq: &sync.Request{ 49 | TemplateData: template.Data(map[string]interface{}{ 50 | "testInterval": "10m", 51 | }), 52 | }, 53 | cfg: model.Widget{ 54 | WidgetSource: model.WidgetSource{ 55 | Gauge: &model.GaugeWidgetSource{ 56 | Query: model.Query{ 57 | Expr: "this_is_a_test[{{ .testInterval }}]", 58 | }, 59 | }, 60 | }, 61 | }, 62 | expQuery: model.Query{ 63 | Expr: "this_is_a_test[10m]", 64 | }, 65 | exp: func(mc *mrender.GaugeWidget) { 66 | mc.On("Sync", false, float64(19)).Return(nil) 67 | }, 68 | }, 69 | { 70 | name: "A gauge without thresholds and in percent value should render ok.", 71 | controllerMetric: &model.Metric{ 72 | Value: 19, 73 | }, 74 | syncReq: &sync.Request{}, 75 | cfg: model.Widget{ 76 | WidgetSource: model.WidgetSource{ 77 | Gauge: &model.GaugeWidgetSource{ 78 | PercentValue: true, 79 | }, 80 | }, 81 | }, 82 | exp: func(mc *mrender.GaugeWidget) { 83 | mc.On("Sync", true, float64(19)).Return(nil) 84 | }, 85 | }, 86 | { 87 | name: "A gauge without thresholds and in percent value with Max and Min and Min should render ok.", 88 | controllerMetric: &model.Metric{ 89 | Value: 150, 90 | }, 91 | syncReq: &sync.Request{}, 92 | cfg: model.Widget{ 93 | WidgetSource: model.WidgetSource{ 94 | Gauge: &model.GaugeWidgetSource{ 95 | PercentValue: true, 96 | Max: 300, 97 | Min: 100, 98 | }, 99 | }, 100 | }, 101 | exp: func(mc *mrender.GaugeWidget) { 102 | mc.On("Sync", true, float64(25)).Return(nil) 103 | }, 104 | }, 105 | { 106 | name: "A gauge with (unordered) thresholds and in absolute value should set the color ok.", 107 | controllerMetric: &model.Metric{ 108 | Value: 19, 109 | }, 110 | syncReq: &sync.Request{}, 111 | cfg: model.Widget{ 112 | WidgetSource: model.WidgetSource{ 113 | Gauge: &model.GaugeWidgetSource{ 114 | Thresholds: []model.Threshold{ 115 | {Color: "#000010", StartValue: 10}, 116 | {Color: "#000020", StartValue: 20}, 117 | {Color: "#000005", StartValue: 5}, 118 | {Color: "#000015", StartValue: 15}, 119 | }, 120 | }, 121 | }, 122 | }, 123 | exp: func(mc *mrender.GaugeWidget) { 124 | mc.On("Sync", false, float64(19)).Return(nil) 125 | mc.On("SetColor", "#000015").Return(nil) 126 | }, 127 | }, 128 | } 129 | 130 | for _, test := range tests { 131 | t.Run(test.name, func(t *testing.T) { 132 | assert := assert.New(t) 133 | 134 | // Mocks. 135 | mgauge := &mrender.GaugeWidget{} 136 | mgauge.On("GetWidgetCfg").Once().Return(test.cfg) 137 | test.exp(mgauge) 138 | 139 | mc := &mcontroller.Controller{} 140 | mc.On("GetSingleMetric", mock.Anything, test.expQuery, mock.Anything).Return(test.controllerMetric, nil) 141 | 142 | var err error 143 | gauge := widget.NewGauge(mc, mgauge) 144 | gauge.Sync(context.Background(), test.syncReq) 145 | 146 | if test.expErr { 147 | assert.Error(err) 148 | } else if assert.NoError(err) { 149 | mc.AssertExpectations(t) 150 | mgauge.AssertExpectations(t) 151 | } 152 | }) 153 | } 154 | } 155 | -------------------------------------------------------------------------------- /internal/view/page/widget/misc.go: -------------------------------------------------------------------------------- 1 | package widget 2 | 3 | import ( 4 | "fmt" 5 | "sync" 6 | 7 | "github.com/slok/grafterm/internal/model" 8 | ) 9 | 10 | // Default colors got from Grafana. 11 | // Check: https://github.com/grafana/grafana/blob/406ef962fc113091bb7229c8f3f0090d63c8392e/packages/grafana-ui/src/utils/colors.ts#L16 12 | var defColors = []string{ 13 | "#7EB26D", // 0: pale green 14 | "#EAB839", // 1: mustard 15 | "#6ED0E0", // 2: light blue 16 | "#EF843C", // 3: orange 17 | "#E24D42", // 4: red 18 | "#1F78C1", // 5: ocean 19 | "#BA43A9", // 6: purple 20 | "#705DA0", // 7: violet 21 | "#508642", // 8: dark green 22 | "#CCA300", // 9: dark sand 23 | "#447EBC", 24 | "#C15C17", 25 | "#890F02", 26 | "#0A437C", 27 | "#6D1F62", 28 | "#584477", 29 | "#B7DBAB", 30 | "#F4D598", 31 | "#70DBED", 32 | "#F9BA8F", 33 | "#F29191", 34 | "#82B5D8", 35 | "#E5A8E2", 36 | "#AEA2E0", 37 | "#629E51", 38 | "#E5AC0E", 39 | "#64B0C8", 40 | "#E0752D", 41 | "#BF1B00", 42 | "#0A50A1", 43 | "#962D82", 44 | "#614D93", 45 | "#9AC48A", 46 | "#F2C96D", 47 | "#65C5DB", 48 | "#F9934E", 49 | "#EA6460", 50 | "#5195CE", 51 | "#D683CE", 52 | "#806EB7", 53 | "#3F6833", 54 | "#967302", 55 | "#2F575E", 56 | "#99440A", 57 | "#58140C", 58 | "#052B51", 59 | "#511749", 60 | "#3F2B5B", 61 | "#E0F9D7", 62 | "#FCEACA", 63 | "#CFFAFF", 64 | "#F9E2D2", 65 | "#FCE2DE", 66 | "#BADFF4", 67 | "#F9D9F9", 68 | "#DEDAF7", 69 | } 70 | 71 | type syncingFlag struct { 72 | syncing bool 73 | mu sync.Mutex 74 | } 75 | 76 | // Set will return true if it has changed the value and false if already 77 | // was on that state, this way the setter knows if other part of the app has 78 | // changed in the interval it was calling set. 79 | func (s *syncingFlag) Set(v bool) bool { 80 | s.mu.Lock() 81 | defer s.mu.Unlock() 82 | 83 | if s.syncing == v { 84 | return false 85 | } 86 | 87 | s.syncing = v 88 | return true 89 | } 90 | 91 | func (s *syncingFlag) Get() bool { 92 | s.mu.Lock() 93 | defer s.mu.Unlock() 94 | 95 | return s.syncing 96 | } 97 | 98 | // widgetColorManager manages the color selection for widgets. 99 | // it knows to get default color, based on series legend... 100 | // The color selector tracks the number of default colors returned 101 | // so it doesn't repeat default colors. 102 | type widgetColorManager struct { 103 | count int 104 | } 105 | 106 | // GetColorFromSeriesLegend will return the configured color for the matching regex with the series 107 | // legend, if there is no match then it will return a default color. 108 | func (w *widgetColorManager) GetColorFromSeriesLegend(cfg model.GraphWidgetSource, legend string) string { 109 | so, ok := seriesOverride(cfg.Visualization.SeriesOverride, legend) 110 | if ok && so.Color != "" { 111 | return so.Color 112 | } 113 | 114 | // No match, get the next default color, 115 | return w.GetDefaultColor() 116 | } 117 | 118 | // GetColorFromThresholds gets the correct color based on a ordered list of thresholds and a value. 119 | func (w widgetColorManager) GetColorFromThresholds(thresholds []model.Threshold, value float64) (hexColor string, err error) { 120 | if len(thresholds) == 0 { 121 | return "", fmt.Errorf("the number of thresholds can't be 0") 122 | } 123 | 124 | // Search the correct color. 125 | threshold := thresholds[0] 126 | for _, t := range thresholds[1:] { 127 | if value >= t.StartValue { 128 | threshold = t 129 | } 130 | } 131 | 132 | return threshold.Color, nil 133 | } 134 | 135 | // GetDefaultColor returns a default color, for each returned default color the manager 136 | // will track how many default colors have been returned so it doesn't repeat until all 137 | // the default color list has been used and it starts again from the first default color. 138 | func (w *widgetColorManager) GetDefaultColor() string { 139 | color := defColors[w.count] 140 | w.count++ 141 | if w.count >= len(defColors) { 142 | w.count = 0 143 | } 144 | 145 | return color 146 | } 147 | 148 | // seriesOverride returns the series override based on the series legend 149 | // if it finds one, if not then it will return false in the ok return 150 | // argument. 151 | func seriesOverride(seriesOverride []model.SeriesOverride, legend string) (so model.SeriesOverride, ok bool) { 152 | for _, so := range seriesOverride { 153 | if so.CompiledRegex != nil && so.CompiledRegex.MatchString(legend) { 154 | return so, true 155 | } 156 | } 157 | 158 | return model.SeriesOverride{}, false 159 | } 160 | -------------------------------------------------------------------------------- /internal/view/page/widget/misc_test.go: -------------------------------------------------------------------------------- 1 | package widget 2 | 3 | import ( 4 | "regexp" 5 | "testing" 6 | 7 | "github.com/slok/grafterm/internal/model" 8 | "github.com/stretchr/testify/assert" 9 | ) 10 | 11 | func TestWidgetColorManager(t *testing.T) { 12 | tests := []struct { 13 | name string 14 | getColor func(wcm widgetColorManager) (string, error) 15 | expColor string 16 | expErr bool 17 | }{ 18 | { 19 | name: "Get default colors in order.", 20 | getColor: func(wcm widgetColorManager) (string, error) { 21 | wcm.GetDefaultColor() 22 | wcm.GetDefaultColor() 23 | wcm.GetDefaultColor() 24 | return wcm.GetDefaultColor(), nil 25 | }, 26 | expColor: "#EF843C", 27 | }, 28 | { 29 | name: "Get threshold colors.", 30 | getColor: func(wcm widgetColorManager) (string, error) { 31 | thresholds := []model.Threshold{ 32 | model.Threshold{ 33 | StartValue: 0, 34 | Color: "#111111", 35 | }, 36 | model.Threshold{ 37 | StartValue: 30, 38 | Color: "#222222", 39 | }, 40 | model.Threshold{ 41 | StartValue: 75, 42 | Color: "#333333", 43 | }, 44 | } 45 | return wcm.GetColorFromThresholds(thresholds, 50) 46 | }, 47 | expColor: "#222222", 48 | }, 49 | { 50 | name: "Geting threshold colors without thresholds should error.", 51 | getColor: func(wcm widgetColorManager) (string, error) { 52 | thresholds := []model.Threshold{} 53 | return wcm.GetColorFromThresholds(thresholds, 50) 54 | }, 55 | expErr: true, 56 | }, 57 | { 58 | name: "Get threshold colors.", 59 | getColor: func(wcm widgetColorManager) (string, error) { 60 | thresholds := []model.Threshold{ 61 | model.Threshold{ 62 | StartValue: 0, 63 | Color: "#111111", 64 | }, 65 | model.Threshold{ 66 | StartValue: 30, 67 | Color: "#222222", 68 | }, 69 | model.Threshold{ 70 | StartValue: 75, 71 | Color: "#333333", 72 | }, 73 | } 74 | return wcm.GetColorFromThresholds(thresholds, 50) 75 | }, 76 | expColor: "#222222", 77 | }, 78 | { 79 | name: "Get color from series legend.", 80 | getColor: func(wcm widgetColorManager) (string, error) { 81 | cfg := model.GraphWidgetSource{ 82 | Visualization: model.GraphVisualization{ 83 | SeriesOverride: []model.SeriesOverride{ 84 | model.SeriesOverride{ 85 | CompiledRegex: regexp.MustCompile("a-.*"), 86 | Color: "#111111", 87 | }, 88 | model.SeriesOverride{ 89 | CompiledRegex: regexp.MustCompile("b-.*"), 90 | Color: "#222222", 91 | }, 92 | model.SeriesOverride{ 93 | CompiledRegex: regexp.MustCompile("c-.*"), 94 | Color: "#333333", 95 | }, 96 | model.SeriesOverride{ 97 | CompiledRegex: regexp.MustCompile("d-.*"), 98 | Color: "#444444", 99 | }, 100 | model.SeriesOverride{ 101 | CompiledRegex: regexp.MustCompile("e-.*"), 102 | Color: "#444444", 103 | }, 104 | }, 105 | }, 106 | } 107 | return wcm.GetColorFromSeriesLegend(cfg, "d-12345"), nil 108 | }, 109 | expColor: "#444444", 110 | }, 111 | { 112 | name: "Get default color when there's no match with series legend regexes.", 113 | getColor: func(wcm widgetColorManager) (string, error) { 114 | cfg := model.GraphWidgetSource{ 115 | Visualization: model.GraphVisualization{ 116 | SeriesOverride: []model.SeriesOverride{ 117 | model.SeriesOverride{ 118 | CompiledRegex: regexp.MustCompile("a-.*"), 119 | Color: "#111111", 120 | }, 121 | }, 122 | }, 123 | } 124 | return wcm.GetColorFromSeriesLegend(cfg, "d-12345"), nil 125 | }, 126 | expColor: "#7EB26D", 127 | }, 128 | } 129 | 130 | for _, test := range tests { 131 | t.Run(test.name, func(t *testing.T) { 132 | assert := assert.New(t) 133 | 134 | var wcm widgetColorManager 135 | color, err := test.getColor(wcm) 136 | 137 | if test.expErr { 138 | assert.Error(err) 139 | } else if assert.NoError(err) { 140 | assert.Equal(test.expColor, color) 141 | } 142 | }) 143 | } 144 | } 145 | -------------------------------------------------------------------------------- /internal/view/page/widget/singlestat.go: -------------------------------------------------------------------------------- 1 | package widget 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "sort" 7 | 8 | "github.com/slok/grafterm/internal/controller" 9 | "github.com/slok/grafterm/internal/model" 10 | "github.com/slok/grafterm/internal/service/unit" 11 | "github.com/slok/grafterm/internal/view/render" 12 | "github.com/slok/grafterm/internal/view/sync" 13 | "github.com/slok/grafterm/internal/view/template" 14 | ) 15 | 16 | const ( 17 | valueTemplateKey = "value" 18 | defValueTemplate = "{{.value}}" 19 | ) 20 | 21 | // singlestat is a widget that represents in text mode. 22 | type singlestat struct { 23 | controller controller.Controller 24 | rendererWidget render.SinglestatWidget 25 | currentColor string 26 | cfg model.Widget 27 | syncLock syncingFlag 28 | } 29 | 30 | // NewSinglestat returns a new Singlestat widget syncer. 31 | func NewSinglestat(controller controller.Controller, rendererWidget render.SinglestatWidget) sync.Syncer { 32 | cfg := rendererWidget.GetWidgetCfg() 33 | 34 | // Sort widget thresholds. Optimization so we don't have to sort every time we calculate 35 | // a color. 36 | sort.Slice(cfg.Singlestat.Thresholds, func(i, j int) bool { 37 | return cfg.Singlestat.Thresholds[i].StartValue < cfg.Singlestat.Thresholds[j].StartValue 38 | }) 39 | 40 | return &singlestat{ 41 | controller: controller, 42 | rendererWidget: rendererWidget, 43 | cfg: cfg, 44 | } 45 | } 46 | 47 | func (s *singlestat) Sync(ctx context.Context, r *sync.Request) error { 48 | // If already syncinc ignore call. 49 | if s.syncLock.Get() { 50 | return nil 51 | } 52 | // If didn't changed the value means some other sync process 53 | // already entered before us. 54 | if !s.syncLock.Set(true) { 55 | return nil 56 | } 57 | defer s.syncLock.Set(false) 58 | 59 | // Gather the value. 60 | templatedQ := s.cfg.Singlestat.Query 61 | templatedQ.Expr = r.TemplateData.Render(templatedQ.Expr) 62 | m, err := s.controller.GetSingleMetric(ctx, templatedQ, r.TimeRangeEnd) 63 | if err != nil { 64 | return fmt.Errorf("error getting single instant metric: %s", err) 65 | } 66 | 67 | // Change the widget color if required. 68 | err = s.changeWidgetColor(m.Value) 69 | if err != nil { 70 | return err 71 | } 72 | 73 | // Update the render view value. 74 | text, err := s.valueToText(r, m.Value) 75 | if err != nil { 76 | return fmt.Errorf("error rendering value: %s", err) 77 | } 78 | err = s.rendererWidget.Sync(text) 79 | if err != nil { 80 | return fmt.Errorf("error setting value on render view widget: %s", err) 81 | } 82 | 83 | return nil 84 | } 85 | 86 | func (s *singlestat) changeWidgetColor(val float64) error { 87 | if len(s.cfg.Singlestat.Thresholds) == 0 { 88 | return nil 89 | } 90 | 91 | color, err := widgetColorManager{}.GetColorFromThresholds(s.cfg.Singlestat.Thresholds, val) 92 | if err != nil { 93 | return fmt.Errorf("error getting threshold color: %s", err) 94 | } 95 | 96 | // If is the same color then don't change the widget color. 97 | if color == s.currentColor { 98 | return nil 99 | } 100 | 101 | // Change the color of the gauge widget. 102 | err = s.rendererWidget.SetColor(color) 103 | if err != nil { 104 | return fmt.Errorf("error setting color on view widget: %s", err) 105 | } 106 | 107 | // Update state. 108 | s.currentColor = color 109 | 110 | return nil 111 | } 112 | 113 | // valueToText will use a templater to get the text. The value 114 | // obtained for the widget will be available under the described 115 | // key.` 116 | func (s *singlestat) valueToText(r *sync.Request, value float64) (string, error) { 117 | var templateData template.Data 118 | 119 | // If we have a unit set transform. 120 | // If unit is unset and value text template neither then apply default 121 | // unit transformation. 122 | wcfg := s.cfg.Singlestat 123 | if wcfg.Unit != "" || (wcfg.Unit == "" && wcfg.ValueText == "") { 124 | f, err := unit.NewUnitFormatter(wcfg.Unit) 125 | if err != nil { 126 | return "", err 127 | } 128 | templateData = r.TemplateData.WithData(map[string]interface{}{ 129 | valueTemplateKey: f(value, wcfg.Decimals), 130 | }) 131 | } else { 132 | templateData = r.TemplateData.WithData(map[string]interface{}{ 133 | valueTemplateKey: value, 134 | }) 135 | } 136 | 137 | vTpl := s.cfg.Singlestat.ValueText 138 | if vTpl == "" { 139 | vTpl = defValueTemplate 140 | } 141 | 142 | return templateData.Render(vTpl), nil 143 | } 144 | -------------------------------------------------------------------------------- /internal/view/page/widget/singlestat_test.go: -------------------------------------------------------------------------------- 1 | package widget_test 2 | 3 | import ( 4 | "context" 5 | "testing" 6 | 7 | "github.com/stretchr/testify/assert" 8 | "github.com/stretchr/testify/mock" 9 | 10 | mcontroller "github.com/slok/grafterm/internal/mocks/controller" 11 | mrender "github.com/slok/grafterm/internal/mocks/view/render" 12 | "github.com/slok/grafterm/internal/model" 13 | "github.com/slok/grafterm/internal/view/page/widget" 14 | "github.com/slok/grafterm/internal/view/sync" 15 | "github.com/slok/grafterm/internal/view/template" 16 | ) 17 | 18 | func TestSinglestatWidget(t *testing.T) { 19 | tests := []struct { 20 | name string 21 | cfg model.Widget 22 | syncReq *sync.Request 23 | controllerMetric *model.Metric 24 | expQuery model.Query 25 | exp func(*mrender.SinglestatWidget) 26 | expErr bool 27 | }{ 28 | { 29 | name: "A singlestat without thresholds should render ok.", 30 | controllerMetric: &model.Metric{ 31 | Value: 19.14, 32 | }, 33 | syncReq: &sync.Request{}, 34 | cfg: model.Widget{ 35 | WidgetSource: model.WidgetSource{ 36 | Singlestat: &model.SinglestatWidgetSource{ 37 | ValueRepresentation: model.ValueRepresentation{ 38 | Unit: "none", 39 | Decimals: 2, 40 | }, 41 | }, 42 | }, 43 | }, 44 | exp: func(mc *mrender.SinglestatWidget) { 45 | mc.On("Sync", "19.14").Return(nil) 46 | }, 47 | }, 48 | { 49 | name: "A singlestat with custom template should render ok.", 50 | controllerMetric: &model.Metric{ 51 | Value: 19.14, 52 | }, 53 | syncReq: &sync.Request{}, 54 | cfg: model.Widget{ 55 | WidgetSource: model.WidgetSource{ 56 | Singlestat: &model.SinglestatWidgetSource{ 57 | ValueText: `this is a test with {{printf "%.1f" .value}} value`, 58 | }, 59 | }, 60 | }, 61 | exp: func(mc *mrender.SinglestatWidget) { 62 | mc.On("Sync", "this is a test with 19.1 value").Return(nil) 63 | }, 64 | }, 65 | { 66 | name: "A singlestat should make templated queries with variables.", 67 | controllerMetric: &model.Metric{ 68 | Value: 19.14, 69 | }, 70 | syncReq: &sync.Request{ 71 | TemplateData: template.Data(map[string]interface{}{ 72 | "testInterval": "10m", 73 | }), 74 | }, 75 | cfg: model.Widget{ 76 | WidgetSource: model.WidgetSource{ 77 | Singlestat: &model.SinglestatWidgetSource{ 78 | ValueRepresentation: model.ValueRepresentation{ 79 | Unit: "none", 80 | Decimals: 2, 81 | }, 82 | Query: model.Query{ 83 | Expr: "this_is_a_test[{{ .testInterval }}]", 84 | }, 85 | }, 86 | }, 87 | }, 88 | expQuery: model.Query{ 89 | Expr: "this_is_a_test[10m]", 90 | }, 91 | exp: func(mc *mrender.SinglestatWidget) { 92 | mc.On("Sync", "19.14").Return(nil) 93 | }, 94 | }, 95 | { 96 | name: "A singlestat with (unordered) thresholds should set the color ok.", 97 | controllerMetric: &model.Metric{ 98 | Value: 19.14, 99 | }, 100 | syncReq: &sync.Request{}, 101 | cfg: model.Widget{ 102 | WidgetSource: model.WidgetSource{ 103 | Singlestat: &model.SinglestatWidgetSource{ 104 | ValueRepresentation: model.ValueRepresentation{ 105 | Unit: "none", 106 | Decimals: 2, 107 | }, 108 | Thresholds: []model.Threshold{ 109 | {Color: "#000010", StartValue: 10}, 110 | {Color: "#000020", StartValue: 20}, 111 | {Color: "#000005", StartValue: 5}, 112 | {Color: "#000015", StartValue: 15}, 113 | }, 114 | }, 115 | }, 116 | }, 117 | exp: func(mc *mrender.SinglestatWidget) { 118 | mc.On("Sync", "19.14").Return(nil) 119 | mc.On("SetColor", "#000015").Return(nil) 120 | }, 121 | }, 122 | { 123 | name: "A singlestat without unit should fallback to the default unit.", 124 | controllerMetric: &model.Metric{ 125 | Value: 192312312321.21, 126 | }, 127 | syncReq: &sync.Request{}, 128 | cfg: model.Widget{ 129 | WidgetSource: model.WidgetSource{ 130 | Singlestat: &model.SinglestatWidgetSource{}, 131 | }, 132 | }, 133 | exp: func(mc *mrender.SinglestatWidget) { 134 | mc.On("Sync", "192 Bil").Return(nil) 135 | }, 136 | }, 137 | } 138 | 139 | for _, test := range tests { 140 | t.Run(test.name, func(t *testing.T) { 141 | assert := assert.New(t) 142 | 143 | // Mocks. 144 | msstat := &mrender.SinglestatWidget{} 145 | msstat.On("GetWidgetCfg").Once().Return(test.cfg) 146 | test.exp(msstat) 147 | 148 | mc := &mcontroller.Controller{} 149 | mc.On("GetSingleMetric", mock.Anything, test.expQuery, mock.Anything).Return(test.controllerMetric, nil) 150 | 151 | singlestat := widget.NewSinglestat(mc, msstat) 152 | err := singlestat.Sync(context.Background(), test.syncReq) 153 | 154 | if test.expErr { 155 | assert.Error(err) 156 | } else if assert.NoError(err) { 157 | mc.AssertExpectations(t) 158 | msstat.AssertExpectations(t) 159 | } 160 | }) 161 | } 162 | } 163 | -------------------------------------------------------------------------------- /internal/view/render/api.go: -------------------------------------------------------------------------------- 1 | package render 2 | 3 | import ( 4 | "context" 5 | 6 | "github.com/slok/grafterm/internal/model" 7 | "github.com/slok/grafterm/internal/view/grid" 8 | ) 9 | 10 | // Renderer is the interface that knows how to load a dashboard to be rendered 11 | // in some target of UI. 12 | type Renderer interface { 13 | LoadDashboard(ctx context.Context, grid *grid.Grid) ([]Widget, error) 14 | Close() 15 | } 16 | 17 | // Widget represnets a widget that can be rendered on the view. 18 | type Widget interface { 19 | GetWidgetCfg() model.Widget 20 | } 21 | 22 | // GaugeWidget knows how to render a Gauge kind widget that can be in percent 23 | // or not and supports color changes. 24 | type GaugeWidget interface { 25 | Widget 26 | Sync(isPercent bool, value float64) error 27 | SetColor(hexColor string) error 28 | } 29 | 30 | // SinglestatWidget knows how to render a Singlestat kind widget that can render text 31 | // and supports changing color. 32 | type SinglestatWidget interface { 33 | Widget 34 | Sync(text string) error 35 | SetColor(hexColor string) error 36 | } 37 | 38 | // Value is the value of a metric. 39 | type Value float64 40 | 41 | // Series are the series that can be rendered. 42 | type Series struct { 43 | Label string 44 | Color string 45 | // XLabels are the labels that will be displayed on the X axis 46 | // the position of the label is the index of the slice. 47 | XLabels []string 48 | // Value slice, if there is no value we will use a nil value 49 | // we could use NaN floats but nil is more idiomatic and easy 50 | // to understand. 51 | Values []*Value 52 | } 53 | 54 | // GraphWidget knows how to render a Graph kind widget that renders lines in 55 | // a two axis space using lines, dots... depending on the render implementation. 56 | type GraphWidget interface { 57 | Widget 58 | // GetGraphPointQuantity will return the number of points the graph can display 59 | // on the X axis at this given moment (is a best effort, when updating the graph 60 | // could have changed the size). 61 | GetGraphPointQuantity() int 62 | // Sync will sync the different series on the graph. 63 | Sync(series []Series) error 64 | } 65 | -------------------------------------------------------------------------------- /internal/view/render/termdash/gauge.go: -------------------------------------------------------------------------------- 1 | package termdash 2 | 3 | import ( 4 | "github.com/mum4k/termdash/cell" 5 | "github.com/mum4k/termdash/container" 6 | "github.com/mum4k/termdash/container/grid" 7 | "github.com/mum4k/termdash/linestyle" 8 | "github.com/mum4k/termdash/widgets/donut" 9 | 10 | "github.com/slok/grafterm/internal/model" 11 | ) 12 | 13 | // gauge satisfies render.GaugeWidget interface. 14 | type gauge struct { 15 | cfg model.Widget 16 | 17 | widget *donut.Donut 18 | element grid.Element 19 | } 20 | 21 | func newGauge(cfg model.Widget) (*gauge, error) { 22 | // Create the widget. 23 | donut, err := donut.New(donut.CellOpts(cell.FgColor(cell.ColorWhite))) 24 | if err != nil { 25 | return nil, err 26 | } 27 | 28 | // Create the element using the new widget. 29 | element := grid.Widget(donut, 30 | container.Border(linestyle.Light), 31 | container.BorderTitle(cfg.Title), 32 | ) 33 | 34 | return &gauge{ 35 | widget: donut, 36 | cfg: cfg, 37 | element: element, 38 | }, nil 39 | } 40 | 41 | func (g *gauge) getElement() grid.Element { 42 | return g.element 43 | } 44 | 45 | func (g *gauge) GetWidgetCfg() model.Widget { 46 | return g.cfg 47 | } 48 | 49 | func (g *gauge) Sync(isPercent bool, value float64) error { 50 | var err error 51 | if isPercent { 52 | err = g.widget.Percent(int(value)) 53 | } else { 54 | max := float64(g.cfg.Gauge.Max) 55 | if max < value { 56 | max = value 57 | } 58 | err = g.widget.Absolute(int(value), int(max)) 59 | } 60 | 61 | if err != nil { 62 | return err 63 | } 64 | 65 | return nil 66 | } 67 | 68 | func (g *gauge) SetColor(hexColor string) error { 69 | color, err := colorHexToTermdash(hexColor) 70 | if err != nil { 71 | return err 72 | } 73 | 74 | // Create a new widget with the current color. 75 | d, err := donut.New(donut.CellOpts(cell.FgColor(color))) 76 | if err != nil { 77 | return err 78 | } 79 | 80 | // Replace the instance value. We need to replace the content of the widget, 81 | // is ok to copy the value in this case although the widget donut has a mutex. 82 | *g.widget = *d 83 | return nil 84 | } 85 | -------------------------------------------------------------------------------- /internal/view/render/termdash/graph.go: -------------------------------------------------------------------------------- 1 | package termdash 2 | 3 | import ( 4 | "fmt" 5 | "math" 6 | 7 | "github.com/mum4k/termdash/cell" 8 | "github.com/mum4k/termdash/container" 9 | "github.com/mum4k/termdash/container/grid" 10 | "github.com/mum4k/termdash/linestyle" 11 | "github.com/mum4k/termdash/widgets/linechart" 12 | "github.com/mum4k/termdash/widgets/text" 13 | 14 | "github.com/slok/grafterm/internal/model" 15 | "github.com/slok/grafterm/internal/service/unit" 16 | "github.com/slok/grafterm/internal/view/render" 17 | ) 18 | 19 | const ( 20 | fullPerc = 99 21 | graphHorizontalPerc = 80 22 | legendHorizontalPerc = 19 23 | paddingHorizontalPerc = 10 24 | graphVerticalPerc = 90 25 | legendVerticalPerc = 4 26 | paddingVerticalPerc = 50 27 | legendCharacter = `⠤⠤` 28 | axesColor = 8 29 | yAxisLabelsColor = 15 30 | xAxisLabelsColor = 248 31 | ) 32 | 33 | // graph satisfies render.GraphWidget interface. 34 | type graph struct { 35 | cfg model.Widget 36 | 37 | widgetGraph *linechart.LineChart 38 | widgetLegend *text.Text 39 | element grid.Element 40 | } 41 | 42 | func newGraph(cfg model.Widget) (*graph, error) { 43 | vf, err := termdashValueFormatter(cfg) 44 | if err != nil { 45 | return nil, err 46 | } 47 | 48 | // Create the Graphwidget. 49 | // TODO(slok): Allow configuring the color of the axis. 50 | lc, err := linechart.New( 51 | linechart.AxesCellOpts(cell.FgColor(cell.ColorNumber(axesColor))), 52 | linechart.YLabelCellOpts(cell.FgColor(cell.ColorNumber(yAxisLabelsColor))), 53 | linechart.XLabelCellOpts(cell.FgColor(cell.ColorNumber(xAxisLabelsColor))), 54 | linechart.YAxisAdaptive(), 55 | linechart.YAxisFormattedValues(vf), 56 | ) 57 | if err != nil { 58 | return nil, err 59 | } 60 | 61 | // If we don't need a legend then use only the graph. 62 | var element grid.Element 63 | var txt *text.Text 64 | if !cfg.Graph.Visualization.Legend.Disable { 65 | txt, err = text.New(text.WrapAtRunes()) 66 | if err != nil { 67 | return nil, err 68 | } 69 | } 70 | 71 | element = elementFromGraphAndLegend(cfg, lc, txt) 72 | 73 | return &graph{ 74 | widgetGraph: lc, 75 | widgetLegend: txt, 76 | cfg: cfg, 77 | element: element, 78 | }, nil 79 | } 80 | 81 | // termdashValueFormatter will get a termdashValueFormatter based 82 | // on the widget configuration. 83 | func termdashValueFormatter(cfg model.Widget) (linechart.ValueFormatter, error) { 84 | axisUnit := cfg.Graph.Visualization.YAxis.Unit 85 | axisDecimals := cfg.Graph.Visualization.YAxis.Decimals 86 | 87 | f, err := unit.NewUnitFormatter(axisUnit) 88 | if err != nil { 89 | return nil, err 90 | } 91 | 92 | // TODO(slok): auto decimals. 93 | // If units by default use default decimals. 94 | if axisUnit == "" && axisDecimals == 0 { 95 | axisDecimals = 2 96 | } 97 | 98 | return func(value float64) string { 99 | return f(value, axisDecimals) 100 | }, nil 101 | } 102 | 103 | func elementFromGraphAndLegend(cfg model.Widget, graph *linechart.LineChart, legend *text.Text) grid.Element { 104 | graphElement := grid.Widget(graph) 105 | 106 | elements := []grid.Element{} 107 | switch { 108 | // Disabled (no legend element). 109 | case cfg.Graph.Visualization.Legend.Disable: 110 | elements = []grid.Element{ 111 | grid.ColWidthPerc(fullPerc, graphElement), 112 | } 113 | // To the right(elements composed by columns). 114 | case cfg.Graph.Visualization.Legend.RightSide: 115 | legendElement := grid.ColWidthPercWithOpts( 116 | fullPerc, 117 | []container.Option{container.PaddingLeftPercent(paddingHorizontalPerc)}, 118 | grid.Widget(legend)) 119 | 120 | elements = []grid.Element{ 121 | grid.ColWidthPerc(graphHorizontalPerc, graphElement), 122 | grid.ColWidthPerc(legendHorizontalPerc, legendElement), 123 | } 124 | // At the bottom(elements composed by rows). 125 | default: 126 | legendElement := grid.RowHeightPercWithOpts( 127 | fullPerc, 128 | []container.Option{container.PaddingTopPercent(paddingVerticalPerc)}, 129 | grid.Widget(legend)) 130 | 131 | elements = []grid.Element{ 132 | grid.RowHeightPerc(graphVerticalPerc, graphElement), 133 | grid.RowHeightPerc(legendVerticalPerc, legendElement), 134 | } 135 | } 136 | 137 | opts := []container.Option{ 138 | container.Border(linestyle.Light), 139 | container.BorderTitle(cfg.Title), 140 | } 141 | element := grid.RowHeightPercWithOpts(fullPerc, opts, elements...) 142 | 143 | return element 144 | } 145 | 146 | func (g *graph) getElement() grid.Element { 147 | return g.element 148 | } 149 | 150 | func (g *graph) GetWidgetCfg() model.Widget { 151 | return g.cfg 152 | } 153 | 154 | func (g *graph) Sync(series []render.Series) error { 155 | // Reset legend on each sync. 156 | if !g.cfg.Graph.Visualization.Legend.Disable { 157 | g.widgetLegend.Reset() 158 | } 159 | 160 | for _, s := range series { 161 | // We fail all the graph sync if one of the series fail. 162 | err := g.syncSeries(s) 163 | if err != nil { 164 | return err 165 | } 166 | } 167 | return nil 168 | } 169 | 170 | // syncSeries will sync the widgets with one of the series. 171 | func (g *graph) syncSeries(series render.Series) error { 172 | color, err := colorHexToTermdash(series.Color) 173 | if err != nil { 174 | return err 175 | } 176 | 177 | err = g.syncGraph(series, color) 178 | if err != nil { 179 | return err 180 | } 181 | 182 | err = g.syncLegend(series, color) 183 | if err != nil { 184 | return err 185 | } 186 | 187 | return nil 188 | } 189 | 190 | // syncGraph will set one series of metrics on the graph. 191 | func (g *graph) syncGraph(series render.Series, color cell.Color) error { 192 | // Convert to float64 values. 193 | values := make([]float64, len(series.Values)) 194 | for i, value := range series.Values { 195 | // Use NaN as no value for Termdash. 196 | v := math.NaN() 197 | if value != nil { 198 | v = float64(*value) 199 | } 200 | values[i] = v 201 | } 202 | 203 | // Sync widget. 204 | err := g.widgetGraph.Series(series.Label, values, 205 | linechart.SeriesCellOpts(cell.FgColor(color)), 206 | linechart.SeriesXLabels(xLabelsSliceToMap(series.XLabels))) 207 | if err != nil { 208 | return err 209 | } 210 | 211 | return nil 212 | } 213 | 214 | // syncLegend will set the legend if required and with the correct format. 215 | func (g *graph) syncLegend(series render.Series, color cell.Color) error { 216 | legend := "" 217 | switch { 218 | // Disabled. 219 | case g.cfg.Graph.Visualization.Legend.Disable: 220 | return nil 221 | // To the right. 222 | case g.cfg.Graph.Visualization.Legend.RightSide: 223 | legend = fmt.Sprintf("%s %s\n", legendCharacter, series.Label) 224 | // At the bottom. 225 | default: 226 | legend = fmt.Sprintf("%s %s ", legendCharacter, series.Label) 227 | } 228 | 229 | // Write the legend on the widget. 230 | err := g.widgetLegend.Write(legend, text.WriteCellOpts(cell.FgColor(color))) 231 | if err != nil { 232 | return err 233 | } 234 | 235 | return nil 236 | } 237 | 238 | func (g *graph) GetGraphPointQuantity() int { 239 | return g.widgetGraph.ValueCapacity() 240 | } 241 | 242 | func xLabelsSliceToMap(labels []string) map[int]string { 243 | mlabel := map[int]string{} 244 | for i, label := range labels { 245 | mlabel[i] = label 246 | } 247 | return mlabel 248 | } 249 | -------------------------------------------------------------------------------- /internal/view/render/termdash/misc.go: -------------------------------------------------------------------------------- 1 | package termdash 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/lucasb-eyer/go-colorful" 7 | "github.com/mum4k/termdash/cell" 8 | ) 9 | 10 | func colorHexToTermdash(color string) (cell.Color, error) { 11 | c, err := colorful.Hex(color) 12 | if err != nil { 13 | return 0, fmt.Errorf("error getting color: %s", err) 14 | } 15 | 16 | cr, cg, cb := c.RGB255() 17 | return cell.ColorRGB24(int(cr), int(cg), int(cb)), nil 18 | } 19 | -------------------------------------------------------------------------------- /internal/view/render/termdash/singlestat.go: -------------------------------------------------------------------------------- 1 | package termdash 2 | 3 | import ( 4 | "github.com/mum4k/termdash/cell" 5 | "github.com/mum4k/termdash/container" 6 | "github.com/mum4k/termdash/container/grid" 7 | "github.com/mum4k/termdash/linestyle" 8 | "github.com/mum4k/termdash/widgets/segmentdisplay" 9 | 10 | "github.com/slok/grafterm/internal/model" 11 | ) 12 | 13 | // singlestat satisfies render.SinglestatWidget interface. 14 | type singlestat struct { 15 | cfg model.Widget 16 | color cell.Color 17 | 18 | widget *segmentdisplay.SegmentDisplay 19 | element grid.Element 20 | } 21 | 22 | func newSinglestat(cfg model.Widget) (*singlestat, error) { 23 | // Create the widget. 24 | sd, err := segmentdisplay.New() 25 | if err != nil { 26 | return nil, err 27 | } 28 | 29 | // Create the element using the new widget. 30 | element := grid.Widget(sd, 31 | container.Border(linestyle.Light), 32 | container.BorderTitle(cfg.Title), 33 | ) 34 | 35 | return &singlestat{ 36 | widget: sd, 37 | color: cell.ColorWhite, 38 | cfg: cfg, 39 | element: element, 40 | }, nil 41 | } 42 | 43 | func (s *singlestat) getElement() grid.Element { 44 | return s.element 45 | } 46 | 47 | func (s *singlestat) GetWidgetCfg() model.Widget { 48 | return s.cfg 49 | } 50 | 51 | func (s *singlestat) Sync(text string) error { 52 | chunks := []*segmentdisplay.TextChunk{ 53 | segmentdisplay.NewChunk( 54 | text, 55 | segmentdisplay.WriteCellOpts(cell.FgColor(s.color))), 56 | } 57 | err := s.widget.Write(chunks) 58 | if err != nil { 59 | return err 60 | } 61 | return nil 62 | } 63 | 64 | func (s *singlestat) SetColor(hexColor string) error { 65 | color, err := colorHexToTermdash(hexColor) 66 | if err != nil { 67 | return err 68 | } 69 | s.color = color 70 | return nil 71 | } 72 | -------------------------------------------------------------------------------- /internal/view/render/termdash/view.go: -------------------------------------------------------------------------------- 1 | package termdash 2 | 3 | import ( 4 | "context" 5 | "time" 6 | 7 | "github.com/mum4k/termdash" 8 | "github.com/mum4k/termdash/container" 9 | "github.com/mum4k/termdash/container/grid" 10 | "github.com/mum4k/termdash/keyboard" 11 | "github.com/mum4k/termdash/terminal/termbox" 12 | "github.com/mum4k/termdash/terminal/terminalapi" 13 | 14 | "github.com/slok/grafterm/internal/model" 15 | "github.com/slok/grafterm/internal/service/log" 16 | graftermgrid "github.com/slok/grafterm/internal/view/grid" 17 | "github.com/slok/grafterm/internal/view/render" 18 | ) 19 | 20 | const ( 21 | rootID = "root" 22 | redrawInterval = 250 * time.Millisecond 23 | ) 24 | 25 | // elementer is an internal interface that all widgets from the termdash 26 | // render engine implementation need to implement, this way the widgets 27 | // can create subelements by their own and the `termDashboard` does not 28 | // to be aware, so a widget can be composed of 2 widgets under the hoods. 29 | type elementer interface { 30 | getElement() grid.Element 31 | } 32 | 33 | // View is what renders the metrics. 34 | type termDashboard struct { 35 | widgets []render.Widget 36 | logger log.Logger 37 | cancel func() 38 | 39 | // Term fields. 40 | terminal *termbox.Terminal 41 | } 42 | 43 | // NewTermDashboard returns a new terminal view, it accepts a cancel function that will 44 | // be called when the terminal rendered quit function is called. This is required because 45 | // the events now are captured by the rendered terminal. 46 | func NewTermDashboard(cancel func(), logger log.Logger) (render.Renderer, error) { 47 | t, err := termbox.New() 48 | if err != nil { 49 | return nil, err 50 | } 51 | 52 | return &termDashboard{ 53 | cancel: cancel, 54 | terminal: t, 55 | logger: logger, 56 | }, nil 57 | } 58 | 59 | func (t *termDashboard) Close() { 60 | t.terminal.Close() 61 | } 62 | 63 | // Run will run the view, its' a blocker. 64 | func (t *termDashboard) LoadDashboard(ctx context.Context, gr *graftermgrid.Grid) ([]render.Widget, error) { 65 | // Create main view (root). 66 | c, err := container.New(t.terminal, container.ID(rootID)) 67 | if err != nil { 68 | return nil, err 69 | } 70 | 71 | // Get the layout from the grid. 72 | gridOpts, err := t.gridLayout(gr) 73 | if err != nil { 74 | return []render.Widget{}, err 75 | } 76 | 77 | err = c.Update(rootID, gridOpts...) 78 | if err != nil { 79 | return []render.Widget{}, err 80 | } 81 | 82 | go func() { 83 | quitter := func(k *terminalapi.Keyboard) { 84 | if k.Key == 'q' || k.Key == 'Q' || k.Key == keyboard.KeyEsc { 85 | t.cancel() 86 | } 87 | } 88 | if err := termdash.Run(ctx, t.terminal, c, termdash.KeyboardSubscriber(quitter), termdash.RedrawInterval(redrawInterval)); err != nil { 89 | t.logger.Errorf("error running termdash terminal: %s", err) 90 | // TODO(slok): exit on error. 91 | } 92 | }() 93 | 94 | return t.widgets, nil 95 | } 96 | 97 | func (t *termDashboard) gridLayout(gr *graftermgrid.Grid) ([]container.Option, error) { 98 | builder := grid.New() 99 | 100 | // Create the rendering widgets. 101 | rowsElements := [][]grid.Element{} 102 | for _, row := range gr.Rows { 103 | rowElements := []grid.Element{} 104 | totalFilled := 0 105 | for _, rowElement := range row.Elements { 106 | cfg := rowElement.Widget 107 | 108 | // New widget. 109 | var element grid.Element 110 | if !rowElement.Empty { 111 | widget, err := t.newWidget(cfg) 112 | if err != nil { 113 | t.logger.Errorf("error creating widget: %s", err) 114 | continue 115 | } 116 | // Add widget to the tracked widgets so the app can control them. 117 | t.widgets = append(t.widgets, widget) 118 | 119 | // Get the grid.Element from our widget and place on the grid. 120 | element = widget.(elementer).getElement() 121 | } 122 | 123 | // Fix the size on the last element. 124 | // Termdash does not allow a column greater than 99, we have 125 | // used percents (0-100), so we remove a 1% from the last element. 126 | // Ugly but makes easy to work with % and is difficult for the 127 | // eye to notice of the 1%. 128 | elementPerc := rowElement.PercentSize 129 | if totalFilled+elementPerc >= 100 { 130 | elementPerc-- 131 | } 132 | totalFilled += elementPerc 133 | 134 | // Place it on the row. 135 | element = grid.ColWidthPerc(elementPerc, element) 136 | rowElements = append(rowElements, element) 137 | } 138 | rowsElements = append(rowsElements, rowElements) 139 | } 140 | 141 | // Add rows to grid. 142 | var gridElements []grid.Element 143 | totalFilled := 0 144 | for i, row := range gr.Rows { 145 | rowElements := rowsElements[i] 146 | rowPerc := row.PercentSize 147 | // Fix the size on the last element. 148 | // Termdash does not allow a rows greater than 99, we have 149 | // used percents (0-100), so we remove a 1% from the last element. 150 | // Ugly but makes easy to work with % and is difficult for the 151 | // eye to notice of the 1%. 152 | if totalFilled+rowPerc >= 100 { 153 | rowPerc-- 154 | } 155 | totalFilled += rowPerc 156 | 157 | // Place the row. 158 | rowElement := grid.RowHeightPerc(rowPerc, rowElements...) 159 | gridElements = append(gridElements, rowElement) 160 | } 161 | 162 | // Add rows. 163 | builder.Add(gridElements...) 164 | 165 | // Get the layout from the grid. 166 | return builder.Build() 167 | } 168 | 169 | func (t *termDashboard) newWidget(widgetcfg model.Widget) (render.Widget, error) { 170 | var widget render.Widget 171 | var err error 172 | 173 | switch { 174 | case widgetcfg.Gauge != nil: 175 | widget, err = newGauge(widgetcfg) 176 | case widgetcfg.Singlestat != nil: 177 | widget, err = newSinglestat(widgetcfg) 178 | case widgetcfg.Graph != nil: 179 | widget, err = newGraph(widgetcfg) 180 | } 181 | 182 | return widget, err 183 | } 184 | -------------------------------------------------------------------------------- /internal/view/sync/sync.go: -------------------------------------------------------------------------------- 1 | package sync 2 | 3 | import ( 4 | "context" 5 | "time" 6 | 7 | "github.com/slok/grafterm/internal/view/template" 8 | ) 9 | 10 | // Request is a sync iteration request, similar approach to an HTTP request. 11 | type Request struct { 12 | TimeRangeStart time.Time 13 | TimeRangeEnd time.Time 14 | TemplateData template.Data 15 | } 16 | 17 | // Syncer is a component that will be synced with the app iteration sync, 18 | // depending on the page it could end rendered in the screen. 19 | type Syncer interface { 20 | Sync(ctx context.Context, r *Request) error 21 | } 22 | -------------------------------------------------------------------------------- /internal/view/template/template.go: -------------------------------------------------------------------------------- 1 | package template 2 | 3 | import ( 4 | "bytes" 5 | "text/template" 6 | ) 7 | 8 | // Data is the object stores the data to be templated in queries, labels, widgets, 9 | // titles... 10 | type Data map[string]interface{} 11 | 12 | // WithData returns the old data + new data in a new Data instance 13 | func (d Data) WithData(data map[string]interface{}) Data { 14 | if d == nil { 15 | d = map[string]interface{}{} 16 | } 17 | 18 | dc := d.deepCopy() 19 | for k, v := range data { 20 | dc[k] = v 21 | } 22 | return dc 23 | } 24 | 25 | func (d Data) deepCopy() Data { 26 | // Copy vars. 27 | dc := map[string]interface{}{} 28 | for k, v := range d { 29 | dc[k] = v 30 | } 31 | 32 | return dc 33 | } 34 | 35 | // Render will render the template using the object data. 36 | func (d Data) Render(tpl string) string { 37 | if d == nil { 38 | d = map[string]interface{}{} 39 | } 40 | 41 | tmpl, err := template.New("").Parse(tpl) 42 | if err != nil { 43 | return "" 44 | } 45 | 46 | var b bytes.Buffer 47 | tmpl.Execute(&b, d) 48 | 49 | return b.String() 50 | } 51 | -------------------------------------------------------------------------------- /internal/view/template/template_test.go: -------------------------------------------------------------------------------- 1 | package template_test 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/stretchr/testify/assert" 7 | 8 | "github.com/slok/grafterm/internal/view/template" 9 | ) 10 | 11 | func newData() template.Data { 12 | return map[string]interface{}{ 13 | "__interval": "2m", 14 | "__range": "10m", 15 | "__start": "2019-04-19T08:38:59+02:00", 16 | "__end": "2019-04-19T10:38:59+02:00", 17 | "custom": "test", 18 | } 19 | } 20 | 21 | func TestDataRender(t *testing.T) { 22 | tests := []struct { 23 | name string 24 | data template.Data 25 | tpl string 26 | exp string 27 | }{ 28 | { 29 | name: "template data", 30 | data: newData(), 31 | tpl: "test: [{{ .__interval }}] {{ .custom }}", 32 | exp: "test: [2m] test", 33 | }, 34 | } 35 | 36 | for _, test := range tests { 37 | t.Run(test.name, func(t *testing.T) { 38 | got := test.data.Render(test.tpl) 39 | assert.Equal(t, test.exp, got) 40 | }) 41 | } 42 | } 43 | 44 | func TestDataCopy(t *testing.T) { 45 | tests := []struct { 46 | name string 47 | data template.Data 48 | transform func(data template.Data) template.Data 49 | expTransformed template.Data 50 | expOriginal template.Data 51 | }{ 52 | { 53 | name: "Variables", 54 | data: newData(), 55 | transform: func(data template.Data) template.Data { 56 | return data.WithData(map[string]interface{}{ 57 | "custom": "customized-on-test", 58 | "newkey": "newVar", 59 | }) 60 | }, 61 | expOriginal: newData(), 62 | expTransformed: map[string]interface{}{ 63 | "__interval": "2m", 64 | "__range": "10m", 65 | "__start": "2019-04-19T08:38:59+02:00", 66 | "__end": "2019-04-19T10:38:59+02:00", 67 | "custom": "customized-on-test", 68 | "newkey": "newVar", 69 | }, 70 | }, 71 | } 72 | 73 | for _, test := range tests { 74 | t.Run(test.name, func(t *testing.T) { 75 | assert := assert.New(t) 76 | 77 | got := test.transform(test.data) 78 | assert.Equal(test.expTransformed, got) 79 | assert.Equal(test.data, test.expOriginal) 80 | }) 81 | } 82 | } 83 | -------------------------------------------------------------------------------- /internal/view/variable/const.go: -------------------------------------------------------------------------------- 1 | package variable 2 | 3 | import ( 4 | "github.com/slok/grafterm/internal/model" 5 | ) 6 | 7 | // ConstVariabler is used to manager contant variables in the application. 8 | type ConstVariabler struct { 9 | cfg model.Variable 10 | } 11 | 12 | // Scope Satisfies Variabler interface. 13 | func (c ConstVariabler) Scope() Scope { 14 | return ScopeDashboard 15 | } 16 | 17 | // IsRepeatable Satisfies Variabler interface. 18 | func (c ConstVariabler) IsRepeatable() bool { 19 | return false 20 | } 21 | 22 | // GetValue Satisfies Variabler interface. 23 | func (c ConstVariabler) GetValue() string { 24 | return c.cfg.Constant.Value 25 | } 26 | -------------------------------------------------------------------------------- /internal/view/variable/interval.go: -------------------------------------------------------------------------------- 1 | package variable 2 | 3 | import ( 4 | "time" 5 | 6 | "github.com/slok/grafterm/internal/model" 7 | "github.com/slok/grafterm/internal/service/unit" 8 | ) 9 | 10 | type intervalVariabler struct { 11 | intervalStr string 12 | cfg model.Variable 13 | } 14 | 15 | // NewIntervalVariabler returns a new variabler that knows how to set 16 | // variables based on the interval, at this moment it only returns 17 | // autoinverval so is not repeatable. 18 | // TODO(slok): make repeatable and allow selecting multiple intervals. 19 | func NewIntervalVariabler(timeRange time.Duration, cfg model.Variable) Variabler { 20 | // Set default auto interval if not 0. 21 | steps := 50 22 | if cfg.Interval.Steps != 0 { 23 | steps = cfg.Interval.Steps 24 | } 25 | dur := unit.NearestDurationFromSteps(timeRange, steps) 26 | durStr := unit.DurationToSimpleString(dur) 27 | 28 | return &intervalVariabler{ 29 | cfg: cfg, 30 | intervalStr: durStr, 31 | } 32 | } 33 | 34 | func (i intervalVariabler) Scope() Scope { 35 | return ScopeDashboard 36 | } 37 | 38 | func (i intervalVariabler) IsRepeatable() bool { 39 | return false 40 | } 41 | 42 | func (i intervalVariabler) GetValue() string { 43 | return i.intervalStr 44 | } 45 | -------------------------------------------------------------------------------- /internal/view/variable/variable.go: -------------------------------------------------------------------------------- 1 | package variable 2 | 3 | import ( 4 | "time" 5 | 6 | "github.com/slok/grafterm/internal/model" 7 | ) 8 | 9 | // Scope is the scope of the variable 10 | type Scope int 11 | 12 | const ( 13 | // ScopeDashboard refers to a variable that is used on dashboard load. 14 | ScopeDashboard Scope = iota 15 | // ScopeSync refers to a variable that is used on every sync. 16 | ScopeSync 17 | ) 18 | 19 | // Variabler represents a variable kind that knows how to get variables. 20 | type Variabler interface { 21 | Scope() Scope 22 | // IsRepeatable will return true If the variable is repeatable. 23 | IsRepeatable() bool 24 | // GetValue returns the value of the variable. 25 | // If is a repeatable variable internally it knows 26 | // how to return the value in one string (e.g `staging|prod|dev`). 27 | GetValue() string 28 | } 29 | 30 | // Repeatable is a variabler that can be repeated. 31 | type Repeatable interface { 32 | Variabler 33 | Select(variableID ...string) 34 | Deselect(variableID ...string) 35 | GetValues() []string 36 | GetAllValues() []string 37 | } 38 | 39 | // FactoryConfig is the configuration required by the variabler factory. 40 | type FactoryConfig struct { 41 | TimeRange time.Duration 42 | Dashboard model.Dashboard 43 | } 44 | 45 | // NewVariablers is a factory that knows how to create variablers. 46 | func NewVariablers(cfg FactoryConfig) (map[string]Variabler, error) { 47 | variablers := map[string]Variabler{} 48 | for _, v := range cfg.Dashboard.Variables { 49 | switch { 50 | case v.Constant != nil: 51 | variablers[v.Name] = &ConstVariabler{cfg: v} 52 | case v.Interval != nil: 53 | variablers[v.Name] = NewIntervalVariabler(cfg.TimeRange, v) 54 | } 55 | } 56 | 57 | return variablers, nil 58 | } 59 | --------------------------------------------------------------------------------