├── .github ├── dependabot.yml └── workflows │ └── tests.yml ├── .gitignore ├── AUTHORS.md ├── CHANGES.md ├── CONTRIBUTING.md ├── LICENSE ├── MANIFEST.in ├── README.md ├── docs ├── backlog.md └── sandbox.md ├── examples └── dashboard_import.py ├── grafana_import ├── __init__.py ├── cli.py ├── conf │ └── grafana-import.yml ├── constants.py ├── grafana.py ├── service.py └── util.py ├── pyproject.toml ├── rebuild.sh ├── setup.py └── tests ├── __init__.py ├── conftest.py ├── test_cli.py ├── test_core.py ├── test_util.py └── util.py /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | # To get started with Dependabot version updates, you'll need to specify which 2 | # package ecosystems to update and where the package manifests are located. 3 | # Please see the documentation for all configuration options: 4 | # https://docs.github.com/github/administering-a-repository/configuration-options-for-dependency-updates 5 | 6 | version: 2 7 | updates: 8 | - package-ecosystem: "pip" 9 | directory: "/" 10 | schedule: 11 | interval: "weekly" 12 | 13 | - package-ecosystem: "github-actions" 14 | directory: "/" 15 | schedule: 16 | interval: "monthly" 17 | -------------------------------------------------------------------------------- /.github/workflows/tests.yml: -------------------------------------------------------------------------------- 1 | name: Tests 2 | 3 | on: 4 | pull_request: ~ 5 | push: 6 | branches: [ main ] 7 | 8 | # Allow job to be triggered manually. 9 | workflow_dispatch: 10 | 11 | # Cancel in-progress jobs when pushing to the same branch. 12 | concurrency: 13 | cancel-in-progress: true 14 | group: ${{ github.workflow }}-${{ github.ref }} 15 | 16 | jobs: 17 | 18 | tests: 19 | 20 | runs-on: ${{ matrix.os }} 21 | strategy: 22 | matrix: 23 | os: ["ubuntu-20.04"] 24 | python-version: ["3.6", "3.13"] 25 | fail-fast: false 26 | 27 | services: 28 | grafana: 29 | image: grafana/grafana:latest 30 | ports: 31 | - 3000:3000 32 | env: 33 | GF_SECURITY_ADMIN_PASSWORD: admin 34 | 35 | env: 36 | OS: ${{ matrix.os }} 37 | PYTHON: ${{ matrix.python-version }} 38 | 39 | name: " 40 | Python ${{ matrix.python-version }}, 41 | OS ${{ matrix.os }}" 42 | steps: 43 | 44 | - name: Acquire sources 45 | uses: actions/checkout@v4 46 | 47 | - name: Set up Python 48 | uses: actions/setup-python@v5 49 | with: 50 | python-version: ${{ matrix.python-version }} 51 | architecture: x64 52 | cache: 'pip' 53 | cache-dependency-path: | 54 | pyproject.toml 55 | setup.py 56 | 57 | - name: Set up project 58 | run: | 59 | 60 | # `setuptools 0.64.0` adds support for editable install hooks (PEP 660). 61 | # https://github.com/pypa/setuptools/blob/main/CHANGES.rst#v6400 62 | pip install pip setuptools --upgrade 63 | 64 | # Install package in editable mode. 65 | pip install --use-pep517 --prefer-binary --editable=.[test,develop] 66 | 67 | - name: Check code style 68 | if: matrix.python-version != '3.6' && matrix.python-version != '3.7' 69 | run: | 70 | poe lint 71 | 72 | - name: Run software tests 73 | run: | 74 | poe test 75 | 76 | - name: Upload coverage to Codecov 77 | uses: codecov/codecov-action@v5 78 | env: 79 | CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }} 80 | with: 81 | files: ./coverage.xml 82 | flags: unittests 83 | env_vars: OS,PYTHON 84 | name: codecov-umbrella 85 | fail_ci_if_error: false 86 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | rebuild.sh 2 | */__pycache__/* 3 | .idea 4 | .vscode/* 5 | build/* 6 | dist/* 7 | *.egg-*/ 8 | .venv* 9 | .coverage* 10 | coverage.xml 11 | -------------------------------------------------------------------------------- /AUTHORS.md: -------------------------------------------------------------------------------- 1 | # Credits 2 | 3 | ## Development Lead 4 | 5 | * @peekjef72 6 | 7 | ## Contributors 8 | 9 | * @amotl 10 | * @jl2397 11 | * @rdleon 12 | * @vrymar / @vitaliirymar 13 | 14 | None yet. Why not be the first? 15 | -------------------------------------------------------------------------------- /CHANGES.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | ## Unreleased 4 | 5 | ## v0.5.0 (2025-02-22) 6 | - Improved Python API authentication when using URL-based connectivity, 7 | by respecting the `credential` keyword argument 8 | - Added basic example program about how to import a dashboard 9 | - Options: Permitted usage without authentication credentials or token 10 | - Options: Do not assume defaults for `ACTION` and `--grafana_label` args 11 | - Options: Print help text when using erroneous parameters 12 | 13 | ## v0.4.0 (2024-10-16) 14 | - Fixed folder argument issue 15 | - Fixed import dashboards into a folder 16 | - Added keep-uid argument to preserve the dashboard uid provided in file 17 | - Added an option to import dashboards from a directory 18 | 19 | Thanks, @vrymar. 20 | 21 | ## v0.3.0 (2024-10-03) 22 | * Permit invocation without configuration file for ad hoc operations. 23 | In this mode, the Grafana URL can optionally be defined using the 24 | environment variable `GRAFANA_URL`. 25 | * Fix exit codes in failure situations. 26 | * Fix exception handling and propagation in failure situations. 27 | * Add feature to support dashboard builders, in the spirit of 28 | dashboard-as-code. Supports Grafonnet, grafana-dashboard, grafanalib, 29 | and any other kind of executable program generating Grafana Dashboard 30 | JSON. 31 | * Add watchdog feature, monitoring the input dashboard for changes on 32 | disk, and re-uploading it, when changed. 33 | * Pass `GRAFANA_TOKEN` environment variable on Grafana initialization. 34 | Thanks, @jl2397. 35 | 36 | ## v0.2.0 (2022-02-05) 37 | * Migrated from grafana_api to grafana_client 38 | 39 | ## v0.1.0 (2022-02-01) 40 | * Fixed behavior for dashboard moved from one folder to another 41 | 42 | ## v0.0.3 (2022-02-31) 43 | * Added "remove dashboard" feature 44 | 45 | ## v0.0.2 (2022-01-07) 46 | * Changed config file format from json to yml 47 | * Added labels in config to define multi grafana servers. 48 | 49 | ## v0.0.1 (2021-03-15) 50 | * First release on GitHub. 51 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing 2 | 3 | Contributions are welcome, and they are greatly appreciated. Every 4 | little helps, and credit will always be given. 5 | 6 | You can contribute in many ways: 7 | 8 | ## Types of Contributions 9 | 10 | ### Report Bugs 11 | 12 | Report bugs at https://github.com/grafana-toolbox/grafana-import/issues. 13 | 14 | If you are reporting a bug, please include: 15 | 16 | * Your operating system name and version. 17 | * Any details about your local setup that might be helpful in troubleshooting. 18 | * Detailed steps to reproduce the bug. 19 | 20 | ### Fix Bugs 21 | 22 | Look through the GitHub issues for bugs. Anything tagged with "bug" 23 | is open to whoever wants to implement it. 24 | 25 | ### Implement Features 26 | 27 | Look through the GitHub issues for features. Anything tagged with "feature" 28 | is open to whoever wants to implement it. 29 | 30 | ### Write Documentation 31 | 32 | This tool could always use more documentation, whether as part of the 33 | official docs, in docstrings, or even on the web in blog posts, 34 | articles, and such. 35 | 36 | ### Submit Feedback 37 | 38 | The best way to send feedback is to file an issue at https://github.com/grafana-toolbox/grafana-import/issues 39 | 40 | If you are proposing a feature: 41 | 42 | * Explain in detail how it would work. 43 | * Keep the scope as narrow as possible, to make it easier to implement. 44 | * Remember that this is a volunteer-driven project, and that contributions 45 | are welcome :) 46 | 47 | ## Get Started! 48 | 49 | Ready to contribute? Here's how to set up `grafana-import` for 50 | local development. 51 | 52 | 1. _Fork_ the `grafana-import` repo on GitHub. 53 | 54 | [Fork](https://github.com/grafana-toolbox/grafana-import/fork) 55 | 56 | 2. Clone your fork locally: 57 | 58 | $ git clone git@github.com:your_name_here/grafana-import.git 59 | 60 | 3. Create a branch for local development: 61 | 62 | $ git checkout -b name-of-your-bugfix-or-feature 63 | 64 | Now you can make your changes locally. 65 | 66 | 4. When you're done making changes, check that your changes pass style and unit 67 | tests, including testing other Python versions with grafana-import 68 | 69 | $ grafana-import 70 | 71 | 5. Commit your changes and push your branch to GitHub: 72 | 73 | $ git add . 74 | $ git commit -m "Your detailed description of your changes." 75 | $ git push origin name-of-your-bugfix-or-feature 76 | 77 | 6. Submit a pull request through the GitHub website. 78 | 79 | ## Pull Request Guidelines 80 | 81 | Before you submit a pull request, check that it meets these guidelines: 82 | 83 | 1. The pull request should include tests. 84 | 2. If the pull request adds functionality, the docs should be updated. Put 85 | your new functionality into a function with a docstring, and add the 86 | feature to the list in README.rst. 87 | 3. The pull request should work for Python 3.6, and for PyPy. 88 | 89 | ## Tips 90 | 91 | For installing a development sandbox, please refer to the documentation 92 | about the [development sandbox](./docs/sandbox.md). 93 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | 2 | Apache License 3 | Version 2.0, January 2004 4 | https://www.apache.org/licenses/ 5 | 6 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 7 | 8 | 1. Definitions. 9 | 10 | "License" shall mean the terms and conditions for use, reproduction, 11 | and distribution as defined by Sections 1 through 9 of this document. 12 | 13 | "Licensor" shall mean the copyright owner or entity authorized by 14 | the copyright owner that is granting the License. 15 | 16 | "Legal Entity" shall mean the union of the acting entity and all 17 | other entities that control, are controlled by, or are under common 18 | control with that entity. For the purposes of this definition, 19 | "control" means (i) the power, direct or indirect, to cause the 20 | direction or management of such entity, whether by contract or 21 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 22 | outstanding shares, or (iii) beneficial ownership of such entity. 23 | 24 | "You" (or "Your") shall mean an individual or Legal Entity 25 | exercising permissions granted by this License. 26 | 27 | "Source" form shall mean the preferred form for making modifications, 28 | including but not limited to software source code, documentation 29 | source, and configuration files. 30 | 31 | "Object" form shall mean any form resulting from mechanical 32 | transformation or translation of a Source form, including but 33 | not limited to compiled object code, generated documentation, 34 | and conversions to other media types. 35 | 36 | "Work" shall mean the work of authorship, whether in Source or 37 | Object form, made available under the License, as indicated by a 38 | copyright notice that is included in or attached to the work 39 | (an example is provided in the Appendix below). 40 | 41 | "Derivative Works" shall mean any work, whether in Source or Object 42 | form, that is based on (or derived from) the Work and for which the 43 | editorial revisions, annotations, elaborations, or other modifications 44 | represent, as a whole, an original work of authorship. For the purposes 45 | of this License, Derivative Works shall not include works that remain 46 | separable from, or merely link (or bind by name) to the interfaces of, 47 | the Work and Derivative Works thereof. 48 | 49 | "Contribution" shall mean any work of authorship, including 50 | the original version of the Work and any modifications or additions 51 | to that Work or Derivative Works thereof, that is intentionally 52 | submitted to Licensor for inclusion in the Work by the copyright owner 53 | or by an individual or Legal Entity authorized to submit on behalf of 54 | the copyright owner. For the purposes of this definition, "submitted" 55 | means any form of electronic, verbal, or written communication sent 56 | to the Licensor or its representatives, including but not limited to 57 | communication on electronic mailing lists, source code control systems, 58 | and issue tracking systems that are managed by, or on behalf of, the 59 | Licensor for the purpose of discussing and improving the Work, but 60 | excluding communication that is conspicuously marked or otherwise 61 | designated in writing by the copyright owner as "Not a Contribution." 62 | 63 | "Contributor" shall mean Licensor and any individual or Legal Entity 64 | on behalf of whom a Contribution has been received by Licensor and 65 | subsequently incorporated within the Work. 66 | 67 | 2. Grant of Copyright License. Subject to the terms and conditions of 68 | this License, each Contributor hereby grants to You a perpetual, 69 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 70 | copyright license to reproduce, prepare Derivative Works of, 71 | publicly display, publicly perform, sublicense, and distribute the 72 | Work and such Derivative Works in Source or Object form. 73 | 74 | 3. Grant of Patent License. Subject to the terms and conditions of 75 | this License, each Contributor hereby grants to You a perpetual, 76 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 77 | (except as stated in this section) patent license to make, have made, 78 | use, offer to sell, sell, import, and otherwise transfer the Work, 79 | where such license applies only to those patent claims licensable 80 | by such Contributor that are necessarily infringed by their 81 | Contribution(s) alone or by combination of their Contribution(s) 82 | with the Work to which such Contribution(s) was submitted. If You 83 | institute patent litigation against any entity (including a 84 | cross-claim or counterclaim in a lawsuit) alleging that the Work 85 | or a Contribution incorporated within the Work constitutes direct 86 | or contributory patent infringement, then any patent licenses 87 | granted to You under this License for that Work shall terminate 88 | as of the date such litigation is filed. 89 | 90 | 4. Redistribution. You may reproduce and distribute copies of the 91 | Work or Derivative Works thereof in any medium, with or without 92 | modifications, and in Source or Object form, provided that You 93 | meet the following conditions: 94 | 95 | (a) You must give any other recipients of the Work or 96 | Derivative Works a copy of this License; and 97 | 98 | (b) You must cause any modified files to carry prominent notices 99 | stating that You changed the files; and 100 | 101 | (c) You must retain, in the Source form of any Derivative Works 102 | that You distribute, all copyright, patent, trademark, and 103 | attribution notices from the Source form of the Work, 104 | excluding those notices that do not pertain to any part of 105 | the Derivative Works; and 106 | 107 | (d) If the Work includes a "NOTICE" text file as part of its 108 | distribution, then any Derivative Works that You distribute must 109 | include a readable copy of the attribution notices contained 110 | within such NOTICE file, excluding those notices that do not 111 | pertain to any part of the Derivative Works, in at least one 112 | of the following places: within a NOTICE text file distributed 113 | as part of the Derivative Works; within the Source form or 114 | documentation, if provided along with the Derivative Works; or, 115 | within a display generated by the Derivative Works, if and 116 | wherever such third-party notices normally appear. The contents 117 | of the NOTICE file are for informational purposes only and 118 | do not modify the License. You may add Your own attribution 119 | notices within Derivative Works that You distribute, alongside 120 | or as an addendum to the NOTICE text from the Work, provided 121 | that such additional attribution notices cannot be construed 122 | as modifying the License. 123 | 124 | You may add Your own copyright statement to Your modifications and 125 | may provide additional or different license terms and conditions 126 | for use, reproduction, or distribution of Your modifications, or 127 | for any such Derivative Works as a whole, provided Your use, 128 | reproduction, and distribution of the Work otherwise complies with 129 | the conditions stated in this License. 130 | 131 | 5. Submission of Contributions. Unless You explicitly state otherwise, 132 | any Contribution intentionally submitted for inclusion in the Work 133 | by You to the Licensor shall be under the terms and conditions of 134 | this License, without any additional terms or conditions. 135 | Notwithstanding the above, nothing herein shall supersede or modify 136 | the terms of any separate license agreement you may have executed 137 | with Licensor regarding such Contributions. 138 | 139 | 6. Trademarks. This License does not grant permission to use the trade 140 | names, trademarks, service marks, or product names of the Licensor, 141 | except as required for reasonable and customary use in describing the 142 | origin of the Work and reproducing the content of the NOTICE file. 143 | 144 | 7. Disclaimer of Warranty. Unless required by applicable law or 145 | agreed to in writing, Licensor provides the Work (and each 146 | Contributor provides its Contributions) on an "AS IS" BASIS, 147 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 148 | implied, including, without limitation, any warranties or conditions 149 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 150 | PARTICULAR PURPOSE. You are solely responsible for determining the 151 | appropriateness of using or redistributing the Work and assume any 152 | risks associated with Your exercise of permissions under this License. 153 | 154 | 8. Limitation of Liability. In no event and under no legal theory, 155 | whether in tort (including negligence), contract, or otherwise, 156 | unless required by applicable law (such as deliberate and grossly 157 | negligent acts) or agreed to in writing, shall any Contributor be 158 | liable to You for damages, including any direct, indirect, special, 159 | incidental, or consequential damages of any character arising as a 160 | result of this License or out of the use or inability to use the 161 | Work (including but not limited to damages for loss of goodwill, 162 | work stoppage, computer failure or malfunction, or any and all 163 | other commercial damages or losses), even if such Contributor 164 | has been advised of the possibility of such damages. 165 | 166 | 9. Accepting Warranty or Additional Liability. While redistributing 167 | the Work or Derivative Works thereof, You may choose to offer, 168 | and charge a fee for, acceptance of support, warranty, indemnity, 169 | or other liability obligations and/or rights consistent with this 170 | License. However, in accepting such obligations, You may act only 171 | on Your own behalf and on Your sole responsibility, not on behalf 172 | of any other Contributor, and only if You agree to indemnify, 173 | defend, and hold each Contributor harmless for any liability 174 | incurred by, or claims asserted against, such Contributor by reason 175 | of your accepting any such warranty or additional liability. 176 | 177 | END OF TERMS AND CONDITIONS 178 | 179 | Copyright 2017 Centreon. 180 | 181 | Licensed under the Apache License, Version 2.0 (the "License"); 182 | you may not use this file except in compliance with the License. 183 | You may obtain a copy of the License at 184 | 185 | https://www.apache.org/licenses/LICENSE-2.0 186 | 187 | Unless required by applicable law or agreed to in writing, software 188 | distributed under the License is distributed on an "AS IS" BASIS, 189 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 190 | See the License for the specific language governing permissions and 191 | limitations under the License. 192 | 193 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include *.md 2 | include LICENSE 3 | recursive-include grafana_import *.yml *.yaml 4 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Grafana import Tool 2 | 3 | [![Tests](https://github.com/grafana-toolbox/grafana-import/actions/workflows/tests.yml/badge.svg)](https://github.com/grafana-toolbox/grafana-import/actions/workflows/tests.yml) 4 | [![Coverage](https://codecov.io/gh/grafana-toolbox/grafana-import/branch/main/graph/badge.svg)](https://app.codecov.io/gh/grafana-toolbox/grafana-import) 5 | [![PyPI Version](https://img.shields.io/pypi/v/grafana-import.svg)](https://pypi.org/project/grafana-import/) 6 | [![Python Version](https://img.shields.io/pypi/pyversions/grafana-import.svg)](https://pypi.org/project/grafana-import/) 7 | [![PyPI Downloads](https://pepy.tech/badge/grafana-import/month)](https://pepy.tech/project/grafana-import/) 8 | [![Status](https://img.shields.io/pypi/status/grafana-import.svg)](https://pypi.org/project/grafana-import/) 9 | [![License](https://img.shields.io/pypi/l/grafana-import.svg)](https://pypi.org/project/grafana-import/) 10 | 11 | _Export and import Grafana dashboards using the [Grafana HTTP API] and 12 | [grafana-client]._ 13 | 14 | 15 | ## Features 16 | 17 | - Export dashboards into JSON format. 18 | - Import dashboards into Grafana, both in native JSON format, or 19 | emitted by dashboard builders, supporting dashboard-as-code workflows. 20 | - Supported builders are [Grafonnet], [grafana-dashboard], [grafanalib], 21 | and any other executable program which emits Grafana Dashboard JSON 22 | on STDOUT. 23 | - The import action preserves the version history of dashboards. 24 | - Watchdog: For a maximum of authoring and editing efficiency, the 25 | watchdog monitors the input dashboard for changes on disk, and 26 | re-uploads it to the Grafana API, when changed. 27 | - Remove dashboards. 28 | 29 | 30 | ## Installation 31 | 32 | ```shell 33 | pip install --upgrade 'grafana-import[builder]' 34 | ``` 35 | 36 | The command outlined above describes a full installation of `grafana-import`, 37 | including support for dashboard builders, aka. dashboard-as-code. 38 | 39 | ## Synopsis 40 | 41 | ### Command-line use 42 | ```shell 43 | grafana-import import -u http://admin:admin@localhost:3000 -i grafana_dashboard.json --overwrite 44 | ``` 45 | 46 | ### API use 47 | ```python 48 | import json 49 | from pathlib import Path 50 | from grafana_import.grafana import Grafana 51 | 52 | dashboard = json.loads(Path("grafana_dashboard.json").read_text()) 53 | gio = Grafana(url="http://localhost:3000", credential=("admin", "admin")) 54 | outcome = gio.import_dashboard(dashboard) 55 | ``` 56 | 57 | ## Ad Hoc Usage 58 | 59 | You can use `grafana-import` in ad hoc mode without a configuration file. 60 | 61 | ### Getting started 62 | 63 | In order to do some orientation flights, start a Grafana instance using Podman 64 | or Docker. 65 | ```shell 66 | docker run --rm -it --name=grafana --publish=3000:3000 \ 67 | --env='GF_SECURITY_ADMIN_PASSWORD=admin' grafana/grafana:latest 68 | ``` 69 | 70 | If you don't have any Grafana dashboard representations at hand, you can 71 | acquire some from the `examples` directory within the `grafana-import` 72 | repository, like this. 73 | ```shell 74 | wget https://github.com/grafana-toolbox/grafana-snippets/raw/main/dashboard/native-play-influxdb.json 75 | wget https://github.com/grafana-toolbox/grafana-snippets/raw/main/dashboard/gd-prometheus.py 76 | ``` 77 | 78 | Define Grafana endpoint. 79 | ```shell 80 | export GRAFANA_URL=http://admin:admin@localhost:3000 81 | ``` 82 | 83 | ### Import from JSON 84 | Import a dashboard from a JSON file. 85 | ```shell 86 | grafana-import import -i native-play-influxdb.json 87 | ``` 88 | 89 | ### Import using a builder 90 | Import a dashboard emitted by a dashboard builder, overwriting it 91 | when a dashboard with the same name already exists in the same folder. 92 | ```shell 93 | grafana-import import --overwrite -i gd-prometheus.py 94 | ``` 95 | 96 | ### Import using reloading 97 | Watch the input dashboard for changes on disk, and re-upload it, when changed. 98 | ```shell 99 | grafana-import import --overwrite --reload -i gd-prometheus.py 100 | ``` 101 | 102 | ### Import dashboards from a directory 103 | Import all dashboards from provided directory 104 | ```shell 105 | grafana-import import -i "./dashboards_folder" 106 | ``` 107 | 108 | ### Export 109 | Export the dashboard titled `my-first-dashboard` to the default export directory. 110 | ```bash 111 | grafana-import export --pretty -d "my-first-dashboard" 112 | ``` 113 | 114 | ### Delete 115 | Delete the dashboard titled `my-first-dashboard` from folder `Applications`. 116 | ```bash 117 | grafana-import remove -f Applications -d "my-first-dashboard" 118 | ``` 119 | 120 | 121 | ## Usage with Configuration File 122 | 123 | You can also use `grafana-import` with a configuration file. In this way, you 124 | can manage and use different Grafana connection profiles, and also use presets 125 | for application-wide configuration settings. 126 | 127 | The configuration is stored in a YAML file. In order to use it optimally, 128 | build a directory structure like this: 129 | ``` 130 | grafana-import/ 131 | - conf/grafana-import.yml 132 | Path to your main configuration file. 133 | - exports/ 134 | Path where exported dashboards will be stored. 135 | - imports/ 136 | Path where dashboards are imported from. 137 | ``` 138 | 139 | Then, enter into your directory, and type in your commands. 140 | 141 | The configuration file uses two sections, `general`, and `grafana`. 142 | 143 | ### `general` section 144 | Configure program directories. 145 | 146 | * **debug**: enable verbose (debug) trace (for dev only...) 147 | * **export_suffix**: when exporting a dashboard, append that suffix to the file name. The suffix can contain plain text and pattern that is translated with strftime command. 148 | * **export_path**: where to store the exported dashboards. 149 | * **import_path**: where to load the dashboards before to import then into grafana server. 150 | 151 | ### `grafana` section 152 | 153 | Grafana authentication settings. You can define multiple Grafana access 154 | configuration sections using different settings for `api_key` or Grafana 155 | server URL. 156 | 157 | * **label**: A label to refer this Grafana server, e.g. `default` 158 | * **protocol**, **host**, **port**: use to build the access url 159 | * **verify_ssl**: to check ssl certificate or not 160 | * **token**: APIKEY with admin right from Grafana to access the REST API. 161 | * **search_api_limit**: the maximum element to retrieve on search over API. 162 | 163 |
164 | 165 | **Example:** 166 | 167 | ```yaml 168 | --- 169 | 170 | general: 171 | debug: false 172 | import_folder: test_import 173 | 174 | grafana: 175 | default: 176 | protocol: http 177 | host: localhost 178 | port: 3000 179 | token: "____APIKEY____" 180 | search_api_limit: 5000 181 | verify_ssl: true 182 | ``` 183 |
184 | 185 | 186 | ## Authentication 187 | 188 | In order to connect to Grafana, you can use either vanilla credentials 189 | (username/password), or an authentication token. Because `grafana-import` 190 | uses `grafana-client`, the same features for defining authentication 191 | settings can be used. See also [grafana-client authentication variants]. 192 | 193 | Vanilla credentials can be embedded into the Grafana URL, to be supplied 194 | using the `--grafana_url` command line argument, or the `GRAFANA_URL` 195 | environment variable. For specifying a Grafana authentication token without 196 | using a configuration file, use the `GRAFANA_TOKEN` environment variable. 197 | 198 | [grafana-client authentication variants]: https://github.com/panodata/grafana-client/#authentication 199 | 200 | 201 | ## Builders 202 | 203 | grafana-import provides support for dashboard-as-code builders. 204 | 205 | To get inspired what you can do, by reading more examples, please also visit 206 | [grafonnet examples], [grafana-dashboard examples], and [grafanalib examples]. 207 | 208 | ### Grafonnet 209 | 210 | [Grafonnet] is a [Jsonnet] library for generating Grafana dashboards. 211 | 212 | The library is generated from JSON Schemas generated by Grok. In turn, 213 | these schemas are generated directly from the Grafana repository, in 214 | order to ensure Grafonnet is always synchronized with the development 215 | of Grafana without much friction. 216 | 217 | #### Install 218 | Install Jsonnet, and its jsonnet-bundler package manager. 219 | ```shell 220 | brew install go-jsonnet jsonnet-bundler 221 | ``` 222 | Install Grafonnet into a Jsonnet project. 223 | ```shell 224 | git clone https://github.com/grafana-toolbox/grafana-snippets 225 | cd grafana-snippets/dashboard/grafonnet-simple 226 | jb install github.com/grafana/grafonnet/gen/grafonnet-latest@main 227 | ``` 228 | 229 | #### Usage 230 | Render dashboard defined in [Grafonnet]/[Jsonnet]. 231 | ```shell 232 | grafana-import import --overwrite -i ./path/to/faro.jsonnet 233 | ``` 234 | 235 | ### grafana-dashboard 236 | Render dashboard defined using [grafana-dashboard]. 237 | ```shell 238 | grafana-import import --overwrite -i ./path/to/gd-dashboard.py 239 | ``` 240 | 241 | ### grafanalib 242 | Render dashboard defined using [grafanalib]. 243 | ```shell 244 | grafana-import import --overwrite -i ./path/to/gl-dashboard.py 245 | ``` 246 | 247 | 248 | ## Help 249 | 250 | `grafana-import --help` 251 | ```shell 252 | usage: grafana-import [-h] [-a] [-b BASE_PATH] [-c CONFIG_FILE] 253 | [-d DASHBOARD_NAME] [-g GRAFANA_LABEL] 254 | [-f GRAFANA_FOLDER] [-i DASHBOARD_FILE] [-o] [-p] [-v] [-k] 255 | [-V] 256 | [ACTION] 257 | 258 | play with grafana dashboards json files. 259 | 260 | positional arguments: 261 | ACTION action to perform. Is one of 'export', 'import' 262 | (default), or 'remove'. 263 | export: lookup for dashboard name in Grafana and dump 264 | it to local file. 265 | import: import a local dashboard file (previously 266 | exported) to Grafana. 267 | remove: lookup for dashboard name in Grafana and remove 268 | it from Grafana server. 269 | 270 | 271 | optional arguments: 272 | -h, --help show this help message and exit 273 | -a, --allow_new if a dashboard with same name exists in an another 274 | folder, allow to create a new dashboard with same name 275 | it that folder. 276 | -b BASE_PATH, --base_path BASE_PATH 277 | set base directory to find default files. 278 | -c CONFIG_FILE, --config_file CONFIG_FILE 279 | path to config files. 280 | -d DASHBOARD_NAME, --dashboard_name DASHBOARD_NAME 281 | name of dashboard to export. 282 | -u GRAFANA_URL, --grafana_url GRAFANA_URL 283 | Grafana URL to connect to. 284 | -g GRAFANA_LABEL, --grafana_label GRAFANA_LABEL 285 | label in the config file that represents the grafana to 286 | connect to. 287 | -f GRAFANA_FOLDER, --grafana_folder GRAFANA_FOLDER 288 | the folder name where to import into Grafana. 289 | -i DASHBOARD_FILE, --dashboard_file DASHBOARD_FILE 290 | path to the dashboard file to import into Grafana. 291 | -k --keep_uid keep uid defined in dashboard file to import into Grafana. When dashboard is overriden, the uid is also overriden. 292 | -o, --overwrite if a dashboard with same name exists in folder, 293 | overwrite it with this new one. 294 | -r, --reload Watch the input dashboard for changes on disk, and 295 | re-upload it, when changed. 296 | -p, --pretty use JSON indentation when exporting or extraction of 297 | dashboards. 298 | -v, --verbose verbose mode; display log message to stdout. 299 | -V, --version display program version and exit. 300 | 301 | ``` 302 | 303 | ## Prior Art 304 | 305 | - https://grafana.com/blog/2020/02/26/how-to-configure-grafana-as-code/ 306 | - https://grafana.com/blog/2022/12/06/a-complete-guide-to-managing-grafana-as-code-tools-tips-and-tricks/ 307 | - https://grafana.github.io/grizzly/ 308 | https://grafana.github.io/grizzly/what-is-grizzly/ 309 | - https://docs.ansible.com/ansible/latest/collections/grafana/grafana/dashboard_module.html#ansible-collections-grafana-grafana-dashboard-module 310 | - https://blog.kevingomez.fr/2023/03/07/three-years-of-grafana-dashboards-as-code/ 311 | - https://github.com/K-Phoen/grabana 312 | - https://github.com/K-Phoen/dark 313 | - https://github.com/grafana/scenes 314 | 315 | 316 | ## Contributing 317 | 318 | Contributions are welcome, and they are greatly appreciated. You can contribute 319 | in many ways, and credit will always be given. 320 | 321 | For learning more how to contribute, see the [contribution guidelines] and 322 | learn how to set up a [development sandbox]. 323 | 324 | 325 | [contribution guidelines]: ./CONTRIBUTING.md 326 | [development sandbox]: ./docs/sandbox.md 327 | [Grafana HTTP API]: https://grafana.com/docs/grafana/latest/http_api/ 328 | [grafana-client]: https://github.com/panodata/grafana-client 329 | [grafana-dashboard]: https://github.com/fzyzcjy/grafana_dashboard_python 330 | [grafana-dashboard examples]: https://github.com/fzyzcjy/grafana_dashboard_python/tree/master/examples 331 | [grafanalib]: https://github.com/weaveworks/grafanalib 332 | [grafanalib examples]: https://github.com/weaveworks/grafanalib/tree/main/grafanalib/tests/examples 333 | [Grafonnet]: https://github.com/grafana/grafonnet 334 | [grafonnet examples]: https://github.com/grafana/grafonnet/tree/main/examples 335 | [Jsonnet]: https://github.com/google/go-jsonnet 336 | -------------------------------------------------------------------------------- /docs/backlog.md: -------------------------------------------------------------------------------- 1 | # grafana-import backlog 2 | 3 | ## Iteration +1 4 | - Print dashboard URL after uploading 5 | - `grafana-dashboard` offers the option to convert Grafana JSON 6 | back to Python code. It should be used on the `export` subsystem, 7 | to provide an alternative output format. 8 | https://github.com/fzyzcjy/grafana_dashboard_python/tree/master/examples/json_to_python 9 | - Builder: Support grafanalib 10 | - https://community.panodata.org/t/grafanalib-is-a-python-library-for-building-grafana-dashboards/102 11 | - Builder: Support Grafonnet 12 | - https://github.com/grafana/grafonnet/ 13 | - https://community.panodata.org/t/grafonnet-a-jsonnet-library-for-generating-grafana-dashboards/158 14 | - Builder: Support Grafana `cog` 15 | - https://github.com/grafana/cog 16 | - Slogan: Import and export Grafana dashboards .... 17 | -------------------------------------------------------------------------------- /docs/sandbox.md: -------------------------------------------------------------------------------- 1 | # Development Sandbox 2 | 3 | 4 | ## Setup 5 | Those commands will get you started with a sandboxed development environment. 6 | After invoking `poe check`, and observing the software tests succeed, you 7 | should be ready to start hacking. 8 | 9 | ```shell 10 | git clone https://github.com/grafana-toolbox/grafana-import 11 | cd grafana-import 12 | python3 -m venv .venv 13 | source .venv/bin/activate 14 | pip install --editable='.[develop,test]' 15 | ``` 16 | 17 | 18 | ## Software tests 19 | 20 | For running the software tests after setup, invoke `poe check`. 21 | Optionally, activate the virtualenv, if you are coming back to 22 | development using a fresh terminal session. 23 | 24 | Run linters and software tests. 25 | ```shell 26 | source .venv/bin/activate 27 | poe check 28 | ``` 29 | 30 | Run a subset of tests. 31 | ```shell 32 | pytest -k core 33 | ``` 34 | 35 | 36 | ## Releasing 37 | 38 | ```shell 39 | # Install a few more prerequisites. 40 | pip install --editable='.[release]' 41 | 42 | # Designate a new version. 43 | git tag v0.1.0 44 | git push --tags 45 | 46 | # Build package, and publish to PyPI. 47 | poe release 48 | ``` 49 | -------------------------------------------------------------------------------- /examples/dashboard_import.py: -------------------------------------------------------------------------------- 1 | """ 2 | ## About 3 | 4 | Upload a Grafana dashboard file in JSON format to the Grafana server. 5 | 6 | ## Walkthrough 7 | 8 | 1. Start Grafana 9 | 10 | docker run --rm -it --publish=3000:3000 --env='GF_SECURITY_ADMIN_PASSWORD=admin' grafana/grafana:11.5.2 11 | 12 | 2. Import dashboard 13 | 14 | wget https://github.com/simonprickett/mongodb-hotel-jobs/raw/refs/heads/main/grafana_dashboard.json 15 | python examples/dashboard_import.py grafana_dashboard.json 16 | 17 | 3. Visit Grafana 18 | 19 | http://localhost:3000/ 20 | Log in using admin:admin. 21 | """ 22 | 23 | import json 24 | import sys 25 | from pathlib import Path 26 | 27 | from grafana_import.grafana import Grafana 28 | 29 | 30 | def main(): 31 | 32 | # Read single positional CLI argument. 33 | dashboard_path = Path(sys.argv[1]) 34 | 35 | # Load dashboard JSON from filesystem. 36 | dashboard = json.loads(dashboard_path.read_text()) 37 | 38 | # Import dashboard JSON to Grafana. 39 | # Note: Adjust parameters to match your Grafana. 40 | # You can use many variants to authenticate with its API. 41 | gio = Grafana(url="http://localhost:3000", credential=("admin", "admin")) 42 | outcome = gio.import_dashboard(dashboard) 43 | if outcome: 44 | print("Grafana dashboard imported successfully") 45 | else: 46 | print("Grafana dashboard import failed") 47 | 48 | 49 | if __name__ == "__main__": 50 | main() 51 | -------------------------------------------------------------------------------- /grafana_import/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/grafana-toolbox/grafana-import/6cc8d88de20761a4a4f72aa2ea938a0014db34cf/grafana_import/__init__.py -------------------------------------------------------------------------------- /grafana_import/cli.py: -------------------------------------------------------------------------------- 1 | """ 2 | Created on Mon March 15th 2021 3 | 4 | @author: jfpik 5 | 6 | Suivi des modifications : 7 | V 0.0.0 - 2021/03/15 - JF. PIK - initial version 8 | 9 | """ 10 | 11 | import argparse 12 | import json 13 | import logging 14 | import os 15 | import re 16 | import sys 17 | import traceback 18 | from datetime import datetime 19 | 20 | import grafana_client.client as GrafanaApi 21 | 22 | import grafana_import.grafana as Grafana 23 | from grafana_import.constants import CONFIG_NAME, PKG_NAME, PKG_VERSION 24 | from grafana_import.service import watchdog_service 25 | from grafana_import.util import grafana_settings, load_yaml_config, read_dashboard_file, setup_logging 26 | 27 | config = None 28 | 29 | 30 | def save_dashboard(config, args, base_path, dashboard_name, dashboard, action): 31 | 32 | output_file = base_path 33 | file_name = dashboard_name 34 | 35 | if "exports_path" in config["general"] and not re.search(r"^(\.|\/)?/", config["general"]["exports_path"]): 36 | output_file = os.path.join(output_file, config["general"]["exports_path"]) 37 | 38 | if "export_suffix" in config["general"]: 39 | file_name += datetime.today().strftime(config["general"]["export_suffix"]) 40 | 41 | if "meta" in dashboard and "folderId" in dashboard["meta"] and dashboard["meta"]["folderId"] != 0: 42 | file_name = dashboard["meta"]["folderTitle"] + "_" + file_name 43 | 44 | file_name = Grafana.remove_accents_and_space(file_name) 45 | output_file = os.path.join(output_file, file_name + ".json") 46 | try: 47 | output = open(output_file, "w") 48 | except OSError as e: 49 | logger.error("File {0} error: {1}.".format(output_file, e.strerror)) 50 | sys.exit(2) 51 | 52 | content = None 53 | if args.pretty: 54 | content = json.dumps(dashboard["dashboard"], sort_keys=True, indent=2) 55 | else: 56 | content = json.dumps(dashboard["dashboard"]) 57 | output.write(content) 58 | output.close() 59 | logger.info(f"OK: Dashboard '{dashboard_name}' {action} to: {output_file}") 60 | 61 | 62 | class myArgs: 63 | attrs = [ 64 | "pattern", 65 | "base_path", 66 | "config_file", 67 | "grafana", 68 | "dashboard_name", 69 | "pretty", 70 | "overwrite", 71 | "allow_new", 72 | "verbose", 73 | "keep_uid", 74 | ] 75 | 76 | def __init__(self): 77 | 78 | for attr in myArgs.attrs: 79 | setattr(self, attr, None) 80 | 81 | def __repr__(self): 82 | obj = {} 83 | for attr in myArgs.attrs: 84 | val = getattr(self, attr) 85 | if val is not None: 86 | obj[attr] = val 87 | return json.dumps(obj) 88 | 89 | 90 | logger = logging.getLogger(__name__) 91 | 92 | 93 | def main(): 94 | 95 | setup_logging() 96 | 97 | # Get command line arguments. 98 | parser = argparse.ArgumentParser(description="play with grafana dashboards json files.") 99 | 100 | def print_help_and_exit(): 101 | parser.print_help(sys.stderr) 102 | parser.exit(1) 103 | 104 | parser.add_argument( 105 | "-a", 106 | "--allow_new", 107 | action="store_true", 108 | default=False, 109 | help="If a dashboard with same name exists in an another folder, " 110 | "allow to create a new dashboard with same name it that folder.", 111 | ) 112 | 113 | parser.add_argument("-b", "--base_path", help="set base directory to find default files.") 114 | parser.add_argument("-c", "--config_file", help="path to config files.") 115 | 116 | parser.add_argument("-d", "--dashboard_name", help="name of dashboard to export.") 117 | 118 | parser.add_argument("-u", "--grafana_url", help="Grafana URL to connect to.", required=False) 119 | 120 | parser.add_argument( 121 | "-g", 122 | "--grafana_label", 123 | help="label in the config file that represents the grafana to connect to.", 124 | ) 125 | 126 | parser.add_argument("-f", "--grafana_folder", help="the folder name where to import into Grafana.") 127 | 128 | parser.add_argument("-i", "--dashboard_file", help="path to the dashboard file to import into Grafana.") 129 | 130 | parser.add_argument( 131 | "-o", 132 | "--overwrite", 133 | action="store_true", 134 | default=False, 135 | help="if a dashboard with same name exists in same folder, overwrite it with this new one.", 136 | ) 137 | 138 | parser.add_argument( 139 | "-r", 140 | "--reload", 141 | action="store_true", 142 | default=False, 143 | help="Watch the input dashboard for changes on disk, and re-upload it, when changed.", 144 | ) 145 | 146 | parser.add_argument( 147 | "-p", "--pretty", action="store_true", help="use JSON indentation when exporting or extraction of dashboards." 148 | ) 149 | 150 | parser.add_argument("-v", "--verbose", action="store_true", help="verbose mode; display log message to stdout.") 151 | 152 | parser.add_argument( 153 | "-V", 154 | "--version", 155 | action="version", 156 | version="{0} {1}".format(PKG_NAME, PKG_VERSION), 157 | help="display program version and exit..", 158 | ) 159 | 160 | parser.add_argument( 161 | "action", 162 | metavar="ACTION", 163 | nargs="?", 164 | choices=["import", "export", "remove"], 165 | help="action to perform. Is one of 'export', 'import' (default), or 'remove'.\n" 166 | "export: lookup for dashboard name in Grafana and dump it to local file.\n" 167 | "import: import a local dashboard file (previously exported) to Grafana.\n" 168 | "remove: lookup for dashboard name in Grafana and remove it from Grafana server.", 169 | ) 170 | 171 | parser.add_argument( 172 | "-k", 173 | "--keep_uid", 174 | action="store_true", 175 | default=False, 176 | help="when importing dashboard, keep dashboard uid defined in the json file.", 177 | ) 178 | 179 | inArgs = myArgs() 180 | args = parser.parse_args(namespace=inArgs) 181 | 182 | base_path = os.curdir 183 | if args.base_path is not None: 184 | base_path = inArgs.base_path 185 | 186 | if args.config_file is None: 187 | config = {"general": {"debug": False}} 188 | else: 189 | config_file = os.path.join(base_path, CONFIG_NAME) 190 | if not re.search(r"^(\.|\/)?/", config_file): 191 | config_file = os.path.join(base_path, args.config_file) 192 | else: 193 | config_file = args.config_file 194 | config = load_yaml_config(config_file) 195 | 196 | if args.verbose is None: 197 | if "debug" in config["general"]: 198 | args.verbose = config["general"]["debug"] 199 | else: 200 | args.verbose = False 201 | 202 | if args.allow_new is None: 203 | args.allow_new = False 204 | 205 | if args.overwrite is None: 206 | args.overwrite = False 207 | 208 | if args.pretty is None: 209 | args.pretty = False 210 | 211 | if args.dashboard_name is not None: 212 | config["general"]["dashboard_name"] = args.dashboard_name 213 | 214 | if args.action == "exporter" and ( 215 | "dashboard_name" not in config["general"] or config["general"]["dashboard_name"] is None 216 | ): 217 | logger.error("ERROR: no dashboard has been specified.") 218 | sys.exit(1) 219 | 220 | config["check_folder"] = False 221 | if args.grafana_folder is not None: 222 | config["general"]["grafana_folder"] = args.grafana_folder 223 | config["check_folder"] = True 224 | 225 | if "export_suffix" not in config["general"] or config["general"]["export_suffix"] is None: 226 | config["general"]["export_suffix"] = "_%Y%m%d%H%M%S" 227 | 228 | if args.keep_uid is None: 229 | args.keep_uid = False 230 | 231 | params = grafana_settings(url=args.grafana_url, config=config, label=args.grafana_label) 232 | params.update( 233 | { 234 | "overwrite": args.overwrite, 235 | "allow_new": args.allow_new, 236 | "keep_uid": args.keep_uid, 237 | } 238 | ) 239 | 240 | try: 241 | grafana_api = Grafana.Grafana(**params) 242 | except Exception as ex: 243 | logger.error(str(ex)) 244 | sys.exit(1) 245 | 246 | # Import 247 | if args.action == "import": 248 | if args.dashboard_file is None: 249 | logger.error("ERROR: no file to import provided!") 250 | sys.exit(1) 251 | 252 | # Compute effective input file path. 253 | import_path = "" 254 | import_file = args.dashboard_file 255 | import_files = [] 256 | 257 | if not re.search(r"^(?:(?:/)|(?:\.?\./))", import_file): 258 | import_path = base_path 259 | if "import_path" in config["general"]: 260 | import_path = os.path.join(import_path, config["general"]["import_path"]) 261 | import_file = os.path.join(import_path, import_file) 262 | import_files.append(import_file) 263 | else: 264 | if os.path.isfile(import_file): 265 | logger.info(f"The path is a file: '{import_file}'") 266 | import_file = os.path.join(import_path, import_file) 267 | import_files.append(import_file) 268 | 269 | if os.path.isdir(import_file): 270 | logger.info(f"The path is a directory: '{import_file}'") 271 | import_files = [ 272 | os.path.join(import_file, f) 273 | for f in os.listdir(import_file) 274 | if os.path.isfile(os.path.join(import_file, f)) 275 | ] 276 | logger.info(f"Found the following files: '{import_files}' in dir '{import_file}'") 277 | 278 | def process_dashboard(file_path): 279 | try: 280 | dash = read_dashboard_file(file_path) 281 | except Exception as ex: 282 | msg = f"Failed to load dashboard from: {file_path}. Reason: {ex}" 283 | logger.exception(msg) 284 | raise IOError(msg) from ex 285 | 286 | try: 287 | res = grafana_api.import_dashboard(dash) 288 | except GrafanaApi.GrafanaClientError as ex: 289 | msg = f"Failed to upload dashboard to Grafana. Reason: {ex}" 290 | logger.exception(msg) 291 | raise IOError(msg) from ex 292 | 293 | title = dash["title"] 294 | folder_name = grafana_api.grafana_folder 295 | if res: 296 | logger.info(f"Dashboard '{title}' imported into folder '{folder_name}'") 297 | else: 298 | msg = f"Failed to import dashboard into Grafana. title={title}, folder={folder_name}" 299 | logger.error(msg) 300 | raise IOError(msg) 301 | 302 | for file in import_files: 303 | print(f"Processing file: {file}") 304 | try: 305 | process_dashboard(file) 306 | except Exception as e: 307 | logger.error(f"Failed to process file {file}. Reason: {str(e)}") 308 | continue 309 | 310 | if args.reload: 311 | for file in import_files: 312 | watchdog_service(import_file, process_dashboard(file)) 313 | 314 | sys.exit(0) 315 | 316 | # Remove 317 | elif args.action == "remove": 318 | dashboard_name = config["general"]["dashboard_name"] 319 | try: 320 | grafana_api.remove_dashboard(dashboard_name) 321 | logger.info(f"OK: Dashboard removed: {dashboard_name}") 322 | sys.exit(0) 323 | except Grafana.GrafanaDashboardNotFoundError as exp: 324 | logger.info(f"KO: Dashboard not found in folder '{exp.folder}': {exp.dashboard}") 325 | sys.exit(1) 326 | except Grafana.GrafanaFolderNotFoundError as exp: 327 | logger.info(f"KO: Folder not found: {exp.folder}") 328 | sys.exit(1) 329 | except GrafanaApi.GrafanaBadInputError as exp: 330 | logger.info(f"KO: Removing dashboard failed: {dashboard_name}. Reason: {exp}") 331 | sys.exit(1) 332 | except Exception: 333 | logger.info("ERROR: Dashboard '{0}' remove exception '{1}'".format(dashboard_name, traceback.format_exc())) 334 | sys.exit(1) 335 | 336 | # Export 337 | elif args.action == "export": 338 | dashboard_name = config["general"]["dashboard_name"] 339 | try: 340 | dash = grafana_api.export_dashboard(dashboard_name) 341 | except (Grafana.GrafanaFolderNotFoundError, Grafana.GrafanaDashboardNotFoundError): 342 | logger.info("KO: Dashboard name not found: {0}".format(dashboard_name)) 343 | sys.exit(1) 344 | except Exception: 345 | logger.info("ERROR: Dashboard '{0}' export exception '{1}'".format(dashboard_name, traceback.format_exc())) 346 | sys.exit(1) 347 | 348 | if dash is not None: 349 | save_dashboard(config, args, base_path, dashboard_name, dash, "exported") 350 | sys.exit(0) 351 | 352 | else: 353 | logger.error(f"Unknown action: {args.action}. Use one of: {parser._actions[-2].choices}") 354 | print_help_and_exit() 355 | 356 | 357 | if __name__ == "__main__": 358 | main() 359 | -------------------------------------------------------------------------------- /grafana_import/conf/grafana-import.yml: -------------------------------------------------------------------------------- 1 | --- 2 | 3 | general: 4 | debug: false 5 | import_folder: test_import 6 | 7 | grafana: 8 | default: 9 | protocol: http 10 | host: localhost 11 | port: 3000 12 | token: "____APIKEY____" 13 | search_api_limit: 5000 14 | verify_ssl: true 15 | -------------------------------------------------------------------------------- /grafana_import/constants.py: -------------------------------------------------------------------------------- 1 | PKG_NAME = "grafana-import" 2 | PKG_VERSION = "0.5.0" 3 | CONFIG_NAME = "conf/grafana-import.yml" 4 | -------------------------------------------------------------------------------- /grafana_import/grafana.py: -------------------------------------------------------------------------------- 1 | import os 2 | import re 3 | import traceback 4 | import typing as t 5 | import unicodedata 6 | 7 | import grafana_client.api as GrafanaApi 8 | import grafana_client.client as GrafanaClient 9 | 10 | from grafana_import.constants import PKG_NAME 11 | 12 | 13 | class GrafanaDashboardNotFoundError(Exception): 14 | """ 15 | input: 16 | dashboard_name 17 | folder 18 | message 19 | 20 | """ 21 | 22 | def __init__(self, dashboard_name, folder, message): 23 | self.dashboard = dashboard_name 24 | self.folder = folder 25 | self.message = message 26 | # Backwards compatible with implementations that rely on just the message. 27 | super(GrafanaDashboardNotFoundError, self).__init__(message) 28 | 29 | 30 | class GrafanaFolderNotFoundError(Exception): 31 | """ 32 | input: 33 | folder 34 | message 35 | 36 | """ 37 | 38 | def __init__(self, folder, message): 39 | self.folder = folder 40 | self.message = message 41 | # Backwards compatible with implementations that rely on just the message. 42 | super(GrafanaFolderNotFoundError, self).__init__(message) 43 | 44 | 45 | def remove_accents_and_space(input_str: str) -> str: 46 | """ 47 | build a valid file name from dashboard name. 48 | 49 | as mentioned in the function name remove .... 50 | 51 | input: a dashboard name 52 | 53 | :result: converted string 54 | """ 55 | nfkd_form = unicodedata.normalize("NFKD", input_str) 56 | res = "".join([c for c in nfkd_form if not unicodedata.combining(c)]) 57 | return re.sub(r"\s+", "_", res) 58 | 59 | 60 | class Grafana: 61 | # * to store the folders list, dashboards list (kind of cache) 62 | folders: t.List[t.Any] = [] 63 | dashboards: t.List[t.Any] = [] 64 | 65 | def __init__(self, **kwargs): 66 | 67 | # Configure Grafana connectivity. 68 | if "url" in kwargs: 69 | self.grafana_api = GrafanaApi.GrafanaApi.from_url( 70 | url=kwargs["url"], credential=kwargs.get("credential", os.environ.get("GRAFANA_TOKEN")) 71 | ) 72 | else: 73 | config = {} 74 | config["protocol"] = kwargs.get("protocol", "http") 75 | config["host"] = kwargs.get("host", "localhost") 76 | config["port"] = kwargs.get("port", 3000) 77 | config["token"] = kwargs.get("token", None) 78 | config["verify_ssl"] = kwargs.get("verify_ssl", True) 79 | 80 | self.grafana_api = GrafanaApi.GrafanaApi( 81 | auth=config["token"], 82 | host=config["host"], 83 | protocol=config["protocol"], 84 | port=config["port"], 85 | verify=config["verify_ssl"], 86 | ) 87 | 88 | self.search_api_limit = kwargs.get("search_api_limit", 5000) 89 | # * set the default destination folder for dash 90 | self.grafana_folder = kwargs.get("folder", "General") 91 | 92 | # * when importing dash, allow to overwrite dash if it already exists 93 | self.overwrite = kwargs.get("overwrite", True) 94 | 95 | # * when importing dash, if dashboard name exists, but destination folder mismatch 96 | # * allow to create new dashboard with same name in specified folder. 97 | self.allow_new = kwargs.get("allow_new", False) 98 | 99 | # * when importing dash, keep dashboard uid defined in the json file. 100 | self.keep_uid = kwargs.get("keep_uid", False) 101 | 102 | # * try to connect to the API 103 | try: 104 | res = self.grafana_api.health.check() 105 | if res["database"] != "ok": 106 | raise Exception("grafana is not UP") 107 | except: 108 | raise 109 | 110 | def find_dashboard(self, dashboard_name: str) -> t.Union[t.Dict[str, t.Any], None]: 111 | """ 112 | Retrieve dashboards which name are matching the lookup named. 113 | Some api version didn't return folderTitle. Requires to lookup in two phases. 114 | """ 115 | 116 | # Init cache for dashboards. 117 | if len(Grafana.dashboards) == 0: 118 | # Collect all dashboard names. 119 | try: 120 | res = self.grafana_api.search.search_dashboards(type_="dash-db", limit=self.search_api_limit) 121 | except Exception as ex: 122 | raise Exception("error: {}".format(traceback.format_exc())) from ex 123 | Grafana.dashboards = res 124 | 125 | dashboards = Grafana.dashboards 126 | 127 | folder = { 128 | "id": 0, 129 | "title": "General", 130 | } 131 | if not re.match("general", self.grafana_folder, re.IGNORECASE): 132 | found_folder = self.get_folder(folder_name=self.grafana_folder) 133 | if found_folder is not None: 134 | folder = found_folder 135 | 136 | board = None 137 | # * find the board uid in the list 138 | for cur_dash in dashboards: 139 | if cur_dash["title"] == dashboard_name: 140 | # set current dashbard as found candidate 141 | board = cur_dash 142 | # check the folder part 143 | if ("folderTitle" in cur_dash and cur_dash["folderTitle"] == folder["title"]) or ( 144 | "folderTitle" not in cur_dash and folder["id"] == 0 145 | ): 146 | # this is a requested folder or no folder ! 147 | break 148 | 149 | return board 150 | 151 | def export_dashboard(self, dashboard_name: str) -> t.Dict[str, t.Any]: 152 | """ 153 | retrive the dashboard object from Grafana server. 154 | params: 155 | dashboard_name (str): name of the dashboard to retrieve 156 | result: 157 | dashboard (dict [json]) 158 | """ 159 | 160 | try: 161 | board = self.find_dashboard(dashboard_name) 162 | 163 | if board is None: 164 | raise GrafanaClient.GrafanaClientError(response=None, message="Not Found", status_code=404) 165 | 166 | # Fetch the dashboard JSON representation by UID. 167 | return self.grafana_api.dashboard.get_dashboard(board["uid"]) 168 | except Exception as ex: 169 | if isinstance(ex, GrafanaClient.GrafanaClientError) and ex.status_code == 404: 170 | raise GrafanaDashboardNotFoundError( 171 | dashboard_name, self.grafana_folder, f"Dashboard not found: {dashboard_name}" 172 | ) from ex 173 | raise 174 | 175 | def remove_dashboard(self, dashboard_name: str) -> t.Dict[str, t.Any]: 176 | """ 177 | Retrieve the dashboard object from Grafana server and remove it. 178 | params: 179 | dashboard_name (str): name of the dashboard to retrieve 180 | result: 181 | True or Exception 182 | """ 183 | 184 | res = {} 185 | folder = { 186 | "id": 0, 187 | "title": self.grafana_folder, 188 | } 189 | 190 | # Check if the destination folder is `General`. 191 | if not re.match("general", self.grafana_folder, re.IGNORECASE): 192 | # ** check 'custom' folder existence (custom != General) 193 | folder = self.get_folder(self.grafana_folder) 194 | if folder is None: 195 | raise GrafanaFolderNotFoundError( 196 | self.grafana_folder, 197 | f"Folder not found: {self.grafana_folder}", 198 | ) 199 | 200 | # Collect the board object itself from it uid. 201 | board = self.find_dashboard(dashboard_name) 202 | 203 | if board is None: 204 | raise GrafanaDashboardNotFoundError(dashboard_name, folder["title"], "dashboard not found") 205 | 206 | if (folder["id"] == 0 and "folderId" in board and board["folderId"] != folder["id"]) or ( 207 | folder["id"] != 0 and "folderId" not in board 208 | ): 209 | raise GrafanaApi.GrafanaBadInputError( 210 | "Dashboard name found but in folder '{0}'!".format(board["folderTitle"]) 211 | ) 212 | 213 | if "uid" in board: 214 | res = self.grafana_api.dashboard.delete_dashboard(board["uid"]) 215 | 216 | return res 217 | 218 | def get_folder(self, folder_name: str = None, folder_uid: str = None): 219 | """ 220 | try to find folder meta data (uid...) from folder name 221 | params: 222 | folder_name (str): name of the folder (case sensitive) into Grafana folders tree 223 | return: 224 | folder object (dict) 225 | """ 226 | if folder_name is None and folder_uid is None: 227 | return None 228 | 229 | # * init cache for folders. 230 | if len(Grafana.folders) == 0: 231 | res = self.grafana_api.folder.get_all_folders() 232 | Grafana.folders = res 233 | 234 | folders = Grafana.folders 235 | folder = None 236 | 237 | for tmp_folder in folders: 238 | 239 | if (folder_name is not None and folder_name == tmp_folder["title"]) or ( 240 | folder_uid is not None and folder_uid == tmp_folder["folderId"] 241 | ): 242 | folder = tmp_folder 243 | break 244 | 245 | return folder 246 | 247 | def import_dashboard(self, dashboard: t.Dict[str, t.Any]) -> bool: 248 | 249 | # ** build a temporary meta dashboard struct to store info 250 | # ** by default dashboard will be overwritten 251 | new_dash: t.Dict[str, t.Any] = { 252 | "dashboard": dashboard, 253 | "overwrite": True, 254 | } 255 | 256 | old_dash = self.find_dashboard(dashboard["title"]) 257 | 258 | # ** check a previous dashboard existence (same folder, same uid) 259 | if old_dash is None: 260 | new_dash["overwrite"] = self.overwrite 261 | dashboard["version"] = 1 262 | 263 | # * check the destination folder is General 264 | if re.match("general", self.grafana_folder, re.IGNORECASE): 265 | new_dash["folderId"] = 0 266 | else: 267 | # ** check 'custom' folder existence (custom != General) 268 | folder = self.get_folder(self.grafana_folder) 269 | if folder is None: 270 | folder = self.grafana_api.folder.create_folder(self.grafana_folder) 271 | 272 | if folder: 273 | new_dash["folderId"] = folder["id"] 274 | else: 275 | raise Exception("KO: grafana folder '{0}' creation failed.".format(self.grafana_folder)) 276 | else: 277 | new_dash["folderId"] = folder["id"] 278 | 279 | # ** several case 280 | # read new folder1/dash1(uid1) => old folder1/dash1(uid1): classic update 281 | # b) read new folder_new/dash1(uid1) => old folder1/dash1(uid1): create new dash in folder_new 282 | # => new folder_new/dash1(uid_new) if allow_new 283 | # c) read new folder_new/dash1(uid_new) => old folder1/dash1(uid1): create new in new folder folder_new 284 | # => classic create (update) 285 | # d) read new folder1/dash1(uid_new) => old folder1/dash1(uid1) 286 | # => new folder1/dash1(uid1) if overwrite 287 | if old_dash is not None: 288 | if "meta" in old_dash and "folderUrl" in old_dash["meta"]: 289 | old_dash["folderId"] = old_dash["meta"]["folderId"] 290 | elif "folderId" not in old_dash: 291 | old_dash["folderId"] = 0 292 | 293 | # case b) get a copy of an existing dash to a folder where dash is not present 294 | if new_dash["folderId"] != old_dash["folderId"]: 295 | # if new_dash['dashboard']['uid'] == old_dash['uid']: 296 | if self.allow_new: 297 | new_dash["overwrite"] = False 298 | # force the creation of a new dashboard 299 | new_dash["dashboard"]["uid"] = None 300 | new_dash["dashboard"]["id"] = None 301 | else: 302 | raise GrafanaClient.GrafanaBadInputError( 303 | "Dashboard with the same title already exists in another folder. " 304 | "Use `allow_new` to permit creation in a different folder." 305 | ) 306 | # ** case d) send a copy to existing dash : update existing 307 | elif new_dash["folderId"] == old_dash["folderId"]: 308 | if ( 309 | "uid" not in new_dash["dashboard"] 310 | or new_dash["dashboard"]["uid"] != old_dash["uid"] 311 | or new_dash["dashboard"]["id"] != old_dash["id"] 312 | ): 313 | if self.overwrite: 314 | if not self.keep_uid: 315 | new_dash["dashboard"]["uid"] = old_dash["uid"] 316 | new_dash["dashboard"]["id"] = old_dash["id"] 317 | else: 318 | raise GrafanaClient.GrafanaBadInputError( 319 | "Dashboard with the same title already exists in this folder with another uid. " 320 | "Use `overwrite` to permit overwriting it." 321 | ) 322 | else: 323 | if not self.keep_uid: 324 | # force the creation of a new dashboard 325 | new_dash["dashboard"]["uid"] = None 326 | 327 | new_dash["dashboard"]["id"] = None 328 | new_dash["overwrite"] = False 329 | 330 | new_dash["message"] = "imported from {0}.".format(PKG_NAME) 331 | 332 | res = self.grafana_api.dashboard.update_dashboard(new_dash) 333 | if res["status"]: 334 | res = True 335 | else: 336 | res = False 337 | 338 | return res 339 | -------------------------------------------------------------------------------- /grafana_import/service.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import time 3 | import typing as t 4 | from pathlib import Path 5 | 6 | from watchdog.events import FileSystemEvent, PatternMatchingEventHandler 7 | from watchdog.observers import Observer 8 | 9 | logger = logging.getLogger(__name__) 10 | 11 | 12 | class SingleFileModifiedHandler(PatternMatchingEventHandler): 13 | 14 | def __init__( 15 | self, 16 | *args, 17 | action: t.Union[t.Callable, None] = None, 18 | **kwargs, 19 | ): 20 | self.action = action 21 | super().__init__(*args, **kwargs) 22 | 23 | def on_modified(self, event: FileSystemEvent) -> None: 24 | super().on_modified(event) 25 | logger.info(f"File was modified: {event.src_path}") 26 | try: 27 | self.action and self.action() 28 | logger.debug(f"File processed successfully: {event.src_path}") 29 | except Exception: 30 | logger.exception(f"Processing file failed: {event.src_path}") 31 | 32 | 33 | def watchdog_service(path: Path, action: t.Union[t.Callable, None] = None) -> None: 34 | """ 35 | https://python-watchdog.readthedocs.io/en/stable/quickstart.html 36 | """ 37 | 38 | import_file = Path(path).absolute() 39 | import_path = import_file.parent 40 | 41 | event_handler = SingleFileModifiedHandler(action=action, patterns=[import_file.name], ignore_directories=True) 42 | observer = Observer() 43 | observer.schedule(event_handler, str(import_path), recursive=False) 44 | observer.start() 45 | try: 46 | while True: 47 | time.sleep(1) 48 | finally: 49 | observer.stop() 50 | observer.join() 51 | -------------------------------------------------------------------------------- /grafana_import/util.py: -------------------------------------------------------------------------------- 1 | import json 2 | import logging 3 | import os 4 | import shlex 5 | import subprocess 6 | import sys 7 | import typing as t 8 | from pathlib import Path 9 | 10 | import yaml 11 | 12 | ConfigType = t.Dict[str, t.Any] 13 | SettingsType = t.Dict[str, t.Union[str, int, bool]] 14 | 15 | 16 | def setup_logging(level=logging.INFO, verbose: bool = False): 17 | log_format = "%(asctime)-15s [%(name)-26s] %(levelname)-8s: %(message)s" 18 | logging.basicConfig(format=log_format, stream=sys.stderr, level=level) 19 | 20 | 21 | def load_yaml_config(config_file: str) -> ConfigType: 22 | """ 23 | Load configuration file in YAML format from disk. 24 | """ 25 | try: 26 | with open(config_file, "r") as cfg_fh: 27 | try: 28 | return yaml.safe_load(cfg_fh) 29 | except yaml.scanner.ScannerError as ex: 30 | mark = ex.problem_mark 31 | msg = "YAML file parsing failed : %s - line: %s column: %s => %s" % ( 32 | config_file, 33 | mark and mark.line + 1, 34 | mark and mark.column + 1, 35 | ex.problem, 36 | ) 37 | raise ValueError(f"Configuration file invalid: {config_file}. Reason: {msg}") from ex 38 | except Exception as ex: 39 | raise ValueError(f"Reading configuration file failed: {ex}") from ex 40 | 41 | 42 | def grafana_settings( 43 | url: t.Union[str, None], config: t.Union[ConfigType, None], label: t.Union[str, None] 44 | ) -> SettingsType: 45 | """ 46 | Acquire Grafana connection profile settings, and application settings. 47 | """ 48 | 49 | params: SettingsType = {} 50 | 51 | # Grafana connectivity. 52 | if url or "GRAFANA_URL" in os.environ: 53 | params.update({"url": url or os.environ["GRAFANA_URL"]}) 54 | if "GRAFANA_TOKEN" in os.environ: 55 | params.update({"token": os.environ["GRAFANA_TOKEN"]}) 56 | 57 | if not params and config is not None and label is not None: 58 | params = grafana_settings_from_config_section(config=config, label=label) 59 | 60 | # Additional application parameters. 61 | if config is not None: 62 | params.update( 63 | { 64 | "search_api_limit": config.get("grafana", {}).get("search_api_limit", 5000), 65 | "folder": config.get("general", {}).get("grafana_folder", "General"), 66 | } 67 | ) 68 | return params 69 | 70 | 71 | def grafana_settings_from_config_section(config: ConfigType, label: t.Union[str, None]) -> SettingsType: 72 | """ 73 | Extract Grafana connection profile from configuration dictionary, by label. 74 | 75 | The configuration contains multiple connection profiles within the `grafana` 76 | section. In order to address a specific profile, this function accepts a 77 | `label` string. 78 | """ 79 | if not label or not config.get("grafana", {}).get(label): 80 | raise ValueError(f"Invalid Grafana configuration label: {label}") 81 | 82 | # Initialize default configuration from Grafana by label. 83 | # FIXME: That is certainly a code smell. 84 | # Q: Has it been introduced later in order to support multiple connection profiles? 85 | # Q: Is it needed to update the original `config` dict, or can it just be omitted? 86 | config["grafana"] = config["grafana"][label] 87 | 88 | if "token" not in config["grafana"]: 89 | raise ValueError(f"Authentication token missing in Grafana configuration at: {label}") 90 | 91 | return { 92 | "host": config["grafana"].get("host", "localhost"), 93 | "protocol": config["grafana"].get("protocol", "http"), 94 | "port": config["grafana"].get("port", "3000"), 95 | "token": config["grafana"].get("token"), 96 | "verify_ssl": config["grafana"].get("verify_ssl", True), 97 | } 98 | 99 | 100 | def file_is_executable(path: t.Union[str, Path]) -> bool: 101 | """ 102 | Is this file executable? 103 | 104 | https://bugs.python.org/issue42497 105 | """ 106 | return os.access(str(path), os.X_OK) 107 | 108 | 109 | def read_dashboard_file(path: t.Union[str, Path]) -> t.Dict[str, t.Any]: 110 | """ 111 | Read dashboard file, and return its representation. 112 | """ 113 | 114 | path = Path(path) 115 | 116 | if path.suffix == ".json": 117 | try: 118 | with open(path, "r") as f: 119 | payload = f.read() 120 | except OSError as ex: 121 | raise IOError(f"Reading file failed: {path}. Reason: {ex.strerror}") from ex 122 | 123 | elif path.suffix == ".jsonnet": 124 | # jsonnet --jpath vendor faro.jsonnet 125 | command = f"jsonnet --jpath {path.parent / 'vendor'} {path}" 126 | payload = subprocess.check_output(shlex.split(command), encoding="utf-8") # noqa: S603 127 | 128 | elif path.suffix == ".py": 129 | command = f"{sys.executable} {path}" 130 | payload = subprocess.check_output(shlex.split(command), encoding="utf-8") # noqa: S603 131 | 132 | elif file_is_executable(path): 133 | payload = subprocess.check_output([path], shell=True, encoding="utf-8") # noqa: S602, S603 134 | 135 | else: 136 | raise NotImplementedError(f"Decoding file type not implemented, or file is not executable: {path.name}") 137 | 138 | try: 139 | return json.loads(payload) 140 | except json.JSONDecodeError as ex: 141 | raise IOError(f"Decoding JSON output from file failed: {path}. Reason: {ex}") from ex 142 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [tool.black] 2 | line-length = 120 3 | 4 | [tool.ruff] 5 | line-length = 120 6 | 7 | lint.select = [ 8 | # Builtins 9 | "A", 10 | # Bugbear 11 | "B", 12 | # comprehensions 13 | "C4", 14 | # Pycodestyle 15 | "E", 16 | # eradicate 17 | "ERA", 18 | # Pyflakes 19 | "F", 20 | # isort 21 | "I", 22 | # pandas-vet 23 | "PD", 24 | # return 25 | "RET", 26 | # Bandit 27 | "S", 28 | # print 29 | "T20", 30 | "W", 31 | # flake8-2020 32 | "YTT", 33 | ] 34 | 35 | lint.extend-ignore = [ 36 | ] 37 | 38 | lint.per-file-ignores."grafana_import/cli.py" = [ 39 | "T201", # `print` found 40 | ] 41 | 42 | lint.per-file-ignores."examples/*" = [ 43 | "T201", # `print` found 44 | ] 45 | 46 | # =================== 47 | # Tasks configuration 48 | # =================== 49 | lint.per-file-ignores."tests/*" = [ 50 | # Use of `assert` detected 51 | "S101", 52 | ] 53 | 54 | [tool.pytest.ini_options] 55 | addopts = "-rA --verbosity=3 --cov --cov-report=term-missing --cov-report=xml" 56 | minversion = "2.0" 57 | log_level = "DEBUG" 58 | log_cli_level = "DEBUG" 59 | log_format = "%(asctime)-15s [%(name)-24s] %(levelname)-8s: %(message)s" 60 | testpaths = [ 61 | "grafana_import", 62 | "tests", 63 | ] 64 | xfail_strict = true 65 | markers = [ 66 | ] 67 | 68 | [tool.coverage.run] 69 | branch = false 70 | omit = [ 71 | "tests/*", 72 | ] 73 | source = [ "grafana_import" ] 74 | 75 | [tool.coverage.report] 76 | fail_under = 0 77 | show_missing = true 78 | 79 | [tool.mypy] 80 | packages = [ "grafana_import" ] 81 | install_types = true 82 | ignore_missing_imports = true 83 | implicit_optional = true 84 | non_interactive = true 85 | 86 | [tool.poe.tasks] 87 | 88 | check = [ 89 | "lint", 90 | "test", 91 | ] 92 | 93 | format = [ 94 | { cmd = "black ." }, 95 | # Configure Ruff not to auto-fix (remove!) unused variables (F841) and `print` statements (T201). 96 | { cmd = "ruff check --fix --ignore=ERA --ignore=F401 --ignore=F841 --ignore=T20 ." }, 97 | { cmd = "pyproject-fmt --keep-full-version pyproject.toml" }, 98 | ] 99 | 100 | lint = [ 101 | { cmd = "ruff check ." }, 102 | { cmd = "black --check ." }, 103 | { cmd = "validate-pyproject pyproject.toml" }, 104 | { cmd = "mypy grafana_import" }, 105 | ] 106 | 107 | release = [ 108 | { cmd = "python -m build" }, 109 | { cmd = "twine upload --skip-existing dist/*" }, 110 | ] 111 | 112 | test = { cmd = "pytest" } 113 | -------------------------------------------------------------------------------- /rebuild.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | rm -rf /usr/local/lib/python3.6/site-packages/grafana_import /usr/local/lib/python3.6/site-packages/grafana_import-0.0.1-py3.6.egg-info/ 4 | 5 | pip3 install . 6 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | from setuptools import find_packages, setup 4 | 5 | from grafana_import.constants import PKG_NAME, PKG_VERSION 6 | 7 | # Global variables 8 | requires = [ 9 | "grafana-client<5", 10 | "jinja2<4", 11 | "pyyaml<7", 12 | "watchdog<5", 13 | ] 14 | 15 | extras = { 16 | "builder": [ 17 | "grafana-dashboard==0.1.1", 18 | "grafanalib==0.7.1", 19 | ], 20 | "develop": [ 21 | "black<25", 22 | "mypy<1.10", 23 | "poethepoet<0.26", 24 | "pyproject-fmt<1.8; python_version>='3.7'", 25 | "ruff<0.5; python_version>='3.7'", 26 | "validate-pyproject<0.17", 27 | ], 28 | "release": [ 29 | "build", 30 | "twine", 31 | ], 32 | "test": [ 33 | "grafana-dashboard<0.2; python_version>='3.7'", 34 | "importlib-resources<7; python_version<'3.9'", 35 | # Pydantic is pulled in by grafana-dashboard. Pydantic 1.x is needed on Python 3.12. 36 | # Otherwise, `Error when building FieldInfo from annotated attribute` happens. 37 | "pydantic<2", 38 | "pytest<9", 39 | "pytest-cov<6", 40 | "responses<0.26", 41 | ], 42 | } 43 | 44 | here = os.path.abspath(os.path.dirname(__file__)) 45 | README = open(os.path.join(here, "README.md")).read() 46 | 47 | setup( 48 | name=PKG_NAME, 49 | version=PKG_VERSION, 50 | description="Export and import Grafana dashboards using the Grafana HTTP API.", 51 | long_description_content_type="text/markdown", 52 | long_description=README, 53 | license="Apache 2.0", 54 | author="Jean-Francois Pik", 55 | author_email="jfpik78@gmail.com", 56 | url="https://github.com/grafana-toolbox/grafana-import", 57 | entry_points={"console_scripts": ["grafana-import = grafana_import.cli:main"]}, 58 | packages=find_packages(), 59 | install_requires=requires, 60 | extras_require=extras, 61 | package_data={"": ["conf/*"]}, 62 | classifiers=[ 63 | "Programming Language :: Python", 64 | "License :: OSI Approved :: Apache Software License", 65 | "Development Status :: 4 - Beta", 66 | "Environment :: Console", 67 | "Intended Audience :: Developers", 68 | "Intended Audience :: Education", 69 | "Intended Audience :: Information Technology", 70 | "Intended Audience :: Manufacturing", 71 | "Intended Audience :: Science/Research", 72 | "Intended Audience :: System Administrators", 73 | "Intended Audience :: Telecommunications Industry", 74 | "Operating System :: POSIX", 75 | "Operating System :: Unix", 76 | "Operating System :: MacOS", 77 | "Programming Language :: Python", 78 | "Programming Language :: Python :: 3.6", 79 | "Programming Language :: Python :: 3.7", 80 | "Programming Language :: Python :: 3.8", 81 | "Programming Language :: Python :: 3.9", 82 | "Programming Language :: Python :: 3.10", 83 | "Programming Language :: Python :: 3.11", 84 | "Programming Language :: Python :: 3.12", 85 | "Programming Language :: Python :: 3.13", 86 | "Topic :: Communications", 87 | "Topic :: Database", 88 | "Topic :: Internet", 89 | "Topic :: Scientific/Engineering :: Human Machine Interfaces", 90 | "Topic :: Scientific/Engineering :: Information Analysis", 91 | "Topic :: Scientific/Engineering :: Interface Engine/Protocol Translator", 92 | "Topic :: Scientific/Engineering :: Visualization", 93 | "Topic :: Software Development :: Embedded Systems", 94 | "Topic :: Software Development :: Libraries", 95 | "Topic :: System :: Archiving", 96 | "Topic :: System :: Networking :: Monitoring", 97 | ], 98 | keywords="grafana http api grafana-client grafana-api http-client " 99 | "grafana-utils grafana-automation grafana-toolbox dashboard grafana-dashboard grafanalib grafonnet", 100 | ) 101 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/grafana-toolbox/grafana-import/6cc8d88de20761a4a4f72aa2ea938a0014db34cf/tests/__init__.py -------------------------------------------------------------------------------- /tests/conftest.py: -------------------------------------------------------------------------------- 1 | import os 2 | import sys 3 | import typing as t 4 | 5 | import pytest 6 | import responses 7 | 8 | from grafana_import.grafana import Grafana 9 | from grafana_import.util import grafana_settings, load_yaml_config 10 | from tests.util import mock_grafana_health, mock_grafana_search 11 | 12 | if sys.version_info < (3, 9): 13 | from importlib_resources import files 14 | else: 15 | from importlib.resources import files 16 | 17 | 18 | @pytest.fixture(scope="session", autouse=True) 19 | def niquests_patch_all(): 20 | """ 21 | Patch module namespace, pretend Niquests is Requests. 22 | """ 23 | from sys import modules 24 | 25 | try: 26 | import niquests 27 | except ImportError: 28 | return 29 | import urllib3 30 | 31 | # Amalgamate the module namespace to make all modules aiming 32 | # to use `requests`, in fact use `niquests` instead. 33 | modules["requests"] = niquests 34 | modules["requests.adapters"] = niquests.adapters 35 | modules["requests.sessions"] = niquests.sessions 36 | modules["requests.exceptions"] = niquests.exceptions 37 | modules["requests.packages.urllib3"] = urllib3 38 | 39 | 40 | @pytest.fixture(scope="session", autouse=True) 41 | def reset_environment(): 42 | """ 43 | Make sure relevant environment variables do not leak into the test suite. 44 | """ 45 | if "GRAFANA_URL" in os.environ: 46 | del os.environ["GRAFANA_URL"] 47 | 48 | 49 | @pytest.fixture 50 | def mocked_responses(): 51 | """ 52 | Provide the `responses` mocking machinery to a pytest environment. 53 | """ 54 | if sys.version_info < (3, 7): 55 | raise pytest.skip("Does not work on Python 3.6") 56 | with responses.RequestsMock() as rsps: 57 | yield rsps 58 | 59 | 60 | @pytest.fixture 61 | def mocked_grafana(mocked_responses): 62 | mock_grafana_health(mocked_responses) 63 | mock_grafana_search(mocked_responses) 64 | 65 | 66 | @pytest.fixture 67 | def config(): 68 | config_file = files("grafana_import") / "conf" / "grafana-import.yml" 69 | return load_yaml_config(str(config_file)) 70 | 71 | 72 | @pytest.fixture 73 | def settings(config): 74 | return grafana_settings(url=None, config=config, label="default") 75 | 76 | 77 | @pytest.fixture(autouse=True) 78 | def reset_grafana_importer(): 79 | Grafana.folders = [] 80 | Grafana.dashboards = [] 81 | 82 | 83 | @pytest.fixture 84 | def gio_factory(settings) -> t.Callable: 85 | def mkgrafana(use_settings: bool = True) -> Grafana: 86 | if use_settings: 87 | return Grafana(**settings) 88 | return Grafana(url="http://localhost:3000") 89 | 90 | return mkgrafana 91 | -------------------------------------------------------------------------------- /tests/test_cli.py: -------------------------------------------------------------------------------- 1 | import json 2 | import re 3 | import shlex 4 | import sys 5 | from pathlib import Path 6 | from unittest import mock 7 | 8 | import pytest 9 | 10 | from grafana_import.cli import main 11 | from tests.util import mkdashboard, open_write_noop 12 | 13 | CONFIG_FILE = "grafana_import/conf/grafana-import.yml" 14 | 15 | 16 | def get_settings_arg(use_settings: bool = True): 17 | if use_settings: 18 | return f"--config_file {CONFIG_FILE}" 19 | return "--grafana_url http://localhost:3000" 20 | 21 | 22 | def test_no_action_failure(caplog): 23 | """ 24 | Verify the program fails when invoked without positional `action` argument. 25 | """ 26 | sys.argv = ["grafana-import"] 27 | with pytest.raises(SystemExit) as ex: 28 | main() 29 | assert ex.match("1") 30 | assert "Unknown action: None. Use one of: ['import', 'export', 'remove']" in caplog.messages 31 | 32 | 33 | @pytest.mark.parametrize("use_settings", [True, False], ids=["config-yes", "config-no"]) 34 | def test_import_dashboard_success(mocked_grafana, mocked_responses, tmp_path, caplog, use_settings): 35 | """ 36 | Verify "import dashboard" works. 37 | """ 38 | mocked_responses.post( 39 | "http://localhost:3000/api/dashboards/db", 40 | json={"status": "ok"}, 41 | status=200, 42 | content_type="application/json", 43 | ) 44 | 45 | dashboard = mkdashboard() 46 | dashboard_file = Path(tmp_path / "dashboard.json") 47 | dashboard_file.write_text(json.dumps(dashboard, indent=2)) 48 | 49 | sys.argv = shlex.split(f"grafana-import import {get_settings_arg(use_settings)} --dashboard_file {dashboard_file}") 50 | 51 | with pytest.raises(SystemExit) as ex: 52 | main() 53 | assert ex.match("0") 54 | 55 | assert "Dashboard 'Dashboard One' imported into folder 'General'" in caplog.messages 56 | 57 | 58 | @pytest.mark.parametrize("use_settings", [True, False], ids=["config-yes", "config-no"]) 59 | def test_export_dashboard_success(mocked_grafana, mocked_responses, caplog, use_settings): 60 | """ 61 | Verify "export dashboard" works. 62 | """ 63 | 64 | mocked_responses.get( 65 | "http://localhost:3000/api/dashboards/uid/618f7589-7e3d-4399-a585-372df9fa5e85", 66 | json={"dashboard": {}}, 67 | status=200, 68 | content_type="application/json", 69 | ) 70 | 71 | sys.argv = shlex.split(f"grafana-import export {get_settings_arg(use_settings)} --dashboard_name foobar") 72 | 73 | with pytest.raises(SystemExit) as ex: 74 | m = mock.patch("builtins.open", open_write_noop) 75 | m.start() 76 | main() 77 | m.stop() 78 | assert ex.match("0") 79 | 80 | assert re.match(r".*OK: Dashboard 'foobar' exported to: ./foobar_\d+.json.*", caplog.text, re.DOTALL) 81 | 82 | 83 | @pytest.mark.parametrize("use_settings", [True, False], ids=["config-yes", "config-no"]) 84 | def test_export_dashboard_notfound(mocked_grafana, mocked_responses, caplog, use_settings): 85 | """ 86 | Verify "export dashboard" fails appropriately when addressed dashboard does not exist. 87 | """ 88 | 89 | mocked_responses.get( 90 | "http://localhost:3000/api/dashboards/uid/618f7589-7e3d-4399-a585-372df9fa5e85", 91 | json={}, 92 | status=404, 93 | content_type="application/json", 94 | ) 95 | 96 | sys.argv = shlex.split(f"grafana-import export {get_settings_arg(use_settings)} --dashboard_name foobar") 97 | with pytest.raises(SystemExit) as ex: 98 | main() 99 | assert ex.match("1") 100 | 101 | assert "Dashboard name not found: foobar" in caplog.text 102 | 103 | 104 | @pytest.mark.parametrize("use_settings", [True, False], ids=["config-yes", "config-no"]) 105 | def test_remove_dashboard_success(mocked_grafana, mocked_responses, caplog, use_settings): 106 | """ 107 | Verify "remove dashboard" works. 108 | """ 109 | mocked_responses.delete( 110 | "http://localhost:3000/api/dashboards/uid/618f7589-7e3d-4399-a585-372df9fa5e85", 111 | json={"status": "ok"}, 112 | status=200, 113 | content_type="application/json", 114 | ) 115 | 116 | sys.argv = shlex.split(f"grafana-import remove {get_settings_arg(use_settings)} --dashboard_name foobar") 117 | 118 | with pytest.raises(SystemExit) as ex: 119 | main() 120 | assert ex.match("0") 121 | 122 | assert "OK: Dashboard removed: foobar" in caplog.text 123 | -------------------------------------------------------------------------------- /tests/test_core.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from grafana_import.grafana import Grafana, GrafanaDashboardNotFoundError, GrafanaFolderNotFoundError 4 | from tests.util import mkdashboard, mock_grafana_health 5 | 6 | 7 | @pytest.mark.parametrize("use_settings", [True, False], ids=["config-yes", "config-no"]) 8 | def test_find_dashboard_success(mocked_grafana, gio_factory, use_settings): 9 | """ 10 | Verify "find dashboard" works. 11 | """ 12 | 13 | gio = gio_factory(use_settings=use_settings) 14 | results = gio.find_dashboard("foobar") 15 | assert results == {"title": "foobar", "uid": "618f7589-7e3d-4399-a585-372df9fa5e85"} 16 | 17 | 18 | @pytest.mark.parametrize("use_settings", [True, False], ids=["config-yes", "config-no"]) 19 | def test_import_dashboard_success(mocked_grafana, mocked_responses, gio_factory, use_settings): 20 | """ 21 | Verify "import dashboard" works. 22 | """ 23 | 24 | mocked_responses.post( 25 | "http://localhost:3000/api/dashboards/db", 26 | json={"status": "ok"}, 27 | status=200, 28 | content_type="application/json", 29 | ) 30 | 31 | dashboard = mkdashboard() 32 | 33 | gio = gio_factory(use_settings=use_settings) 34 | outcome = gio.import_dashboard(dashboard) 35 | 36 | assert outcome is True 37 | 38 | 39 | @pytest.mark.parametrize("use_settings", [True, False], ids=["config-yes", "config-no"]) 40 | def test_export_dashboard_success(mocked_grafana, mocked_responses, gio_factory, use_settings): 41 | """ 42 | Verify "export dashboard" works. 43 | """ 44 | mocked_responses.get( 45 | "http://localhost:3000/api/dashboards/uid/618f7589-7e3d-4399-a585-372df9fa5e85", 46 | json={"dashboard": {}}, 47 | status=200, 48 | content_type="application/json", 49 | ) 50 | 51 | gio = gio_factory(use_settings=use_settings) 52 | dashboard = gio.export_dashboard("foobar") 53 | 54 | assert dashboard == {"dashboard": {}} 55 | 56 | 57 | def test_export_dashboard_notfound(mocked_grafana, mocked_responses, gio_factory): 58 | """ 59 | Verify "export dashboard" using an unknown dashboard croaks as expected. 60 | """ 61 | gio = gio_factory() 62 | with pytest.raises(GrafanaDashboardNotFoundError) as ex: 63 | gio.export_dashboard("unknown") 64 | assert ex.match("Dashboard not found: unknown") 65 | 66 | 67 | def test_remove_dashboard_success(mocked_grafana, mocked_responses, settings): 68 | """ 69 | Verify "remove dashboard" works. 70 | """ 71 | mocked_responses.delete( 72 | "http://localhost:3000/api/dashboards/uid/618f7589-7e3d-4399-a585-372df9fa5e85", 73 | json={"status": "ok"}, 74 | status=200, 75 | content_type="application/json", 76 | ) 77 | 78 | gio = Grafana(**settings) 79 | outcome = gio.remove_dashboard("foobar") 80 | assert outcome == {"status": "ok"} 81 | 82 | 83 | def test_remove_dashboard_folder_not_found(mocked_responses, settings): 84 | """ 85 | Verify "remove dashboard" works. 86 | """ 87 | 88 | mock_grafana_health(mocked_responses) 89 | 90 | mocked_responses.get( 91 | "http://localhost:3000/api/folders", 92 | json=[], 93 | status=200, 94 | content_type="application/json", 95 | ) 96 | 97 | settings["folder"] = "non-standard" 98 | gio = Grafana(**settings) 99 | 100 | with pytest.raises(GrafanaFolderNotFoundError) as ex: 101 | gio.remove_dashboard("foobar") 102 | 103 | assert ex.match("Folder not found: non-standard") 104 | -------------------------------------------------------------------------------- /tests/test_util.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | 3 | import pytest 4 | import requests 5 | 6 | from grafana_import.util import read_dashboard_file 7 | 8 | MINIMAL_JSON_URL = "https://github.com/grafana-toolbox/grafana-snippets/raw/main/dashboard/native-minimal.json" 9 | 10 | 11 | @pytest.fixture 12 | def minimal_json_payload() -> str: 13 | return requests.get(MINIMAL_JSON_URL, timeout=5).text 14 | 15 | 16 | def test_read_dashboard_json(tmp_path, minimal_json_payload): 17 | """ 18 | Verify reading a traditional Grafana dashboard in JSON format. 19 | """ 20 | minimal_json = Path(tmp_path) / "minimal.json" 21 | minimal_json.write_text(minimal_json_payload) 22 | 23 | dashboard = read_dashboard_file(minimal_json) 24 | assert dashboard["title"] == "grafana-snippets » Synthetic minimal dashboard" 25 | 26 | 27 | def test_read_dashboard_python(tmp_path, minimal_json_payload): 28 | """ 29 | Verify reading a traditional Grafana dashboard in JSON format. 30 | """ 31 | minimal_json = Path(tmp_path) / "minimal.json" 32 | minimal_json.write_text(minimal_json_payload) 33 | 34 | dashboard = read_dashboard_file(minimal_json) 35 | assert dashboard["title"] == "grafana-snippets » Synthetic minimal dashboard" 36 | 37 | 38 | def test_read_dashboard_unknown(tmp_path): 39 | """ 40 | A file name without a known suffix will trip the program. 41 | """ 42 | example_foo = Path(tmp_path) / "example.foo" 43 | example_foo.touch() 44 | 45 | with pytest.raises(NotImplementedError) as ex: 46 | read_dashboard_file(example_foo) 47 | assert ex.match("Decoding file type not implemented, or file is not executable: example.foo") 48 | 49 | 50 | def test_read_dashboard_builder_file_executable(tmp_path): 51 | """ 52 | A file name without suffix will automatically be considered a builder, and must be executable. 53 | """ 54 | builder = Path(tmp_path) / "minimal-builder" 55 | builder.write_text(f"#!/usr/bin/env sh\ncurl --location {MINIMAL_JSON_URL}") 56 | builder.chmod(0o777) 57 | 58 | dashboard = read_dashboard_file(builder) 59 | assert dashboard["title"] == "grafana-snippets » Synthetic minimal dashboard" 60 | 61 | 62 | def test_read_dashboard_builder_unknown(tmp_path): 63 | """ 64 | A file of "builder" nature, which is not executable, will trip the program. 65 | """ 66 | builder = Path(tmp_path) / "unknown-builder" 67 | builder.touch() 68 | 69 | with pytest.raises(NotImplementedError) as ex: 70 | read_dashboard_file(builder) 71 | assert ex.match("Decoding file type not implemented, or file is not executable: unknown-builder") 72 | -------------------------------------------------------------------------------- /tests/util.py: -------------------------------------------------------------------------------- 1 | import builtins 2 | import io 3 | import json 4 | import typing as t 5 | 6 | import pytest 7 | from responses import RequestsMock 8 | 9 | if t.TYPE_CHECKING: 10 | from mypy.typeshed.stdlib._typeshed import FileDescriptorOrPath, OpenTextMode 11 | 12 | 13 | def mock_grafana_health(responses: RequestsMock) -> None: 14 | """ 15 | Baseline mock for each Grafana conversation. 16 | """ 17 | responses.get( 18 | "http://localhost:3000/api/health", 19 | json={"database": "ok"}, 20 | status=200, 21 | content_type="application/json", 22 | ) 23 | 24 | 25 | def mock_grafana_search(responses: RequestsMock) -> None: 26 | responses.get( 27 | "http://localhost:3000/api/search?type=dash-db&limit=5000", 28 | json=[{"title": "foobar", "uid": "618f7589-7e3d-4399-a585-372df9fa5e85"}], 29 | status=200, 30 | content_type="application/json", 31 | ) 32 | 33 | 34 | def mkdashboard() -> t.Dict[str, t.Any]: 35 | """ 36 | Example Grafana dashboard, generated using the `grafana-dashboard` package. 37 | 38 | https://github.com/fzyzcjy/grafana_dashboard_python/blob/master/examples/python_to_json/input_python/dashboard-one.py 39 | """ 40 | pytest.importorskip( 41 | "grafana_dashboard", reason="Skipping dashboard generation because `grafana-dashboard` is not available" 42 | ) 43 | 44 | from grafana_dashboard.manual_models import TimeSeries 45 | from grafana_dashboard.model.dashboard_types_gen import Dashboard, GridPos 46 | from grafana_dashboard.model.prometheusdataquery_types_gen import PrometheusDataQuery 47 | 48 | dashboard = Dashboard( 49 | title="Dashboard One", 50 | panels=[ 51 | TimeSeries( 52 | title="Panel Title", 53 | gridPos=GridPos(x=0, y=0, w=12, h=9), 54 | targets=[ 55 | PrometheusDataQuery( 56 | datasource="Prometheus", 57 | expr='avg(1 - rate(node_cpu_seconds_total{mode="idle"}[$__rate_interval])) by (instance, job)', 58 | legendFormat="{{instance}}", 59 | ) 60 | ], 61 | ) 62 | ], 63 | ).auto_panel_ids() 64 | return json.loads(dashboard.to_grafana_json()) 65 | 66 | 67 | # Bookkeeping for `open_write_noop`. 68 | real_open = builtins.open 69 | 70 | 71 | def open_write_noop(file: "FileDescriptorOrPath", mode: "OpenTextMode" = "r", **kwargs) -> t.IO: 72 | """ 73 | A replacement for `builtins.open`, masking all write operations. 74 | """ 75 | if mode and mode.startswith("w"): 76 | if "b" in mode: 77 | return io.BytesIO() 78 | return io.StringIO() 79 | return real_open(file=file, mode=mode, **kwargs) 80 | --------------------------------------------------------------------------------