├── .dockerignore ├── .gitignore ├── .travis.yml ├── Dockerfile ├── LICENSE ├── MANIFEST.in ├── Makefile ├── README.md ├── docker-compose.yml ├── docker-requirements.txt ├── grafcli.conf.example ├── grafcli ├── __init__.py ├── args.py ├── commands.py ├── completer.py ├── core.py ├── documents.py ├── exceptions.py ├── resources │ ├── __init__.py │ ├── common.py │ ├── local.py │ ├── remote.py │ ├── resources.py │ └── templates.py ├── storage │ ├── __init__.py │ ├── api.py │ ├── elastic.py │ ├── sql.py │ ├── storage.py │ └── system.py └── utils.py ├── requirements.txt ├── scripts └── grafcli ├── setup.py └── tests ├── __init__.py ├── integration ├── Dockerfile ├── entrypoint.sh ├── example-dashboard.json ├── helpers.bash ├── run_tests.sh └── wait-for-it.sh ├── test_documents.py ├── test_resources.py └── test_resources_common.py /.dockerignore: -------------------------------------------------------------------------------- 1 | # Created by .ignore support plugin (hsz.mobi) 2 | .idea 3 | 4 | ### Python template 5 | # Byte-compiled / optimized / DLL files 6 | __pycache__/ 7 | *.py[cod] 8 | *$py.class 9 | 10 | # Installer logs 11 | pip-log.txt 12 | pip-delete-this-directory.txt -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.pyc 2 | *.pyo 3 | 4 | *.swp 5 | *.swo 6 | 7 | .idea/ 8 | *.iml 9 | 10 | grafcli/version.py 11 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: python 2 | python: 3 | - "3.5" 4 | 5 | services: 6 | - docker 7 | 8 | install: 9 | - docker build -t grafcli-dev -f tests/integration/Dockerfile . 10 | 11 | script: 12 | - docker run -v $TRAVIS_BUILD_DIR:/app --entrypoint make grafcli-dev unittests 13 | - make integration 14 | 15 | deploy: 16 | provider: pypi 17 | user: m110 18 | password: 19 | secure: DwxMfZ/m6BljD2HbaSGdoWS0LUAhXlIMPcHrWsqgIK/raKUrxBw0DuFSvj1TnovPcKbUqUuBpNpezAL2zdUOLsF4ecnX7ZscqEWhE5qIAu2w1sFL7oCivFm92JSHBYwv4mabMqYU/lKFFjQ2IIOwBEQhM7Wmk9rw2rL0vD/U6tpEHD/6uYUPKOGNutukEobPdQRce5vISKCQI4e5ZGQUx9BHB1UCLRtClb7iteHLuyDF5cpRbnzES3ASpmnch0nm4tmdEbQI53LiIafZaOazUz7rfs9XbGcVCwLRsoE01wGma0E4Ra1r2VzV+rSHvoXYTYQifY/7H6giJaOJtA6BpV7bW1jUTqdFcEa7136uxQqDvQP+sKC3/FLVWlVIwpBODOzEkxpHe2x+QvHmVR99nbXJ5cxnChtk+ixU+hxwLO/VhDuAfJZDHbu/FS8GP2aPnQmBIKsWW3QZtp0pA5iuOacMpjLqorw0BRX/0BJ58iIlzpEL1O1uyJ4bV014ssX34pus6yNqtb4QEktWIlpf0/P9P8hEf1kQdu9fwMnba4LBqJjmQ4PCjHIqKCOhomfkUW/Avrw9y9LlCTrCuLlluFLItDcaMB5p/3Tp5p/UXy0f8MakoJZU0aV4uszje7yWkgydR4GFUsDQnRcUvTP4TP/A8SD7Bvyc3nx0396LBr4= 20 | on: 21 | tags: true 22 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM python:3.7-alpine 2 | MAINTAINER f1yegor 3 | 4 | ADD docker-requirements.txt /app/requirements.txt 5 | ADD grafcli.conf.example /etc/grafcli/grafcli.conf 6 | 7 | RUN pip3 install --upgrade pip 8 | RUN pip3 install -r /app/requirements.txt 9 | RUN pip3 install grafcli 10 | 11 | VOLUME ["/etc/grafcli/"] 12 | VOLUME ["/db"] 13 | 14 | ENTRYPOINT ["grafcli"] 15 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2015 m1_10sz 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | 23 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include grafcli.conf.example 2 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | all: 2 | 3 | unittests: 4 | python -m unittest discover 5 | 6 | integration: 7 | docker-compose up --exit-code-from grafcli-dev --abort-on-container-exit --force-recreate 8 | 9 | run_integration: 10 | ./tests/integration/run_tests.sh 11 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # grafcli 2 | 3 | [![Build Status](https://travis-ci.org/m110/grafcli.svg?branch=master)](https://travis-ci.org/m110/grafcli) 4 | 5 | [Grafana](http://grafana.org) CLI for fast and easy dashboards management. 6 | 7 | **Please note** that grafcli has been tested for a while, but it still can have some minor defects. All help by contributions and suggestions is welcomed! Remember to back up your dashboards first. 8 | 9 | Also note that grafcli was created when grafana itself lacked some features, like exports or API. Although it's still nice to have some of those in form of a CLI application. 10 | 11 | Credit goes to [b3niup](https://github.com/b3niup) for the original idea! 12 | 13 | ## Featuring: 14 | 15 | * Dashboards backup and restore. 16 | * Easy rows and panels moving/copying between dashboards. 17 | * Editing dashboards/rows/panels in-place. 18 | * Templates of dashboards, rows and panels. 19 | * File export/import. 20 | * Interactive CLI with completions support. 21 | * Compatibility across older Grafana versions. 22 | * ...and more! 23 | 24 | ## Why? 25 | 26 | * Lets you easily manage your dashboards using just your keyboard. 27 | * Provides convenient way to backup dashboards and restore them. 28 | * Can be used by any shell script with even more logic added. 29 | 30 | ## How? 31 | 32 | Grafcli connects to Grafana HTTP API or directly to one of the backends (Elastic, SQLite, MySQL, PostgreSQL) and modifies the dashboards. This is all hidden behind an interface you already know well, similar to *nix filesystem. 33 | 34 | ## Requirements 35 | 36 | * [Python 3](http://python.org) 37 | * [climb](https://github.com/m110/climb) 38 | * [pygments](http://pygments.org/) 39 | * Depending on which storage backends you use, you need to install as well: 40 | * [requests](http://docs.python-requests.org/en/master/) 41 | * `pip install requests` 42 | * [elasticsearch-py](http://github.com/elastic/elasticsearch-py) 43 | * `pip install elasticsearch` 44 | * [psycopg2](http://initd.org/psycopg/) 45 | * `pip install psycopg2` 46 | * [MySQL Connector/Python](http://dev.mysql.com/downloads/connector/python/) 47 | * `pip install mysql-connector-python-rf` 48 | 49 | ## Installation 50 | 51 | ``` 52 | pip3 install grafcli 53 | ``` 54 | 55 | or get the source and run: 56 | 57 | ``` 58 | python3 setup.py install 59 | ``` 60 | 61 | Then define your hosts in the config file (see below for details). 62 | ``` 63 | cp /etc/grafcli/grafcli.conf.example /etc/grafcli/grafcli.conf 64 | ``` 65 | 66 | You will need at least one of the backend libraries listed above (except sqlite3, which comes with Python). 67 | 68 | ## TODO 69 | 70 | * Improve confirmation prompt. 71 | * Improve completions. 72 | * Implement asterisk (*) handling. 73 | 74 | # Usage 75 | 76 | ## Navigation 77 | 78 | Use `cd` and `ls` commands to list nodes and move around. 79 | 80 | ``` 81 | [/]> cd templates 82 | [/templates]> ls 83 | dashboards 84 | rows 85 | panels 86 | [/templates]> ls dashboards 87 | example_dashboard 88 | another_dashboard 89 | [/templates]> ls dashboards/example_dashboard 90 | 1-Example-Row 91 | 2-Another-Row 92 | 3-Yet-Another-Row 93 | [/templates]> ls dashboards/example_dashboard/1-Example-Row 94 | 1-Example-Panel 95 | 2-Another-Panel 96 | ``` 97 | 98 | ## Directories 99 | 100 | In the root directory, you will find three basic directories: 101 | 102 | * `backups` - for storing backups of your dashboards (surprised?). 103 | * `remote` - which lets you access remote hosts. 104 | * `templates` - that contains templates of dashboards, rows and panels. 105 | 106 | ## Management 107 | 108 | Most of the arguments here are paths to a dashboard, row or panel. 109 | 110 | * `cat ` - display JSON of given element. 111 | * `$EDITOR ` - edit the JSON of given element in-place and update it afterwards. Editor name can be set in the config file. 112 | * `merge ` - merge documents together. Merge tool can be set in the config file. 113 | * `cp ` - copies one element to another. Can be used to copy whole dashboards, rows or single panels. 114 | * `mv ` - the same as `cp`, but moves (renames) the source. 115 | * `rm ` - removes the element. 116 | * `template ` - saves element as template. 117 | * `backup ` - saves backup of all dashboards from remote host as .tgz archive. 118 | * `restore ` - restores saved backup. 119 | * `export ` - saves the JSON-encoded element to file. 120 | * `import ` - loads the JSON-encoded element from file. 121 | * `pos ` - change position of row in a dashboard or panel in a row. 122 | 123 | # Configuration 124 | 125 | Grafcli will attempt to read `./grafcli.conf`, `~/.grafcli.conf` and `/etc/grafcli/grafcli.conf` in that order. 126 | 127 | Here is the configuration file explained. 128 | ```ini 129 | [grafcli] 130 | # Your favorite editor - this name will act as a command! 131 | editor = vim 132 | # Executable used as merge tool. Paths will be passed as arguments. 133 | mergetool = vimdiff 134 | # Commands history file. Leave empty to disable. 135 | history = ~/.grafcli_history 136 | # Additional verbosity, if needed. 137 | verbose = off 138 | # Answer 'yes' to all overwrite prompts. 139 | force = on 140 | 141 | [resources] 142 | # Directory where all local data will be stored (including backups). 143 | data-dir = ~/.grafcli 144 | 145 | # List of remote Grafana hosts. 146 | # The key names do not matter, as long as matching section exists. 147 | # Set the value to off to disable the host. 148 | [hosts] 149 | host.example.com = on 150 | 151 | [host.example.com] 152 | type = elastic 153 | # In case of more hosts, use comma-separated values. 154 | hosts = host1.example.com,host2.example.com 155 | port = 9200 156 | index = grafana-dash 157 | ssl = off 158 | # HTTP user and password, if any. 159 | user = 160 | password = 161 | ``` 162 | 163 | You can use other backends as well. 164 | 165 | HTTP API: 166 | ```ini 167 | [api.example.com] 168 | type = api 169 | url = http://localhost:3000/api 170 | # Use either user and password or just the token 171 | user = 172 | password = 173 | # token can also be stored in the GRAFANA_API_TOKEN environment variable 174 | token = 175 | ``` 176 | 177 | MySQL: 178 | ```ini 179 | [mysql.example.com] 180 | type = mysql 181 | host = mysql.example.com 182 | port = 3306 183 | user = grafana 184 | password = 185 | database = grafana 186 | ``` 187 | 188 | PostgreSQL: 189 | ```ini 190 | [postgresql.example.com] 191 | type = postgresql 192 | host = postgresql.example.com 193 | port = 5432 194 | user = grafana 195 | password = 196 | database = grafana 197 | ``` 198 | 199 | SQLite: 200 | ```ini 201 | [sqlite.example.com] 202 | type = sqlite 203 | path = /opt/grafana/data/grafana.db 204 | ``` 205 | 206 | # Tips 207 | 208 | ## Batch mode 209 | 210 | Any command can be passed directly as arguments to grafcli, which will exit just after executing it. If you run it without arguments, you will get to interactive mode (preferable choice in most cases). 211 | 212 | Batch mode: 213 | ```bash 214 | $ grafcli ls remote 215 | host.example.com 216 | another.example.com 217 | $ 218 | ``` 219 | 220 | Interactive mode: 221 | ```bash 222 | $ grafcli 223 | [/]> ls remote 224 | host.example.com 225 | another.example.com 226 | [/]> 227 | ``` 228 | 229 | ## Short names 230 | 231 | All rows and panels names start with a number and it may seem that typing all that stuff gets boring soon. There are completions available (triggered by the `TAB` key) to help you with that. 232 | 233 | It is enough to provide just the number of the row or panel. So instead of typing: 234 | ``` 235 | [/]> cp /templates/dashboards/dashboard/1-Top-Row/1-Top-Panel /remote/example/dashboard/1-Top-Row 236 | ``` 237 | You can just do: 238 | ``` 239 | [/]> cp /templates/dashboards/dashboard/1/1 /remote/example/dashboard/1 240 | ``` 241 | 242 | But then again, TAB-completions make it easy enough to type full names. 243 | 244 | # Examples 245 | 246 | Some of the common operations. 247 | 248 | * Store dashboard as template (saved to `templates/dashboards/main_dashboard`): 249 | 250 | ``` 251 | [/]> template remote/example/main_dashboard 252 | ``` 253 | 254 | * Create the exact copy of dashboard's template: 255 | 256 | ``` 257 | [/templates/dashboards]> cp main_dashboard new_dashboard 258 | ``` 259 | 260 | * Update remote dashboard with local template: 261 | 262 | ``` 263 | [/]> cp templates/dashboards/new_dashboard remote/main_dashboard 264 | ``` 265 | 266 | * Move row from one dashboard to another (adds one more row to destination dashboard): 267 | 268 | ``` 269 | [/templates/dashboards]> cp main_dashboard/1-Top-Row new_dashboard 270 | ``` 271 | 272 | * Move row from one dashboard to another and replace existing row: 273 | 274 | ``` 275 | [/templates/dashboards]> cp main_dashboard/1-Top-Row new_dashboard/2-Some-Existing-Row 276 | ``` 277 | 278 | * Copy panel between rows (add one more panel to destination row). 279 | 280 | ``` 281 | [/templates/dashboards]> cp main_dashboard/1-Top-Row/1-Top-Panel new_dashboard/1-Top-Row 282 | ``` 283 | 284 | * Copy panel between rows and replace existing panel. 285 | 286 | ``` 287 | [/templates/dashboards]> cp main_dashboard/1-Top-Row/1-Top-Panel new_dashboard/1-Top-Row/2-Second-Panel 288 | ``` 289 | 290 | * Backup all dashboards. 291 | 292 | ``` 293 | [/] backup remote/example ~/backup.tgz 294 | ``` 295 | 296 | * Restore a backup. 297 | 298 | ``` 299 | [/] restore ~/backup.tgz remote/example 300 | ``` 301 | 302 | * Import dashboard from a file. 303 | 304 | ``` 305 | [/]> import ~/dashboard.json templates/dashboards/dashboard 306 | ``` 307 | 308 | * Export dashboard to a file. 309 | 310 | ``` 311 | [/]> export templates/dashboards/dashboard ~/dashboard.json 312 | ``` 313 | 314 | # Development 315 | 316 | ## Running tests 317 | 318 | Unit tests can be run by: 319 | 320 | ``` 321 | make unittests 322 | ``` 323 | 324 | To run integration tests: 325 | 326 | ``` 327 | make integration 328 | ``` 329 | 330 | The first run of integration tests can take a bit longer, since images will be built and downloaded. 331 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: '3' 2 | services: 3 | grafana: 4 | image: grafana/grafana 5 | networks: 6 | - shared 7 | grafcli-dev: 8 | build: 9 | context: . 10 | dockerfile: tests/integration/Dockerfile 11 | networks: 12 | - shared 13 | volumes: 14 | - .:/app 15 | command: run_integration 16 | networks: 17 | shared: 18 | driver: bridge 19 | -------------------------------------------------------------------------------- /docker-requirements.txt: -------------------------------------------------------------------------------- 1 | climb 2 | pygments 3 | elasticsearch 4 | #psycopg2 5 | mysql-connector-python-rf 6 | -------------------------------------------------------------------------------- /grafcli.conf.example: -------------------------------------------------------------------------------- 1 | [grafcli] 2 | editor = vim 3 | mergetool = vimdiff 4 | history = ~/.grafcli_history 5 | verbose = off 6 | force = on 7 | colorize = on 8 | 9 | [resources] 10 | data-dir = ~/.grafcli 11 | 12 | [hosts] 13 | localhost = on 14 | 15 | [localhost] 16 | type = api 17 | url = http://grafana:3000/api 18 | user = admin 19 | password = admin 20 | -------------------------------------------------------------------------------- /grafcli/__init__.py: -------------------------------------------------------------------------------- 1 | from .core import GrafCLI 2 | -------------------------------------------------------------------------------- /grafcli/args.py: -------------------------------------------------------------------------------- 1 | from climb.args import Args 2 | from climb.config import config 3 | 4 | 5 | class GrafArgs(Args): 6 | 7 | def _load_commands(self): 8 | ls = self._add_command("ls", "list resources") 9 | ls.add_argument("path", nargs="?", default=None, help="resource path (defaults to current)") 10 | 11 | cd = self._add_command("cd", "set current path to resource") 12 | cd.add_argument("path", nargs="?", default=None, help="resource path (defaults to /)") 13 | 14 | cat = self._add_command("cat", "display resource's content") 15 | cat.add_argument("path", nargs="?", help="path of resource to be displayed") 16 | 17 | cp = self._add_command("cp", "copy resource") 18 | cp.add_argument("-m", action="store_true", default=False, help="match slug name and update if exists", dest="match_slug") 19 | cp.add_argument("source", nargs="*") 20 | cp.add_argument("destination", nargs="?", default=None) 21 | 22 | mv = self._add_command("mv", "move (rename) resource") 23 | mv.add_argument("-m", action="store_true", default=False, help="match slug name and update if exists", dest="match_slug") 24 | mv.add_argument("source", nargs="*") 25 | mv.add_argument("destination", nargs="?", default=None) 26 | 27 | rm = self._add_command("rm", "remove resources") 28 | rm.add_argument("path", nargs="?", default=None, help="resource path") 29 | 30 | template = self._add_command("template", "save resource as a template") 31 | template.add_argument("path", nargs="?", default=None, help="resource path") 32 | 33 | editor = self._add_command(config['grafcli']['editor'], "edit resource's content in best editor possible") 34 | editor.add_argument("path", nargs="?", help="path of resource to be edited") 35 | editor.set_defaults(command='editor') 36 | 37 | merge = self._add_command("merge", "merge together two or more resources") 38 | merge.add_argument("paths", nargs="*", help="paths of resources to be merged") 39 | 40 | pos = self._add_command("pos", "set position of row in a dashboard or panel in a row") 41 | pos.add_argument("path", nargs="?", help="path of row or panel") 42 | pos.add_argument("position", nargs="?", help="absolute or relative position to be set") 43 | 44 | backup = self._add_command("backup", "backup all dashboards from remote host") 45 | backup.add_argument("path", nargs="?", default=None, help="remote host path") 46 | backup.add_argument("system_path", nargs="?", default=None, help="system path for .tgz file") 47 | 48 | restore = self._add_command("restore", "restore saved backup") 49 | restore.add_argument("system_path", nargs="?", default=None, help="system path for .tgz file") 50 | restore.add_argument("path", nargs="?", default=None, help="remote host path") 51 | 52 | file_export = self._add_command("export", "export resource to file") 53 | file_export.add_argument("path", nargs="?", default=None, help="resource path") 54 | file_export.add_argument("system_path", nargs="?", default=None, help="system path") 55 | file_export.set_defaults(command='file_export') 56 | 57 | file_import = self._add_command("import", "import resource from file") 58 | file_import.add_argument("-m", action="store_true", default=False, help="match slug name and update if exists", dest="match_slug") 59 | file_import.add_argument("system_path", nargs="?", default=None, help="system path") 60 | file_import.add_argument("path", nargs="?", default=None, help="resource path") 61 | file_import.set_defaults(command='file_import') 62 | -------------------------------------------------------------------------------- /grafcli/commands.py: -------------------------------------------------------------------------------- 1 | import os 2 | import re 3 | import json 4 | import shutil 5 | import tarfile 6 | import tempfile 7 | from climb.config import config 8 | from climb.commands import Commands, command, completers 9 | from climb.exceptions import CLIException 10 | from climb.paths import format_path, split_path, ROOT_PATH 11 | 12 | from grafcli.documents import Document, Dashboard, Row, Panel 13 | from grafcli.exceptions import CommandCancelled 14 | from grafcli.resources import Resources 15 | from grafcli.storage.system import to_file_format, from_file_format 16 | from grafcli.utils import json_pretty 17 | 18 | 19 | class GrafCommands(Commands): 20 | 21 | def __init__(self, cli): 22 | super().__init__(cli) 23 | self._resources = Resources() 24 | 25 | @command 26 | @completers('path') 27 | def ls(self, path=None): 28 | path = format_path(self._cli.current_path, path) 29 | 30 | result = self._resources.list(path) 31 | 32 | return "\n".join(sorted(result)) 33 | 34 | @command 35 | @completers('path') 36 | def cd(self, path=None): 37 | path = format_path(self._cli.current_path, path, default=ROOT_PATH) 38 | 39 | # No exception means correct path 40 | self._resources.list(path) 41 | self._cli.set_current_path(path) 42 | 43 | @command 44 | @completers('path') 45 | def cat(self, path): 46 | path = format_path(self._cli.current_path, path) 47 | 48 | document = self._resources.get(path) 49 | return json_pretty(document.source, colorize=config['grafcli'].getboolean('colorize')) 50 | 51 | @command 52 | @completers('path') 53 | def cp(self, source, destination, match_slug=False): 54 | if len(source) < 2: 55 | raise CLIException("No destination provided") 56 | 57 | destination = source.pop(-1) 58 | destination_path = format_path(self._cli.current_path, destination) 59 | 60 | for path in source: 61 | source_path = format_path(self._cli.current_path, path) 62 | 63 | document = self._resources.get(source_path) 64 | if match_slug: 65 | destination_path = self._match_slug(document, destination_path) 66 | 67 | self._resources.save(destination_path, document) 68 | 69 | self._cli.log("cp: {} -> {}", source_path, destination_path) 70 | 71 | @command 72 | @completers('path') 73 | def mv(self, source, destination, match_slug=False): 74 | if len(source) < 2: 75 | raise CLIException("No destination provided") 76 | 77 | destination = source.pop(-1) 78 | destination_path = format_path(self._cli.current_path, destination) 79 | 80 | for path in source: 81 | source_path = format_path(self._cli.current_path, path) 82 | document = self._resources.get(source_path) 83 | 84 | if match_slug: 85 | destination_path = self._match_slug(document, destination_path) 86 | 87 | self._resources.save(destination_path, document) 88 | self._resources.remove(source_path) 89 | 90 | self._cli.log("mv: {} -> {}", source_path, destination_path) 91 | 92 | @command 93 | @completers('path') 94 | def rm(self, path): 95 | path = format_path(self._cli.current_path, path) 96 | self._resources.remove(path) 97 | 98 | self._cli.log("rm: {}", path) 99 | 100 | @command 101 | @completers('path') 102 | def template(self, path): 103 | path = format_path(self._cli.current_path, path) 104 | document = self._resources.get(path) 105 | 106 | if isinstance(document, Dashboard): 107 | template = 'dashboards' 108 | elif isinstance(document, Row): 109 | template = 'rows' 110 | elif isinstance(document, Panel): 111 | template = 'panels' 112 | else: 113 | raise CLIException("Unknown document type: {}".format( 114 | document.__class__.__name__)) 115 | 116 | template_path = "/templates/{}".format(template) 117 | self._resources.save(template_path, document) 118 | 119 | self._cli.log("template: {} -> {}", path, template_path) 120 | 121 | @command 122 | @completers('path') 123 | def editor(self, path): 124 | path = format_path(self._cli.current_path, path) 125 | document = self._resources.get(path) 126 | 127 | tmp_file = tempfile.mktemp(suffix=".json") 128 | 129 | with open(tmp_file, 'w') as file: 130 | file.write(json_pretty(document.source)) 131 | 132 | cmd = "{} {}".format(config['grafcli']['editor'], tmp_file) 133 | exit_status = os.system(cmd) 134 | 135 | if not exit_status: 136 | self._cli.log("Updating: {}".format(path)) 137 | self.file_import(tmp_file, path) 138 | 139 | os.unlink(tmp_file) 140 | 141 | @command 142 | @completers('path') 143 | def merge(self, paths): 144 | if len(paths) < 2: 145 | raise CLIException("Provide at least two paths") 146 | 147 | tmp_files = [] 148 | 149 | for path in paths: 150 | formatted_path = format_path(self._cli.current_path, path) 151 | document = self._resources.get(formatted_path) 152 | 153 | tmp_file = tempfile.mktemp(suffix=".json") 154 | tmp_files.append((formatted_path, tmp_file)) 155 | 156 | with open(tmp_file, 'w') as file: 157 | file.write(json_pretty(document.source)) 158 | 159 | cmd = "{} {}".format(config['grafcli'].get('mergetool', 'vimdiff'), ' '.join([v[1] for v in tmp_files])) 160 | exit_status = os.system(cmd) 161 | 162 | for path, tmp_file in tmp_files: 163 | if not exit_status: 164 | self._cli.log("Updating: {}".format(path)) 165 | self.file_import(tmp_file, path) 166 | 167 | os.unlink(tmp_file) 168 | 169 | @command 170 | @completers('path') 171 | def pos(self, path, position): 172 | if not path: 173 | raise CLIException("No path provided") 174 | 175 | if not position: 176 | raise CLIException("No position provided") 177 | 178 | path = format_path(self._cli.current_path, path) 179 | parts = split_path(path) 180 | 181 | parent_path = '/'.join(parts[:-1]) 182 | child = parts[-1] 183 | 184 | parent = self._resources.get(parent_path) 185 | parent.move_child(child, position) 186 | 187 | self._resources.save(parent_path, parent) 188 | 189 | @command 190 | @completers('path', 'system_path') 191 | def backup(self, path, system_path): 192 | if not path: 193 | raise CLIException("No path provided") 194 | 195 | if not system_path: 196 | raise CLIException("No system path provided") 197 | 198 | path = format_path(self._cli.current_path, path) 199 | system_path = os.path.expanduser(system_path) 200 | 201 | documents = self._resources.list(path) 202 | if not documents: 203 | raise CLIException("Nothing to backup") 204 | 205 | tmp_dir = tempfile.mkdtemp() 206 | archive = tarfile.open(name=system_path, mode="w:gz") 207 | 208 | for doc_name in documents: 209 | file_name = to_file_format(doc_name) 210 | file_path = os.path.join(tmp_dir, file_name) 211 | doc_path = os.path.join(path, doc_name) 212 | 213 | self.file_export(doc_path, file_path) 214 | archive.add(file_path, arcname=file_name) 215 | 216 | archive.close() 217 | shutil.rmtree(tmp_dir) 218 | 219 | @command 220 | @completers('system_path', 'path') 221 | def restore(self, system_path, path): 222 | system_path = os.path.expanduser(system_path) 223 | path = format_path(self._cli.current_path, path) 224 | 225 | tmp_dir = tempfile.mkdtemp() 226 | with tarfile.open(name=system_path, mode="r:gz") as archive: 227 | archive.extractall(path=tmp_dir) 228 | 229 | for name in os.listdir(tmp_dir): 230 | try: 231 | file_path = os.path.join(tmp_dir, name) 232 | doc_path = os.path.join(path, from_file_format(name)) 233 | self.file_import(file_path, doc_path) 234 | except CommandCancelled: 235 | pass 236 | 237 | shutil.rmtree(tmp_dir) 238 | 239 | @command 240 | @completers('path', 'system_path') 241 | def file_export(self, path, system_path): 242 | path = format_path(self._cli.current_path, path) 243 | system_path = os.path.expanduser(system_path) 244 | document = self._resources.get(path) 245 | 246 | with open(system_path, 'w') as file: 247 | file.write(json_pretty(document.source)) 248 | 249 | self._cli.log("export: {} -> {}", path, system_path) 250 | 251 | @command 252 | @completers('system_path', 'path') 253 | def file_import(self, system_path, path, match_slug=False): 254 | system_path = os.path.expanduser(system_path) 255 | path = format_path(self._cli.current_path, path) 256 | 257 | with open(system_path, 'r') as file: 258 | content = file.read() 259 | 260 | document = Document.from_source(json.loads(content)) 261 | 262 | if match_slug: 263 | path = self._match_slug(document, path) 264 | 265 | self._resources.save(path, document) 266 | 267 | self._cli.log("import: {} -> {}", system_path, path) 268 | 269 | def _match_slug(self, document, destination): 270 | pattern = re.compile(r'^\d+-{}$'.format(document.slug)) 271 | 272 | children = self._resources.list(destination) 273 | matches = [child for child in children 274 | if pattern.search(child)] 275 | 276 | if not matches: 277 | return destination 278 | 279 | if len(matches) > 2: 280 | raise CLIException("Too many matching slugs, be more specific") 281 | 282 | return "{}/{}".format(destination, matches[0]) 283 | -------------------------------------------------------------------------------- /grafcli/completer.py: -------------------------------------------------------------------------------- 1 | from climb.completer import Completer 2 | from climb.paths import ROOT_PATH, SEPARATOR 3 | 4 | 5 | class GrafCompleter(Completer): 6 | 7 | def path(self, arg, text): 8 | if arg and not arg.endswith(SEPARATOR): 9 | # List one level up 10 | absolute = arg.startswith(ROOT_PATH) 11 | arg = SEPARATOR.join(arg.split(SEPARATOR)[:-1]) 12 | if absolute: 13 | arg = ROOT_PATH + arg 14 | 15 | paths = [p for p in self._cli.commands.ls(path=arg).split() 16 | if p.startswith(text)] 17 | 18 | if len(paths) == 1: 19 | return ["{}/".format(paths[0])] 20 | 21 | return paths 22 | -------------------------------------------------------------------------------- /grafcli/core.py: -------------------------------------------------------------------------------- 1 | from climb import Climb 2 | from functools import partial 3 | 4 | from grafcli.args import GrafArgs 5 | from grafcli.commands import GrafCommands 6 | from grafcli.completer import GrafCompleter 7 | 8 | 9 | GrafCLI = partial(Climb, 10 | 'grafcli', 11 | args=GrafArgs, 12 | commands=GrafCommands, 13 | completer=GrafCompleter, 14 | skip_delims=['-']) 15 | -------------------------------------------------------------------------------- /grafcli/documents.py: -------------------------------------------------------------------------------- 1 | import re 2 | from abc import ABCMeta, abstractmethod 3 | 4 | from grafcli.exceptions import InvalidPath, InvalidDocument, DocumentNotFound 5 | 6 | ID_PATTERN = re.compile(r'^(\d+)-?') 7 | SLUG_CHARS_PATTERN = re.compile(r'[^a-zA-Z0-9_]') 8 | SLUG_HYPHEN_PATTERN = re.compile(r'-+') 9 | 10 | 11 | def get_id(name): 12 | match = ID_PATTERN.search(name) 13 | if not match: 14 | raise InvalidPath("Name should start with ID") 15 | 16 | return int(match.group(1)) 17 | 18 | 19 | def slug(name): 20 | name = SLUG_CHARS_PATTERN.sub(r'-', name) 21 | name = SLUG_HYPHEN_PATTERN.sub(r'-', name) 22 | name = name.strip('-') 23 | return '-'.join(name.lower().split()) 24 | 25 | 26 | def relative_index(index, position): 27 | if position.startswith('+'): 28 | index += int(position[1:]) 29 | elif position.startswith('-'): 30 | index -= int(position[1:]) 31 | else: 32 | index = int(position)-1 33 | 34 | return index 35 | 36 | 37 | class Document(object, metaclass=ABCMeta): 38 | _id = None 39 | _name = None 40 | _source = None 41 | 42 | @classmethod 43 | def from_source(cls, source): 44 | if 'rows' in source: 45 | return Dashboard(source, '') 46 | elif 'panels' in source: 47 | return Row(source) 48 | elif 'id' in source: 49 | return Panel(source) 50 | else: 51 | raise InvalidDocument("Could not recognize document type by source") 52 | 53 | @abstractmethod 54 | def update(self, document): 55 | pass 56 | 57 | @abstractmethod 58 | def remove_child(self, name): 59 | pass 60 | 61 | @abstractmethod 62 | def move_child(self, name, position): 63 | pass 64 | 65 | @property 66 | def id(self): 67 | return self._id 68 | 69 | @property 70 | def name(self): 71 | return self._name 72 | 73 | @property 74 | def source(self): 75 | return self._source 76 | 77 | @property 78 | def parent(self): 79 | return None 80 | 81 | @property 82 | def title(self): 83 | return self._source['title'] 84 | 85 | @property 86 | def slug(self): 87 | return slug(self.title) 88 | 89 | 90 | class Dashboard(Document): 91 | 92 | def __init__(self, source, id): 93 | self._id = id 94 | self._name = id 95 | self._load(source) 96 | 97 | def _load(self, source): 98 | self._source = source 99 | 100 | self._rows = [] 101 | for row in source['rows']: 102 | self._add_row(row) 103 | 104 | def update(self, document): 105 | if isinstance(document, Dashboard): 106 | self._load(document.source.copy()) 107 | elif isinstance(document, Row): 108 | row = self._add_row(document.source) 109 | row.update_panel_ids() 110 | else: 111 | raise InvalidDocument("Can not update {} with {}" 112 | .format(self.__class__.__name__, 113 | document.__class__.__name__)) 114 | 115 | def remove_child(self, name): 116 | id = self._get_row_id(name) 117 | del self._rows[id-1] 118 | 119 | def move_child(self, name, position): 120 | child = self.row(name) 121 | index = relative_index(self._rows.index(child), position) 122 | 123 | self._rows.remove(child) 124 | self._rows.insert(index, child) 125 | 126 | self._refresh_rows_id() 127 | 128 | def _add_row(self, source): 129 | max_id = len(self._rows) 130 | row = Row(source, max_id+1, self) 131 | self._rows.append(row) 132 | return row 133 | 134 | def _refresh_rows_id(self): 135 | for i, row in enumerate(self._rows): 136 | row.set_id(i+1) 137 | 138 | def row(self, name): 139 | id = self._get_row_id(name) 140 | return self._rows[id-1] 141 | 142 | def _get_row_id(self, name): 143 | id = get_id(name) 144 | if id <= 0 or len(self._rows) < id: 145 | raise DocumentNotFound("There is no row at index {}".format(id)) 146 | 147 | return id 148 | 149 | def max_panel_id(self): 150 | if self.rows: 151 | return max([row.max_panel_id() 152 | for row in self.rows]) 153 | else: 154 | return 0 155 | 156 | def set_id(self, id): 157 | self._id = id 158 | 159 | @property 160 | def rows(self): 161 | return self._rows 162 | 163 | @property 164 | def source(self): 165 | self._source['rows'] = [row.source for row in self._rows] 166 | return self._source 167 | 168 | 169 | class Row(Document): 170 | def __init__(self, source, id=0, dashboard=None): 171 | self._dashboard = dashboard 172 | self._load(source, id) 173 | 174 | def _load(self, source, id): 175 | self._id = id 176 | self._source = source 177 | self._set_name(id) 178 | 179 | self._panels = [] 180 | for panel in source['panels']: 181 | self._add_panel(panel, keep_id=True) 182 | 183 | def update(self, document): 184 | if isinstance(document, Row): 185 | self._load(document.source.copy(), document.id) 186 | self.update_panel_ids() 187 | elif isinstance(document, Panel): 188 | self._add_panel(document.source, keep_id=False) 189 | else: 190 | raise InvalidDocument("Can not update {} with {}" 191 | .format(self.__class__.__name__, 192 | document.__class__.__name__)) 193 | 194 | def remove_child(self, name): 195 | self._panels.remove(self.panel(name)) 196 | 197 | def move_child(self, name, position): 198 | child = self.panel(name) 199 | index = relative_index(self._panels.index(child), position) 200 | 201 | self._panels.remove(child) 202 | self._panels.insert(index, child) 203 | 204 | def set_id(self, id): 205 | self._id = id 206 | self._source['id'] = id 207 | self._set_name(id) 208 | 209 | def _set_name(self, id): 210 | title = slug(self.title) 211 | 212 | if id: 213 | self._name = "{}-{}".format(self._id, title) 214 | else: 215 | self._name = title 216 | 217 | def _add_panel(self, source, keep_id): 218 | if keep_id: 219 | id = source['id'] 220 | elif self._dashboard: 221 | id = self._dashboard.max_panel_id() + 1 222 | else: 223 | id = self.max_panel_id() + 1 224 | 225 | panel = Panel(source, id, self) 226 | self._panels.append(panel) 227 | 228 | def update_panel_ids(self): 229 | for panel in self._panels: 230 | panel.set_id(0) 231 | 232 | for panel in self._panels: 233 | if self._dashboard: 234 | max_id = self._dashboard.max_panel_id() 235 | else: 236 | max_id = self.max_panel_id() 237 | 238 | panel.set_id(max_id + 1) 239 | 240 | def panel(self, name): 241 | id = get_id(name) 242 | panels = [p for p in self._panels 243 | if p.id == id] 244 | 245 | if not panels: 246 | raise DocumentNotFound("There is no panel with id {}".format(id)) 247 | 248 | return panels[0] 249 | 250 | def max_panel_id(self): 251 | if self.panels: 252 | return max([panel.id 253 | for panel in self.panels]) 254 | else: 255 | return 0 256 | 257 | @property 258 | def panels(self): 259 | return self._panels 260 | 261 | @property 262 | def source(self): 263 | self._source['panels'] = [panel.source for panel in self._panels] 264 | return self._source 265 | 266 | @property 267 | def parent(self): 268 | return self._dashboard 269 | 270 | 271 | class Panel(Document): 272 | def __init__(self, source, id=0, row=None): 273 | self._id = id 274 | self._row = row 275 | self._load(source) 276 | 277 | def _load(self, source): 278 | source['id'] = self._id 279 | self._source = source 280 | self._name = "{}-{}".format(self._id, slug(self.title)) 281 | 282 | def set_id(self, id): 283 | self._id = id 284 | self._source['id'] = id 285 | 286 | def update(self, document): 287 | if isinstance(document, Panel): 288 | self._load(document.source.copy()) 289 | else: 290 | raise InvalidDocument("Can not update {} with {}" 291 | .format(self.__class__.__name__, 292 | document.__class__.__name__)) 293 | 294 | @property 295 | def parent(self): 296 | return self._row 297 | 298 | def remove_child(self, name): 299 | raise InvalidDocument("Panel has no child nodes") 300 | 301 | def move_child(self, name, position): 302 | raise InvalidDocument("Panel has no child nodes") 303 | -------------------------------------------------------------------------------- /grafcli/exceptions.py: -------------------------------------------------------------------------------- 1 | from climb.exceptions import CLIException 2 | 3 | 4 | class HostConfigError(CLIException): 5 | pass 6 | 7 | 8 | class MissingHostName(CLIException): 9 | pass 10 | 11 | 12 | class MissingTemplateCategory(CLIException): 13 | pass 14 | 15 | 16 | class InvalidPath(CLIException): 17 | pass 18 | 19 | 20 | class InvalidDocument(CLIException): 21 | pass 22 | 23 | 24 | class DocumentNotFound(CLIException): 25 | pass 26 | 27 | 28 | class CommandCancelled(CLIException): 29 | pass 30 | -------------------------------------------------------------------------------- /grafcli/resources/__init__.py: -------------------------------------------------------------------------------- 1 | from grafcli.resources.resources import Resources -------------------------------------------------------------------------------- /grafcli/resources/common.py: -------------------------------------------------------------------------------- 1 | from grafcli.documents import Dashboard 2 | from grafcli.exceptions import InvalidPath, DocumentNotFound, InvalidDocument 3 | from grafcli.utils import confirm_prompt 4 | 5 | 6 | class CommonResources(object): 7 | _storage = None 8 | 9 | def list(self, dashboard_name=None, row_name=None, panel_name=None): 10 | if not dashboard_name: 11 | return self._storage.list() 12 | 13 | dashboard = self.get(dashboard_name) 14 | 15 | if not row_name: 16 | return [row.name for row in dashboard.rows] 17 | 18 | row = dashboard.row(row_name) 19 | panels = [panel.name for panel in row.panels] 20 | 21 | if panel_name: 22 | if panel_name in panels: 23 | raise InvalidPath("Panel contains no sub-nodes") 24 | else: 25 | raise DocumentNotFound("There is no such panel: {}".format(panel_name)) 26 | else: 27 | return panels 28 | 29 | def get(self, dashboard_name=None, row_name=None, panel_name=None): 30 | if not dashboard_name: 31 | raise InvalidPath("Provide the dashboard at least") 32 | 33 | dashboard = self._storage.get(dashboard_name) 34 | 35 | if not row_name: 36 | return dashboard 37 | 38 | if not panel_name: 39 | return dashboard.row(row_name) 40 | 41 | return dashboard.row(row_name).panel(panel_name) 42 | 43 | def save(self, document, dashboard_name=None, row_name=None, panel_name=None): 44 | if dashboard_name: 45 | try: 46 | origin_document = self.get(dashboard_name, row_name, panel_name) 47 | 48 | if type(document) == type(origin_document): 49 | confirm_prompt("Overwrite {}?".format(origin_document.name)) 50 | 51 | origin_document.update(document) 52 | 53 | dashboard = origin_document 54 | while dashboard.parent: 55 | dashboard = dashboard.parent 56 | except DocumentNotFound: 57 | if not isinstance(document, Dashboard): 58 | raise 59 | 60 | dashboard = document 61 | dashboard.set_id(dashboard_name) 62 | else: 63 | dashboard = document 64 | 65 | if not isinstance(dashboard, Dashboard): 66 | raise InvalidDocument("Can not save {} as dashboard" 67 | .format(type(document).__name__)) 68 | 69 | self._storage.save(dashboard.id, dashboard) 70 | 71 | def remove(self, dashboard_name=None, row_name=None, panel_name=None): 72 | if not dashboard_name: 73 | raise InvalidPath("Provide the dashboard at least") 74 | 75 | if row_name: 76 | dashboard = self.get(dashboard_name) 77 | 78 | if panel_name: 79 | dashboard.row(row_name).remove_child(panel_name) 80 | else: 81 | dashboard.remove_child(row_name) 82 | 83 | self._storage.save(dashboard.id, dashboard) 84 | else: 85 | self._storage.remove(dashboard_name) 86 | -------------------------------------------------------------------------------- /grafcli/resources/local.py: -------------------------------------------------------------------------------- 1 | from grafcli.storage.system import SystemStorage 2 | from grafcli.resources.common import CommonResources 3 | 4 | 5 | class LocalResources(CommonResources): 6 | def __init__(self, local_dir): 7 | self._storage = SystemStorage(local_dir) 8 | -------------------------------------------------------------------------------- /grafcli/resources/remote.py: -------------------------------------------------------------------------------- 1 | from grafcli.storage import get_remote_storage 2 | from grafcli.resources.common import CommonResources 3 | 4 | 5 | class RemoteResources(CommonResources): 6 | 7 | def __init__(self, host): 8 | self._storage = get_remote_storage(host) 9 | -------------------------------------------------------------------------------- /grafcli/resources/resources.py: -------------------------------------------------------------------------------- 1 | from climb.config import config 2 | from climb.paths import split_path 3 | 4 | from grafcli.exceptions import InvalidPath, MissingHostName, MissingTemplateCategory 5 | from grafcli.resources.remote import RemoteResources 6 | from grafcli.resources.templates import DashboardsTemplates, RowsTemplates, PanelTemplates, CATEGORIES 7 | from grafcli.resources.local import LocalResources 8 | 9 | LOCAL_DIR = 'backups' 10 | 11 | 12 | class Resources(object): 13 | 14 | def __init__(self): 15 | self._resources = { 16 | 'backups': LocalResources(LOCAL_DIR), 17 | 'remote': {}, 18 | 'templates': { 19 | 'dashboards': DashboardsTemplates(), 20 | 'rows': RowsTemplates(), 21 | 'panels': PanelTemplates(), 22 | }, 23 | } 24 | 25 | def list(self, path): 26 | """Returns list of sub-nodes for given path.""" 27 | try: 28 | manager, parts = self._parse_path(path) 29 | except MissingHostName: 30 | return [host for host in config['hosts'] 31 | if config.getboolean('hosts', host)] 32 | except MissingTemplateCategory: 33 | return CATEGORIES 34 | 35 | if not manager and not parts: 36 | return sorted(self._resources.keys()) 37 | 38 | return manager.list(*parts) 39 | 40 | def get(self, path): 41 | """Returns resource data.""" 42 | manager, parts = self._parse_path(path) 43 | if not parts: 44 | raise InvalidPath("No path supplied") 45 | 46 | return manager.get(*parts) 47 | 48 | def save(self, path, document): 49 | """Returns resource data.""" 50 | manager, parts = self._parse_path(path) 51 | return manager.save(document, *parts) 52 | 53 | def remove(self, path): 54 | """Removes resource.""" 55 | manager, parts = self._parse_path(path) 56 | if not parts: 57 | raise InvalidPath("No path supplied") 58 | 59 | return manager.remove(*parts) 60 | 61 | def _parse_path(self, path): 62 | parts = split_path(path) 63 | 64 | if not parts: 65 | return None, [] 66 | 67 | resource = parts.pop(0) 68 | if resource == 'remote': 69 | if not parts: 70 | raise MissingHostName("Provide remote host name") 71 | 72 | host = parts.pop(0) 73 | if host not in self._resources['remote']: 74 | self._resources['remote'][host] = RemoteResources(host) 75 | 76 | manager = self._resources['remote'][host] 77 | elif resource == 'templates': 78 | if not parts: 79 | raise MissingTemplateCategory("Provide template category") 80 | 81 | category = parts.pop(0) 82 | if category not in self._resources['templates']: 83 | raise InvalidPath("Invalid template category: {}".format(category)) 84 | 85 | manager = self._resources['templates'][category] 86 | else: 87 | try: 88 | manager = self._resources[resource] 89 | except KeyError: 90 | raise InvalidPath("Invalid resource: {}".format(resource)) 91 | 92 | return manager, parts 93 | -------------------------------------------------------------------------------- /grafcli/resources/templates.py: -------------------------------------------------------------------------------- 1 | import os 2 | from abc import ABCMeta 3 | 4 | from grafcli.exceptions import InvalidDocument 5 | from grafcli.documents import Dashboard, Row 6 | from grafcli.resources.local import LocalResources 7 | 8 | DASHBOARDS = 'dashboards' 9 | ROWS = 'rows' 10 | PANELS = 'panels' 11 | 12 | CATEGORIES = (DASHBOARDS, ROWS, PANELS) 13 | 14 | TEMPLATES_DIR = 'templates' 15 | DASHBOARDS_DIR = os.path.join(TEMPLATES_DIR, DASHBOARDS) 16 | ROWS_DIR = os.path.join(TEMPLATES_DIR, ROWS) 17 | PANELS_DIR = os.path.join(TEMPLATES_DIR, PANELS) 18 | 19 | DEFAULT = 'default' 20 | DEFAULT_ROW = '1-default' 21 | 22 | 23 | class CommonTemplates(object, metaclass=ABCMeta): 24 | _base_dir = None 25 | 26 | def __init__(self): 27 | self._resources = LocalResources(self._base_dir) 28 | 29 | 30 | class DashboardsTemplates(CommonTemplates): 31 | _base_dir = DASHBOARDS_DIR 32 | 33 | def get(self, dashboard_name=None, row_name=None, panel_name=None): 34 | return self._resources.get(dashboard_name, row_name, panel_name) 35 | 36 | def remove(self, dashboard_name=None, row_name=None, panel_name=None): 37 | return self._resources.remove(dashboard_name, row_name, panel_name) 38 | 39 | def list(self, dashboard_name=None, row_name=None, panel_name=None): 40 | return self._resources.list(dashboard_name, row_name, panel_name) 41 | 42 | def save(self, document, dashboard_name=None, row_name=None, panel_name=None): 43 | return self._resources.save(document, dashboard_name, row_name, panel_name) 44 | 45 | 46 | class RowsTemplates(CommonTemplates): 47 | _base_dir = ROWS_DIR 48 | 49 | def __init__(self): 50 | super().__init__() 51 | 52 | if DEFAULT not in self._resources.list(): 53 | source = { 54 | 'rows': [], 55 | 'title': DEFAULT, 56 | } 57 | dashboard = Dashboard(source, DEFAULT) 58 | self._resources.save(dashboard, DEFAULT) 59 | 60 | def list(self, row_name=None, panel_name=None): 61 | return self._resources.list(DEFAULT, row_name, panel_name) 62 | 63 | def get(self, row_name=None, panel_name=None): 64 | return self._resources.get(DEFAULT, row_name, panel_name) 65 | 66 | def save(self, document, row_name=None, panel_name=None): 67 | if isinstance(document, Dashboard): 68 | raise InvalidDocument("Can not add Dashboard as row template") 69 | 70 | return self._resources.save(document, DEFAULT, row_name, panel_name) 71 | 72 | def remove(self, row_name=None, panel_name=None): 73 | return self._resources.remove(DEFAULT, row_name, panel_name) 74 | 75 | 76 | class PanelTemplates(CommonTemplates): 77 | _base_dir = ROWS_DIR 78 | 79 | def __init__(self): 80 | super().__init__() 81 | 82 | if DEFAULT not in self._resources.list(): 83 | source = { 84 | 'rows': [ 85 | { 86 | 'panels': [], 87 | 'title': DEFAULT, 88 | } 89 | ], 90 | 'title': DEFAULT, 91 | } 92 | dashboard = Dashboard(source, DEFAULT) 93 | self._resources.save(dashboard, DEFAULT) 94 | 95 | def list(self, panel_name=None): 96 | return self._resources.list(DEFAULT, DEFAULT_ROW, panel_name) 97 | 98 | def get(self, panel_name=None): 99 | return self._resources.get(DEFAULT, DEFAULT_ROW, panel_name) 100 | 101 | def save(self, document, panel_name=None): 102 | if isinstance(document, Row): 103 | raise InvalidDocument("Can not add Row as panel template") 104 | 105 | return self._resources.save(document, DEFAULT, DEFAULT_ROW, panel_name) 106 | 107 | def remove(self, panel_name=None): 108 | return self._resources.remove(DEFAULT, DEFAULT_ROW, panel_name) 109 | -------------------------------------------------------------------------------- /grafcli/storage/__init__.py: -------------------------------------------------------------------------------- 1 | from climb.config import config 2 | 3 | from grafcli.exceptions import HostConfigError 4 | from grafcli.storage.storage import Storage 5 | from grafcli.storage.elastic import ElasticStorage 6 | from grafcli.storage.sql import MySQLStorage, PostgreSQLStorage, SQLiteStorage 7 | from grafcli.storage.api import APIStorage 8 | 9 | 10 | STORAGE_TYPES = { 11 | 'elastic': ElasticStorage, 12 | 'mysql': MySQLStorage, 13 | 'postgresql': PostgreSQLStorage, 14 | 'sqlite': SQLiteStorage, 15 | 'api': APIStorage, 16 | } 17 | 18 | 19 | def get_remote_storage(host): 20 | if host not in config['hosts']: 21 | raise HostConfigError("No such host defined: {}".format(host)) 22 | 23 | if not config.getboolean('hosts', host): 24 | raise HostConfigError("Host {} is disabled".format(host)) 25 | 26 | if host not in config: 27 | raise HostConfigError("Missing config section for host {}".format(host)) 28 | 29 | storage_type = config[host]['type'] 30 | if storage_type not in STORAGE_TYPES: 31 | raise HostConfigError("Unknown storage type: {}".format(storage_type)) 32 | 33 | storage_class = STORAGE_TYPES[storage_type] 34 | return storage_class(host) 35 | -------------------------------------------------------------------------------- /grafcli/storage/api.py: -------------------------------------------------------------------------------- 1 | import os 2 | import requests 3 | from climb.config import config 4 | 5 | from grafcli.storage import Storage 6 | from grafcli.documents import Dashboard 7 | from grafcli.exceptions import DocumentNotFound 8 | 9 | 10 | class APIStorage(Storage): 11 | 12 | def __init__(self, host): 13 | super().__init__(host) 14 | self._config = config[host] 15 | 16 | def _call(self, method, url, data=None): 17 | full_url = os.path.join(self._config['url'], url) 18 | auth = None 19 | headers = {} 20 | 21 | api_token = os.getenv('GRAFANA_API_TOKEN'); 22 | if api_token: 23 | headers['Authorization'] = 'Bearer {}'.format(api_token) 24 | elif self._config.get('token'): 25 | headers['Authorization'] = 'Bearer {}'.format(self._config['token']) 26 | else: 27 | auth = (self._config['user'], self._config['password']) 28 | 29 | response = requests.request(method, full_url, 30 | headers=headers, 31 | auth=auth, 32 | json=data) 33 | response.raise_for_status() 34 | return response.json() 35 | 36 | def list(self): 37 | return [row['uri'].split('/')[-1] 38 | for row in self._call('GET', 'search')] 39 | 40 | def get(self, dashboard_id): 41 | try: 42 | source = self._call('GET', 'dashboards/db/{}'.format(dashboard_id)) 43 | except requests.HTTPError as exc: 44 | if exc.response.status_code == 404: 45 | raise DocumentNotFound("There is no such dashboard: {}".format(dashboard_id)) 46 | 47 | raise 48 | return Dashboard(source['dashboard'], dashboard_id) 49 | 50 | def save(self, dashboard_id, dashboard): 51 | if not dashboard_id: 52 | dashboard_id = dashboard.slug 53 | 54 | data = { 55 | "dashboard": dashboard.source, 56 | } 57 | 58 | try: 59 | self._call('GET', 'dashboards/db/{}'.format(dashboard_id)) 60 | data["overwrite"] = True 61 | except requests.HTTPError as exc: 62 | if exc.response.status_code != 404: 63 | raise 64 | data["dashboard"]["id"] = None 65 | 66 | self._call('POST', 'dashboards/db', data) 67 | 68 | def remove(self, dashboard_id): 69 | self._call('DELETE', 'dashboards/db/{}'.format(dashboard_id)) 70 | -------------------------------------------------------------------------------- /grafcli/storage/elastic.py: -------------------------------------------------------------------------------- 1 | import json 2 | import warnings 3 | from climb.config import config 4 | 5 | from grafcli.documents import Dashboard 6 | from grafcli.exceptions import DocumentNotFound 7 | from grafcli.storage import Storage 8 | from grafcli.utils import try_import 9 | 10 | Elasticsearch = getattr(try_import('elasticsearch'), 'Elasticsearch', None) 11 | 12 | DASHBOARD_TYPE = "dashboard" 13 | SEARCH_LIMIT = 100 14 | 15 | warnings.simplefilter("ignore") 16 | 17 | 18 | class ElasticStorage(Storage): 19 | def __init__(self, host): 20 | self._host = host 21 | self._config = config[host] 22 | self._default_index = self._config['index'] 23 | 24 | addresses = self._config['hosts'].split(',') 25 | port = int(self._config['port']) 26 | 27 | use_ssl = self._config.getboolean('ssl') 28 | if self._config['user'] and self._config['password']: 29 | http_auth = (self._config['user'], self._config['password']) 30 | else: 31 | http_auth = None 32 | 33 | self._connection = Elasticsearch(addresses, 34 | port=port, 35 | use_ssl=use_ssl, 36 | http_auth=http_auth) 37 | 38 | def list(self): 39 | hits = self._search(doc_type=DASHBOARD_TYPE, 40 | _source=False) 41 | 42 | return [hit['_id'] for hit in hits] 43 | 44 | def get(self, dashboard_id): 45 | hits = self._search(doc_type=DASHBOARD_TYPE, 46 | _source=["dashboard"], 47 | body={'query': {'match': {'_id': dashboard_id}}}) 48 | 49 | if not hits: 50 | raise DocumentNotFound("There is no such dashboard: {}".format(dashboard_id)) 51 | 52 | source = json.loads(hits[0]['_source']['dashboard']) 53 | 54 | return Dashboard(source, dashboard_id) 55 | 56 | def save(self, dashboard_id, dashboard): 57 | body = {'dashboard': json.dumps(dashboard.source)} 58 | 59 | try: 60 | self.get(dashboard_id) 61 | self._update(doc_type=DASHBOARD_TYPE, 62 | body={'doc': body}, 63 | id=dashboard_id) 64 | except DocumentNotFound: 65 | self._create(doc_type=DASHBOARD_TYPE, 66 | body=body, 67 | id=dashboard_id) 68 | 69 | def remove(self, dashboard_id): 70 | self._remove(doc_type=DASHBOARD_TYPE, 71 | id=dashboard_id) 72 | 73 | def _search(self, **kwargs): 74 | self._fill_index(kwargs) 75 | result = self._connection.search(size=SEARCH_LIMIT, **kwargs) 76 | return result['hits']['hits'] 77 | 78 | def _create(self, **kwargs): 79 | self._fill_index(kwargs) 80 | return self._connection.create(**kwargs) 81 | 82 | def _update(self, **kwargs): 83 | self._fill_index(kwargs) 84 | return self._connection.update(**kwargs) 85 | 86 | def _remove(self, **kwargs): 87 | self._fill_index(kwargs) 88 | return self._connection.delete(**kwargs) 89 | 90 | def _fill_index(self, kwargs): 91 | if 'index' not in kwargs: 92 | kwargs['index'] = self._default_index 93 | -------------------------------------------------------------------------------- /grafcli/storage/sql.py: -------------------------------------------------------------------------------- 1 | import json 2 | import os 3 | import re 4 | from abc import ABCMeta, abstractmethod 5 | 6 | from climb.config import config 7 | 8 | from grafcli.documents import Dashboard 9 | from grafcli.exceptions import DocumentNotFound 10 | from grafcli.storage import Storage 11 | from grafcli.utils import try_import 12 | 13 | sqlite3 = try_import('sqlite3') 14 | mysql = try_import('mysql.connector') 15 | psycopg2 = try_import('psycopg2') 16 | 17 | SELECT_PATTERN = re.compile(r'^select', re.IGNORECASE) 18 | 19 | 20 | class SQLStorage(Storage, metaclass=ABCMeta): 21 | NOW = "NOW()" 22 | 23 | def __init__(self, host): 24 | self._host = host 25 | self._config = config[host] 26 | self._connection = None 27 | self._setup() 28 | 29 | @abstractmethod 30 | def _setup(self): 31 | """Should initialize _connection attribute.""" 32 | 33 | def _execute(self, query, **kwargs): 34 | cursor = self._connection.cursor() 35 | 36 | cursor.execute(query, kwargs) 37 | 38 | if SELECT_PATTERN.search(query): 39 | return cursor.fetchall() 40 | else: 41 | self._connection.commit() 42 | return None 43 | 44 | def list(self): 45 | query = """SELECT slug 46 | FROM dashboard 47 | ORDER BY id ASC""" 48 | 49 | result = self._execute(query) 50 | 51 | return [row[0] for row in result] 52 | 53 | def get(self, dashboard_id): 54 | query = """SELECT data 55 | FROM dashboard 56 | WHERE slug = %(slug)s""" 57 | 58 | result = self._execute(query, slug=dashboard_id) 59 | if not result: 60 | raise DocumentNotFound("There is no such dashboard: {}".format(dashboard_id)) 61 | 62 | source = result[0][0] 63 | if isinstance(source, bytes): 64 | source = source.decode('utf-8') 65 | 66 | source = json.loads(source) 67 | 68 | return Dashboard(source, dashboard_id) 69 | 70 | def save(self, dashboard_id, dashboard): 71 | try: 72 | self.get(dashboard_id) 73 | query = """UPDATE dashboard 74 | SET data = %(data)s, title = %(title)s, slug = %(new_slug)s 75 | WHERE slug = %(slug)s""" 76 | self._execute(query, 77 | data=json.dumps(dashboard.source), 78 | title=dashboard.title, 79 | new_slug=dashboard.slug, 80 | slug=dashboard_id) 81 | except DocumentNotFound: 82 | query = """INSERT INTO dashboard (version, slug, title, data, org_id, created, updated) 83 | VALUES (1, %(slug)s, %(title)s, %(data)s, 1, {now}, {now})""".format(now=self.NOW) 84 | self._execute(query, slug=dashboard_id, title=dashboard.title, data=json.dumps(dashboard.source)) 85 | 86 | def remove(self, dashboard_id): 87 | query = """DELETE FROM dashboard 88 | WHERE slug = %(slug)s""" 89 | 90 | self._execute(query, slug=dashboard_id) 91 | 92 | 93 | class SQLiteStorage(SQLStorage): 94 | NOW = "DATETIME('now')" 95 | 96 | def _setup(self): 97 | path = os.path.expanduser(self._config['path']) 98 | self._connection = sqlite3.connect(path) 99 | 100 | def _execute(self, query, **kwargs): 101 | query = query.replace('%s', '?') 102 | query = re.sub(r'%\((\w+)\)s', r':\1', query) 103 | return super()._execute(query, **kwargs) 104 | 105 | 106 | class MySQLStorage(SQLStorage): 107 | def _setup(self): 108 | self._connection = mysql.connect(host=self._config['host'], 109 | port=int(self._config['port']), 110 | user=self._config['user'], 111 | password=self._config['password'], 112 | database=self._config['database']) 113 | self._connection.autocommit = True 114 | 115 | 116 | class PostgreSQLStorage(SQLStorage): 117 | def _setup(self): 118 | self._connection = psycopg2.connect(host=self._config['host'], 119 | port=int(self._config['port']), 120 | user=self._config['user'], 121 | password=self._config['password'], 122 | database=self._config['database']) 123 | -------------------------------------------------------------------------------- /grafcli/storage/storage.py: -------------------------------------------------------------------------------- 1 | from abc import ABCMeta, abstractmethod 2 | 3 | 4 | class Storage(object, metaclass=ABCMeta): 5 | 6 | def __init__(self, host): 7 | pass 8 | 9 | @abstractmethod 10 | def list(self): 11 | pass 12 | 13 | @abstractmethod 14 | def get(self, dashboard_id): 15 | pass 16 | 17 | @abstractmethod 18 | def save(self, dashboard_id, dashboard): 19 | pass 20 | 21 | @abstractmethod 22 | def remove(self, dashboard_id): 23 | pass 24 | -------------------------------------------------------------------------------- /grafcli/storage/system.py: -------------------------------------------------------------------------------- 1 | import os 2 | import json 3 | from climb.config import config 4 | 5 | from grafcli.documents import Dashboard 6 | from grafcli.exceptions import DocumentNotFound 7 | from grafcli.storage import Storage 8 | 9 | def data_dir(): 10 | return os.path.expanduser(config['resources'].get('data-dir', '')) 11 | 12 | 13 | class SystemStorage(Storage): 14 | def __init__(self, base_dir): 15 | self._base_dir = base_dir 16 | makepath(self._base_dir) 17 | 18 | def list(self): 19 | return list_files(self._base_dir) 20 | 21 | def get(self, dashboard_id): 22 | try: 23 | source = read_file(self._base_dir, dashboard_id) 24 | return Dashboard(source, dashboard_id) 25 | except DocumentNotFound: 26 | raise DocumentNotFound("There is no such dashboard: {}".format(dashboard_id)) 27 | 28 | def save(self, dashboard_id, dashboard): 29 | write_file(self._base_dir, dashboard_id, dashboard.source) 30 | 31 | def remove(self, dashboard_id): 32 | remove_file(self._base_dir, dashboard_id) 33 | 34 | 35 | def list_files(*paths): 36 | full_path = os.path.join(data_dir(), *paths) 37 | if not os.path.isdir(full_path): 38 | raise DocumentNotFound("No documents found") 39 | 40 | return [from_file_format(file) 41 | for file in os.listdir(full_path)] 42 | 43 | 44 | def read_file(directory, name): 45 | full_path = os.path.join(data_dir(), directory, to_file_format(name)) 46 | if not os.path.isfile(full_path): 47 | raise DocumentNotFound("File not found: {}".format(full_path)) 48 | 49 | with open(full_path, 'r') as f: 50 | return json.loads(f.read()) 51 | 52 | 53 | def write_file(directory, name, data): 54 | full_path = os.path.join(data_dir(), directory, to_file_format(name)) 55 | 56 | with open(full_path, 'w') as f: 57 | f.write(json.dumps(data)) 58 | 59 | 60 | def remove_file(directory, name): 61 | full_path = os.path.join(data_dir(), directory, to_file_format(name)) 62 | if not os.path.isfile(full_path): 63 | raise DocumentNotFound("File not found: {}".format(full_path)) 64 | 65 | os.unlink(full_path) 66 | 67 | 68 | def makepath(path): 69 | full_path = os.path.join(data_dir(), path) 70 | os.makedirs(full_path, mode=0o755, exist_ok=True) 71 | 72 | 73 | def to_file_format(filename): 74 | return "{}.json".format(filename) 75 | 76 | 77 | def from_file_format(filename): 78 | return filename.replace('.json', '') 79 | -------------------------------------------------------------------------------- /grafcli/utils.py: -------------------------------------------------------------------------------- 1 | import json 2 | import importlib 3 | from pygments import highlight, lexers, formatters 4 | from climb.config import config 5 | 6 | from grafcli.exceptions import CommandCancelled 7 | 8 | 9 | def json_pretty(data, colorize=False): 10 | pretty = json.dumps(data, 11 | sort_keys=True, 12 | indent=4, 13 | separators=(',', ': ')) 14 | 15 | if colorize: 16 | pretty = highlight(pretty, lexers.JsonLexer(), formatters.TerminalFormatter()) 17 | 18 | return pretty.strip() 19 | 20 | 21 | def confirm_prompt(question): 22 | if config['grafcli'].getboolean('force'): 23 | return 24 | 25 | answer = input("{} [y/n]: ".format(question)) 26 | 27 | if answer not in ('y', 'Y', 'yes', 'YES'): 28 | raise CommandCancelled("Cancelled.") 29 | 30 | 31 | def try_import(module_name): 32 | try: 33 | return importlib.import_module(module_name) 34 | except ImportError: 35 | return None 36 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | climb 2 | pygments 3 | requests 4 | -------------------------------------------------------------------------------- /scripts/grafcli: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | import sys 3 | from climb.exceptions import CLIException 4 | 5 | from grafcli.core import GrafCLI 6 | 7 | 8 | def main(): 9 | cli = GrafCLI() 10 | 11 | if len(sys.argv) > 1: 12 | try: 13 | result = cli.execute(*sys.argv[1:]) 14 | if result: 15 | print(result) 16 | 17 | return 0 18 | except CLIException as exc: 19 | print(exc) 20 | return 1 21 | else: 22 | cli.run() 23 | return 0 24 | 25 | 26 | if __name__ == "__main__": 27 | sys.exit(main()) 28 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | import os 3 | from subprocess import check_output 4 | from setuptools import setup, find_packages 5 | 6 | # Version management by: 7 | # http://blogs.nopcode.org/brainstorm/2013/05/20/pragmatic-python-versioning-via-setuptools-and-git-tags/ 8 | 9 | version_py = os.path.join(os.path.dirname(__file__), 'grafcli', 'version.py') 10 | 11 | try: 12 | version_git = check_output(["git", "describe", "--tags"]).rstrip().decode() 13 | except: 14 | with open(version_py, 'r') as fh: 15 | version_git = open(version_py).read().strip().split('=')[-1].replace('"','') 16 | 17 | version_msg = "# Do not edit this file, pipeline versioning is governed by git tags" 18 | with open(version_py, 'w') as fh: 19 | fh.write(version_msg + os.linesep + "__version__=" + version_git) 20 | 21 | 22 | setup(name='grafcli', 23 | version=version_git, 24 | description='Grafana CLI management tool', 25 | author='Milosz Smolka', 26 | author_email='m110@m110.pl', 27 | url='https://github.com/m110/grafcli', 28 | packages=find_packages(exclude=['tests']), 29 | scripts=['scripts/grafcli'], 30 | data_files=[('/etc/grafcli', ['grafcli.conf.example'])], 31 | install_requires=['climb>=0.3.2', 'pygments', 'requests'], 32 | classifiers=[ 33 | 'Development Status :: 3 - Alpha', 34 | 'Environment :: Console', 35 | 'Intended Audience :: End Users/Desktop', 36 | 'Intended Audience :: Developers', 37 | 'Intended Audience :: System Administrators', 38 | 'License :: OSI Approved :: MIT License', 39 | 'Operating System :: POSIX', 40 | 'Programming Language :: Python :: 3.4', 41 | 'Topic :: System :: Systems Administration', 42 | ]) 43 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/m110/grafcli/5c5ba2cec7454703345e6d2efc478d354827379b/tests/__init__.py -------------------------------------------------------------------------------- /tests/integration/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM python:3.7-alpine 2 | 3 | ADD tests/integration/entrypoint.sh /entrypoint.sh 4 | ADD tests/integration/wait-for-it.sh /wait-for-it.sh 5 | 6 | RUN sed -i s/6/cdn/ /etc/apk/repositories && apk add --update --progress make bats 7 | ADD requirements.txt /app/requirements.txt 8 | RUN pip3 install --upgrade pip 9 | RUN pip3 install -r /app/requirements.txt 10 | 11 | RUN mkdir -p /etc/grafcli 12 | RUN ln -s /app/grafcli.conf.example /etc/grafcli/grafcli.conf 13 | RUN ln -s /app/scripts/grafcli /usr/local/bin/grafcli 14 | 15 | ENV PYTHONPATH=/app 16 | 17 | WORKDIR /app 18 | ENTRYPOINT /entrypoint.sh 19 | -------------------------------------------------------------------------------- /tests/integration/entrypoint.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | /wait-for-it.sh grafana:3000 3 | make run_integration 4 | -------------------------------------------------------------------------------- /tests/integration/example-dashboard.json: -------------------------------------------------------------------------------- 1 | { 2 | "__inputs": [ 3 | { 4 | "name": "DS_GRAPHITE", 5 | "label": "Graphite", 6 | "description": "", 7 | "type": "datasource", 8 | "pluginId": "graphite", 9 | "pluginName": "Graphite" 10 | }, 11 | { 12 | "name": "VAR_PREFIX", 13 | "type": "constant", 14 | "label": "CollectD Metric Prefix", 15 | "value": "collectd", 16 | "description": "" 17 | } 18 | ], 19 | "__requires": [ 20 | { 21 | "type": "panel", 22 | "id": "graph", 23 | "name": "Graph", 24 | "version": "" 25 | }, 26 | { 27 | "type": "grafana", 28 | "id": "grafana", 29 | "name": "Grafana", 30 | "version": "3.1.0" 31 | }, 32 | { 33 | "type": "datasource", 34 | "id": "graphite", 35 | "name": "Graphite", 36 | "version": "1.0.0" 37 | } 38 | ], 39 | "id": null, 40 | "title": "CollectD Server Metrics", 41 | "tags": [ 42 | "collectd" 43 | ], 44 | "style": "dark", 45 | "timezone": "browser", 46 | "editable": true, 47 | "hideControls": false, 48 | "sharedCrosshair": true, 49 | "rows": [ 50 | { 51 | "collapse": false, 52 | "editable": true, 53 | "height": "250px", 54 | "panels": [ 55 | { 56 | "aliasColors": { 57 | "1.steal": "#E24D42", 58 | "steal": "#E24D42" 59 | }, 60 | "bars": false, 61 | "datasource": "${DS_GRAPHITE}", 62 | "editable": true, 63 | "error": false, 64 | "fill": 2, 65 | "grid": { 66 | "threshold1": null, 67 | "threshold1Color": "rgba(216, 200, 27, 0.27)", 68 | "threshold2": null, 69 | "threshold2Color": "rgba(234, 112, 112, 0.22)" 70 | }, 71 | "id": 15, 72 | "legend": { 73 | "alignAsTable": true, 74 | "avg": true, 75 | "current": false, 76 | "hideZero": false, 77 | "max": false, 78 | "min": false, 79 | "rightSide": true, 80 | "show": false, 81 | "total": false, 82 | "values": true 83 | }, 84 | "lines": true, 85 | "linewidth": 2, 86 | "links": [], 87 | "nullPointMode": "connected", 88 | "percentage": false, 89 | "pointradius": 5, 90 | "points": false, 91 | "renderer": "flot", 92 | "seriesOverrides": [], 93 | "span": 6, 94 | "stack": true, 95 | "steppedLine": false, 96 | "targets": [ 97 | { 98 | "refId": "A", 99 | "target": "aliasByNode($prefix.$server.aggregation.cpu-average.cpu.{system,wait,user}, 5)", 100 | "textEditor": false 101 | } 102 | ], 103 | "timeFrom": null, 104 | "timeShift": null, 105 | "title": "CPU Average", 106 | "tooltip": { 107 | "msResolution": false, 108 | "shared": true, 109 | "sort": 0, 110 | "value_type": "individual" 111 | }, 112 | "type": "graph", 113 | "xaxis": { 114 | "show": true 115 | }, 116 | "yaxes": [ 117 | { 118 | "format": "percent", 119 | "logBase": 1, 120 | "max": null, 121 | "min": 0, 122 | "show": true 123 | }, 124 | { 125 | "format": "short", 126 | "logBase": 1, 127 | "max": null, 128 | "min": null, 129 | "show": true 130 | } 131 | ] 132 | }, 133 | { 134 | "aliasColors": {}, 135 | "bars": false, 136 | "datasource": "${DS_GRAPHITE}", 137 | "editable": true, 138 | "error": false, 139 | "fill": 1, 140 | "grid": { 141 | "threshold1": null, 142 | "threshold1Color": "rgba(216, 200, 27, 0.27)", 143 | "threshold2": null, 144 | "threshold2Color": "rgba(234, 112, 112, 0.22)" 145 | }, 146 | "id": 10, 147 | "legend": { 148 | "alignAsTable": true, 149 | "avg": true, 150 | "current": false, 151 | "max": false, 152 | "min": false, 153 | "rightSide": true, 154 | "show": true, 155 | "total": false, 156 | "values": true 157 | }, 158 | "lines": true, 159 | "linewidth": 2, 160 | "links": [], 161 | "nullPointMode": "connected", 162 | "percentage": false, 163 | "pointradius": 5, 164 | "points": false, 165 | "renderer": "flot", 166 | "seriesOverrides": [], 167 | "span": 6, 168 | "stack": true, 169 | "steppedLine": false, 170 | "targets": [ 171 | { 172 | "refId": "A", 173 | "target": "aliasByNode($prefix.$server.memory.memory.{cached,free,buffered}, 4)", 174 | "textEditor": true 175 | } 176 | ], 177 | "timeFrom": null, 178 | "timeShift": null, 179 | "title": "Memory Available", 180 | "tooltip": { 181 | "msResolution": true, 182 | "shared": true, 183 | "sort": 0, 184 | "value_type": "individual" 185 | }, 186 | "type": "graph", 187 | "xaxis": { 188 | "show": true 189 | }, 190 | "yaxes": [ 191 | { 192 | "format": "bits", 193 | "logBase": 1, 194 | "max": null, 195 | "min": 0, 196 | "show": true 197 | }, 198 | { 199 | "format": "short", 200 | "logBase": 1, 201 | "max": null, 202 | "min": null, 203 | "show": true 204 | } 205 | ] 206 | } 207 | ], 208 | "title": "New row" 209 | }, 210 | { 211 | "collapse": false, 212 | "editable": true, 213 | "height": "250px", 214 | "panels": [ 215 | { 216 | "aliasColors": {}, 217 | "bars": false, 218 | "datasource": "${DS_GRAPHITE}", 219 | "editable": true, 220 | "error": false, 221 | "fill": 1, 222 | "grid": { 223 | "threshold1": null, 224 | "threshold1Color": "rgba(216, 200, 27, 0.27)", 225 | "threshold2": null, 226 | "threshold2Color": "rgba(234, 112, 112, 0.22)" 227 | }, 228 | "id": 3, 229 | "legend": { 230 | "alignAsTable": true, 231 | "avg": true, 232 | "current": false, 233 | "max": false, 234 | "min": false, 235 | "rightSide": true, 236 | "show": false, 237 | "total": false, 238 | "values": true 239 | }, 240 | "lines": true, 241 | "linewidth": 2, 242 | "links": [], 243 | "nullPointMode": "connected", 244 | "percentage": false, 245 | "pointradius": 5, 246 | "points": false, 247 | "renderer": "flot", 248 | "seriesOverrides": [], 249 | "span": 4, 250 | "stack": false, 251 | "steppedLine": false, 252 | "targets": [ 253 | { 254 | "refId": "A", 255 | "target": "aliasByNode($prefix.$server.load.load.shortterm, 4)", 256 | "textEditor": true 257 | } 258 | ], 259 | "timeFrom": null, 260 | "timeShift": null, 261 | "title": "Load", 262 | "tooltip": { 263 | "msResolution": false, 264 | "shared": true, 265 | "sort": 0, 266 | "value_type": "individual" 267 | }, 268 | "type": "graph", 269 | "xaxis": { 270 | "show": true 271 | }, 272 | "yaxes": [ 273 | { 274 | "format": "short", 275 | "logBase": 1, 276 | "max": null, 277 | "min": null, 278 | "show": true 279 | }, 280 | { 281 | "format": "short", 282 | "logBase": 1, 283 | "max": null, 284 | "min": null, 285 | "show": true 286 | } 287 | ] 288 | }, 289 | { 290 | "aliasColors": {}, 291 | "bars": false, 292 | "datasource": "${DS_GRAPHITE}", 293 | "editable": true, 294 | "error": false, 295 | "fill": 1, 296 | "grid": { 297 | "threshold1": null, 298 | "threshold1Color": "rgba(216, 200, 27, 0.27)", 299 | "threshold2": null, 300 | "threshold2Color": "rgba(234, 112, 112, 0.22)" 301 | }, 302 | "id": 5, 303 | "legend": { 304 | "avg": false, 305 | "current": false, 306 | "max": false, 307 | "min": false, 308 | "show": false, 309 | "total": false, 310 | "values": false 311 | }, 312 | "lines": true, 313 | "linewidth": 2, 314 | "links": [], 315 | "nullPointMode": "connected", 316 | "percentage": false, 317 | "pointradius": 5, 318 | "points": false, 319 | "renderer": "flot", 320 | "seriesOverrides": [], 321 | "span": 2, 322 | "stack": false, 323 | "steppedLine": false, 324 | "targets": [ 325 | { 326 | "refId": "A", 327 | "target": "alias(perSecond($prefix.$server.processes.fork_rate), 'new processes')", 328 | "textEditor": true 329 | } 330 | ], 331 | "timeFrom": null, 332 | "timeShift": null, 333 | "title": "Process Fork/sec", 334 | "tooltip": { 335 | "msResolution": false, 336 | "shared": true, 337 | "sort": 0, 338 | "value_type": "cumulative" 339 | }, 340 | "type": "graph", 341 | "xaxis": { 342 | "show": true 343 | }, 344 | "yaxes": [ 345 | { 346 | "format": "short", 347 | "logBase": 1, 348 | "max": null, 349 | "min": null, 350 | "show": true 351 | }, 352 | { 353 | "format": "short", 354 | "logBase": 1, 355 | "max": null, 356 | "min": null, 357 | "show": true 358 | } 359 | ] 360 | }, 361 | { 362 | "aliasColors": {}, 363 | "bars": false, 364 | "datasource": "${DS_GRAPHITE}", 365 | "editable": true, 366 | "error": false, 367 | "fill": 1, 368 | "grid": { 369 | "threshold1": null, 370 | "threshold1Color": "rgba(216, 200, 27, 0.27)", 371 | "threshold2": null, 372 | "threshold2Color": "rgba(234, 112, 112, 0.22)" 373 | }, 374 | "id": 4, 375 | "legend": { 376 | "avg": false, 377 | "current": false, 378 | "max": false, 379 | "min": false, 380 | "show": true, 381 | "total": false, 382 | "values": false 383 | }, 384 | "lines": true, 385 | "linewidth": 2, 386 | "links": [], 387 | "nullPointMode": "connected", 388 | "percentage": false, 389 | "pointradius": 5, 390 | "points": false, 391 | "renderer": "flot", 392 | "seriesOverrides": [], 393 | "span": 6, 394 | "stack": false, 395 | "steppedLine": false, 396 | "targets": [ 397 | { 398 | "refId": "A", 399 | "target": "aliasByNode($prefix.$server.processes.ps_state.*, 4)", 400 | "textEditor": false 401 | } 402 | ], 403 | "timeFrom": null, 404 | "timeShift": null, 405 | "title": "Processes", 406 | "tooltip": { 407 | "msResolution": false, 408 | "shared": true, 409 | "sort": 0, 410 | "value_type": "cumulative" 411 | }, 412 | "type": "graph", 413 | "xaxis": { 414 | "show": true 415 | }, 416 | "yaxes": [ 417 | { 418 | "format": "short", 419 | "logBase": 1, 420 | "max": null, 421 | "min": null, 422 | "show": true 423 | }, 424 | { 425 | "format": "short", 426 | "logBase": 1, 427 | "max": null, 428 | "min": null, 429 | "show": true 430 | } 431 | ] 432 | } 433 | ], 434 | "title": "New row" 435 | }, 436 | { 437 | "collapse": false, 438 | "editable": true, 439 | "height": "250px", 440 | "panels": [ 441 | { 442 | "aliasColors": {}, 443 | "bars": false, 444 | "datasource": "${DS_GRAPHITE}", 445 | "editable": true, 446 | "error": false, 447 | "fill": 1, 448 | "grid": { 449 | "threshold1": null, 450 | "threshold1Color": "rgba(216, 200, 27, 0.27)", 451 | "threshold2": null, 452 | "threshold2Color": "rgba(234, 112, 112, 0.22)" 453 | }, 454 | "id": 7, 455 | "legend": { 456 | "avg": false, 457 | "current": false, 458 | "max": false, 459 | "min": false, 460 | "show": true, 461 | "total": false, 462 | "values": false 463 | }, 464 | "lines": false, 465 | "linewidth": 2, 466 | "links": [], 467 | "nullPointMode": "connected", 468 | "percentage": false, 469 | "pointradius": 1, 470 | "points": true, 471 | "renderer": "flot", 472 | "seriesOverrides": [], 473 | "span": 4, 474 | "stack": false, 475 | "steppedLine": false, 476 | "targets": [ 477 | { 478 | "refId": "A", 479 | "target": "aliasByNode(perSecond($prefix.$server.disk.*.disk_ops.read), 3)", 480 | "textEditor": true 481 | } 482 | ], 483 | "timeFrom": null, 484 | "timeShift": null, 485 | "title": "Read IOPS", 486 | "tooltip": { 487 | "msResolution": false, 488 | "shared": true, 489 | "sort": 0, 490 | "value_type": "cumulative" 491 | }, 492 | "type": "graph", 493 | "xaxis": { 494 | "show": true 495 | }, 496 | "yaxes": [ 497 | { 498 | "format": "short", 499 | "logBase": 1, 500 | "max": null, 501 | "min": null, 502 | "show": true 503 | }, 504 | { 505 | "format": "short", 506 | "logBase": 1, 507 | "max": null, 508 | "min": null, 509 | "show": true 510 | } 511 | ] 512 | }, 513 | { 514 | "aliasColors": {}, 515 | "bars": false, 516 | "datasource": "${DS_GRAPHITE}", 517 | "editable": true, 518 | "error": false, 519 | "fill": 1, 520 | "grid": { 521 | "threshold1": null, 522 | "threshold1Color": "rgba(216, 200, 27, 0.27)", 523 | "threshold2": null, 524 | "threshold2Color": "rgba(234, 112, 112, 0.22)" 525 | }, 526 | "id": 8, 527 | "legend": { 528 | "avg": false, 529 | "current": false, 530 | "max": false, 531 | "min": false, 532 | "show": true, 533 | "total": false, 534 | "values": false 535 | }, 536 | "lines": false, 537 | "linewidth": 2, 538 | "links": [], 539 | "nullPointMode": "connected", 540 | "percentage": false, 541 | "pointradius": 1, 542 | "points": true, 543 | "renderer": "flot", 544 | "seriesOverrides": [], 545 | "span": 4, 546 | "stack": false, 547 | "steppedLine": false, 548 | "targets": [ 549 | { 550 | "refId": "A", 551 | "target": "aliasByNode(perSecond($prefix.$server.disk.*.disk_ops.write), 3)", 552 | "textEditor": true 553 | } 554 | ], 555 | "timeFrom": null, 556 | "timeShift": null, 557 | "title": "Write IOPS", 558 | "tooltip": { 559 | "msResolution": false, 560 | "shared": true, 561 | "sort": 0, 562 | "value_type": "cumulative" 563 | }, 564 | "type": "graph", 565 | "xaxis": { 566 | "show": true 567 | }, 568 | "yaxes": [ 569 | { 570 | "format": "short", 571 | "logBase": 1, 572 | "max": null, 573 | "min": null, 574 | "show": true 575 | }, 576 | { 577 | "format": "short", 578 | "logBase": 1, 579 | "max": null, 580 | "min": null, 581 | "show": true 582 | } 583 | ] 584 | }, 585 | { 586 | "aliasColors": {}, 587 | "bars": false, 588 | "datasource": "${DS_GRAPHITE}", 589 | "editable": true, 590 | "error": false, 591 | "fill": 1, 592 | "grid": { 593 | "threshold1": null, 594 | "threshold1Color": "rgba(216, 200, 27, 0.27)", 595 | "threshold2": null, 596 | "threshold2Color": "rgba(234, 112, 112, 0.22)" 597 | }, 598 | "id": 2, 599 | "legend": { 600 | "alignAsTable": true, 601 | "avg": true, 602 | "current": false, 603 | "max": false, 604 | "min": false, 605 | "rightSide": true, 606 | "show": false, 607 | "total": false, 608 | "values": true 609 | }, 610 | "lines": true, 611 | "linewidth": 2, 612 | "links": [], 613 | "nullPointMode": "connected", 614 | "percentage": false, 615 | "pointradius": 5, 616 | "points": false, 617 | "renderer": "flot", 618 | "seriesOverrides": [], 619 | "span": 4, 620 | "stack": false, 621 | "steppedLine": false, 622 | "targets": [ 623 | { 624 | "refId": "A", 625 | "target": "aliasByNode($prefix.$server.df.*.percent_bytes.used, 1, 3)", 626 | "textEditor": false 627 | } 628 | ], 629 | "timeFrom": null, 630 | "timeShift": null, 631 | "title": "Disk Usage", 632 | "tooltip": { 633 | "msResolution": true, 634 | "shared": true, 635 | "sort": 0, 636 | "value_type": "individual" 637 | }, 638 | "type": "graph", 639 | "xaxis": { 640 | "show": true 641 | }, 642 | "yaxes": [ 643 | { 644 | "format": "percent", 645 | "logBase": 1, 646 | "max": 100, 647 | "min": 0, 648 | "show": true 649 | }, 650 | { 651 | "format": "short", 652 | "logBase": 1, 653 | "max": null, 654 | "min": null, 655 | "show": true 656 | } 657 | ] 658 | } 659 | ], 660 | "title": "New row" 661 | }, 662 | { 663 | "collapse": false, 664 | "editable": true, 665 | "height": "250px", 666 | "panels": [ 667 | { 668 | "aliasColors": {}, 669 | "bars": false, 670 | "datasource": "${DS_GRAPHITE}", 671 | "editable": true, 672 | "error": false, 673 | "fill": 1, 674 | "grid": { 675 | "threshold1": null, 676 | "threshold1Color": "rgba(216, 200, 27, 0.27)", 677 | "threshold2": null, 678 | "threshold2Color": "rgba(234, 112, 112, 0.22)" 679 | }, 680 | "id": 9, 681 | "legend": { 682 | "avg": false, 683 | "current": false, 684 | "max": false, 685 | "min": false, 686 | "show": true, 687 | "total": false, 688 | "values": false 689 | }, 690 | "lines": true, 691 | "linewidth": 2, 692 | "links": [], 693 | "nullPointMode": "connected", 694 | "percentage": false, 695 | "pointradius": 5, 696 | "points": false, 697 | "renderer": "flot", 698 | "seriesOverrides": [ 699 | { 700 | "alias": "out", 701 | "transform": "negative-Y" 702 | } 703 | ], 704 | "span": 4, 705 | "stack": false, 706 | "steppedLine": false, 707 | "targets": [ 708 | { 709 | "refId": "A", 710 | "target": "alias(scale(perSecond($prefix.$server.interface.*.if_octets.rx), 8), 'in')", 711 | "textEditor": false 712 | }, 713 | { 714 | "refId": "B", 715 | "target": "alias(scale(perSecond($prefix.$server.interface.*.if_octets.tx), 8), 'out')", 716 | "textEditor": false 717 | } 718 | ], 719 | "timeFrom": null, 720 | "timeShift": null, 721 | "title": "Network Traffic/sec", 722 | "tooltip": { 723 | "msResolution": true, 724 | "shared": true, 725 | "sort": 0, 726 | "value_type": "cumulative" 727 | }, 728 | "type": "graph", 729 | "xaxis": { 730 | "show": true 731 | }, 732 | "yaxes": [ 733 | { 734 | "format": "bps", 735 | "logBase": 1, 736 | "max": null, 737 | "min": null, 738 | "show": true 739 | }, 740 | { 741 | "format": "short", 742 | "logBase": 1, 743 | "max": null, 744 | "min": null, 745 | "show": true 746 | } 747 | ] 748 | }, 749 | { 750 | "aliasColors": {}, 751 | "bars": false, 752 | "datasource": "${DS_GRAPHITE}", 753 | "editable": true, 754 | "error": false, 755 | "fill": 1, 756 | "grid": { 757 | "threshold1": null, 758 | "threshold1Color": "rgba(216, 200, 27, 0.27)", 759 | "threshold2": null, 760 | "threshold2Color": "rgba(234, 112, 112, 0.22)" 761 | }, 762 | "id": 11, 763 | "legend": { 764 | "avg": false, 765 | "current": false, 766 | "max": false, 767 | "min": false, 768 | "show": true, 769 | "total": false, 770 | "values": false 771 | }, 772 | "lines": true, 773 | "linewidth": 2, 774 | "links": [], 775 | "nullPointMode": "connected", 776 | "percentage": false, 777 | "pointradius": 5, 778 | "points": false, 779 | "renderer": "flot", 780 | "seriesOverrides": [ 781 | { 782 | "alias": "out", 783 | "transform": "negative-Y" 784 | } 785 | ], 786 | "span": 4, 787 | "stack": false, 788 | "steppedLine": false, 789 | "targets": [ 790 | { 791 | "refId": "A", 792 | "target": "alias(removeBelowValue(perSecond($prefix.$server.interface.*.if_errors.rx), 1), 'in')", 793 | "textEditor": true 794 | }, 795 | { 796 | "refId": "B", 797 | "target": "alias(removeBelowValue(perSecond($prefix.$server.interface.*.if_errors.tx), 1), 'out')", 798 | "textEditor": true 799 | } 800 | ], 801 | "timeFrom": null, 802 | "timeShift": null, 803 | "title": "Network Errors/sec", 804 | "tooltip": { 805 | "msResolution": false, 806 | "shared": true, 807 | "sort": 0, 808 | "value_type": "cumulative" 809 | }, 810 | "type": "graph", 811 | "xaxis": { 812 | "show": true 813 | }, 814 | "yaxes": [ 815 | { 816 | "format": "none", 817 | "logBase": 1, 818 | "max": null, 819 | "min": null, 820 | "show": true 821 | }, 822 | { 823 | "format": "short", 824 | "logBase": 1, 825 | "max": null, 826 | "min": null, 827 | "show": true 828 | } 829 | ] 830 | }, 831 | { 832 | "aliasColors": {}, 833 | "bars": false, 834 | "datasource": "${DS_GRAPHITE}", 835 | "editable": true, 836 | "error": false, 837 | "fill": 1, 838 | "grid": { 839 | "threshold1": null, 840 | "threshold1Color": "rgba(216, 200, 27, 0.27)", 841 | "threshold2": null, 842 | "threshold2Color": "rgba(234, 112, 112, 0.22)" 843 | }, 844 | "id": 12, 845 | "legend": { 846 | "avg": false, 847 | "current": false, 848 | "max": false, 849 | "min": false, 850 | "show": true, 851 | "total": false, 852 | "values": false 853 | }, 854 | "lines": true, 855 | "linewidth": 2, 856 | "links": [], 857 | "nullPointMode": "connected", 858 | "percentage": false, 859 | "pointradius": 5, 860 | "points": false, 861 | "renderer": "flot", 862 | "seriesOverrides": [ 863 | { 864 | "alias": "out", 865 | "transform": "negative-Y" 866 | } 867 | ], 868 | "span": 4, 869 | "stack": false, 870 | "steppedLine": false, 871 | "targets": [ 872 | { 873 | "refId": "A", 874 | "target": "alias(perSecond($prefix.$server.interface.*.if_packets.rx), 'in')", 875 | "textEditor": true 876 | }, 877 | { 878 | "refId": "B", 879 | "target": "alias(perSecond($prefix.$server.interface.*.if_packets.tx), 'out')", 880 | "textEditor": true 881 | } 882 | ], 883 | "timeFrom": null, 884 | "timeShift": null, 885 | "title": "Network Packets/sec", 886 | "tooltip": { 887 | "msResolution": false, 888 | "shared": true, 889 | "sort": 0, 890 | "value_type": "cumulative" 891 | }, 892 | "type": "graph", 893 | "xaxis": { 894 | "show": true 895 | }, 896 | "yaxes": [ 897 | { 898 | "format": "none", 899 | "logBase": 1, 900 | "max": null, 901 | "min": null, 902 | "show": true 903 | }, 904 | { 905 | "format": "short", 906 | "logBase": 1, 907 | "max": null, 908 | "min": null, 909 | "show": true 910 | } 911 | ] 912 | } 913 | ], 914 | "title": "New row" 915 | } 916 | ], 917 | "time": { 918 | "from": "now-15m", 919 | "to": "now" 920 | }, 921 | "timepicker": { 922 | "now": true, 923 | "refresh_intervals": [ 924 | "5s", 925 | "10s", 926 | "30s", 927 | "1m", 928 | "5m", 929 | "15m", 930 | "30m", 931 | "1h", 932 | "2h", 933 | "1d" 934 | ], 935 | "time_options": [ 936 | "5m", 937 | "15m", 938 | "1h", 939 | "6h", 940 | "12h", 941 | "24h", 942 | "2d", 943 | "7d", 944 | "30d" 945 | ] 946 | }, 947 | "templating": { 948 | "list": [ 949 | { 950 | "allFormat": "glob", 951 | "current": { 952 | "value": "${VAR_PREFIX}", 953 | "text": "${VAR_PREFIX}" 954 | }, 955 | "datasource": null, 956 | "hide": 2, 957 | "includeAll": false, 958 | "multi": false, 959 | "multiFormat": "glob", 960 | "name": "prefix", 961 | "options": [ 962 | { 963 | "value": "${VAR_PREFIX}", 964 | "text": "${VAR_PREFIX}" 965 | } 966 | ], 967 | "query": "${VAR_PREFIX}", 968 | "refresh": 0, 969 | "refresh_on_load": false, 970 | "type": "constant" 971 | }, 972 | { 973 | "allFormat": "glob", 974 | "current": {}, 975 | "datasource": "${DS_GRAPHITE}", 976 | "hide": 0, 977 | "includeAll": true, 978 | "multi": false, 979 | "multiFormat": "glob", 980 | "name": "server", 981 | "options": [], 982 | "query": "$prefix.*", 983 | "refresh": 1, 984 | "refresh_on_load": false, 985 | "type": "query" 986 | } 987 | ] 988 | }, 989 | "annotations": { 990 | "list": [] 991 | }, 992 | "refresh": "10s", 993 | "schemaVersion": 12, 994 | "version": 2, 995 | "links": [], 996 | "gnetId": 24, 997 | "description": "CollectD & Graphite Server Metrics Dashboard with CPU, Memory, IO & Disk Stats" 998 | } -------------------------------------------------------------------------------- /tests/integration/helpers.bash: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | function assert_no_dashboards() { 4 | run grafcli ls /remote/localhost 5 | [ "$status" -eq 0 ] 6 | [ "$output" = "" ] 7 | } 8 | 9 | function assert_only_dashboard() { 10 | run grafcli ls /remote/localhost 11 | [ "$status" -eq 0 ] 12 | [ "$output" = "$1" ] 13 | } 14 | 15 | function assert_dashboards() { 16 | run grafcli ls /remote/localhost 17 | for dashboard in "$@"; do 18 | in_array "$dashboard" "${lines[@]}" 19 | done 20 | } 21 | 22 | 23 | function in_array() { 24 | local element 25 | for element in "${@:2}"; do 26 | [[ "$element" == "$1" ]] && return 0; 27 | done 28 | 29 | return 1 30 | } 31 | -------------------------------------------------------------------------------- /tests/integration/run_tests.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bats 2 | load helpers 3 | 4 | readonly DASHBOARD_SLUG=collectd-server-metrics 5 | readonly NEW_DASHBOARD_SLUG=collectd-server-metrics-two 6 | 7 | @test "Ensure there are no dashboards" { 8 | assert_no_dashboards 9 | } 10 | 11 | @test "Import dashboard" { 12 | grafcli import /app/tests/integration/example-dashboard.json /remote/localhost 13 | assert_only_dashboard "$DASHBOARD_SLUG" 14 | } 15 | 16 | @test "Import dashboard with the same slug name" { 17 | grafcli import /app/tests/integration/example-dashboard.json /remote/localhost 18 | assert_only_dashboard "$DASHBOARD_SLUG" 19 | } 20 | 21 | @test "Remove dashboards" { 22 | grafcli rm "/remote/localhost/$DASHBOARD_SLUG" 23 | assert_no_dashboards 24 | } 25 | -------------------------------------------------------------------------------- /tests/integration/wait-for-it.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | # Use this script to test if a given TCP host/port are available 3 | 4 | cmdname=$(basename $0) 5 | 6 | echoerr() { if [[ $QUIET -ne 1 ]]; then echo "$@" 1>&2; fi } 7 | 8 | usage() 9 | { 10 | cat << USAGE >&2 11 | Usage: 12 | $cmdname host:port [-s] [-t timeout] [-- command args] 13 | -h HOST | --host=HOST Host or IP under test 14 | -p PORT | --port=PORT TCP port under test 15 | Alternatively, you specify the host and port as host:port 16 | -s | --strict Only execute subcommand if the test succeeds 17 | -q | --quiet Don't output any status messages 18 | -t TIMEOUT | --timeout=TIMEOUT 19 | Timeout in seconds, zero for no timeout 20 | -- COMMAND ARGS Execute command with args after the test finishes 21 | USAGE 22 | exit 1 23 | } 24 | 25 | wait_for() 26 | { 27 | if [[ $TIMEOUT -gt 0 ]]; then 28 | echoerr "$cmdname: waiting $TIMEOUT seconds for $HOST:$PORT" 29 | else 30 | echoerr "$cmdname: waiting for $HOST:$PORT without a timeout" 31 | fi 32 | start_ts=$(date +%s) 33 | while : 34 | do 35 | if [[ $ISBUSY -eq 1 ]]; then 36 | nc -z $HOST $PORT 37 | result=$? 38 | else 39 | (echo > /dev/tcp/$HOST/$PORT) >/dev/null 2>&1 40 | result=$? 41 | fi 42 | if [[ $result -eq 0 ]]; then 43 | end_ts=$(date +%s) 44 | echoerr "$cmdname: $HOST:$PORT is available after $((end_ts - start_ts)) seconds" 45 | break 46 | fi 47 | sleep 1 48 | done 49 | return $result 50 | } 51 | 52 | wait_for_wrapper() 53 | { 54 | # In order to support SIGINT during timeout: http://unix.stackexchange.com/a/57692 55 | if [[ $QUIET -eq 1 ]]; then 56 | timeout $BUSYTIMEFLAG $TIMEOUT $0 --quiet --child --host=$HOST --port=$PORT --timeout=$TIMEOUT & 57 | else 58 | timeout $BUSYTIMEFLAG $TIMEOUT $0 --child --host=$HOST --port=$PORT --timeout=$TIMEOUT & 59 | fi 60 | PID=$! 61 | trap "kill -INT -$PID" INT 62 | wait $PID 63 | RESULT=$? 64 | if [[ $RESULT -ne 0 ]]; then 65 | echoerr "$cmdname: timeout occurred after waiting $TIMEOUT seconds for $HOST:$PORT" 66 | fi 67 | return $RESULT 68 | } 69 | 70 | # process arguments 71 | while [[ $# -gt 0 ]] 72 | do 73 | case "$1" in 74 | *:* ) 75 | hostport=(${1//:/ }) 76 | HOST=${hostport[0]} 77 | PORT=${hostport[1]} 78 | shift 1 79 | ;; 80 | --child) 81 | CHILD=1 82 | shift 1 83 | ;; 84 | -q | --quiet) 85 | QUIET=1 86 | shift 1 87 | ;; 88 | -s | --strict) 89 | STRICT=1 90 | shift 1 91 | ;; 92 | -h) 93 | HOST="$2" 94 | if [[ $HOST == "" ]]; then break; fi 95 | shift 2 96 | ;; 97 | --host=*) 98 | HOST="${1#*=}" 99 | shift 1 100 | ;; 101 | -p) 102 | PORT="$2" 103 | if [[ $PORT == "" ]]; then break; fi 104 | shift 2 105 | ;; 106 | --port=*) 107 | PORT="${1#*=}" 108 | shift 1 109 | ;; 110 | -t) 111 | TIMEOUT="$2" 112 | if [[ $TIMEOUT == "" ]]; then break; fi 113 | shift 2 114 | ;; 115 | --timeout=*) 116 | TIMEOUT="${1#*=}" 117 | shift 1 118 | ;; 119 | --) 120 | shift 121 | CLI=("$@") 122 | break 123 | ;; 124 | --help) 125 | usage 126 | ;; 127 | *) 128 | echoerr "Unknown argument: $1" 129 | usage 130 | ;; 131 | esac 132 | done 133 | 134 | if [[ "$HOST" == "" || "$PORT" == "" ]]; then 135 | echoerr "Error: you need to provide a host and port to test." 136 | usage 137 | fi 138 | 139 | TIMEOUT=${TIMEOUT:-15} 140 | STRICT=${STRICT:-0} 141 | CHILD=${CHILD:-0} 142 | QUIET=${QUIET:-0} 143 | 144 | # check to see if timeout is from busybox? 145 | # check to see if timeout is from busybox? 146 | TIMEOUT_PATH=$(realpath $(which timeout)) 147 | if [[ $TIMEOUT_PATH =~ "busybox" ]]; then 148 | ISBUSY=1 149 | BUSYTIMEFLAG="-t" 150 | else 151 | ISBUSY=0 152 | BUSYTIMEFLAG="" 153 | fi 154 | 155 | if [[ $CHILD -gt 0 ]]; then 156 | wait_for 157 | RESULT=$? 158 | exit $RESULT 159 | else 160 | if [[ $TIMEOUT -gt 0 ]]; then 161 | wait_for_wrapper 162 | RESULT=$? 163 | else 164 | wait_for 165 | RESULT=$? 166 | fi 167 | fi 168 | 169 | if [[ $CLI != "" ]]; then 170 | if [[ $RESULT -ne 0 && $STRICT -eq 1 ]]; then 171 | echoerr "$cmdname: strict mode, refusing to execute subprocess" 172 | exit $RESULT 173 | fi 174 | exec "${CLI[@]}" 175 | else 176 | exit $RESULT 177 | fi 178 | -------------------------------------------------------------------------------- /tests/test_documents.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | import os 3 | import sys 4 | import unittest 5 | 6 | LIB_PATH = os.path.dirname(os.path.realpath(__file__)) + '/../' 7 | 8 | sys.path.append(LIB_PATH) 9 | 10 | from grafcli.exceptions import InvalidPath, InvalidDocument, DocumentNotFound 11 | from grafcli.documents import Dashboard, Row, Panel, get_id, slug 12 | 13 | 14 | def dashboard_source(rows=None): 15 | if not rows: 16 | rows = [] 17 | 18 | return { 19 | 'rows': rows, 20 | 'title': 'Any Dashboard title', 21 | } 22 | 23 | 24 | def row_source(title, panels=None): 25 | if not panels: 26 | panels = [] 27 | 28 | return { 29 | 'title': title, 30 | 'panels': panels, 31 | } 32 | 33 | 34 | def panel_source(id, title): 35 | return { 36 | 'id': id, 37 | 'title': title, 38 | } 39 | 40 | 41 | def mock_dashboard(id): 42 | source = dashboard_source([ 43 | row_source("A", [ 44 | panel_source(1, "AA"), 45 | panel_source(2, "AB") 46 | ]), 47 | row_source("B", [ 48 | panel_source(3, "BA"), 49 | panel_source(4, "BB") 50 | ]), 51 | ]) 52 | 53 | return Dashboard(source, id) 54 | 55 | 56 | def mock_row(name="Any row", id=1): 57 | source = row_source(name, [ 58 | panel_source(1, "First panel"), 59 | panel_source(2, "Second panel"), 60 | ]) 61 | 62 | return Row(source, id) 63 | 64 | 65 | def mock_panel(id=1): 66 | source = panel_source(id, "Any panel") 67 | 68 | return Panel(source, id) 69 | 70 | 71 | def rows(dashboard): 72 | return [row.name for row in dashboard.rows] 73 | 74 | def panels(row): 75 | return [panel.name for panel in row.panels] 76 | 77 | 78 | class DocumentsTest(unittest.TestCase): 79 | 80 | def test_get_id(self): 81 | with self.assertRaises(InvalidPath): 82 | get_id('invalid-path') 83 | 84 | self.assertEqual(get_id('1-any-name'), 1) 85 | self.assertEqual(get_id('42-any-name'), 42) 86 | 87 | def test_slug(self): 88 | tests = [ 89 | ("a b c d", "a-b-c-d"), 90 | ("a-b--c---d", "a-b-c-d"), 91 | ("A_B_C D", "a_b_c-d"), 92 | ("a @# $% b %", "a-b"), 93 | ("a 10%", "a-10"), 94 | ("a 10 b 20%", "a-10-b-20"), 95 | ] 96 | 97 | for test, expected in tests: 98 | self.assertEqual(slug(test), expected) 99 | 100 | def test_dashboard(self): 101 | dashboard = mock_dashboard('any_dashboard') 102 | 103 | self.assertEqual(dashboard.id, 'any_dashboard') 104 | self.assertEqual(dashboard.name, 'any_dashboard') 105 | self.assertEqual(len(dashboard.rows), 2) 106 | 107 | self.assertEqual(dashboard.row('1-any-name').id, 1) 108 | self.assertEqual(dashboard.row('2-any-name').id, 2) 109 | with self.assertRaises(DocumentNotFound): 110 | dashboard.row('3-any-name') 111 | with self.assertRaises(DocumentNotFound): 112 | dashboard.row('0-any-name') 113 | 114 | self.assertEqual(dashboard.title, 'Any Dashboard title') 115 | self.assertEqual(dashboard.slug, 'any-dashboard-title') 116 | 117 | def test_dashboard_update(self): 118 | dashboard = mock_dashboard('any_dashboard') 119 | 120 | new_dashboard = mock_dashboard('new_dashboard') 121 | dashboard.update(new_dashboard) 122 | self.assertEqual(dashboard.id, 'any_dashboard') 123 | 124 | row = Row(row_source("new row")) 125 | dashboard.update(row) 126 | self.assertEqual(len(dashboard.rows), 3) 127 | self.assertEqual(dashboard.rows[2].id, 3) 128 | 129 | row_with_panels = mock_row() 130 | dashboard.update(row_with_panels) 131 | self.assertEqual(dashboard.max_panel_id(), 6) 132 | 133 | panel = Panel(panel_source(1, "any panel")) 134 | with self.assertRaises(InvalidDocument): 135 | dashboard.update(panel) 136 | 137 | def test_dashboard_remove_child(self): 138 | dashboard = mock_dashboard('any_dashboard') 139 | 140 | dashboard.remove_child("1-first-row") 141 | self.assertEqual(len(dashboard.rows), 1) 142 | 143 | dashboard.remove_child("1-second-row") 144 | self.assertEqual(len(dashboard.rows), 0) 145 | 146 | with self.assertRaises(DocumentNotFound): 147 | dashboard.remove_child("1-any-row") 148 | 149 | def test_dashboard_move_child(self): 150 | dashboard = mock_dashboard('any_dashboard') 151 | row = Row(row_source("C")) 152 | dashboard.update(row) 153 | row = Row(row_source("D")) 154 | dashboard.update(row) 155 | 156 | self.assertListEqual(rows(dashboard), ["1-a", "2-b", "3-c", "4-d"]) 157 | 158 | dashboard.move_child("4-d", '1') 159 | self.assertListEqual(rows(dashboard), ["1-d", "2-a", "3-b", "4-c"]) 160 | 161 | dashboard.move_child("1-d", '+1') 162 | self.assertListEqual(rows(dashboard), ["1-a", "2-d", "3-b", "4-c"]) 163 | 164 | dashboard.move_child("3-b", '-2') 165 | self.assertListEqual(rows(dashboard), ["1-b", "2-a", "3-d", "4-c"]) 166 | 167 | dashboard.move_child("2-a", '4') 168 | self.assertListEqual(rows(dashboard), ["1-b", "2-d", "3-c", "4-a"]) 169 | 170 | def test_dashboard_max_panel_id(self): 171 | dashboard = mock_dashboard('any_dashboard') 172 | 173 | self.assertEqual(dashboard.max_panel_id(), 4) 174 | 175 | dashboard.rows[0].panels.append(Panel(panel_source(5, "Low id panel"), 5)) 176 | dashboard.rows[1].panels.append(Panel(panel_source(15, "High id panel"), 15)) 177 | 178 | self.assertEqual(dashboard.max_panel_id(), 15) 179 | 180 | def test_row(self): 181 | row = mock_row() 182 | 183 | self.assertEqual(row.id, 1) 184 | self.assertEqual(row.name, '1-any-row') 185 | self.assertEqual(len(row.panels), 2) 186 | 187 | self.assertEqual(row.panel('1-any-name').id, 1) 188 | self.assertEqual(row.panel('2-any-name').id, 2) 189 | with self.assertRaises(DocumentNotFound): 190 | row.panel('3-any-name') 191 | with self.assertRaises(DocumentNotFound): 192 | row.panel('0-any-name') 193 | 194 | self.assertEqual(row.max_panel_id(), 2) 195 | 196 | def test_row_with_panel_with_custom_id(self): 197 | row = Row(row_source("Any row", [panel_source(99, "Any panel")])) 198 | self.assertEqual(row.panel('99-any-panel').id, 99) 199 | 200 | row.update(Panel(panel_source(10, "New panel"))) 201 | self.assertEqual(len(row.panels), 2) 202 | self.assertEqual(row.panel('100-new-panel').id, 100) 203 | self.assertEqual(row.panel('100-new-panel').name, '100-new-panel') 204 | 205 | def test_row_update(self): 206 | dashboard = mock_dashboard('any_dashboard') 207 | row = dashboard.rows[0] 208 | 209 | new_row = mock_row('New row', 2) 210 | 211 | row.update(new_row) 212 | self.assertEqual(row.id, 2) 213 | self.assertEqual(row.name, '2-new-row') 214 | 215 | panel = mock_panel() 216 | row.update(panel) 217 | self.assertEqual(len(row.panels), 3) 218 | self.assertEqual(row.panels[2].id, 7) 219 | self.assertEqual(row.panels[2].name, '7-any-panel') 220 | 221 | with self.assertRaises(InvalidDocument): 222 | row.update(dashboard) 223 | 224 | def test_row_remove_child(self): 225 | row = mock_row() 226 | 227 | row.remove_child("1-First-panel") 228 | self.assertEqual(len(row.panels), 1) 229 | 230 | row.remove_child("2-Second-panel") 231 | self.assertEqual(len(row.panels), 0) 232 | 233 | with self.assertRaises(DocumentNotFound): 234 | row.remove_child("1-any-panel") 235 | 236 | def test_row_move_child(self): 237 | row = Row(row_source("Any row")) 238 | new_panels = [Panel(panel_source(1, 'A')), 239 | Panel(panel_source(2, 'B')), 240 | Panel(panel_source(3, 'C')), 241 | Panel(panel_source(4, 'D'))] 242 | for panel in new_panels: 243 | row.update(panel) 244 | 245 | self.assertListEqual(panels(row), ["1-a", "2-b", "3-c", "4-d"]) 246 | 247 | row.move_child("4-d", '1') 248 | self.assertListEqual(panels(row), ["4-d", "1-a", "2-b", "3-c"]) 249 | 250 | row.move_child("4-d", '+1') 251 | self.assertListEqual(panels(row), ["1-a", "4-d", "2-b", "3-c"]) 252 | 253 | row.move_child("2-b", '-2') 254 | self.assertListEqual(panels(row), ["2-b", "1-a", "4-d", "3-c"]) 255 | 256 | row.move_child("1-a", '4') 257 | self.assertListEqual(panels(row), ["2-b", "4-d", "3-c", "1-a"]) 258 | 259 | def test_panel(self): 260 | panel = mock_panel() 261 | 262 | self.assertEqual(panel.id, 1) 263 | self.assertEqual(panel.name, '1-any-panel') 264 | 265 | def test_panel_update(self): 266 | panel = mock_panel() 267 | new_panel = Panel(panel_source(2, "New panel"), 2) 268 | 269 | panel.update(new_panel) 270 | 271 | self.assertEqual(panel.id, 1) 272 | self.assertEqual(panel.name, '1-new-panel') 273 | 274 | dashboard = mock_dashboard('any_dashboard') 275 | with self.assertRaises(InvalidDocument): 276 | panel.update(dashboard) 277 | 278 | row = mock_row() 279 | with self.assertRaises(InvalidDocument): 280 | panel.update(row) 281 | 282 | def test_panel_remove_child(self): 283 | panel = mock_panel() 284 | 285 | with self.assertRaises(InvalidDocument): 286 | panel.remove_child("1-any-name") 287 | 288 | 289 | if __name__ == "__main__": 290 | unittest.main() 291 | -------------------------------------------------------------------------------- /tests/test_resources.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | import os 3 | import sys 4 | import unittest 5 | from unittest.mock import patch 6 | 7 | LIB_PATH = os.path.dirname(os.path.realpath(__file__)) + '/../' 8 | CONFIG_PATH = os.path.join(LIB_PATH, 'grafcli.conf.example') 9 | 10 | sys.path.append(LIB_PATH) 11 | 12 | from climb.config import load_config_file 13 | load_config_file(CONFIG_PATH) 14 | 15 | from grafcli.resources import Resources 16 | from grafcli.exceptions import InvalidPath 17 | from grafcli.resources.local import LocalResources 18 | from grafcli.resources.templates import DashboardsTemplates, RowsTemplates, PanelTemplates 19 | 20 | 21 | class ResourcesTest(unittest.TestCase): 22 | 23 | def setUp(self): 24 | self.remote_patcher = patch('grafcli.resources.resources.RemoteResources') 25 | self.remote_resources = self.remote_patcher.start() 26 | 27 | def tearDown(self): 28 | self.remote_patcher.stop() 29 | 30 | def test_list(self): 31 | r = Resources() 32 | 33 | self.assertEqual(r.list(None), ['backups', 'remote', 'templates']) 34 | self.assertEqual(r.list('remote'), ['localhost']) 35 | self.assertEqual(r.list('templates'), ('dashboards', 'rows', 'panels')) 36 | 37 | with self.assertRaises(InvalidPath): 38 | r.list('invalid_path') 39 | 40 | def test_get_empty(self): 41 | r = Resources() 42 | with self.assertRaises(InvalidPath): 43 | r.get(None) 44 | 45 | def test_parse_path(self): 46 | r = Resources() 47 | 48 | manager, parts = r._parse_path('/backups/a/b/c') 49 | self.assertIsInstance(manager, LocalResources) 50 | self.assertListEqual(parts, ['a', 'b', 'c']) 51 | 52 | manager, parts = r._parse_path('/templates/dashboards/a/b') 53 | self.assertIsInstance(manager, DashboardsTemplates) 54 | self.assertListEqual(parts, ['a', 'b']) 55 | 56 | manager, parts = r._parse_path('/remote/host.example.com/a/b') 57 | self.remote_resources.assert_called_once_with('host.example.com') 58 | self.assertListEqual(parts, ['a', 'b']) 59 | 60 | with self.assertRaises(InvalidPath): 61 | r._parse_path('/invalid/path') 62 | 63 | 64 | if __name__ == "__main__": 65 | unittest.main() 66 | -------------------------------------------------------------------------------- /tests/test_resources_common.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | import os 3 | import sys 4 | import unittest 5 | from unittest.mock import Mock 6 | 7 | LIB_PATH = os.path.dirname(os.path.realpath(__file__)) + '/../' 8 | CONFIG_PATH = os.path.join(LIB_PATH, 'grafcli.conf.example') 9 | 10 | sys.path.append(LIB_PATH) 11 | 12 | from climb.config import load_config_file 13 | load_config_file(CONFIG_PATH) 14 | 15 | from grafcli.exceptions import InvalidPath, DocumentNotFound, InvalidDocument 16 | from grafcli.resources.common import CommonResources 17 | from grafcli.documents import Dashboard, Row, Panel 18 | 19 | from tests.test_documents import dashboard_source, row_source, panel_source, mock_dashboard 20 | 21 | 22 | class DummyResources(CommonResources): 23 | def __init__(self): 24 | self._storage = Mock() 25 | 26 | self._storage.dashboard_id = None 27 | self._storage.dashboard = None 28 | 29 | def get(_): 30 | return mock_dashboard('any_dashboard') 31 | 32 | def save(dashboard_id, dashboard): 33 | self._storage.dashboard_id = dashboard_id 34 | self._storage.dashboard = dashboard 35 | 36 | self._storage.get.side_effect = get 37 | self._storage.save.side_effect = save 38 | 39 | 40 | class CommonResourcesTest(unittest.TestCase): 41 | 42 | def test_list(self): 43 | res = DummyResources() 44 | res._storage.list.return_value = ['any_dashboard_1', 'any_dashboard_2'] 45 | 46 | self.assertListEqual(res.list(), 47 | ['any_dashboard_1', 'any_dashboard_2']) 48 | 49 | self.assertListEqual(res.list('any_dashboard'), 50 | ['1-a', '2-b']) 51 | 52 | self.assertListEqual(res.list('any_dashboard', '1-a'), 53 | ['1-aa', '2-ab']) 54 | self.assertListEqual(res.list('any_dashboard', '2-B'), 55 | ['3-ba', '4-bb']) 56 | 57 | with self.assertRaises(DocumentNotFound): 58 | res.list('any_dashboard', '3-c') 59 | 60 | with self.assertRaises(InvalidPath): 61 | res.list('any_dashboard', '1-a', '1-aa') 62 | 63 | def test_get(self): 64 | res = DummyResources() 65 | dashboard = res.get('any_dashboard') 66 | 67 | self.assertIsInstance(dashboard, Dashboard) 68 | self.assertEqual(dashboard.id, 'any_dashboard') 69 | 70 | row = res.get('any_dashboard', '1-a') 71 | self.assertIsInstance(row, Row) 72 | self.assertEqual(row.id, 1) 73 | self.assertEqual(row.name, '1-a') 74 | 75 | panel = res.get('any_dashboard', '1-a', '1-aa') 76 | self.assertIsInstance(panel, Panel) 77 | self.assertEqual(panel.id, 1) 78 | self.assertEqual(panel.name, '1-aa') 79 | 80 | with self.assertRaises(InvalidPath): 81 | res.get() 82 | 83 | with self.assertRaises(DocumentNotFound): 84 | res.get('any_dashboard', '3-c') 85 | 86 | with self.assertRaises(DocumentNotFound): 87 | res.get('any_dashboard', '1-a', '3-ac') 88 | 89 | def test_save_dashboard(self): 90 | res = DummyResources() 91 | dashboard = Dashboard(dashboard_source(), 'new_dashboard') 92 | 93 | # Add new dashboard 94 | res.save(dashboard) 95 | self.assertEqual(res._storage.dashboard_id, 'new_dashboard') 96 | self.assertEqual(res._storage.dashboard.id, 'new_dashboard') 97 | 98 | # Replace dashboard 99 | res.save(dashboard, 'any_dashboard') 100 | self.assertEqual(res._storage.dashboard_id, 'any_dashboard') 101 | self.assertEqual(res._storage.dashboard.id, 'any_dashboard') 102 | 103 | # Add new dashboard with custom name 104 | res._storage.get.side_effect = DocumentNotFound 105 | res.save(dashboard, 'custom_dashboard') 106 | self.assertEqual(res._storage.dashboard_id, 'custom_dashboard') 107 | self.assertEqual(res._storage.dashboard.id, 'custom_dashboard') 108 | 109 | def test_save_row(self): 110 | res = DummyResources() 111 | row = Row(row_source("New row", [])) 112 | 113 | with self.assertRaises(InvalidDocument): 114 | res.save(row) 115 | 116 | # Add new row 117 | res.save(row, 'any_dashboard') 118 | self.assertEqual(res._storage.dashboard_id, 'any_dashboard') 119 | self.assertEqual(len(res._storage.dashboard.rows), 3) 120 | self.assertEqual(res._storage.dashboard.row('3-new-row').name, '3-new-row') 121 | 122 | # Replace row 123 | res.save(row, 'any_dashboard', '1-a') 124 | self.assertEqual(res._storage.dashboard_id, 'any_dashboard') 125 | self.assertEqual(len(res._storage.dashboard.rows), 2) 126 | self.assertEqual(len(res._storage.dashboard.row('1-a').panels), 0) 127 | 128 | # Add new row with custom name 129 | res._storage.get.side_effect = DocumentNotFound 130 | with self.assertRaises(DocumentNotFound): 131 | res.save(row, 'any_dashboard', '100-new-row') 132 | 133 | def test_save_panel(self): 134 | res = DummyResources() 135 | panel = Panel(panel_source(42, "ac")) 136 | 137 | with self.assertRaises(InvalidDocument): 138 | res.save(panel) 139 | 140 | # Add new panel 141 | res.save(panel, 'any_dashboard', '1-a') 142 | self.assertEqual(res._storage.dashboard_id, 'any_dashboard') 143 | self.assertEqual(len(res._storage.dashboard.row('1-a').panels), 3) 144 | self.assertEqual(res._storage.dashboard.row('1-a').panel('5-ac').name, '5-ac') 145 | 146 | # Replace panel 147 | res.save(panel, 'any_dashboard', '1-a', '1-aa') 148 | self.assertEqual(res._storage.dashboard_id, 'any_dashboard') 149 | self.assertEqual(len(res._storage.dashboard.row('1-a').panels), 2) 150 | 151 | # Add new panel with custom name 152 | res._storage.get.side_effect = DocumentNotFound 153 | with self.assertRaises(DocumentNotFound): 154 | res.save(panel, 'any_dashboard', '1-a', '100-new-panel') 155 | 156 | def test_remove_dashboard(self): 157 | res = DummyResources() 158 | 159 | with self.assertRaises(InvalidPath): 160 | res.remove() 161 | 162 | res.remove('any_dashboard') 163 | res._storage.remove.assert_called_once_with('any_dashboard') 164 | 165 | def test_remove_row(self): 166 | res = DummyResources() 167 | 168 | res.remove('any_dashboard', '1-a') 169 | self.assertEqual(res._storage.dashboard_id, 'any_dashboard') 170 | self.assertEqual(len(res._storage.dashboard.rows), 1) 171 | 172 | def test_remove_panel(self): 173 | res = DummyResources() 174 | 175 | res.remove('any_dashboard', '1-a', '1-aa') 176 | self.assertEqual(res._storage.dashboard_id, 'any_dashboard') 177 | self.assertEqual(len(res._storage.dashboard.row('1-aa').panels), 1) 178 | 179 | 180 | if __name__ == "__main__": 181 | unittest.main() 182 | --------------------------------------------------------------------------------