├── .coveragerc
├── .dockerignore
├── .editorconfig
├── .github
├── ISSUE_TEMPLATE
│ ├── bug_report.md
│ ├── feature_request.md
│ └── question-or-other-issue.md
├── dependabot.yml
└── workflows
│ ├── codeql-analysis.yml
│ ├── publish-docker.yml
│ ├── publish-to-pypi.yml
│ ├── security_scanning.yml
│ └── tests.yml
├── .gitignore
├── .pre-commit-config.yaml
├── CHANGELOG.md
├── Dockerfile
├── Gemfile
├── HISTORY.md
├── LICENSE
├── MANIFEST.in
├── README.md
├── RELEASE.md
├── Rakefile
├── docs
├── Debian-Jessie.md
├── Deployment-setups.md
└── EL7.md
├── poetry.lock
├── puppetboard-s2i-template.yaml
├── puppetboard
├── __init__.py
├── app.py
├── core.py
├── default_settings.py
├── docker_settings.py
├── errors.py
├── forms.py
├── schedulers
│ └── classes.py
├── static
│ ├── css
│ │ ├── fonts.css
│ │ ├── puppetboard.css
│ │ └── radiator.css
│ ├── custom-natural-time-delta
│ │ └── natural-time-delta.js
│ ├── custom-natural
│ │ └── natural.js
│ ├── favicon.ico
│ ├── favicon.svg
│ ├── fonts
│ │ ├── cousine
│ │ │ ├── d6lIkaiiRdih4SpP_SAvzAbt.woff2
│ │ │ ├── d6lIkaiiRdih4SpP_SQvzA.woff2
│ │ │ ├── d6lIkaiiRdih4SpP_SYvzAbt.woff2
│ │ │ ├── d6lIkaiiRdih4SpP_ScvzAbt.woff2
│ │ │ ├── d6lIkaiiRdih4SpP_SgvzAbt.woff2
│ │ │ ├── d6lIkaiiRdih4SpP_SkvzAbt.woff2
│ │ │ ├── d6lIkaiiRdih4SpP_SovzAbt.woff2
│ │ │ └── d6lIkaiiRdih4SpP_SsvzAbt.woff2
│ │ ├── lato
│ │ │ ├── S6u8w4BMUTPHjxsAUi-qJCY.woff2
│ │ │ ├── S6u8w4BMUTPHjxsAXC-q.woff2
│ │ │ ├── S6u9w4BMUTPHh6UVSwaPGR_p.woff2
│ │ │ ├── S6u9w4BMUTPHh6UVSwiPGQ.woff2
│ │ │ ├── S6u_w4BMUTPHjxsI5wq_FQft1dw.woff2
│ │ │ ├── S6u_w4BMUTPHjxsI5wq_Gwft.woff2
│ │ │ ├── S6uyw4BMUTPHjx4wXg.woff2
│ │ │ └── S6uyw4BMUTPHjxAwXjeu.woff2
│ │ └── open-sans
│ │ │ ├── memSYaGs126MiZpBA-UvWbX2vVnXBbObj2OVZyOOSr4dVJWUgsjZ0B4gaVI.woff2
│ │ │ ├── memSYaGs126MiZpBA-UvWbX2vVnXBbObj2OVZyOOSr4dVJWUgsjZ0B4iaVIGxA.woff2
│ │ │ ├── memSYaGs126MiZpBA-UvWbX2vVnXBbObj2OVZyOOSr4dVJWUgsjZ0B4jaVIGxA.woff2
│ │ │ ├── memSYaGs126MiZpBA-UvWbX2vVnXBbObj2OVZyOOSr4dVJWUgsjZ0B4kaVIGxA.woff2
│ │ │ ├── memSYaGs126MiZpBA-UvWbX2vVnXBbObj2OVZyOOSr4dVJWUgsjZ0B4saVIGxA.woff2
│ │ │ ├── memSYaGs126MiZpBA-UvWbX2vVnXBbObj2OVZyOOSr4dVJWUgsjZ0B4taVIGxA.woff2
│ │ │ ├── memSYaGs126MiZpBA-UvWbX2vVnXBbObj2OVZyOOSr4dVJWUgsjZ0B4uaVIGxA.woff2
│ │ │ └── memSYaGs126MiZpBA-UvWbX2vVnXBbObj2OVZyOOSr4dVJWUgsjZ0B4vaVIGxA.woff2
│ ├── js
│ │ ├── dailychart.js
│ │ ├── pretty.js
│ │ ├── radiator.js
│ │ ├── scroll.top.js
│ │ └── utils.js
│ └── libs
│ │ ├── billboard.js
│ │ ├── billboard.min.css
│ │ └── billboard.pkgd.min.js
│ │ ├── datatables.net-buttons-se
│ │ ├── buttons.semanticui.min.css
│ │ └── buttons.semanticui.min.js
│ │ ├── datatables.net-buttons
│ │ ├── buttons.colVis.min.js
│ │ ├── buttons.html5.min.js
│ │ └── dataTables.buttons.min.js
│ │ ├── datatables.net-se
│ │ ├── dataTables.semanticui.min.css
│ │ └── dataTables.semanticui.min.js
│ │ ├── datatables.net
│ │ └── jquery.dataTables.min.js
│ │ ├── fomantic-ui
│ │ ├── semantic.min.css
│ │ ├── semantic.min.js
│ │ └── themes
│ │ │ └── default
│ │ │ └── assets
│ │ │ ├── fonts
│ │ │ ├── brand-icons.eot
│ │ │ ├── brand-icons.svg
│ │ │ ├── brand-icons.ttf
│ │ │ ├── brand-icons.woff
│ │ │ ├── brand-icons.woff2
│ │ │ ├── icons.eot
│ │ │ ├── icons.svg
│ │ │ ├── icons.ttf
│ │ │ ├── icons.woff
│ │ │ ├── icons.woff2
│ │ │ ├── outline-icons.eot
│ │ │ ├── outline-icons.svg
│ │ │ ├── outline-icons.ttf
│ │ │ ├── outline-icons.woff
│ │ │ └── outline-icons.woff2
│ │ │ └── images
│ │ │ └── flags.png
│ │ ├── jquery
│ │ └── jquery.min.js
│ │ ├── jszip
│ │ └── jszip.min.js
│ │ └── moment.js
│ │ └── moment-with-locales.min.js
├── templates
│ ├── 400.html
│ ├── 403.html
│ ├── 404.html
│ ├── 412.html
│ ├── 500.html
│ ├── _facts_sorter.html
│ ├── _macros.html
│ ├── _static_offline.html
│ ├── _static_online.html
│ ├── catalog.html
│ ├── catalog_compare.html
│ ├── catalogs.html
│ ├── catalogs.json.tpl
│ ├── class.html
│ ├── class_resource.json.tpl
│ ├── classes.html
│ ├── classes.json.tpl
│ ├── fact.html
│ ├── facts.html
│ ├── failures.html
│ ├── index.html
│ ├── inventory.html
│ ├── inventory.json.tpl
│ ├── layout.html
│ ├── metric.html
│ ├── metrics.html
│ ├── node.html
│ ├── nodes.html
│ ├── query.html
│ ├── radiator.html
│ ├── report.html
│ ├── reports.html
│ └── reports.json.tpl
├── utils.py
├── version.py
└── views
│ ├── catalogs.py
│ ├── classes.py
│ ├── dailychart.py
│ ├── facts.py
│ ├── failures.py
│ ├── index.py
│ ├── inventory.py
│ ├── metrics.py
│ ├── nodes.py
│ ├── query.py
│ ├── radiator.py
│ └── reports.py
├── pylintrc
├── pyproject.toml
├── pytest.ini
├── requirements-docker.txt
├── requirements-test.txt
├── requirements.txt
├── screenshots
├── class.png
├── classes.png
├── fact.png
├── fact_value.png
├── facts.png
├── failures.png
├── inventory.png
├── metric.png
├── metrics.png
├── node.png
├── nodes.png
├── overview.png
├── query_result_json.png
├── query_result_table.png
├── radiator.png
└── report.png
├── settings.py.sample
├── setup.cfg
├── setup.py
├── test
├── __init__.py
├── conftest.py
├── data
│ └── test_json_report_ok
├── test_app.py
├── test_app_error.py
├── test_core.py
├── test_docker_settings.py
├── test_form.py
├── test_utils.py
└── views
│ ├── __init__.py
│ ├── test_catalogs.py
│ ├── test_classes.py
│ ├── test_dailychart.py
│ ├── test_facts.py
│ ├── test_index.py
│ ├── test_inventory.py
│ ├── test_metrics.py
│ ├── test_nodes.py
│ ├── test_query.py
│ ├── test_radiator.py
│ └── test_reports.py
└── wsgi.py
/.coveragerc:
--------------------------------------------------------------------------------
1 | [report]
2 | exclude_lines =
3 | pragma: notest
4 |
5 | [run]
6 | source =
7 | puppetboard
8 |
9 | omit =
10 | # don't check the test themselves, obviously
11 | test/*
12 |
13 | # no point in checking these
14 | setup.py
15 | wsgi.py
16 |
--------------------------------------------------------------------------------
/.dockerignore:
--------------------------------------------------------------------------------
1 | # roughly the same as in .gitignore
2 | .idea
3 | .mypy_cache
4 | .pytest_cache
5 | build
6 | dist
7 | puppetboard.egg-info
8 | venv
9 |
10 | # git-related files
11 | .git
12 |
13 | # we don't need these in the release too
14 | .github
15 | docs
16 | screenshots
17 | test
18 |
--------------------------------------------------------------------------------
/.editorconfig:
--------------------------------------------------------------------------------
1 | # http://editorconfig.org
2 | root = true
3 |
4 | [*]
5 | indent_style = space
6 | indent_size = 2
7 | end_of_line = lf
8 | charset = utf-8
9 | trim_trailing_whitespace = true
10 | insert_final_newline = true
11 |
12 | # Use 4 spaces for the Python files
13 | [*.py]
14 | indent_size = 4
15 | max_line_length = 80
16 |
17 | # The JSON files contain newlines inconsistently
18 | [*.json]
19 | insert_final_newline = ignore
20 |
21 | # Minified JavaScript files shouldn't be changed
22 | [**.min.js]
23 | indent_style = ignore
24 | insert_final_newline = ignore
25 |
26 | [*.md]
27 | trim_trailing_whitespace = false
28 |
29 |
--------------------------------------------------------------------------------
/.github/ISSUE_TEMPLATE/bug_report.md:
--------------------------------------------------------------------------------
1 | ---
2 | name: Bug report
3 | about: Create a report to help us improve
4 | title: ''
5 | labels: bug
6 | assignees: ''
7 |
8 | ---
9 |
10 | **Describe the bug**
11 |
12 | A clear and concise description of what the bug is.
13 |
14 | **Puppetboard version**
15 |
16 | F.e. 4.0.1
17 |
18 | **Environment and installation method**
19 |
20 | F.e.:
21 | * Centos 7 and Python 3.8 from Software Collections,
22 | * installed with the Forge module v9.0.0 and using the PyPI package (default).
23 |
--------------------------------------------------------------------------------
/.github/ISSUE_TEMPLATE/feature_request.md:
--------------------------------------------------------------------------------
1 | ---
2 | name: Feature request
3 | about: Suggest an idea for this project
4 | title: ''
5 | labels: enhancement
6 | assignees: ''
7 |
8 | ---
9 |
10 | A clear and concise description of what you want to happen.
11 |
--------------------------------------------------------------------------------
/.github/ISSUE_TEMPLATE/question-or-other-issue.md:
--------------------------------------------------------------------------------
1 | ---
2 | name: Question or other issue
3 | about: Non-bug and non-feature requests go here :)
4 | title: ''
5 | labels: ''
6 | assignees: ''
7 |
8 | ---
9 |
10 | Write whatever you like :)
11 |
--------------------------------------------------------------------------------
/.github/dependabot.yml:
--------------------------------------------------------------------------------
1 | version: 2
2 | updates:
3 | - package-ecosystem: "pip"
4 | directory: "/"
5 | schedule:
6 | interval: daily
7 | time: "13:00"
8 | open-pull-requests-limit: 10
9 | groups:
10 | python:
11 | update-types:
12 | - "major"
13 | - "patch"
14 | - "minor"
15 | patterns:
16 | - "*"
17 |
18 | # Maintain dependencies for GitHub Actions
19 | - package-ecosystem: github-actions
20 | directory: "/"
21 | schedule:
22 | interval: daily
23 | time: "13:00"
24 | open-pull-requests-limit: 10
25 |
26 | - package-ecosystem: "docker"
27 | directory: "/"
28 | schedule:
29 | interval: "daily"
30 | time: "13:00"
31 | open-pull-requests-limit: 10
32 |
--------------------------------------------------------------------------------
/.github/workflows/codeql-analysis.yml:
--------------------------------------------------------------------------------
1 | # For most projects, this workflow file will not need changing; you simply need
2 | # to commit it to your repository.
3 | #
4 | # You may wish to alter this file to override the set of languages analyzed,
5 | # or to provide custom queries or build logic.
6 | #
7 | # ******** NOTE ********
8 | # We have attempted to detect the languages in your repository. Please check
9 | # the `language` matrix defined below to confirm you have the correct set of
10 | # supported CodeQL languages.
11 | #
12 | name: "CodeQL"
13 |
14 | on:
15 | push:
16 | branches: [ "master" ]
17 | pull_request:
18 | branches: [ "master" ]
19 | schedule:
20 | - cron: '35 0 * * 5'
21 |
22 | jobs:
23 | analyze:
24 | name: Analyze
25 | # Runner size impacts CodeQL analysis time. To learn more, please see:
26 | # - https://gh.io/recommended-hardware-resources-for-running-codeql
27 | # - https://gh.io/supported-runners-and-hardware-resources
28 | # - https://gh.io/using-larger-runners
29 | # Consider using larger runners for possible analysis time improvements.
30 | runs-on: ${{ (matrix.language == 'swift' && 'macos-latest') || 'ubuntu-latest' }}
31 | timeout-minutes: ${{ (matrix.language == 'swift' && 120) || 360 }}
32 | permissions:
33 | # required for all workflows
34 | security-events: write
35 |
36 | # only required for workflows in private repositories
37 | actions: read
38 | contents: read
39 |
40 | strategy:
41 | fail-fast: false
42 | matrix:
43 | language: [ 'javascript-typescript', 'python', 'ruby' ]
44 | # CodeQL supports [ 'c-cpp', 'csharp', 'go', 'java-kotlin', 'javascript-typescript', 'python', 'ruby', 'swift' ]
45 | # Use only 'java-kotlin' to analyze code written in Java, Kotlin or both
46 | # Use only 'javascript-typescript' to analyze code written in JavaScript, TypeScript or both
47 | # Learn more about CodeQL language support at https://aka.ms/codeql-docs/language-support
48 |
49 | steps:
50 | - name: Checkout repository
51 | uses: actions/checkout@v4
52 |
53 | # Initializes the CodeQL tools for scanning.
54 | - name: Initialize CodeQL
55 | uses: github/codeql-action/init@v3
56 | with:
57 | languages: ${{ matrix.language }}
58 | # If you wish to specify custom queries, you can do so here or in a config file.
59 | # By default, queries listed here will override any specified in a config file.
60 | # Prefix the list here with "+" to use these queries and those in the config file.
61 |
62 | # For more details on CodeQL's query packs, refer to: https://docs.github.com/en/code-security/code-scanning/automatically-scanning-your-code-for-vulnerabilities-and-errors/configuring-code-scanning#using-queries-in-ql-packs
63 | # queries: security-extended,security-and-quality
64 |
65 |
66 | # Autobuild attempts to build any compiled languages (C/C++, C#, Go, Java, or Swift).
67 | # If this step fails, then you should remove it and run the build manually (see below)
68 | - name: Autobuild
69 | uses: github/codeql-action/autobuild@v3
70 |
71 | # ℹ️ Command-line programs to run using the OS shell.
72 | # 📚 See https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsrun
73 |
74 | # If the Autobuild fails above, remove it and uncomment the following three lines.
75 | # modify them (or add more) to build your code if your project, please refer to the EXAMPLE below for guidance.
76 |
77 | # - run: |
78 | # echo "Run, Build Application using script"
79 | # ./location_of_script_within_repo/buildscript.sh
80 |
81 | - name: Perform CodeQL Analysis
82 | uses: github/codeql-action/analyze@v3
83 | with:
84 | category: "/language:${{matrix.language}}"
85 |
--------------------------------------------------------------------------------
/.github/workflows/publish-docker.yml:
--------------------------------------------------------------------------------
1 | name: publish 🐳 Docker image
2 |
3 | on:
4 | push:
5 | tags:
6 | - "v*"
7 |
8 | jobs:
9 | build-and-push-container:
10 | runs-on: ubuntu-latest
11 | permissions:
12 | contents: read
13 | packages: write
14 | steps:
15 |
16 | - name: cut v from tag
17 | env:
18 | TAG: ${{ github.ref_name }}
19 | id: split
20 | run: echo "tag=${TAG:1}" >> $GITHUB_OUTPUT
21 |
22 | - uses: voxpupuli/gha-build-and-publish-a-container@v2
23 | with:
24 | registry_password: ${{ secrets.GITHUB_TOKEN }}
25 | build_arch: linux/amd64,linux/arm64
26 | docker_username: voxpupulibot
27 | docker_password: ${{ secrets.DOCKERHUB_BOT_PASSWORD}}
28 | tags: |
29 | ghcr.io/${{ github.repository }}:${{ steps.split.outputs.tag }}
30 | ghcr.io/${{ github.repository }}:latest
31 | docker.io/${{ github.repository }}:${{ steps.split.outputs.tag }}
32 | docker.io/${{ github.repository }}:latest
33 |
34 | - name: Update Docker Hub Description
35 | uses: peter-evans/dockerhub-description@v4
36 | with:
37 | username: voxpupulibot
38 | password: ${{ secrets.DOCKERHUB_BOT_PASSWORD }}
39 |
--------------------------------------------------------------------------------
/.github/workflows/publish-to-pypi.yml:
--------------------------------------------------------------------------------
1 | name: publish 🐍 egg to PyPI
2 |
3 | on:
4 | push:
5 | tags:
6 | - "v*"
7 |
8 | jobs:
9 | build-n-publish:
10 |
11 | runs-on: ubuntu-latest
12 |
13 | steps:
14 | - uses: actions/checkout@v4
15 | - name: Set up Python 3.12
16 | uses: actions/setup-python@v5
17 | with:
18 | python-version: 3.12
19 | - name: Install dependencies
20 | run: |
21 | sudo apt install python3-poetry
22 | - name: Build
23 | run: |
24 | poetry install --with=test
25 | poetry build
26 | - name: Publish distribution 📦 to PyPI
27 | uses: pypa/gh-action-pypi-publish@v1.12.4
28 | with:
29 | password: ${{ secrets.PYPI_API_TOKEN_PUPPETBOARD }}
30 | # repository_url: https://test.pypi.org/legacy/
31 | - name: Create release in GitHub
32 | id: create_release
33 | uses: actions/create-release@v1
34 | env:
35 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} # This token is provided by Actions, you do not need to create your own token
36 | with:
37 | tag_name: ${{ github.ref }}
38 | release_name: ${{ github.ref }}
39 | draft: false
40 | prerelease: false
41 |
--------------------------------------------------------------------------------
/.github/workflows/security_scanning.yml:
--------------------------------------------------------------------------------
1 | ---
2 | name: Security Scanning 🕵️
3 |
4 | on:
5 | push:
6 | branches:
7 | - main
8 | - master
9 | pull_request:
10 | branches:
11 | - main
12 | - master
13 |
14 | jobs:
15 | scan_ci_container:
16 | name: 'Scan CI container'
17 | runs-on: ubuntu-latest
18 | permissions:
19 | actions: read
20 | contents: read
21 | security-events: write
22 | steps:
23 | - name: Checkout repository
24 | uses: actions/checkout@v4
25 |
26 | - name: Build CI container
27 | uses: docker/build-push-action@v6
28 | with:
29 | tags: 'ci/puppetboard:${{ github.sha }}'
30 | push: false
31 |
32 | - name: Scan image with Anchore Grype
33 | uses: anchore/scan-action@v6
34 | id: scan
35 | with:
36 | image: 'ci/puppetboard:${{ github.sha }}'
37 | fail-build: false
38 |
39 | - name: Inspect action SARIF report
40 | run: jq . ${{ steps.scan.outputs.sarif }}
41 |
42 | - name: Upload Anchore scan SARIF report
43 | uses: github/codeql-action/upload-sarif@v3
44 | with:
45 | sarif_file: ${{ steps.scan.outputs.sarif }}
46 |
--------------------------------------------------------------------------------
/.github/workflows/tests.yml:
--------------------------------------------------------------------------------
1 | name: tests (unit)
2 |
3 | on:
4 | pull_request: {}
5 | push:
6 | branches:
7 | - master
8 |
9 | jobs:
10 | test:
11 |
12 | runs-on: ubuntu-latest
13 |
14 | strategy:
15 | max-parallel: 5
16 | matrix:
17 | python-version: [3.9, '3.10', 3.11, 3.12, 3.13]
18 |
19 | steps:
20 | - uses: actions/checkout@v4
21 | - name: Set up Python ${{ matrix.python-version }}
22 | uses: actions/setup-python@v5
23 | with:
24 | python-version: ${{ matrix.python-version }}
25 | - name: Install dependencies
26 | run: |
27 | sudo apt install python3-poetry
28 | poetry install --with=test
29 | poetry run mypy --install-types --non-interactive puppetboard/ test/
30 | - name: Test
31 | run: |
32 | poetry run pytest --cov=. --cov-report=xml --strict-markers --mypy -v puppetboard test
33 | poetry run pylint --errors-only puppetboard test
34 | - name: Upload coverage to Codecov
35 | uses: codecov/codecov-action@v5.4.2
36 | with:
37 | token: ${{ secrets.CODECOV_TOKEN }}
38 | fail_ci_if_error: true
39 | - name: Build
40 | run: |
41 | poetry build -v
42 |
43 | build_docker_image:
44 | name: 'Test building a container'
45 | runs-on: ubuntu-latest
46 | permissions:
47 | contents: read
48 | steps:
49 | - name: Checkout repository
50 | uses: actions/checkout@v4
51 | - name: Build Docker image
52 | uses: docker/build-push-action@v6
53 | with:
54 | context: .
55 | push: false
56 |
57 | security-tests:
58 | runs-on: ubuntu-latest
59 | steps:
60 | - uses: actions/checkout@v4
61 | - name: Set up Python 3.13
62 | uses: actions/setup-python@v5
63 | with:
64 | python-version: 3.13
65 | - name: Run bandit
66 | run: |
67 | sudo apt install python3-poetry
68 | poetry install --with=test
69 | poetry run bandit -r puppetboard
70 |
71 | tests:
72 | needs:
73 | - test
74 | - build_docker_image
75 | - security-tests
76 | runs-on: ubuntu-latest
77 | name: Test suite
78 | steps:
79 | - run: echo Test suite completed
80 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | *.py[cod]
2 |
3 | # Editor tmp files
4 | .*.sw*
5 |
6 | # C extensions
7 | *.so
8 |
9 | # Packages
10 | *.egg
11 | *.egg-info
12 | dist
13 | build
14 | .eggs
15 | eggs
16 | parts
17 | bin
18 | var
19 | sdist
20 | develop-eggs
21 | .installed.cfg
22 | lib
23 | lib64
24 |
25 | # Installer logs
26 | pip-log.txt
27 |
28 | # Unit test / coverage reports
29 | .cache
30 | .coverage
31 | coverage.xml
32 | .tox
33 | .mypy_cache
34 | .pytest_cache
35 |
36 | # Translations
37 | *.mo
38 |
39 | # Mr Developer
40 | .mr.developer.cfg
41 | .project
42 | .pydevproject
43 |
44 | # PuppetDB Settings
45 | /settings.py
46 |
47 | # Virtual Environment
48 | venv
49 |
50 | # IDE files
51 | .idea
52 |
53 | Gemfile.lock
54 | .bundle
55 | .vendor
56 |
--------------------------------------------------------------------------------
/.pre-commit-config.yaml:
--------------------------------------------------------------------------------
1 | # See https://pre-commit.com for more information
2 | # See https://pre-commit.com/hooks.html for more hooks
3 | repos:
4 | - repo: https://github.com/gdubicki/pre-commit-pngquant
5 | rev: 9fad42f9df2ec8306c73d3e7c0628893ead0ebeb
6 | hooks:
7 | - id: pngquant
8 | args: [--speed=1]
9 |
--------------------------------------------------------------------------------
/Dockerfile:
--------------------------------------------------------------------------------
1 | FROM python:3.13-alpine
2 |
3 | LABEL org.label-schema.maintainer="Voxpupuli Team " \
4 | org.label-schema.vendor="Voxpupuli" \
5 | org.label-schema.url="https://github.com/voxpupuli/puppetboard" \
6 | org.label-schema.license="Apache-2.0" \
7 | org.label-schema.vcs-url="https://github.com/voxpupuli/puppetboard" \
8 | org.label-schema.schema-version="1.0" \
9 | org.label-schema.dockerfile="/Dockerfile"
10 |
11 | ENV PUPPETBOARD_PORT 80
12 | ENV PUPPETBOARD_HOST 0.0.0.0
13 | ENV PUPPETBOARD_STATUS_ENDPOINT /status
14 | ENV PUPPETBOARD_SETTINGS docker_settings.py
15 | EXPOSE 80
16 |
17 | HEALTHCHECK --interval=1m --timeout=5s --start-period=10s CMD python3 -c "import requests; import sys; rc = 0 if requests.get('http://localhost:${PUPPETBOARD_PORT}${PUPPETBOARD_URL_PREFIX:-}${PUPPETBOARD_STATUS_ENDPOINT}').ok else 255; sys.exit(rc)"
18 |
19 | RUN apk add --no-cache gcc libmemcached-dev libc-dev zlib-dev
20 | RUN mkdir -p /usr/src/app/
21 | WORKDIR /usr/src/app/
22 | COPY . /usr/src/app
23 | RUN pip install --no-cache-dir -r requirements-docker.txt .
24 |
25 | COPY Dockerfile /
26 |
27 | RUN addgroup -S appgroup && adduser -S appuser -G appgroup
28 | USER appuser
29 |
30 | CMD gunicorn -b ${PUPPETBOARD_HOST}:${PUPPETBOARD_PORT} --preload --workers="${PUPPETBOARD_WORKERS:-1}" -e SCRIPT_NAME="${PUPPETBOARD_URL_PREFIX:-}" --access-logfile=- puppetboard.app:app
31 |
--------------------------------------------------------------------------------
/Gemfile:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | source ENV['GEM_SOURCE'] || 'https://rubygems.org'
4 |
5 | group :release, optional: true do
6 | gem 'faraday-retry', '~> 2.1', require: false
7 | gem 'github_changelog_generator', '~> 1.16.4', require: false
8 | end
9 |
--------------------------------------------------------------------------------
/MANIFEST.in:
--------------------------------------------------------------------------------
1 | include README.md
2 | include CHANGELOG.md
3 | include LICENSE
4 | recursive-include puppetboard/static *
5 | recursive-include puppetboard/templates *
6 | include requirements*.txt
7 |
--------------------------------------------------------------------------------
/RELEASE.md:
--------------------------------------------------------------------------------
1 | # How to release
2 |
3 | ## On a fork do
4 |
5 | ```shell
6 | git switch -c release-x.y.z
7 | ```
8 |
9 | Edit [pyproject.toml](pyproject.toml) and set future version. With poetry locally, you can also do `poetry version ` to automatically update.
10 |
11 | ```shell
12 | bundle config set --local path .vendor
13 | bundle config set --local with 'release'
14 | bundle install
15 |
16 | CHANGELOG_GITHUB_TOKEN="token_MC_token-face" bundle exec rake changelog
17 | git add -A
18 | git commit -m 'Release X.Y.Z'
19 | git push origin releae-x.y.z
20 | ```
21 |
22 | ## as a maintainer on upstream do
23 |
24 | ```shell
25 | git switch master
26 | git pull
27 | git tag $version
28 | git push --tags
29 | ```
30 |
--------------------------------------------------------------------------------
/Rakefile:
--------------------------------------------------------------------------------
1 | begin
2 | require 'github_changelog_generator/task'
3 | rescue LoadError
4 | # github_changelog_generator is an optional group
5 | else
6 | GitHubChangelogGenerator::RakeTask.new :changelog do |config|
7 | version = File.readlines('pyproject.toml').find{ |l| l.match(/version/) }.split(' ').last.gsub('"', '')
8 | config.future_release = "v#{version}" if /^\d+\.\d+.\d+$/.match?(version)
9 | config.header = "# Changelog\n\nAll notable changes to this project will be documented in this file."
10 | config.exclude_labels = %w[duplicate question invalid wontfix wont-fix skip-changelog github_actions]
11 | config.user = 'voxpupuli'
12 | config.project = 'puppetboard'
13 | end
14 | end
15 |
--------------------------------------------------------------------------------
/docs/Debian-Jessie.md:
--------------------------------------------------------------------------------
1 | # Install Using debian jessie
2 |
3 | ```
4 | $ apt-get install python-pip git
5 |
6 | $ mkdir /opt/voxpupuli-puppetboard/
7 | $ cd /opt/voxpupuli-puppetboard/
8 | $ git clone https://github.com/voxpupuli/puppetboard
9 | $ cd /opt/voxpupuli-puppetboard/puppetboard
10 | $ pip install puppetboard
11 |
12 | ```
13 |
14 | * /etc/apache2/sites-available/voxpupuli-puppetboard.conf
15 |
16 | ```
17 |
18 | ServerName puppetboard.my.domain
19 | WSGIDaemonProcess puppetboard user=www-data group=www-data threads=5 python-path=/usr/local/lib/python2.7/dist-packages/puppetboard:python-home=/opt/voxpupuli-puppetboard/puppetboard:/opt/voxpupuli-puppetboard/puppetboard/puppetboard:/usr/local/lib/python2.7/dist-packages/puppetboard/static
20 | WSGIScriptAlias / /opt/voxpupuli-puppetboard/puppetboard/wsgi.py
21 | ErrorLog /var/log/apache2/puppetboard.error.log
22 | CustomLog /var/log/apache2/puppetboard.access.log combined
23 |
24 |
25 |
26 | Order deny,allow
27 | Allow from all
28 | Require all granted
29 |
30 |
31 |
32 | Alias /static /usr/local/lib/python2.7/dist-packages/puppetboard/static
33 |
34 | Satisfy Any
35 | Allow from all
36 | Require all granted
37 |
38 |
39 |
40 | WSGIProcessGroup puppetboard
41 | WSGIApplicationGroup %{GLOBAL}
42 | Order deny,allow
43 | Allow from all
44 | Require all granted
45 |
46 |
47 | ```
48 |
49 | ```
50 | $ a2ensite voxpupuli-puppetboard.conf
51 | ```
52 |
53 | * /opt/voxpupuli-puppetboard/puppetboard/wsgi.py
54 | ```
55 | from __future__ import absolute_import
56 | import os
57 |
58 | import sys
59 | sys.path.append('/opt/voxpupuli-puppetboard/puppetboard')
60 |
61 | from puppetboard.app import app as application
62 | ```
63 |
--------------------------------------------------------------------------------
/docs/EL7.md:
--------------------------------------------------------------------------------
1 | # Steps to get this working with EL7
2 |
3 | ## Modules
4 |
5 | You will likely need to track down more module dependencies, though this is what
6 | I used.
7 |
8 | `Puppetfile`
9 |
10 | ```ruby
11 | mode 'apache',
12 | :git => 'https://github.com/puppetlabs/puppetlabs-apache.git',
13 | :ref => '3.4.0'
14 |
15 | mod 'apt',
16 | :git => 'https://github.com/puppetlabs/puppetlabs-apt.git',
17 | :ref => '6.2.1'
18 |
19 | mod 'concat',
20 | :git => 'https://github.com/puppetlabs/puppetlabs-concat.git',
21 | :ref => '5.1.0'
22 |
23 | mod 'epel',
24 | :git => 'https://github.com/stahnma/puppet-module-epel.git',
25 | :ref => '1.3.1'
26 |
27 | mod 'firewall',
28 | :git => 'https://github.com/puppetlabs/puppetlabs-firewall.git',
29 | :ref => '1.14.0'
30 |
31 | mod 'inifile',
32 | :git => 'https://github.com/puppetlabs/puppetlabs-inifile.git',
33 | :ref => '2.4.0'
34 |
35 | mod 'postgresql',
36 | :git => 'https://github.com/puppetlabs/puppet-postgresql.git',
37 | :ref => '5.11.0'
38 |
39 | mod 'puppetdb',
40 | :git => 'https://github.com/puppetlabs/puppetlabs-puppetdb.git',
41 | :ref => '7.1.0'
42 |
43 | mod 'puppetboard',
44 | :git => 'https://github.com/voxpupuli/puppet-puppetboard.git',
45 | :ref => 'v5.0.0'
46 |
47 | mod 'python',
48 | :git => 'https://github.com/voxpupuli/puppet-python.git',
49 | :ref => 'v2.2.2'
50 |
51 | mod 'selinux',
52 | :git => 'https://github.com/ghoneycutt/puppet-module-selinux.git',
53 | :ref => 'v2.2.0'
54 |
55 | mod 'stdlib',
56 | :git => 'https://github.com/puppetlabs/puppetlabs-stdlib.git',
57 | :ref => '5.1.0'
58 |
59 | mod 'translate',
60 | :git => 'https://github.com/puppetlabs/puppetlabs-translate.git',
61 | :ref => '1.2.0'
62 | ```
63 |
64 | ## Manifests
65 |
66 | ### Role
67 |
68 | `role/manifests/puppetboard.pp`
69 |
70 | ```puppet
71 | # @summary Role for puppetboard
72 | #
73 | class role::puppetboard {
74 |
75 | include ::profile::puppetboard
76 | }
77 | ```
78 |
79 | ### Profile
80 |
81 | `profile/manifests/puppetboard.pp`
82 |
83 | ```puppet
84 | # Class: profile::puppetboard
85 | #
86 | # Puppetboard is a WebUI to inspect PuppetDB
87 | #
88 | class profile::puppetboard {
89 |
90 | include ::apache
91 |
92 | $puppetboard_certname = $trusted['certname']
93 | $ssl_dir = '/etc/httpd/ssl'
94 |
95 | file { $ssl_dir:
96 | ensure => 'directory',
97 | owner => 'root',
98 | group => 'root',
99 | mode => '0755',
100 | }
101 |
102 | file { "${ssl_dir}/certs":
103 | ensure => 'directory',
104 | owner => 'root',
105 | group => 'root',
106 | mode => '0755',
107 | }
108 |
109 | file { "${ssl_dir}/private_keys":
110 | ensure => 'directory',
111 | owner => 'root',
112 | group => 'root',
113 | mode => '0750',
114 | }
115 |
116 | file { "${ssl_dir}/certs/ca.pem":
117 | ensure => 'file',
118 | owner => 'root',
119 | group => 'root',
120 | mode => '0644',
121 | source => "${::settings::ssldir}/certs/ca.pem",
122 | before => Class['::puppetboard'],
123 | }
124 |
125 | file { "${ssl_dir}/certs/${puppetboard_certname}.pem":
126 | ensure => 'file',
127 | owner => 'root',
128 | group => 'root',
129 | mode => '0644',
130 | source => "${::settings::ssldir}/certs/${puppetboard_certname}.pem",
131 | before => Class['::puppetboard'],
132 | }
133 |
134 | file { "${ssl_dir}/private_keys/${puppetboard_certname}.pem":
135 | ensure => 'file',
136 | owner => 'root',
137 | group => 'root',
138 | mode => '0644',
139 | source => "${::settings::ssldir}/private_keys/${puppetboard_certname}.pem",
140 | before => Class['::puppetboard'],
141 | }
142 |
143 | class { '::puppetboard':
144 | groups => 'root',
145 | manage_git => true,
146 | manage_virtualenv => true,
147 | manage_selinux => false,
148 | puppetdb_host => 'puppetdb.example.com',
149 | puppetdb_port => 8081,
150 | puppetdb_key => "${ssl_dir}/private_keys/${puppetboard_certname}.pem",
151 | puppetdb_ssl_verify => "${ssl_dir}/certs/ca.pem",
152 | puppetdb_cert => "${ssl_dir}/certs/${puppetboard_certname}.pem",
153 | reports_count => 40,
154 | }
155 |
156 | class { '::apache::mod::wsgi':
157 | wsgi_socket_prefix => '/var/run/wsgi',
158 | }
159 |
160 | class { '::puppetboard::apache::vhost':
161 | vhost_name => 'puppetboard.example.com',
162 | port => 80,
163 | }
164 | }
165 | ```
166 |
167 | ## Hiera data
168 |
169 | ```yaml
170 | ---
171 |
172 | # Personal preference, you don't *need* this.
173 | puppetboard::enable_catalog: true
174 |
175 | python::dev: true
176 |
177 | selinux::mode: permissive
178 | ```
179 |
180 | I put this in `data/role/puppetboard.yaml` with `hiera.yaml` like the
181 | following, though you could put this under certname or wherever you see
182 | fit.
183 |
184 | ```yaml
185 | ---
186 | version: 5
187 | defaults:
188 | # The default value for "datadir" is "data" under the same directory as the hiera.yaml
189 | # file (this file)
190 | # When specifying a datadir, make sure the directory exists.
191 | # See https://puppet.com/docs/puppet/latest/environments_about.html for further details on environments.
192 | # datadir: data
193 | # data_hash: yaml_data
194 | hierarchy:
195 | - name: "Per-node data (yaml version)"
196 | path: "nodes/%{::trusted.certname}.yaml"
197 | - name: "Role data"
198 | paths:
199 | - "role/%{facts.role}.yaml"
200 | - name: "Other YAML hierarchy levels"
201 | paths:
202 | - "common.yaml"
203 | ```
204 |
--------------------------------------------------------------------------------
/puppetboard/__init__.py:
--------------------------------------------------------------------------------
1 | #
2 | # Puppetboard
3 | #
4 |
--------------------------------------------------------------------------------
/puppetboard/app.py:
--------------------------------------------------------------------------------
1 | from __future__ import absolute_import
2 | from __future__ import unicode_literals
3 |
4 | import logging
5 | import os
6 | import sys
7 | from datetime import datetime
8 |
9 | from flask import render_template, Response
10 |
11 | # these imports are required by Flask - DO NOT remove them although they look unused
12 | # noinspection PyUnresolvedReferences
13 | import puppetboard.views.catalogs # noqa: F401
14 | # noinspection PyUnresolvedReferences
15 | import puppetboard.views.classes # noqa: F401
16 | # noinspection PyUnresolvedReferences
17 | import puppetboard.views.dailychart # noqa: F401
18 | # noinspection PyUnresolvedReferences
19 | import puppetboard.views.facts # noqa: F401
20 | # noinspection PyUnresolvedReferences
21 | import puppetboard.views.index # noqa: F401
22 | # noinspection PyUnresolvedReferences
23 | import puppetboard.views.inventory # noqa: F401
24 | # noinspection PyUnresolvedReferences
25 | import puppetboard.views.metrics # noqa: F401
26 | # noinspection PyUnresolvedReferences
27 | import puppetboard.views.nodes # noqa: F401
28 | # noinspection PyUnresolvedReferences
29 | import puppetboard.views.query # noqa: F401
30 | # noinspection PyUnresolvedReferences
31 | import puppetboard.views.radiator # noqa: F401
32 | # noinspection PyUnresolvedReferences
33 | import puppetboard.views.reports # noqa: F401
34 | # noinspection PyUnresolvedReferences
35 | import puppetboard.views.failures # noqa: F401
36 | import puppetboard.errors # noqa: F401
37 |
38 | from puppetboard.core import get_app, get_puppetdb, get_scheduler
39 | from puppetboard.version import __version__
40 | from puppetboard.utils import is_a_test, check_db_version, check_secret_key
41 |
42 | app = get_app()
43 | puppetdb = get_puppetdb()
44 | get_scheduler()
45 | running_as = os.path.basename(sys.argv[0])
46 | if not is_a_test():
47 | check_db_version(puppetdb)
48 | check_secret_key(app.config.get('SECRET_KEY'))
49 |
50 | logging.basicConfig(level=app.config['LOGLEVEL'].upper())
51 | log = logging.getLogger(__name__)
52 |
53 | menu_entries = [
54 | ('index', 'Overview'),
55 | ('failures', 'Failures'),
56 | ('nodes', 'Nodes'),
57 | ('facts', 'Facts'),
58 | ('reports', 'Reports'),
59 | ('metrics', 'Metrics'),
60 | ('inventory', 'Inventory'),
61 | ('catalogs', 'Catalogs'),
62 | ('classes', 'Classes'),
63 | ('radiator', 'Radiator'),
64 | ('query', 'Query'),
65 | ]
66 |
67 | if not app.config.get('ENABLE_QUERY'):
68 | menu_entries.remove(('query', 'Query'))
69 |
70 | if not app.config.get('ENABLE_CATALOG'):
71 | menu_entries.remove(('catalogs', 'Catalogs'))
72 |
73 | if not app.config.get('ENABLE_CLASS'):
74 | menu_entries.remove(('classes', 'Classes'))
75 |
76 | app.jinja_env.globals.update(menu_entries=menu_entries)
77 |
78 |
79 | @app.context_processor
80 | def utility_processor():
81 | def now(format='%m/%d/%Y %H:%M:%S'):
82 | """returns the formated datetime"""
83 | return datetime.now().strftime(format)
84 |
85 | def version():
86 | return __version__
87 |
88 | def fact_os_detection(os_facts):
89 | os_name = ""
90 | os_family = os_facts['family']
91 |
92 | try:
93 | if os_family == "windows":
94 | os_name = os_facts["windows"]["product_name"]
95 | elif os_family == "Darwin":
96 | os_name = os_facts["macosx"]["product"]
97 | else:
98 | os_name = os_facts["distro"]["description"]
99 | except KeyError:
100 | pass
101 |
102 | return os_name
103 |
104 | return dict(
105 | now=now,
106 | version=version,
107 | fact_os_detection=fact_os_detection,
108 | )
109 |
110 |
111 | @app.route('/offline/')
112 | def offline_static(filename):
113 | mimetype = 'text/html'
114 | if filename.endswith('.css'):
115 | mimetype = 'text/css'
116 | elif filename.endswith('.js'):
117 | mimetype = 'text/javascript'
118 |
119 | return Response(response=render_template('static/%s' % filename),
120 | status=200, mimetype=mimetype)
121 |
122 |
123 | @app.route('/status')
124 | def health_status():
125 | return 'OK'
126 |
--------------------------------------------------------------------------------
/puppetboard/default_settings.py:
--------------------------------------------------------------------------------
1 | import secrets
2 |
3 | PUPPETDB_HOST = 'localhost'
4 | PUPPETDB_PORT = 8080
5 | PUPPETDB_PROTO = None
6 | PUPPETDB_SSL_VERIFY = True
7 | PUPPETDB_KEY = None
8 | PUPPETDB_CERT = None
9 | PUPPETDB_TIMEOUT = 20
10 | DEFAULT_ENVIRONMENT = 'production'
11 | # this empty string has to be changed, we validate it with check_secret_key()
12 | SECRET_KEY = '' # nosec
13 | UNRESPONSIVE_HOURS = 2
14 | ENABLE_QUERY = True
15 | # Uncomment to restrict the enabled PuppetDB endpoints in the query page.
16 | # ENABLED_QUERY_ENDPOINTS = ['facts', 'nodes']
17 | LOCALISE_TIMESTAMP = True
18 | LOGLEVEL = 'info'
19 | NORMAL_TABLE_COUNT = 100
20 | LITTLE_TABLE_COUNT = 10
21 | TABLE_COUNT_SELECTOR = [10, 20, 50, 100, 500]
22 | DISPLAYED_METRICS = ['resources.total',
23 | 'events.failure',
24 | 'events.success',
25 | 'resources.skipped',
26 | 'events.noop']
27 | OFFLINE_MODE = False
28 | ENABLE_CATALOG = False
29 | OVERVIEW_FILTER = None
30 | PAGE_TITLE = "Puppetboard"
31 | GRAPH_TYPE = 'pie'
32 | GRAPH_FACTS = ['architecture',
33 | 'clientversion',
34 | 'domain',
35 | 'lsbcodename',
36 | 'lsbdistcodename',
37 | 'lsbdistid',
38 | 'lsbdistrelease',
39 | 'lsbmajdistrelease',
40 | 'netmask',
41 | 'osfamily',
42 | 'puppetversion',
43 | 'processorcount']
44 | INVENTORY_FACTS = [('Hostname', 'trusted'),
45 | ('IP Address', 'ipaddress'),
46 | ('OS', 'os'),
47 | ('Architecture', 'hardwaremodel'),
48 | ('Kernel Version', 'kernelrelease'),
49 | ('Puppet Version', 'puppetversion'), ]
50 |
51 | INVENTORY_FACT_TEMPLATES = {
52 | 'trusted': (
53 | """"""
54 | """{{value.hostname}}"""
55 | """"""
56 | ),
57 | 'os': "{{ fact_os_detection(value) }}",
58 | }
59 | REFRESH_RATE = 30
60 | DAILY_REPORTS_CHART_ENABLED = True
61 | DAILY_REPORTS_CHART_DAYS = 8
62 | WITH_EVENT_NUMBERS = True
63 | SHOW_ERROR_AS = 'friendly' # or 'raw'
64 | CODE_PREFIX_TO_REMOVE = '/etc/puppetlabs/code/environments(/.*?/modules)?'
65 | FAVORITE_ENVS = [
66 | 'production',
67 | 'staging',
68 | 'qa',
69 | 'test',
70 | 'dev',
71 | ]
72 |
73 | ENABLE_CLASS = False
74 | # mapping between the status of the events (from PuppetDB) and the columns of the table to display
75 | CLASS_EVENTS_STATUS_COLUMNS = [
76 | # ('skipped', 'Skipped'),
77 | ('failure', 'Failure'),
78 | ('success', 'Success'),
79 | ('noop', 'Noop'),
80 | ]
81 | # Type of caching object to use when `SCHEDULER_ENABLED` is set to `True`.
82 | # If more than one worker, use a shared backend (e.g. `MemcachedCache`)
83 | # to allow the sharing of the cache between the processes.
84 | CACHE_TYPE = 'SimpleCache'
85 | # Cache litefime in second
86 | CACHE_DEFAULT_TIMEOUT = 3600
87 |
88 | # List of scheduled jobs to trigger
89 | # * `id`: job's ID
90 | # * `func`: full path of the function to execute
91 | # * `trigger`: should be 'interval' if you want to run the job at regular intervals
92 | # * `seconds`: number of seconds between 2 triggered jobs
93 | SCHEDULER_JOBS = [{
94 | 'id': 'do_build_async_cache_1',
95 | 'func': 'puppetboard.schedulers.classes:build_async_cache',
96 | 'trigger': 'interval',
97 | 'seconds': 300,
98 | }]
99 | SCHEDULER_ENABLED = False
100 | SCHEDULER_LOCK_BIND_PORT = 49100
101 |
--------------------------------------------------------------------------------
/puppetboard/errors.py:
--------------------------------------------------------------------------------
1 | from flask import render_template
2 | from werkzeug.exceptions import InternalServerError
3 |
4 | from puppetboard.core import environments, get_app
5 |
6 | app = get_app()
7 |
8 |
9 | @app.errorhandler(400)
10 | def bad_request(e):
11 | envs = environments()
12 | return render_template('400.html', envs=envs), 400
13 |
14 |
15 | @app.errorhandler(403)
16 | def forbidden(e):
17 | envs = environments()
18 | return render_template('403.html', envs=envs), 403
19 |
20 |
21 | @app.errorhandler(404)
22 | def not_found(e):
23 | envs = environments()
24 | return render_template('404.html', envs=envs), 404
25 |
26 |
27 | @app.errorhandler(412)
28 | def precond_failed(e):
29 | """We're slightly abusing 412 to handle missing features
30 | depending on the API version."""
31 | envs = environments()
32 | return render_template('412.html', envs=envs), 412
33 |
34 |
35 | @app.errorhandler(500)
36 | def server_error(e):
37 | envs = {}
38 | try:
39 | envs = environments()
40 | except InternalServerError:
41 | pass
42 | return render_template('500.html', envs=envs), 500
43 |
--------------------------------------------------------------------------------
/puppetboard/forms.py:
--------------------------------------------------------------------------------
1 | from collections import OrderedDict
2 |
3 | from flask_wtf import FlaskForm
4 | from wtforms import (BooleanField, SelectField, TextAreaField, validators)
5 |
6 | from puppetboard.core import get_app
7 |
8 | app = get_app()
9 | QUERY_ENDPOINTS = OrderedDict([
10 | # PuppetDB API endpoint, Form name
11 | ('pql', 'PQL'),
12 | ('nodes', 'Nodes'),
13 | ('resources', 'Resources'),
14 | ('facts', 'Facts'),
15 | ('factsets', 'Fact Sets'),
16 | ('fact-paths', 'Fact Paths'),
17 | ('fact-contents', 'Fact Contents'),
18 | ('reports', 'Reports'),
19 | ('events', 'Events'),
20 | ('catalogs', 'Catalogs'),
21 | ('edges', 'Edges'),
22 | ('environments', 'Environments'),
23 | ])
24 | ENABLED_QUERY_ENDPOINTS = app.config.get(
25 | 'ENABLED_QUERY_ENDPOINTS', list(QUERY_ENDPOINTS.keys()))
26 |
27 |
28 | class QueryForm(FlaskForm):
29 | """The form used to allow freeform queries to be executed against
30 | PuppetDB."""
31 | query = TextAreaField('Query', [validators.DataRequired(
32 | message='A query is required.')])
33 | endpoints = SelectField('API endpoint', choices=[
34 | (key, value) for key, value in QUERY_ENDPOINTS.items()
35 | if key in ENABLED_QUERY_ENDPOINTS], default='pql')
36 | rawjson = BooleanField('Raw JSON')
37 |
--------------------------------------------------------------------------------
/puppetboard/schedulers/classes.py:
--------------------------------------------------------------------------------
1 | from pypuppetdb.QueryBuilder import (AndOperator, InOperator, FromOperator,
2 | EqualsOperator, NullOperator, OrOperator,
3 | ExtractOperator, LessEqualOperator, SubqueryOperator)
4 |
5 | from puppetboard.core import get_app, get_cache, get_puppetdb, environments, stream_template, REPORTS_COLUMNS
6 | from puppetboard.utils import (yield_or_stop, check_env, get_or_abort)
7 | from puppetboard.views.classes import (get_status_from_events)
8 |
9 | app = get_app()
10 | cache = get_cache()
11 | puppetdb = get_puppetdb()
12 |
13 | events_status_columns = ['skipped','failure','success','noop']
14 |
15 |
16 | def build_async_cache():
17 | """Scheduled job triggered at regular interval in order to pre-compute the
18 | results to display in the classes view.
19 | The result contains the events associated with the last reports and is
20 | stored in the cache.
21 | """
22 | columns = [col for col in app.config['CLASS_EVENTS_STATUS_COLUMNS'] if col[0] in events_status_columns]
23 |
24 | envs = puppetdb.environments()
25 | for env in puppetdb.environments():
26 | env = env['name']
27 | query = AndOperator()
28 | query.add(EqualsOperator("environment", env))
29 | # get events from last report for each active node
30 | query_in = InOperator('hash')
31 | query_ex = ExtractOperator()
32 | query_ex.add_field('latest_report_hash')
33 | query_from = FromOperator('nodes')
34 | query_null = NullOperator('deactivated', True)
35 | query_ex.add_query(query_null)
36 | query_from.add_query(query_ex)
37 | query_in.add_query(query_from)
38 | reportlist = puppetdb.reports(query=query_in)
39 |
40 | new_cache = {}
41 | for report in yield_or_stop(reportlist):
42 | report_hash = report.hash_
43 | for event in yield_or_stop(report.events()):
44 | containing_class = event.item['class']
45 | status = event.status
46 | new_cache[containing_class] = new_cache.get(containing_class, {})
47 | new_cache[containing_class][report_hash] = new_cache[containing_class].get(report_hash, {
48 | 'node_name': report.node,
49 | 'node_status': report.status,
50 | 'class_status': 'skipped',
51 | 'report_hash': report_hash,
52 | 'nb_events_per_status': {col[0]: 0 for col in columns},
53 | })
54 | if status in new_cache[containing_class][report_hash]['nb_events_per_status']:
55 | new_cache[containing_class][report_hash]['nb_events_per_status'][status] += 1
56 | for class_name in new_cache:
57 | for report_hash, report in new_cache[class_name].items():
58 | status = get_status_from_events(report['nb_events_per_status'])
59 | new_cache[class_name][report_hash]['class_status'] = get_status_from_events(report['nb_events_per_status'])
60 |
61 | cache.set(f'classes_resource_{env}', new_cache)
62 |
--------------------------------------------------------------------------------
/puppetboard/static/css/radiator.css:
--------------------------------------------------------------------------------
1 | html,body,div,span,object,iframe,h1,h2,h3,h4,h5,h6,p,blockquote,pre,a,abbr,acronym,address,code,del,dfn,em,img,q,dl,dt,dd,ol,ul,li,fieldset,form,label,legend,table,caption,tbody,tfoot,thead,tr,th,td {margin:0;padding:0;border:0;font-weight:inherit;font-style:inherit;font-size:100%;font-family:inherit;vertical-align:baseline;}
2 | body{line-height:20px;}
3 | table{border-collapse:separate;border-spacing:0;}
4 | caption,th,td {text-align:left;font-weight:normal;}
5 | table,td,th {vertical-align:middle;}
6 | blockquote:before,blockquote:after,q:before,q:after {content:"";}
7 | blockquote,q {quotes:"" "";}
8 | a img {border:none;}
9 | ul {list-style-position:inside;}
10 | body.radiator_controller {font-size:1000%;line-height:1;font-family:"Helvetica Neue",Arial,Helvetica,sans-serif;background-color:#000;}
11 | body.radiator_controller table.node_summary {padding:20px;position:absolute;height:100%;width:100%;}
12 | body.radiator_controller table.node_summary .count_column {min-width:2em;width:2em;}
13 | body.radiator_controller table.node_summary tr:last-child td {border-bottom:none;}
14 | body.radiator_controller table.node_summary tr:last-child td .label {border-left:0px #000 solid;}
15 | body.radiator_controller table.node_summary tr.unreported .percent {background-color:#3D96AE;border-radius:0 3px 3px 0;}
16 | body.radiator_controller table.node_summary tr.unreported .label,body.radiator_controller table.node_summary tr.unreported .count {color:#3D96AE;}
17 | body.radiator_controller table.node_summary tr.unreported .label {border-left:1px #333 dashed;}
18 | body.radiator_controller table.node_summary tr.failed .percent {background-color:#cc2211;border-radius:0 3px 3px 0;}
19 | body.radiator_controller table.node_summary tr.failed .label,body.radiator_controller table.node_summary tr.failed .count {color:#cc2211;}
20 | body.radiator_controller table.node_summary tr.failed .label {border-left:1px #333 dashed;}
21 | body.radiator_controller table.node_summary tr.noop .percent {background-color:#DB843D;border-radius:0 3px 3px 0;}
22 | body.radiator_controller table.node_summary tr.noop .label,body.radiator_controller table.node_summary tr.noop .count {color:#DB843D;}
23 | body.radiator_controller table.node_summary tr.noop .label {border-left:1px #333 dashed;}
24 | body.radiator_controller table.node_summary tr.changed .percent {background-color:#4572A7;border-radius:0 3px 3px 0;}
25 | body.radiator_controller table.node_summary tr.changed .label,body.radiator_controller table.node_summary tr.changed .count {color:#4572A7;}
26 | body.radiator_controller table.node_summary tr.changed .label {border-left:1px #333 dashed;}
27 | body.radiator_controller table.node_summary tr.unchanged .percent {background-color:#89A54E;border-radius:0 3px 3px 0;}
28 | body.radiator_controller table.node_summary tr.unchanged .label,body.radiator_controller table.node_summary tr.unchanged .count {color:#89A54E;}
29 | body.radiator_controller table.node_summary tr.unchanged .label {border-left:1px #333 dashed;}
30 | body.radiator_controller table.node_summary tr.total {color:#fff;background-color:#181818;}
31 | body.radiator_controller table.node_summary tr.total .percent {background-color:white;border-radius:0 3px 3px 0;}
32 | body.radiator_controller table.node_summary tr.total .label,body.radiator_controller table.node_summary tr.total .count {color:white;}
33 | body.radiator_controller table.node_summary tr.total .label {border-left:1px #333 dashed;}
34 | body.radiator_controller table.node_summary tr.total .percent {display:none;}
35 | body.radiator_controller table.node_summary tr.total td {border-top:1px solid #fff;}
36 | body.radiator_controller table.node_summary tr td {color:#ccc;font-weight:normal;position:relative;border-bottom:1px solid #333;vertical-align:baseline;}
37 | body.radiator_controller table.node_summary tr td div {position:relative;height:100%;}
38 | body.radiator_controller table.node_summary tr td .percent {color:#000;position:absolute;top:0;left:0;height:100%;overflow:hidden;}
39 | body.radiator_controller table.node_summary tr td .percent span {margin-left:0.1em;}
40 | body.radiator_controller table.node_summary tr td .label {position:relative;height:100%;}
41 | body.radiator_controller table.node_summary tr td .label span {margin-left:0.1em;}
42 | body.radiator_controller table.node_summary tr td .count {text-align:right;width:100%;display:inline-block;font-weight:bold;margin-top:-0.12em;}
43 |
--------------------------------------------------------------------------------
/puppetboard/static/custom-natural-time-delta/natural-time-delta.js:
--------------------------------------------------------------------------------
1 | /**
2 | * Created by Shodhan Save on Jan 23, 2018.
3 | * Updated @ Jan 25, 2018
4 | * Modified for the Puppetboard by Greg Dubicki.
5 | */
6 |
7 | /**
8 | * This plug-in allows sorting of human-readable time delta, viz.,
9 | * "1 week", "2 weeks 3 days", "4 weeks 5 days 6 hours", "1:24 hours" etc.
10 | *
11 | * Currently this plugin supports time range from microseconds to decades.
12 | *
13 | * The plugin also takes care of singular and plural values like week(s)
14 | *
15 | * @name Natural Time Delta
16 | * @summary Sort human-readable time delta
17 | *
18 | * @example
19 | * $("#example").DataTable({
20 | * columnDefs: [
21 | * { "type": "natural-time-delta", "targets": 2 }
22 | * ]
23 | * });
24 | */
25 |
26 | jQuery.extend(jQuery.fn.dataTableExt.oSort,{
27 | "natural-time-delta-pre" : function(data){
28 | // get the non-formatted value from title
29 | data = data.match(/title="(.*?)"/)[1].toLowerCase();
30 |
31 | var total_duration = 0;
32 | var pattern = /(\d+\s*decades?\s*)?(\d+\s*years?\s*)?(\d+\s*months?\s*)?(\d+\s*weeks?\s*)?(\d+\s*days?\s*)?(\d+:?\d*?\s*hours?\s*)?(\d+\s*minutes?\s*)?(\d+\s*seconds?\s*)?(\d+\s*milliseconds?\s*)?(\d+\s*microseconds?\s*)?/i
33 | var get_duration = function (el, unit_name, duration_in_seconds) {
34 | if (el === undefined) {
35 | return 0;
36 | }
37 |
38 | var split_by = unit_name[0]
39 | var no_of_units = el.split(split_by)[0].trim()
40 |
41 | if ((unit_name === "hour") && (no_of_units.split(':').length === 2)) {
42 | // this is hour with minutes looking like this: "1:26 hours"
43 | var hours = parseFloat(no_of_units.split(':')[0]);
44 | var minutes = parseFloat(no_of_units.split(':')[1]);
45 | return (hours * 60 * 60) + (minutes * 60);
46 | } else {
47 | return parseFloat(no_of_units) * duration_in_seconds;
48 | }
49 | };
50 |
51 | var matches = data.match(pattern);
52 | matches.reverse();
53 |
54 | var time_elements = [
55 | {"unit_name": "microsecond", "duration_in_seconds": 1 / 1000 / 1000},
56 | {"unit_name": "millisecond", "duration_in_seconds": 1 / 1000},
57 | {"unit_name": "second", "duration_in_seconds": 1},
58 | {"unit_name": "minute", "duration_in_seconds": 1 * 60},
59 | {"unit_name": "hour", "duration_in_seconds": 1 * 60 * 60},
60 | {"unit_name": "day", "duration_in_seconds": 1 * 60 * 60 * 24},
61 | {"unit_name": "week", "duration_in_seconds": 1 * 60 * 60 * 24 * 7},
62 | {"unit_name": "month", "duration_in_seconds": 1 * 60 * 60 * 24 * 7 * 30},
63 | {"unit_name": "year", "duration_in_seconds": 1 * 60 * 60 * 24 * 7 * 30 * 12},
64 | {"unit_name": "decade", "duration_in_seconds": 1 * 60 * 60 * 24 * 7 * 30 * 12 * 10},
65 | ];
66 |
67 | time_elements.forEach(function (el, i) {
68 | var duration = get_duration(matches[i], el["unit_name"], el["duration_in_seconds"]);
69 | total_duration += duration;
70 | });
71 |
72 | return total_duration || -1;
73 | },
74 |
75 | "natural-time-delta-asc" : function (a, b) {
76 | return ((a < b) ? -1 : ((a > b) ? 1 : 0));
77 | },
78 |
79 | "natural-time-delta-desc" : function (a, b) {
80 | return ((a < b) ? 1 : ((a > b) ? -1 : 0));
81 | }
82 | });
83 |
--------------------------------------------------------------------------------
/puppetboard/static/custom-natural/natural.js:
--------------------------------------------------------------------------------
1 | /**
2 | * Created by Allan Jardine on Feb 17, 2023.
3 | * Updated @ Feb 22, 2024
4 | * Modified for the Puppetboard by ArthurWuTW.
5 | */
6 |
7 | /**
8 | * Data can often be a complicated mix of numbers and letters (file names
9 | * are a common example) and sorting them in a natural manner is quite a
10 | * difficult problem.
11 | *
12 | * Fortunately the Javascript `localeCompare` method is now widely supported
13 | * and provides a natural sorting method we can use with DataTables.
14 | *
15 | * @name Natural sorting
16 | * @summary Sort data with a mix of numbers and letters _naturally_.
17 | *
18 | * @example
19 | * // Natural sorting
20 | * new DataTable('#myTable',
21 | * columnDefs: [
22 | * { type: 'natural', target: 0 }
23 | * ]
24 | * } );
25 | *
26 | */
27 |
28 |
29 | jQuery.extend(jQuery.fn.dataTableExt.oSort,{
30 |
31 | "natural-asc" : function (a, b) {
32 | return a.localeCompare(b, navigator.languages[0] || navigator.language, {
33 | numeric: true,
34 | ignorePunctuation: true,
35 | });
36 | },
37 |
38 | "natural-desc" : function (a, b) {
39 | return (a.localeCompare(b, navigator.languages[0] || navigator.language, { numeric: true, ignorePunctuation: true }) *
40 | -1);
41 | }
42 | });
43 |
--------------------------------------------------------------------------------
/puppetboard/static/favicon.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/voxpupuli/puppetboard/6b6e8dfbbd9b35ce540598f6dde4811e544db446/puppetboard/static/favicon.ico
--------------------------------------------------------------------------------
/puppetboard/static/favicon.svg:
--------------------------------------------------------------------------------
1 |
2 |
40 |
--------------------------------------------------------------------------------
/puppetboard/static/fonts/cousine/d6lIkaiiRdih4SpP_SAvzAbt.woff2:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/voxpupuli/puppetboard/6b6e8dfbbd9b35ce540598f6dde4811e544db446/puppetboard/static/fonts/cousine/d6lIkaiiRdih4SpP_SAvzAbt.woff2
--------------------------------------------------------------------------------
/puppetboard/static/fonts/cousine/d6lIkaiiRdih4SpP_SQvzA.woff2:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/voxpupuli/puppetboard/6b6e8dfbbd9b35ce540598f6dde4811e544db446/puppetboard/static/fonts/cousine/d6lIkaiiRdih4SpP_SQvzA.woff2
--------------------------------------------------------------------------------
/puppetboard/static/fonts/cousine/d6lIkaiiRdih4SpP_SYvzAbt.woff2:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/voxpupuli/puppetboard/6b6e8dfbbd9b35ce540598f6dde4811e544db446/puppetboard/static/fonts/cousine/d6lIkaiiRdih4SpP_SYvzAbt.woff2
--------------------------------------------------------------------------------
/puppetboard/static/fonts/cousine/d6lIkaiiRdih4SpP_ScvzAbt.woff2:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/voxpupuli/puppetboard/6b6e8dfbbd9b35ce540598f6dde4811e544db446/puppetboard/static/fonts/cousine/d6lIkaiiRdih4SpP_ScvzAbt.woff2
--------------------------------------------------------------------------------
/puppetboard/static/fonts/cousine/d6lIkaiiRdih4SpP_SgvzAbt.woff2:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/voxpupuli/puppetboard/6b6e8dfbbd9b35ce540598f6dde4811e544db446/puppetboard/static/fonts/cousine/d6lIkaiiRdih4SpP_SgvzAbt.woff2
--------------------------------------------------------------------------------
/puppetboard/static/fonts/cousine/d6lIkaiiRdih4SpP_SkvzAbt.woff2:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/voxpupuli/puppetboard/6b6e8dfbbd9b35ce540598f6dde4811e544db446/puppetboard/static/fonts/cousine/d6lIkaiiRdih4SpP_SkvzAbt.woff2
--------------------------------------------------------------------------------
/puppetboard/static/fonts/cousine/d6lIkaiiRdih4SpP_SovzAbt.woff2:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/voxpupuli/puppetboard/6b6e8dfbbd9b35ce540598f6dde4811e544db446/puppetboard/static/fonts/cousine/d6lIkaiiRdih4SpP_SovzAbt.woff2
--------------------------------------------------------------------------------
/puppetboard/static/fonts/cousine/d6lIkaiiRdih4SpP_SsvzAbt.woff2:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/voxpupuli/puppetboard/6b6e8dfbbd9b35ce540598f6dde4811e544db446/puppetboard/static/fonts/cousine/d6lIkaiiRdih4SpP_SsvzAbt.woff2
--------------------------------------------------------------------------------
/puppetboard/static/fonts/lato/S6u8w4BMUTPHjxsAUi-qJCY.woff2:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/voxpupuli/puppetboard/6b6e8dfbbd9b35ce540598f6dde4811e544db446/puppetboard/static/fonts/lato/S6u8w4BMUTPHjxsAUi-qJCY.woff2
--------------------------------------------------------------------------------
/puppetboard/static/fonts/lato/S6u8w4BMUTPHjxsAXC-q.woff2:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/voxpupuli/puppetboard/6b6e8dfbbd9b35ce540598f6dde4811e544db446/puppetboard/static/fonts/lato/S6u8w4BMUTPHjxsAXC-q.woff2
--------------------------------------------------------------------------------
/puppetboard/static/fonts/lato/S6u9w4BMUTPHh6UVSwaPGR_p.woff2:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/voxpupuli/puppetboard/6b6e8dfbbd9b35ce540598f6dde4811e544db446/puppetboard/static/fonts/lato/S6u9w4BMUTPHh6UVSwaPGR_p.woff2
--------------------------------------------------------------------------------
/puppetboard/static/fonts/lato/S6u9w4BMUTPHh6UVSwiPGQ.woff2:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/voxpupuli/puppetboard/6b6e8dfbbd9b35ce540598f6dde4811e544db446/puppetboard/static/fonts/lato/S6u9w4BMUTPHh6UVSwiPGQ.woff2
--------------------------------------------------------------------------------
/puppetboard/static/fonts/lato/S6u_w4BMUTPHjxsI5wq_FQft1dw.woff2:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/voxpupuli/puppetboard/6b6e8dfbbd9b35ce540598f6dde4811e544db446/puppetboard/static/fonts/lato/S6u_w4BMUTPHjxsI5wq_FQft1dw.woff2
--------------------------------------------------------------------------------
/puppetboard/static/fonts/lato/S6u_w4BMUTPHjxsI5wq_Gwft.woff2:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/voxpupuli/puppetboard/6b6e8dfbbd9b35ce540598f6dde4811e544db446/puppetboard/static/fonts/lato/S6u_w4BMUTPHjxsI5wq_Gwft.woff2
--------------------------------------------------------------------------------
/puppetboard/static/fonts/lato/S6uyw4BMUTPHjx4wXg.woff2:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/voxpupuli/puppetboard/6b6e8dfbbd9b35ce540598f6dde4811e544db446/puppetboard/static/fonts/lato/S6uyw4BMUTPHjx4wXg.woff2
--------------------------------------------------------------------------------
/puppetboard/static/fonts/lato/S6uyw4BMUTPHjxAwXjeu.woff2:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/voxpupuli/puppetboard/6b6e8dfbbd9b35ce540598f6dde4811e544db446/puppetboard/static/fonts/lato/S6uyw4BMUTPHjxAwXjeu.woff2
--------------------------------------------------------------------------------
/puppetboard/static/fonts/open-sans/memSYaGs126MiZpBA-UvWbX2vVnXBbObj2OVZyOOSr4dVJWUgsjZ0B4gaVI.woff2:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/voxpupuli/puppetboard/6b6e8dfbbd9b35ce540598f6dde4811e544db446/puppetboard/static/fonts/open-sans/memSYaGs126MiZpBA-UvWbX2vVnXBbObj2OVZyOOSr4dVJWUgsjZ0B4gaVI.woff2
--------------------------------------------------------------------------------
/puppetboard/static/fonts/open-sans/memSYaGs126MiZpBA-UvWbX2vVnXBbObj2OVZyOOSr4dVJWUgsjZ0B4iaVIGxA.woff2:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/voxpupuli/puppetboard/6b6e8dfbbd9b35ce540598f6dde4811e544db446/puppetboard/static/fonts/open-sans/memSYaGs126MiZpBA-UvWbX2vVnXBbObj2OVZyOOSr4dVJWUgsjZ0B4iaVIGxA.woff2
--------------------------------------------------------------------------------
/puppetboard/static/fonts/open-sans/memSYaGs126MiZpBA-UvWbX2vVnXBbObj2OVZyOOSr4dVJWUgsjZ0B4jaVIGxA.woff2:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/voxpupuli/puppetboard/6b6e8dfbbd9b35ce540598f6dde4811e544db446/puppetboard/static/fonts/open-sans/memSYaGs126MiZpBA-UvWbX2vVnXBbObj2OVZyOOSr4dVJWUgsjZ0B4jaVIGxA.woff2
--------------------------------------------------------------------------------
/puppetboard/static/fonts/open-sans/memSYaGs126MiZpBA-UvWbX2vVnXBbObj2OVZyOOSr4dVJWUgsjZ0B4kaVIGxA.woff2:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/voxpupuli/puppetboard/6b6e8dfbbd9b35ce540598f6dde4811e544db446/puppetboard/static/fonts/open-sans/memSYaGs126MiZpBA-UvWbX2vVnXBbObj2OVZyOOSr4dVJWUgsjZ0B4kaVIGxA.woff2
--------------------------------------------------------------------------------
/puppetboard/static/fonts/open-sans/memSYaGs126MiZpBA-UvWbX2vVnXBbObj2OVZyOOSr4dVJWUgsjZ0B4saVIGxA.woff2:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/voxpupuli/puppetboard/6b6e8dfbbd9b35ce540598f6dde4811e544db446/puppetboard/static/fonts/open-sans/memSYaGs126MiZpBA-UvWbX2vVnXBbObj2OVZyOOSr4dVJWUgsjZ0B4saVIGxA.woff2
--------------------------------------------------------------------------------
/puppetboard/static/fonts/open-sans/memSYaGs126MiZpBA-UvWbX2vVnXBbObj2OVZyOOSr4dVJWUgsjZ0B4taVIGxA.woff2:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/voxpupuli/puppetboard/6b6e8dfbbd9b35ce540598f6dde4811e544db446/puppetboard/static/fonts/open-sans/memSYaGs126MiZpBA-UvWbX2vVnXBbObj2OVZyOOSr4dVJWUgsjZ0B4taVIGxA.woff2
--------------------------------------------------------------------------------
/puppetboard/static/fonts/open-sans/memSYaGs126MiZpBA-UvWbX2vVnXBbObj2OVZyOOSr4dVJWUgsjZ0B4uaVIGxA.woff2:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/voxpupuli/puppetboard/6b6e8dfbbd9b35ce540598f6dde4811e544db446/puppetboard/static/fonts/open-sans/memSYaGs126MiZpBA-UvWbX2vVnXBbObj2OVZyOOSr4dVJWUgsjZ0B4uaVIGxA.woff2
--------------------------------------------------------------------------------
/puppetboard/static/fonts/open-sans/memSYaGs126MiZpBA-UvWbX2vVnXBbObj2OVZyOOSr4dVJWUgsjZ0B4vaVIGxA.woff2:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/voxpupuli/puppetboard/6b6e8dfbbd9b35ce540598f6dde4811e544db446/puppetboard/static/fonts/open-sans/memSYaGs126MiZpBA-UvWbX2vVnXBbObj2OVZyOOSr4dVJWUgsjZ0B4vaVIGxA.woff2
--------------------------------------------------------------------------------
/puppetboard/static/js/dailychart.js:
--------------------------------------------------------------------------------
1 | jQuery(function ($) {
2 | let url = "daily_reports_chart.json";
3 | let certname = $("#dailyReportsChart").data("certname");
4 | let days = parseInt($("#dailyReportsChart").data("days"));
5 | let defaultJSON = []
6 |
7 | for (let index = days-1; index >= 0; index--) {
8 | defaultJSON.push({
9 | day: moment().startOf('day').subtract(index, 'days').format('YYYY-MM-DD'),
10 | unchanged: 0,
11 | changed: 0,
12 | failed: 0
13 | })
14 | }
15 |
16 | let chart = bb.generate({
17 | bindto: "#dailyReportsChart",
18 | data: {
19 | type: "bar",
20 | json: defaultJSON,
21 | keys: {
22 | x: "day",
23 | value: ["failed", "changed", "unchanged"],
24 | },
25 | groups: [["failed", "changed", "unchanged"]],
26 | colors: {
27 | // Must match CSS colors
28 | failed: "#AA4643",
29 | changed: "#4572A7",
30 | unchanged: "#89A54E",
31 | },
32 | },
33 | size: {
34 | height: 160,
35 | },
36 | axis: {
37 | x: {
38 | type: "category",
39 | },
40 | },
41 | });
42 |
43 | if (typeof certname !== typeof undefined && certname !== false) {
44 | // truncate /node/certname from URL, to determine path to json
45 | url =
46 | window.location.href.replace(/\/node\/[^/]+$/, "") +
47 | "/daily_reports_chart.json?certname=" +
48 | certname
49 | }
50 |
51 | $.getJSON(url, function(data) {
52 | chart.load({json: data.result})
53 | });
54 | })
55 |
--------------------------------------------------------------------------------
/puppetboard/static/js/pretty.js:
--------------------------------------------------------------------------------
1 | function pretty_print(to_show) {
2 |
3 | if (Object.prototype.toString.call(to_show) === "[object String]") {
4 |
5 | // Print plain string as-is to avoid making it surrounded with ""
6 | to_show = '' + to_show + '';
7 |
8 | } else {
9 |
10 | // Pretty-print the JSON, with syntax highlight
11 | // Based on https://stackoverflow.com/a/7220510/2693875
12 |
13 | let is_complex = false;
14 |
15 | to_show = JSON.stringify(to_show, null, 4); // spacing level = 4
16 | to_show = to_show.replace(/&/g, '&').replace(//g, '>');
17 | to_show = to_show.replace(/("(\\u[a-zA-Z0-9]{4}|\\[^u]|[^\\"])*"(\s*:)?|\b(true|false|null)\b|-?\d+(?:\.\d*)?(?:[eE][+\-]?\d+)?)/g, function (match) {
18 | let cls = 'number';
19 | if (/^"/.test(match)) {
20 | if (/:$/.test(match)) {
21 | is_complex = true
22 | cls = 'key';
23 | } else {
24 | cls = 'string';
25 | }
26 | } else if (/true|false/.test(match)) {
27 | cls = 'boolean';
28 | } else if (/null/.test(match)) {
29 | cls = 'null';
30 | }
31 | return '' + match + '';
32 | });
33 |
34 | if (is_complex) {
35 | // Add pre tag for indentation to be visible
36 | to_show = '' + to_show + '
'
37 | }
38 |
39 | }
40 |
41 | return to_show
42 | }
43 |
--------------------------------------------------------------------------------
/puppetboard/static/js/radiator.js:
--------------------------------------------------------------------------------
1 | function resizeMe() {
2 | var preferredHeight = 944;
3 | var displayHeight = $(window).height();
4 | var percentageHeight = displayHeight / preferredHeight;
5 |
6 | var preferredWidth = 1100;
7 | var displayWidth = $(window).width();
8 | var percentageWidth = displayWidth / preferredWidth;
9 |
10 | var newFontSize;
11 | if (percentageHeight < percentageWidth) {
12 | newFontSize = Math.floor("960" * percentageHeight) - 30;
13 | } else {
14 | newFontSize = Math.floor("960" * percentageWidth) - 30;
15 | }
16 | $("body").css("font-size", newFontSize + "%")
17 | }
18 |
19 | $(document).ready(function() {
20 | $(window).on('resize', resizeMe).trigger('resize');
21 | })
22 |
--------------------------------------------------------------------------------
/puppetboard/static/js/scroll.top.js:
--------------------------------------------------------------------------------
1 | jQuery(function($) {
2 | $(document).scroll(function() {
3 | if ( $(window).scrollTop() > 100 ) {
4 | $('#scroll-btn-top').addClass('show');
5 | } else {
6 | $('#scroll-btn-top').removeClass('show');
7 | }
8 | });
9 |
10 | $('#scroll-btn-top').click(function() {
11 | $('html, body').animate( { scrollTop: 0 }, 500 );
12 | });
13 | });
14 |
--------------------------------------------------------------------------------
/puppetboard/static/js/utils.js:
--------------------------------------------------------------------------------
1 | /**
2 | * Simple debounce function
3 | */
4 | debounce = function(cb, delay = 250) {
5 | let timeout
6 |
7 | return (...args) => {
8 | clearTimeout(timeout)
9 | timeout = setTimeout(() => {
10 | cb(...args)
11 | }, delay)
12 | }
13 | }
14 |
15 | /**
16 | * Transform date string with tz to shorter string.
17 | * If localisation enabled in config, date is timezoned with browser
18 | * If localisation disabled in config, date is UTC
19 | */
20 | $.fn.transformDatetime = function (format = "MMM DD YYYY - HH:mm:ss") {
21 | this.each(function () {
22 | let $el = $(this)
23 | let localise = $el.data("localise")
24 | let dt = moment($el.text())
25 |
26 | if (!dt.isValid()) return
27 |
28 | if (localise) {
29 | $el.text(dt.format("MMM DD YYYY - HH:mm:ss"))
30 | } else {
31 | $el.text(dt.utc().format("MMM DD YYYY - HH:mm:ss"))
32 | }
33 | })
34 | return this
35 | }
36 |
37 |
38 | /**
39 | * Enable filtering on fact list
40 | *
41 | * If text is entered as input, it will use that text to create a regexp
42 | * that will match each fact name.
43 | * If a match is found, make sure the fact name is visible.
44 | * If no match is found, make sure the fact name is hidden.
45 | * Then, we display all segments and hide all those that do not have
46 | * at least a visible fact name.
47 | */
48 | $.fn.factList = function () {
49 | this.each(function() {
50 | const $el = $(this)
51 | const $input = $el.find("input[name=filter]")
52 | const $segments = $el.find(".segment")
53 | const $facts = $el.find(".segment li")
54 |
55 | $input.on("input", debounce(function (e) {
56 | const text = $input.val()
57 |
58 | if (!text) {
59 | $segments.show()
60 | $facts.show()
61 | return
62 | }
63 |
64 | const pattern = new RegExp(text, "i")
65 |
66 | $facts.each(function () {
67 | const $fact = $(this)
68 |
69 | if (pattern.test($fact.text())) {
70 | $fact.show()
71 | } else {
72 | $fact.hide()
73 | }
74 | })
75 | $segments.show()
76 | $segments.not(':has(li:visible)').hide()
77 | }, 250))
78 | })
79 |
80 | return this
81 | }
82 |
83 | $(document).ready(function () {
84 | $("[data-localise]").transformDatetime()
85 |
86 | let dataTable = $("#main-table").DataTable()
87 | $("#main-table-search").on("input", function () {
88 | dataTable.search(this.value).draw()
89 | })
90 |
91 | $('#fact-list').factList()
92 | })
93 |
--------------------------------------------------------------------------------
/puppetboard/static/libs/billboard.js/billboard.min.css:
--------------------------------------------------------------------------------
1 | /*!
2 | * Copyright (c) 2017 ~ present NAVER Corp.
3 | * billboard.js project is licensed under the MIT license
4 | *
5 | * billboard.js, JavaScript chart library
6 | * https://naver.github.io/billboard.js/
7 | *
8 | * @version 3.5.1
9 | */.bb svg{-webkit-tap-highlight-color:rgba(0,0,0,0);font:10px sans-serif}.bb line,.bb path{fill:none;stroke:#000}.bb .bb-button,.bb text{-webkit-user-select:none;-moz-user-select:none;user-select:none}.bb-bars path,.bb-event-rect,.bb-legend-item-tile,.bb-xgrid-focus,.bb-ygrid,.bb-ygrid-focus{shape-rendering:crispEdges}.bb-chart-arc .bb-gauge-value{fill:#000}.bb-chart-arc path{stroke:#fff}.bb-chart-arc rect{stroke:#fff;stroke-width:1}.bb-chart-arc text{fill:#fff;font-size:13px}.bb-axis{shape-rendering:crispEdges}.bb-grid{pointer-events:none}.bb-grid line{stroke:#aaa}.bb-grid text{fill:#aaa}.bb-xgrid,.bb-ygrid{stroke-dasharray:3 3}.bb-text.bb-empty{fill:grey;font-size:2em}.bb-line{stroke-width:1px}.bb-circle._expanded_{stroke-width:1px;stroke:#fff}.bb-selected-circle{fill:#fff;stroke-width:2px}.bb-bar{stroke-width:0}.bb-bar._expanded_{fill-opacity:.75}.bb-candlestick{stroke-width:1px}.bb-candlestick._expanded_{fill-opacity:.75}.bb-circles.bb-focused,.bb-target.bb-focused{opacity:1}.bb-circles.bb-focused path.bb-line,.bb-circles.bb-focused path.bb-step,.bb-target.bb-focused path.bb-line,.bb-target.bb-focused path.bb-step{stroke-width:2px}.bb-circles.bb-defocused,.bb-target.bb-defocused{opacity:.3!important}.bb-circles.bb-defocused .text-overlapping,.bb-target.bb-defocused .text-overlapping{opacity:.05!important}.bb-region{fill:#4682b4}.bb-brush .extent,.bb-region,.bb-zoom-brush{fill-opacity:.1}.bb-legend-item{font-size:12px;user-select:none}.bb-legend-item-hidden{opacity:.15}.bb-legend-background{fill:#fff;stroke:#d3d3d3;stroke-width:1;opacity:.75}.bb-title{font:14px sans-serif}.bb-tooltip-container{user-select:none;z-index:10}.bb-tooltip{background-color:#fff;border-collapse:collapse;border-spacing:0;-webkit-box-shadow:7px 7px 12px -9px #777;-moz-box-shadow:7px 7px 12px -9px #777;box-shadow:7px 7px 12px -9px #777;empty-cells:show;opacity:.9}.bb-tooltip tr{border:1px solid #ccc}.bb-tooltip th{background-color:#aaa;color:#fff;font-size:14px;padding:2px 5px;text-align:left}.bb-tooltip td{background-color:#fff;border-left:1px dotted #999;font-size:13px;padding:3px 6px}.bb-tooltip td>span,.bb-tooltip td>svg{display:inline-block;height:10px;margin-right:6px;width:10px}.bb-tooltip.value{text-align:right}.bb-area{stroke-width:0;opacity:.2}.bb-chart-arcs-title{dominant-baseline:middle;font-size:1.3em}text.bb-chart-arcs-gauge-title{dominant-baseline:middle;font-size:2.7em}.bb-chart-arcs .bb-chart-arcs-background{fill:#e0e0e0;stroke:#fff}.bb-chart-arcs .bb-chart-arcs-gauge-unit{fill:#000;font-size:16px}.bb-chart-arcs .bb-chart-arcs-gauge-max,.bb-chart-arcs .bb-chart-arcs-gauge-min{fill:#777}.bb-chart-arcs .bb-levels circle{fill:none;stroke:#848282;stroke-width:.5px}.bb-chart-arcs .bb-levels text{fill:#848282}.bb-chart-radars .bb-levels polygon{fill:none;stroke:#848282;stroke-width:.5px}.bb-chart-radars .bb-levels text{fill:#848282}.bb-chart-radars .bb-axis line{stroke:#848282;stroke-width:.5px}.bb-chart-radars .bb-axis text{cursor:default;font-size:1.15em}.bb-chart-radars .bb-shapes polygon{fill-opacity:.2;stroke-width:1px}.bb-button{position:absolute;right:10px;top:10px}.bb-button .bb-zoom-reset{background-color:#fff;border:1px solid #ccc;border-radius:5px;cursor:pointer;font-size:11px;padding:5px}
--------------------------------------------------------------------------------
/puppetboard/static/libs/datatables.net-buttons-se/buttons.semanticui.min.js:
--------------------------------------------------------------------------------
1 | /*!
2 | Bootstrap integration for DataTables' Buttons
3 | ©2016 SpryMedia Ltd - datatables.net/license
4 | */
5 | (function(b){"function"===typeof define&&define.amd?define(["jquery","datatables.net-se","datatables.net-buttons"],function(a){return b(a,window,document)}):"object"===typeof exports?module.exports=function(a,c){a||(a=window);c&&c.fn.dataTable||(c=require("datatables.net-se")(a,c).$);c.fn.dataTable.Buttons||require("datatables.net-buttons")(a,c);return b(c,a,a.document)}:b(jQuery,window,document)})(function(b,a,c,e){a=b.fn.dataTable;b.extend(!0,a.Buttons.defaults,{dom:{container:{className:"dt-buttons ui basic buttons"},
6 | button:{tag:"button",className:"dt-button ui button",spacerClass:"dt-button ui button"},collection:{tag:"div",className:"ui basic vertical buttons",closeButton:!1},splitWrapper:{tag:"div",className:"dt-btn-split-wrapper buttons",closeButton:!1},splitDropdown:{tag:"button",text:"▼",className:"ui floating button dt-btn-split-drop dropdown icon",closeButton:!1},splitDropdownButton:{tag:"button",className:"dt-btn-split-drop-button ui button",closeButton:!1}}});b(c).on("buttons-popover.dt",function(){var d=
7 | !1;b(".dtsp-panesContainer").each(function(){b(this).is("button")||(d=!0)});d&&b(".dtsp-panesContainer").removeClass("vertical buttons")});return a.Buttons});
8 |
--------------------------------------------------------------------------------
/puppetboard/static/libs/datatables.net-buttons/buttons.colVis.min.js:
--------------------------------------------------------------------------------
1 | /*!
2 | Column visibility buttons for Buttons and DataTables.
3 | 2016 SpryMedia Ltd - datatables.net/license
4 | */
5 | (function(h){"function"===typeof define&&define.amd?define(["jquery","datatables.net","datatables.net-buttons"],function(e){return h(e,window,document)}):"object"===typeof exports?module.exports=function(e,g){e||(e=window);g&&g.fn.dataTable||(g=require("datatables.net")(e,g).$);g.fn.dataTable.Buttons||require("datatables.net-buttons")(e,g);return h(g,e,e.document)}:h(jQuery,window,document)})(function(h,e,g,l){e=h.fn.dataTable;h.extend(e.ext.buttons,{colvis:function(b,a){var c=null,d={extend:"collection",
6 | init:function(f,k){c=k},text:function(f){return f.i18n("buttons.colvis","Column visibility")},className:"buttons-colvis",closeButton:!1,buttons:[{extend:"columnsToggle",columns:a.columns,columnText:a.columnText}]};b.on("column-reorder.dt"+a.namespace,function(f,k,m){b.button(null,b.button(null,c).node()).collectionRebuild([{extend:"columnsToggle",columns:a.columns,columnText:a.columnText}])});return d},columnsToggle:function(b,a){return b.columns(a.columns).indexes().map(function(c){return{extend:"columnToggle",
7 | columns:c,columnText:a.columnText}}).toArray()},columnToggle:function(b,a){return{extend:"columnVisibility",columns:a.columns,columnText:a.columnText}},columnsVisibility:function(b,a){return b.columns(a.columns).indexes().map(function(c){return{extend:"columnVisibility",columns:c,visibility:a.visibility,columnText:a.columnText}}).toArray()},columnVisibility:{columns:l,text:function(b,a,c){return c._columnText(b,c)},className:"buttons-columnVisibility",action:function(b,a,c,d){b=a.columns(d.columns);
8 | a=b.visible();b.visible(d.visibility!==l?d.visibility:!(a.length&&a[0]))},init:function(b,a,c){var d=this;a.attr("data-cv-idx",c.columns);b.on("column-visibility.dt"+c.namespace,function(f,k){k.bDestroying||k.nTable!=b.settings()[0].nTable||d.active(b.column(c.columns).visible())}).on("column-reorder.dt"+c.namespace,function(f,k,m){c.destroying||1!==b.columns(c.columns).count()||(d.text(c._columnText(b,c)),d.active(b.column(c.columns).visible()))});this.active(b.column(c.columns).visible())},destroy:function(b,
9 | a,c){b.off("column-visibility.dt"+c.namespace).off("column-reorder.dt"+c.namespace)},_columnText:function(b,a){var c=b.column(a.columns).index(),d=b.settings()[0].aoColumns[c].sTitle;d||(d=b.column(c).header().innerHTML);d=d.replace(/\n/g," ").replace(/
/gi," ").replace(/
9 | Please have a look at the log output for further information.
10 | {% endblock %}
11 |
--------------------------------------------------------------------------------
/puppetboard/templates/_facts_sorter.html:
--------------------------------------------------------------------------------
1 | {% macro get_facts_sorter_type(fact) -%}
2 |
3 | {% set sorter_map = {
4 | 'kernelrelease': 'natural',
5 | 'uptime': 'natural-time-delta'
6 | } %}
7 |
8 | {% set sorter = sorter_map.get(fact, None) %}
9 | {% if sorter != None %}
10 | "type": "{{ sorter }}",
11 | {% endif %}
12 | {%- endmacro %}
13 |
--------------------------------------------------------------------------------
/puppetboard/templates/_static_offline.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
--------------------------------------------------------------------------------
/puppetboard/templates/_static_online.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
--------------------------------------------------------------------------------
/puppetboard/templates/catalog.html:
--------------------------------------------------------------------------------
1 | {% extends 'layout.html' %}
2 | {% block content %}
3 |
4 |
5 |
6 | Summary
7 |
8 |
9 |
10 | Certname |
11 | Version |
12 | Transaction UUID |
13 | Code ID |
14 |
15 |
16 |
17 |
18 | {{catalog.node}} |
19 | {{catalog.version}} |
20 | {{catalog.transaction_uuid}} |
21 | {{catalog.code_id}} |
22 |
23 |
24 |
25 |
26 | Resources
27 |
28 |
29 |
30 | Resource |
31 | Location |
32 |
33 |
34 |
35 | {% for resource in catalog.get_resources() %}
36 |
37 | {{resource.type_}}[{{resource.name}}] |
38 | {{resource.sourcefile}}:{{resource.sourceline}} |
39 |
40 | {% endfor %}
41 |
42 |
43 |
44 | Edges
45 |
46 |
47 |
48 | Source |
49 | Relationship |
50 | Target |
51 |
52 |
53 |
54 | {% for edge in catalog.get_edges() %}
55 |
56 | {{edge.source}} |
57 | {{edge.relationship}} |
58 | {{edge.target}} |
59 |
60 | {% endfor %}
61 |
62 |
63 | {% endblock content %}
64 |
--------------------------------------------------------------------------------
/puppetboard/templates/catalog_compare.html:
--------------------------------------------------------------------------------
1 | {% extends 'layout.html' %}
2 | {% block content %}
3 |
4 |
5 |
6 |
7 |
8 |
9 | Comparing |
10 | Against |
11 |
12 |
13 | {{compare.node}} |
14 | {{against.node}} |
15 |
16 |
17 |
18 |
19 |
20 | Resources |
21 |
22 |
23 | {% for resource in compare.get_resources() %}
24 |
25 | {{resource.type_}}[{{resource.name}}] |
26 |
27 | {% endfor %}
28 |
29 |
30 | |
31 |
32 |
33 |
34 | Resources |
35 |
36 |
37 | {% for resource in against.get_resources() %}
38 |
39 | {{resource.type_}}[{{resource.name}}] |
40 |
41 | {% endfor %}
42 |
43 |
44 | |
45 |
46 |
47 |
48 |
49 |
50 |
51 | Edges |
52 | -> |
53 | Target |
54 |
55 |
56 |
57 | {% for edge in compare.get_edges() %}
58 |
59 | {{edge.source}} |
60 | {{edge.relationship}} |
61 | {{edge.target}} |
62 |
63 | {% endfor %}
64 |
65 |
66 | |
67 |
68 |
69 |
70 |
71 | Edge |
72 | -> |
73 | Target |
74 |
75 |
76 |
77 | {% for edge in against.get_edges() %}
78 |
79 | {{edge.source}} |
80 | {{edge.relationship}} |
81 | {{edge.target}} |
82 |
83 | {% endfor %}
84 |
85 |
86 | |
87 |
88 |
89 |
90 | {% endblock content %}
91 |
--------------------------------------------------------------------------------
/puppetboard/templates/catalogs.html:
--------------------------------------------------------------------------------
1 | {% extends 'layout.html' %}
2 | {% import '_macros.html' as macros %}
3 | {% block content %}
4 |
5 |
6 |
7 | {% for column in columns %}
8 | {{ column.name }} |
9 | {% endfor %}
10 |
11 |
12 |
13 |
14 |
15 | {% endblock content %}
16 | {% block onload_script %}
17 | {% macro extra_options(caller) %}
18 | "order": [[ 0, "asc" ]],
19 | {% endmacro %}
20 | {{ macros.datatable_init(table_html_id="catalogs_table", ajax_url=url_for('catalogs_ajax', env=current_env, compare=compare), data=None, default_length=config.NORMAL_TABLE_COUNT, length_selector=config.TABLE_COUNT_SELECTOR, extra_options=extra_options) }}
21 | {% endblock onload_script %}
22 |
--------------------------------------------------------------------------------
/puppetboard/templates/catalogs.json.tpl:
--------------------------------------------------------------------------------
1 | {
2 | "draw": {{draw}},
3 | "recordsTotal": {{total}},
4 | "recordsFiltered": {{total_filtered}},
5 | "data": [
6 | {% for catalog in catalogs -%}
7 | {%- if not loop.first %},{%- endif -%}
8 | [
9 | {%- for column in columns -%}
10 | {%- if not loop.first %},{%- endif -%}
11 | {%- if column.attr == 'catalog_timestamp' -%}
12 | "{{ catalog.catalog_timestamp }}"
13 | {%- elif column.type == 'node' -%}
14 | {% filter jsonprint %}{{ catalog.certname }}{% endfilter %}
15 | {%- elif column.attr == 'form' -%}
16 | {% filter jsonprint -%}
17 |
32 | {%- endfilter -%}
33 | {%- else -%}
34 | ""
35 | {%- endif -%}
36 | {%- endfor -%}
37 | ]
38 | {% endfor %}
39 | ]
40 | }
41 |
--------------------------------------------------------------------------------
/puppetboard/templates/class.html:
--------------------------------------------------------------------------------
1 | {% extends 'layout.html' %}
2 | {% import '_macros.html' as macros %}
3 | {% block content %}
4 | {{ class_name }}
5 |
6 |
7 |
8 | Node |
9 | Node Status |
10 | Class Status |
11 |
12 |
13 |
14 |
15 |
16 | {% endblock content %}
17 | {% block onload_script %}
18 | {% macro extra_options(caller) %}
19 | 'serverSide': false,
20 | {% endmacro %}
21 | {{ macros.datatable_init(table_html_id="class_table", ajax_url=url_for('class_resource_ajax', env=current_env, class_name=class_name), data=None, default_length=config.NORMAL_TABLE_COUNT, length_selector=config.TABLE_COUNT_SELECTOR, extra_options=extra_options) }}
22 | {% endblock onload_script %}
23 |
--------------------------------------------------------------------------------
/puppetboard/templates/class_resource.json.tpl:
--------------------------------------------------------------------------------
1 | {%- import '_macros.html' as macros -%}
2 | {
3 | "draw": {{draw}},
4 | "recordsTotal": {{total}},
5 | "recordsFiltered": {{total_filtered}},
6 | "data": [
7 | {% for report_hash, node in nodes_data.items() -%}
8 | {%- if not loop.first %},{%- endif -%}
9 | [
10 | {% filter jsonprint %}{{ node.node_name }}{% endfilter %},
11 | {% filter jsonprint %}{{ node.node_status|upper }}{% endfilter %},
12 | {% filter jsonprint %}{{ node.class_status|upper }}{% endfilter %}
13 | ]
14 | {% endfor -%}
15 | ]
16 | }
17 |
--------------------------------------------------------------------------------
/puppetboard/templates/classes.html:
--------------------------------------------------------------------------------
1 | {% extends 'layout.html' %}
2 | {% import '_macros.html' as macros %}
3 | {% block content %}
4 |
5 |
6 |
7 | Class |
8 | Nb Nodes |
9 | {% for column in columns %}
10 | {{column[1]}} |
11 | {% endfor %}
12 |
13 |
14 |
15 |
16 |
17 | {% endblock content %}
18 | {% block onload_script %}
19 | {% macro extra_options(caller) %}
20 | 'serverSide': false,
21 | {% endmacro %}
22 | {{ macros.datatable_init(table_html_id="classes_table", ajax_url=url_for('classes_ajax', env=current_env), data=None, default_length=config.NORMAL_TABLE_COUNT, length_selector=config.TABLE_COUNT_SELECTOR, extra_options=extra_options) }}
23 | {% endblock onload_script %}
24 |
--------------------------------------------------------------------------------
/puppetboard/templates/classes.json.tpl:
--------------------------------------------------------------------------------
1 | {%- import '_macros.html' as macros -%}
2 | {
3 | "draw": {{draw}},
4 | "recordsTotal": {{total}},
5 | "recordsFiltered": {{total_filtered}},
6 | "data": [
7 | {% for class in classes_data -%}
8 | {%- if not loop.first %},{%- endif -%}
9 | [
10 | {% filter jsonprint %}{{ class }}{% endfilter %},
11 | {% filter jsonprint %}{{ classes_data[class]['nb_nodes'] }}{% endfilter %},
12 | {%- for column in columns -%}
13 | {%- if not loop.first %},{%- endif -%}
14 | {%- if column in classes_data[class]['nb_nodes_per_class_status'] -%}
15 | {{ macros.event_status_counts(status=column, count=classes_data[class]['nb_nodes_per_class_status'][column]) | jsonprint }}
16 | {%- else -%}
17 | ""
18 | {%- endif -%}
19 | {%- endfor -%}
20 | ]
21 | {% endfor -%}
22 | ]
23 | }
24 |
--------------------------------------------------------------------------------
/puppetboard/templates/fact.html:
--------------------------------------------------------------------------------
1 | {% extends 'layout.html' %}
2 | {% import '_macros.html' as macros %}
3 | {% block head %}
4 |
5 |
6 | {% endblock head %}
7 |
8 | {% block onload_script %}
9 | {% macro extra_options(caller) %}
10 | // No per page AJAX
11 | 'serverSide': false,
12 | {% endmacro %}
13 |
14 | {{ macros.datatable_init(table_html_id="facts_table", ajax_url=url_for('fact_ajax', env=current_env, fact=fact, value=value), data=None, default_length=config.NORMAL_TABLE_COUNT, length_selector=config.TABLE_COUNT_SELECTOR, extra_options=extra_options, fact=fact) }}
15 |
16 | {% if render_graph %}
17 | table.on('xhr', function(e, settings, json){
18 | var fact_values = json['chart'].map(function(item) { return [item.label, item.value]; }).filter(function(item){return item[0];}).sort(function(a,b){return b[1] - a[1];});
19 | var realdata = fact_values.slice(0, 15);
20 | var otherdata = fact_values.slice(15);
21 | if (otherdata.length > 0) {
22 | realdata.push(["other", otherdata.reduce(function(a,b){return a + b[1];},0)]);
23 | }
24 | bb.generate({
25 | bindto: '#factChart',
26 | data: {
27 | columns: realdata,
28 | type : '{{config.GRAPH_TYPE|default('pie')}}',
29 | }
30 | });
31 | })
32 | {% endif %}
33 |
34 | {% if value %}
35 | try {
36 | $("#value").html(pretty_print(JSON.parse({{ value_json | tojson }})));
37 | } catch (e) {
38 | $("#value").html(pretty_print('{{ value_json }}'));
39 | }
40 | {% endif %}
41 |
42 | {% endblock onload_script %}
43 |
44 | {% block content %}
45 | {% if render_graph %}
46 |
47 | {% endif %}
48 | {{ fact }}{% if value %} : {% endif %}
49 |
50 |
51 |
52 | Node |
53 | {% if not value %}Value | {% endif %}
54 |
55 |
56 |
57 |
58 |
59 | {% endblock content %}
60 |
--------------------------------------------------------------------------------
/puppetboard/templates/facts.html:
--------------------------------------------------------------------------------
1 | {% extends 'layout.html' %}
2 | {% block content %}
3 |
4 |
5 |
6 |
7 |
8 | {%- for column in facts_columns %}
9 |
10 | {%- for letter in column %}
11 | {%- if letter is not none %}
12 |
20 | {%- endif %}
21 | {%- endfor %}
22 |
23 | {%- endfor %}
24 |
25 |
26 | {% endblock content %}
27 |
--------------------------------------------------------------------------------
/puppetboard/templates/failures.html:
--------------------------------------------------------------------------------
1 | {% extends 'layout.html' %}
2 | {% import '_macros.html' as macros %}
3 | {% block content %}
4 |
5 |
6 |
7 | {{ macros.checkbox_toggle("friendly", current_show_error_as, "friendly", "raw", "Friendly error messages") }}
8 |
17 |
18 |
19 | Certname |
20 | Report time |
21 | Error |
22 |
23 |
24 |
25 | {% for failure in failures %}
26 |
27 |
28 |
29 | {{failure.certname}}
30 |
31 | |
32 |
33 |
34 | {{failure.timestamp}}
35 |
36 | |
37 |
38 | {{failure.error | safe}}
39 | |
40 |
41 | {% endfor %}
42 |
43 |
44 |
45 | {% endblock content %}
46 |
--------------------------------------------------------------------------------
/puppetboard/templates/inventory.html:
--------------------------------------------------------------------------------
1 | {% extends 'layout.html' %}
2 | {% import '_macros.html' as macros %}
3 | {% block content %}
4 |
5 |
6 |
7 | {% for head in fact_headers %}
8 | {{head}} |
9 | {% endfor %}
10 |
11 |
12 |
13 |
14 |
15 | {% endblock content %}
16 | {% block onload_script %}
17 | {% macro extra_options(caller) %}
18 | 'serverSide': false,
19 | {% endmacro %}
20 | {{ macros.datatable_init(table_html_id="inventory_table", ajax_url=url_for('inventory_ajax', env=current_env), data=None, default_length=config.NORMAL_TABLE_COUNT, length_selector=config.TABLE_COUNT_SELECTOR, extra_options=extra_options) }}
21 | {% endblock onload_script %}
22 |
--------------------------------------------------------------------------------
/puppetboard/templates/inventory.json.tpl:
--------------------------------------------------------------------------------
1 | {%- import '_macros.html' as macros -%}
2 | {
3 | "draw": {{draw}},
4 | "recordsTotal": {{total}},
5 | "recordsFiltered": {{total_filtered}},
6 | "data": [
7 | {% for node in fact_data -%}
8 | {%- if not loop.first %},{%- endif -%}
9 | [
10 | {%- for column in columns -%}
11 | {%- if not loop.first %},{%- endif -%}
12 | {%- if column in ['fqdn', 'hostname'] -%}
13 | {% filter jsonprint %}{{ node }}{% endfilter %}
14 | {%- elif fact_data[node][column] -%}
15 | {{ fact_data[node][column] | jsonprint }}
16 | {%- else -%}
17 | ""
18 | {%- endif -%}
19 | {%- endfor -%}
20 | ]
21 | {% endfor -%}
22 | ]
23 | }
24 |
--------------------------------------------------------------------------------
/puppetboard/templates/layout.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | {{config.PAGE_TITLE}}
6 |
7 |
8 |
9 | {%- filter indent(width=4) %}
10 | {%- if config.OFFLINE_MODE %}
11 | {%- include "_static_offline.html" %}
12 | {%- else %}
13 | {%- include "_static_online.html" %}
14 | {%- endif %}
15 | {%- endfilter %}
16 |
17 | {#- CSS LOCAL #}
18 |
19 |
20 | {#- JS LOCAL #}
21 |
22 |
23 |
24 |
25 | {%- block script %}{% endblock script %}
26 |
43 | {%- block head %}{% endblock head %}
44 |
45 |
46 |
47 |
72 |
73 |
74 |
75 | {% block content %} {% endblock content %}
76 |
77 |
78 |
79 |
80 |
81 |
82 |
83 |
84 |
85 |
86 |
--------------------------------------------------------------------------------
/puppetboard/templates/metric.html:
--------------------------------------------------------------------------------
1 | {% extends 'layout.html' %}
2 | {% block content %}
3 |
13 |
14 |
15 |
16 | Option |
17 | Value |
18 |
19 |
20 |
21 | {% for key,value in metric %}
22 |
23 | {{key}} |
24 | {% if value is mapping %}
25 | {{value|jsonprint}} |
26 | {% else %}
27 | {{value}} |
28 | {% endif %}
29 |
30 | {% endfor %}
31 |
32 |
33 | {% endblock content %}
34 |
--------------------------------------------------------------------------------
/puppetboard/templates/metrics.html:
--------------------------------------------------------------------------------
1 | {% extends 'layout.html' %}
2 | {% block content %}
3 | Metrics
4 |
5 |
6 |
7 |
8 | {% for metric in metrics %}
9 | -
10 | {{metric}}
11 |
12 | {% endfor %}
13 |
14 | {% endblock content %}
15 |
--------------------------------------------------------------------------------
/puppetboard/templates/node.html:
--------------------------------------------------------------------------------
1 | {% extends 'layout.html' %}
2 | {% import '_macros.html' as macros %}
3 | {% block head %}
4 | {% block script %}
5 | {% if config.DAILY_REPORTS_CHART_ENABLED %}
6 |
7 | {% endif %}
8 | {% endblock script %}
9 | {% endblock head %}
10 | {% block onload_script %}
11 | {% macro extra_options(caller) %}
12 | 'pagingType': 'simple',
13 | "bFilter": false,
14 | {% endmacro %}
15 | {% macro facts_extra_options(caller) %}
16 | 'paging': false,
17 | // No per page AJAX
18 | 'serverSide': false,
19 | {% endmacro %}
20 | {{ macros.datatable_init(table_html_id="reports_table", ajax_url=url_for('reports_ajax', env=current_env, node_name=node.name), data=None, default_length=config.LITTLE_TABLE_COUNT, length_selector=config.TABLE_COUNT_SELECTOR, extra_options=extra_options) }}
21 | {{ macros.datatable_init(table_html_id="facts_table", ajax_url=url_for('fact_ajax', env=current_env, node=node.name), data=None, default_length=config.LITTLE_TABLE_COUNT, length_selector=config.TABLE_COUNT_SELECTOR, extra_options=facts_extra_options) }}
22 | {% endblock onload_script %}
23 |
24 | {% block content %}
25 |
26 |
27 |
28 |
Details
29 |
30 |
31 |
32 | Certname |
33 | {{node.name}} |
34 |
35 |
36 | Facts |
37 | {{node.facts_timestamp}} |
38 |
39 |
40 | Catalog |
41 | {{node.catalog_timestamp}} |
42 |
43 |
44 | Report |
45 | {{node.report_timestamp}} |
46 |
47 |
48 |
49 |
50 |
51 |
Reports
52 | {% if config.DAILY_REPORTS_CHART_ENABLED %}
53 |
56 | {% endif %}
57 |
58 |
59 |
60 | {% for column in columns %}
61 | {{ column.name }} |
62 | {% endfor %}
63 |
64 |
65 |
66 |
67 |
68 |
Show All
69 |
70 |
71 |
72 |
Facts
73 |
74 |
75 |
76 | Name |
77 | Value |
78 |
79 |
80 |
81 |
82 |
83 |
84 |
85 | {% endblock content %}
86 |
--------------------------------------------------------------------------------
/puppetboard/templates/nodes.html:
--------------------------------------------------------------------------------
1 | {% extends 'layout.html' %}
2 | {% import '_macros.html' as macros %}
3 | {% block content %}
4 |
5 |
11 |
12 |
13 |
14 |
15 | Filter By Status
16 |
17 |
24 |
25 |
26 |
27 |
36 |
37 |
38 | Status |
39 | Certname |
40 | Catalog |
41 | Report |
42 | |
43 |
44 |
45 |
46 | {% for node in nodes %}
47 |
48 |
49 | {% if node.latest_report_hash %}
50 | {{macros.status_counts(status=node.status, node_name=node.name, events=node.events, unreported_time=node.unreported_time, current_env=current_env, report_hash=node.latest_report_hash)}}
51 | {% else %}
52 | {{macros.status_counts(status=node.status, node_name=node.name, events=node.events, unreported_time=node.unreported_time, current_env=current_env)}}
53 | {% endif %}
54 | |
55 | {{node.name}} |
56 |
57 | {% if node.catalog_timestamp %}
58 | {{node.catalog_timestamp}}
59 | {% else %}
60 |
61 | {% endif %}
62 | |
63 |
64 | {% if node.report_timestamp %}
65 | {{ node.report_timestamp }}
66 | {% else %}
67 |
68 | {% endif %}
69 | |
70 |
71 | {% if node.report_timestamp %}
72 |
73 | {% endif %}
74 | |
75 |
76 | {% endfor %}
77 |
78 |
79 | {% endblock content %}
80 |
--------------------------------------------------------------------------------
/puppetboard/templates/query.html:
--------------------------------------------------------------------------------
1 | {% extends 'layout.html' %}
2 | {% import '_macros.html' as macros %}
3 |
4 | {% block content %}
5 | Compose
6 | {% with messages = get_flashed_messages(with_categories=true) %}
7 | {% if messages %}
8 | {% for category, message in messages %}
9 |
10 | {{ message }}
11 |
12 | {% endfor %}
13 | {% endif %}
14 | {% endwith %}
15 |
16 |
25 |
26 |
35 |
36 |
55 |
56 | {% if result or zero_results or error_text %}
57 |
58 |
59 |
60 |
61 |
62 | {% if result %}
63 |
Number of results: {{ result | length }}
64 | {% if form.rawjson.data %}
65 |
66 |
73 | {% else %}
74 |
75 |
76 |
77 | {% for column in columns %}
78 | {{ column }} |
79 | {% endfor %}
80 |
81 |
82 |
83 |
84 |
85 | {% endif %}
86 |
87 | {% elif zero_results %}
88 |
89 |
90 |
91 |
The query was successful but the response is empty. Please try changing query conditions.
92 |
93 |
94 | {% elif error_text %}
95 |
96 |
97 |
98 |
{{ error_text }}
99 |
100 |
101 | {% endif %}
102 |
103 |
104 |
105 | {% endif %}
106 | {% endblock content %}
107 |
108 | {% block onload_script %}
109 | {% if not form.rawjson.data %}
110 | {% macro extra_options(caller) %}
111 | 'columns': [
112 | {% for column in columns %}
113 | {
114 | "title": "{{ quote_columns_data(column) }}",
115 | {% if column in ['node', 'certname'] %}
116 | "render": function (data, type, full, meta) {
117 | return `${data}`
118 | },
119 | {% endif %}
120 | },
121 | {% endfor %}
122 | ],
123 | 'serverSide': false,
124 | {% endmacro %}
125 |
126 | {% if not result %}
127 | {% set result = [] %}
128 | {% endif %}
129 | {{ macros.datatable_init(table_html_id="query_table", ajax_url=None, data=result|tojson, default_length=config.NORMAL_TABLE_COUNT, length_selector=config.TABLE_COUNT_SELECTOR, extra_options=extra_options) }}
130 | {% endif %}
131 | {% endblock onload_script %}
132 |
--------------------------------------------------------------------------------
/puppetboard/templates/radiator.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 | {{config.PAGE_TITLE}}
5 |
6 |
7 | {% if config.REFRESH_RATE > 0 %}
8 |
9 | {% endif %}
10 |
11 | {% if config.OFFLINE_MODE %}
12 |
13 | {% else %}
14 |
15 | {% endif %}
16 |
17 |
18 |
19 |
20 |
21 |
22 | {{stats['failed']}}
23 | |
24 |
25 |
26 |
27 | Failed
28 |
29 |
30 | Failed
31 |
32 |
33 | |
34 |
35 |
36 |
37 | {{stats['unreported']}}
38 | |
39 |
40 |
41 |
42 | Unreported
43 |
44 |
45 | Unreported
46 |
47 |
48 | |
49 |
50 |
51 |
52 | {{stats['noop']}}
53 | |
54 |
55 |
56 |
57 | Noop
58 |
59 |
60 | Noop
61 |
62 |
63 | |
64 |
65 |
66 |
67 | {{stats['changed']}}
68 | |
69 |
70 |
71 |
72 | Changed
73 |
74 |
75 | Changed
76 |
77 |
78 | |
79 |
80 |
81 |
82 | {{stats['unchanged']}}
83 | |
84 |
85 |
86 |
87 | Unchanged
88 |
89 |
90 | Unchanged
91 |
92 |
93 | |
94 |
95 |
96 |
97 | {{total}}
98 | |
99 |
100 |
101 |
102 | Total
103 |
104 |
105 | |
106 |
107 |
108 |
109 |
110 |
--------------------------------------------------------------------------------
/puppetboard/templates/reports.html:
--------------------------------------------------------------------------------
1 | {% extends 'layout.html' %}
2 | {% import '_macros.html' as macros %}
3 | {% block content %}
4 |
5 |
6 |
7 |
End Time (between)
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
20 | {% for status in ['failed', 'changed', 'unchanged', 'noop'] %}
21 |
22 |
23 |
24 |
25 |
26 |
27 | {% endfor %}
28 |
29 |
30 |
31 |
32 |
33 | {% for column in columns %}
34 | {{ column.name }} |
35 | {% endfor %}
36 |
37 |
38 |
39 |
40 |
41 | {% endblock content %}
42 | {% block onload_script %}
43 | {% macro extra_options(caller) %}
44 | // No initial loading
45 | "deferLoading": true,
46 | {% endmacro %}
47 | {{ macros.datatable_init(table_html_id="reports_table", ajax_url=url_for('reports_ajax', env=current_env, node_name=node_name), data=None, default_length=config.NORMAL_TABLE_COUNT, length_selector=config.TABLE_COUNT_SELECTOR, extra_options=extra_options) }}
48 | function date_filter_change(){
49 | var minDate = $('#min').prop('value');
50 | var maxDate = $('#max').prop('value');
51 |
52 | var data = {};
53 |
54 | if(minDate != '') {
55 | data['min'] = minDate;
56 | }
57 |
58 | if(maxDate != '') {
59 | data['max'] = maxDate;
60 | }
61 |
62 | table.column(0).search(JSON.stringify(data)).draw();
63 | }
64 |
65 | // Event listener for status filters
66 | function status_filter_change(){
67 | var sum = '';
68 | var failed = $('#failed').prop('checked');
69 | var changed = $('#changed').prop('checked');
70 | var unchanged = $('#unchanged').prop('checked');
71 | var noop = $('#noop').prop('checked');
72 | if ( failed && changed && unchanged && noop) { sum = '*'; }
73 | else if (!(failed || changed || unchanged || noop)) { sum = 'none'; }
74 | else {
75 | if (failed) { sum += 'failed|'; }
76 | if (changed) { sum += 'changed|'; }
77 | if (unchanged) { sum += 'unchanged|'; }
78 | if (noop) { sum += 'noop|'; }
79 | }
80 | table.column(1).search(sum).draw();
81 | }
82 | $('#failed, #changed, #unchanged, #noop').change(status_filter_change);
83 | $('#min, #max').change(date_filter_change);
84 | // Call at init - fix page reload behavior
85 | status_filter_change();
86 | date_filter_change();
87 | {% endblock onload_script %}
88 |
--------------------------------------------------------------------------------
/puppetboard/templates/reports.json.tpl:
--------------------------------------------------------------------------------
1 | {%- import '_macros.html' as macros -%}
2 | {
3 | "draw": {{draw}},
4 | "recordsTotal": {{total}},
5 | "recordsFiltered": {{total_filtered}},
6 | "data": [
7 | {%- set report_flag = false -%}
8 | {% for report in reports -%}
9 | {%- if not loop.first %},{%- endif -%}
10 | [
11 | {%- set column_flag = false -%}
12 | {%- for column in columns -%}
13 | {%- if not loop.first %},{%- endif -%}
14 | {%- if column.type == 'datetime' -%}
15 | "{{ report[column.attr] }}"
16 | {%- elif column.type == 'status' -%}
17 | {% filter jsonprint -%}
18 | {{ macros.report_status(status=report.status, node_name=report.node, metrics=metrics[report.hash_], report_hash=report.hash_, current_env=current_env) }}
19 | {%- endfilter %}
20 | {%- elif column.type == 'node' -%}
21 | {% filter jsonprint %}{{ report.node }}{% endfilter %}
22 | {%- else -%}
23 | {{ report[column.attr] | jsonprint }}
24 | {%- endif -%}
25 | {%- endfor -%}
26 | ]
27 | {% endfor %}
28 | ]
29 | }
30 |
--------------------------------------------------------------------------------
/puppetboard/version.py:
--------------------------------------------------------------------------------
1 | #
2 | # Puppetboard version module
3 | #
4 |
5 | import importlib.metadata
6 | __version__ = importlib.metadata.version('puppetboard')
7 |
--------------------------------------------------------------------------------
/puppetboard/views/dailychart.py:
--------------------------------------------------------------------------------
1 | from datetime import datetime, timedelta
2 |
3 | from flask import (
4 | request, jsonify
5 | )
6 | from pypuppetdb.QueryBuilder import (ExtractOperator, AndOperator,
7 | EqualsOperator, FunctionOperator,
8 | GreaterEqualOperator)
9 | from pypuppetdb.QueryBuilder import (LessOperator)
10 | from pypuppetdb.utils import UTC
11 |
12 | from puppetboard.core import get_app, get_puppetdb
13 | from puppetboard.utils import (get_or_abort)
14 |
15 | app = get_app()
16 | puppetdb = get_puppetdb()
17 |
18 |
19 | DATE_FORMAT = "%Y-%m-%d"
20 | DATETIME_FORMAT = "%Y-%m-%dT%H:%M:%S.%fZ"
21 |
22 |
23 | @app.route('/daily_reports_chart.json',
24 | defaults={'env': app.config['DEFAULT_ENVIRONMENT']})
25 | @app.route('//daily_reports_chart.json')
26 | def daily_reports_chart(env):
27 | """Return JSON data to generate a bar chart of daily runs.
28 |
29 | If certname is passed as GET argument, the data will target that
30 | node only.
31 | """
32 | certname = request.args.get('certname')
33 | result = get_or_abort(
34 | get_daily_reports_chart,
35 | db=puppetdb,
36 | env=env,
37 | days_number=app.config['DAILY_REPORTS_CHART_DAYS'],
38 | certname=certname,
39 | )
40 | return jsonify(result=result)
41 |
42 |
43 | def _iter_dates(days_number, reverse=False):
44 | """Return a list of datetime pairs AB, BC, CD, ... that represent the
45 | 24hs time ranges of today (until this midnight) and the
46 | previous days.
47 | """
48 | one_day = timedelta(days=1)
49 | today = datetime.now().replace(hour=0, minute=0, second=0, microsecond=0, tzinfo=UTC())
50 | days_list = list(today + one_day * (1 - i) for i in range(days_number + 1))
51 | if reverse:
52 | days_list.reverse()
53 | return zip(days_list, days_list[1:])
54 | return zip(days_list[1:], days_list)
55 |
56 |
57 | def _build_query(env, start, end, certname=None):
58 | """Build a extract query with optional certname and environment."""
59 | query = ExtractOperator()
60 | query.add_field(FunctionOperator('count'))
61 | query.add_field('status')
62 | subquery = AndOperator()
63 | subquery.add(GreaterEqualOperator('producer_timestamp', start))
64 | subquery.add(LessOperator('producer_timestamp', end))
65 | if certname is not None:
66 | subquery.add(EqualsOperator('certname', certname))
67 | if env != '*':
68 | subquery.add(EqualsOperator('environment', env))
69 | query.add_query(subquery)
70 | query.add_group_by("status")
71 | return query
72 |
73 |
74 | def _format_report_data(day, query_output):
75 | """Format the output of the query to a simpler dict."""
76 | result = {'day': day, 'changed': 0, 'unchanged': 0, 'failed': 0}
77 | for out in query_output:
78 | if out['status'] == 'changed':
79 | result['changed'] = out['count']
80 | elif out['status'] == 'unchanged':
81 | result['unchanged'] = out['count']
82 | elif out['status'] == 'failed':
83 | result['failed'] = out['count']
84 | return result
85 |
86 |
87 | def get_daily_reports_chart(db, env, days_number, certname=None):
88 | """Return the sum of each report status (changed, unchanged, failed)
89 | per day, for today and the previous N days.
90 |
91 | This information is used to present a chart.
92 |
93 | :param db: The puppetdb.
94 | :param env: Sum up the reports in this environment.
95 | :param days_number: How many days to sum, including today.
96 | :param certname: If certname is passed, only the reports of that
97 | certname will be added. If certname is not passed, all reports in
98 | the database will be considered.
99 | """
100 | result = []
101 | for start, end in _iter_dates(days_number, reverse=True):
102 | query = _build_query(
103 | env=env,
104 | start=start.strftime(DATETIME_FORMAT),
105 | end=end.strftime(DATETIME_FORMAT),
106 | certname=certname,
107 | )
108 | day = start.strftime(DATE_FORMAT)
109 | output = db._query('reports', query=query)
110 | result.append(_format_report_data(day, output))
111 | return result
112 |
--------------------------------------------------------------------------------
/puppetboard/views/failures.py:
--------------------------------------------------------------------------------
1 | from flask import Response, stream_with_context, abort
2 | from pypuppetdb.QueryBuilder import AndOperator, EqualsOperator
3 |
4 | from puppetboard.core import get_app, get_puppetdb, environments, stream_template, to_html, \
5 | get_friendly_error, get_raw_error
6 | from puppetboard.utils import check_env, yield_or_stop
7 |
8 | app = get_app()
9 | puppetdb = get_puppetdb()
10 |
11 |
12 | @app.route('/failures', defaults={'env': app.config['DEFAULT_ENVIRONMENT'],
13 | 'show_error_as': app.config['SHOW_ERROR_AS']})
14 | @app.route('/failures/', defaults={'env': app.config['DEFAULT_ENVIRONMENT']})
15 | @app.route('//failures', defaults={'show_error_as': app.config['SHOW_ERROR_AS']})
16 | @app.route('//failures/')
17 | def failures(env: str, show_error_as: str):
18 | nodes_query = AndOperator()
19 | nodes_query.add(EqualsOperator('latest_report_status', 'failed'))
20 |
21 | envs = environments()
22 | check_env(env, envs)
23 | if env != '*':
24 | nodes_query.add(EqualsOperator("catalog_environment", env))
25 |
26 | if show_error_as not in ['friendly', 'raw']:
27 | abort(404)
28 |
29 | nodes = puppetdb.nodes(
30 | query=nodes_query,
31 | with_status=True,
32 | with_event_numbers=False,
33 | )
34 |
35 | failures = []
36 |
37 | for node in yield_or_stop(nodes):
38 |
39 | report_query = AndOperator()
40 | report_query.add(EqualsOperator('hash', node.latest_report_hash))
41 |
42 | reports = puppetdb.reports(
43 | query=report_query,
44 | )
45 |
46 | latest_failed_report = next(reports)
47 |
48 | source = None
49 | message = None
50 | for log in latest_failed_report.logs:
51 | if log['level'] not in ['info', 'notice', 'warning']:
52 | if log['source'] != 'Facter':
53 | source = log['source']
54 | message = log['message']
55 | break
56 |
57 | if source and message:
58 | if show_error_as == 'friendly':
59 | error = to_html(get_friendly_error(source, message, node.name))
60 | else:
61 | error = get_raw_error(source, message)
62 | else:
63 | error = to_html(f'Node {node.name} is failing but we could not find the errors')
64 |
65 | failure = {
66 | 'certname': node.name,
67 | 'timestamp': node.report_timestamp,
68 | 'error': error,
69 | 'report_hash': node.latest_report_hash,
70 | }
71 | failures.append(failure)
72 |
73 | return Response(stream_with_context(
74 | stream_template('failures.html',
75 | failures=failures,
76 | envs=envs,
77 | current_env=env,
78 | current_show_error_as=show_error_as)))
79 |
--------------------------------------------------------------------------------
/puppetboard/views/index.py:
--------------------------------------------------------------------------------
1 | from flask import render_template
2 | from pypuppetdb.QueryBuilder import AndOperator, EqualsOperator, FunctionOperator, ExtractOperator
3 |
4 | from puppetboard.core import get_app, get_puppetdb, environments
5 | from puppetboard.utils import get_or_abort, check_env
6 |
7 | app = get_app()
8 | puppetdb = get_puppetdb()
9 |
10 |
11 | @app.route('/', defaults={'env': app.config['DEFAULT_ENVIRONMENT']})
12 | @app.route('//')
13 | def index(env):
14 | """This view generates the index page and displays a set of metrics and
15 | latest reports on nodes fetched from PuppetDB.
16 |
17 | :param env: Search for nodes in this (Catalog and Fact) environment
18 | :type env: :obj:`string`
19 | """
20 | envs = environments()
21 | metrics = {
22 | 'num_nodes': 0,
23 | 'num_resources': 0,
24 | 'avg_resources_node': 0,
25 | }
26 |
27 | if env != app.config['DEFAULT_ENVIRONMENT']:
28 | check_env(env, envs)
29 |
30 | if env == '*':
31 | query = app.config['OVERVIEW_FILTER']
32 |
33 | prefix = 'puppetlabs.puppetdb.population'
34 | num_nodes = get_or_abort(puppetdb.metric, f"{prefix}:name=num-nodes")
35 | num_resources = get_or_abort(puppetdb.metric, f"{prefix}:name=num-resources")
36 |
37 | metrics['num_nodes'] = num_nodes['Value']
38 | metrics['num_resources'] = num_resources['Value']
39 | try:
40 | # Compute our own average because avg_resources_node['Value']
41 | # returns a string of the format "num_resources/num_nodes"
42 | # example: "1234/9" instead of doing the division itself.
43 | metrics['avg_resources_node'] = "{0:10.0f}".format(
44 | (num_resources['Value'] / num_nodes['Value']))
45 | except ZeroDivisionError:
46 | metrics['avg_resources_node'] = 0
47 | else:
48 | query = AndOperator()
49 | query.add(EqualsOperator('catalog_environment', env))
50 |
51 | num_nodes_query = ExtractOperator()
52 | num_nodes_query.add_field(FunctionOperator('count'))
53 | num_nodes_query.add_query(query)
54 |
55 | if app.config['OVERVIEW_FILTER'] is not None:
56 | query.add(app.config['OVERVIEW_FILTER'])
57 |
58 | num_resources_query = ExtractOperator()
59 | num_resources_query.add_field(FunctionOperator('count'))
60 | num_resources_query.add_query(EqualsOperator("environment", env))
61 |
62 | num_nodes = get_or_abort(
63 | puppetdb._query,
64 | 'nodes',
65 | query=num_nodes_query)
66 | num_resources = get_or_abort(
67 | puppetdb._query,
68 | 'resources',
69 | query=num_resources_query)
70 | metrics['num_nodes'] = num_nodes[0]['count']
71 | metrics['num_resources'] = num_resources[0]['count']
72 | try:
73 | metrics['avg_resources_node'] = "{0:10.0f}".format(
74 | (num_resources[0]['count'] / num_nodes[0]['count']))
75 | except ZeroDivisionError:
76 | metrics['avg_resources_node'] = 0
77 |
78 | nodes = get_or_abort(puppetdb.nodes,
79 | query=query,
80 | unreported=app.config['UNRESPONSIVE_HOURS'],
81 | with_status=True,
82 | with_event_numbers=app.config['WITH_EVENT_NUMBERS'])
83 |
84 | nodes_overview = []
85 | stats = {
86 | 'changed': 0,
87 | 'unchanged': 0,
88 | 'failed': 0,
89 | 'unreported': 0,
90 | 'noop': 0,
91 | }
92 |
93 | for node in nodes:
94 | if node.status == 'unreported':
95 | stats['unreported'] += 1
96 | elif node.status == 'changed':
97 | stats['changed'] += 1
98 | elif node.status == 'failed':
99 | stats['failed'] += 1
100 | elif node.status == 'noop':
101 | stats['noop'] += 1
102 | else:
103 | stats['unchanged'] += 1
104 |
105 | if node.status != 'unchanged':
106 | nodes_overview.append(node)
107 |
108 | return render_template(
109 | 'index.html',
110 | metrics=metrics,
111 | nodes=nodes_overview,
112 | stats=stats,
113 | envs=envs,
114 | current_env=env,
115 | )
116 |
--------------------------------------------------------------------------------
/puppetboard/views/inventory.py:
--------------------------------------------------------------------------------
1 | from flask import (
2 | render_template, request, render_template_string
3 | )
4 | from pypuppetdb.QueryBuilder import (AndOperator,
5 | EqualsOperator, OrOperator)
6 |
7 | from puppetboard.core import get_app, get_puppetdb, environments
8 | from puppetboard.utils import (check_env)
9 |
10 | app = get_app()
11 | puppetdb = get_puppetdb()
12 |
13 |
14 | def inventory_facts():
15 | # a list of facts descriptions to go in table header
16 | headers = []
17 | # a list of inventory fact names
18 | fact_names = []
19 |
20 | # load the list of items/facts we want in our inventory
21 | inv_facts = app.config['INVENTORY_FACTS']
22 |
23 | # generate a list of descriptions and a list of fact names
24 | # from the list of tuples inv_facts.
25 | for desc, name in inv_facts:
26 | headers.append(desc)
27 | fact_names.append(name)
28 |
29 | return headers, fact_names
30 |
31 |
32 | @app.route('/inventory', defaults={'env': app.config['DEFAULT_ENVIRONMENT']})
33 | @app.route('//inventory')
34 | def inventory(env):
35 | """Fetch all (active) nodes from PuppetDB and stream a table displaying
36 | those nodes along with a set of facts about them.
37 |
38 | :param env: Search for facts in this environment
39 | :type env: :obj:`string`
40 | """
41 | envs = environments()
42 | check_env(env, envs)
43 | headers, fact_names = inventory_facts()
44 |
45 | return render_template(
46 | 'inventory.html',
47 | envs=envs,
48 | current_env=env,
49 | fact_headers=headers)
50 |
51 |
52 | @app.route('/inventory/json', defaults={'env': app.config['DEFAULT_ENVIRONMENT']})
53 | @app.route('//inventory/json')
54 | def inventory_ajax(env):
55 | """Backend endpoint for inventory table"""
56 | draw = int(request.args.get('draw', 0))
57 |
58 | envs = environments()
59 | check_env(env, envs)
60 | headers, fact_names = inventory_facts()
61 | fact_templates = app.config['INVENTORY_FACT_TEMPLATES']
62 |
63 | query = AndOperator()
64 | fact_query = OrOperator()
65 | fact_query.add([EqualsOperator("name", name) for name in fact_names])
66 | query.add(fact_query)
67 |
68 | if env != '*':
69 | query.add(EqualsOperator("environment", env))
70 |
71 | facts = puppetdb.facts(query=query)
72 |
73 | fact_data = {}
74 | for fact in facts:
75 | if fact.node not in fact_data:
76 | fact_data[fact.node] = {}
77 |
78 | fact_value = fact.value
79 |
80 | if fact.name in fact_templates:
81 | fact_template = fact_templates[fact.name]
82 | fact_value = render_template_string(
83 | fact_template,
84 | current_env=env,
85 | value=fact_value,
86 | )
87 |
88 | fact_data[fact.node][fact.name] = fact_value
89 |
90 | total = len(fact_data)
91 |
92 | return render_template(
93 | 'inventory.json.tpl',
94 | draw=draw,
95 | total=total,
96 | total_filtered=total,
97 | fact_data=fact_data,
98 | columns=fact_names)
99 |
--------------------------------------------------------------------------------
/puppetboard/views/metrics.py:
--------------------------------------------------------------------------------
1 | import logging
2 | from urllib.parse import unquote
3 |
4 | from flask import (
5 | render_template
6 | )
7 |
8 | from puppetboard.core import get_app, get_puppetdb, environments
9 | from puppetboard.utils import get_or_abort, check_env
10 |
11 | app = get_app()
12 | puppetdb = get_puppetdb()
13 |
14 | logging.basicConfig(level=app.config['LOGLEVEL'].upper())
15 | log = logging.getLogger(__name__)
16 |
17 |
18 | @app.route('/metrics', defaults={'env': app.config['DEFAULT_ENVIRONMENT']})
19 | @app.route('//metrics')
20 | def metrics(env):
21 | """Lists all available metrics that PuppetDB is aware of.
22 |
23 | :param env: While this parameter serves no function purpose it is required
24 | for the environments template block
25 | :type env: :obj:`string`
26 | """
27 | envs = environments()
28 | check_env(env, envs)
29 |
30 | # the list response is a dict in the format:
31 | # {
32 | # "domain1": {
33 | # "property1": {
34 | # ...
35 | # }
36 | # },
37 | # "domain2": {
38 | # "property2": {
39 | # ...
40 | # }
41 | # }
42 | # }
43 | # The MBean names are the combination of the domain and the properties
44 | # with a ":" in between, example:
45 | # domain1:property1
46 | # domain2:property2
47 | # reference: https://jolokia.org/reference/html/protocol.html#list
48 | metrics_domains = get_or_abort(puppetdb.metric)
49 | metrics = []
50 | # get all of the domains
51 | for domain in list(metrics_domains.keys()):
52 | # iterate over all of the properties in this domain
53 | properties = list(metrics_domains[domain].keys())
54 | for prop in properties:
55 | # combine the current domain and each property with
56 | # a ":" in between
57 | metrics.append(domain + ':' + prop)
58 |
59 | return render_template('metrics.html',
60 | metrics=sorted(metrics),
61 | envs=envs,
62 | current_env=env)
63 |
64 |
65 | @app.route('/metric/',
66 | defaults={'env': app.config['DEFAULT_ENVIRONMENT']})
67 | @app.route('//metric/')
68 | def metric(env, metric):
69 | """Lists all information about the metric of the given name.
70 |
71 | :param env: While this parameter serves no function purpose it is required
72 | for the environments template block
73 | :type env: :obj:`string`
74 | """
75 | envs = environments()
76 | check_env(env, envs)
77 |
78 | name = unquote(metric)
79 | metric = get_or_abort(puppetdb.metric, metric)
80 | return render_template(
81 | 'metric.html',
82 | name=name,
83 | metric=sorted(metric.items()),
84 | envs=envs,
85 | current_env=env)
86 |
--------------------------------------------------------------------------------
/puppetboard/views/nodes.py:
--------------------------------------------------------------------------------
1 | from datetime import datetime, timedelta
2 |
3 | from flask import (
4 | Response, stream_with_context, request, render_template
5 | )
6 | from pypuppetdb.QueryBuilder import (AndOperator,
7 | EqualsOperator, NullOperator, OrOperator,
8 | LessEqualOperator)
9 |
10 | from puppetboard.core import get_app, get_puppetdb, environments, stream_template, REPORTS_COLUMNS
11 | from puppetboard.utils import (yield_or_stop, check_env, get_or_abort)
12 |
13 | app = get_app()
14 | puppetdb = get_puppetdb()
15 |
16 |
17 | @app.route('/nodes', defaults={'env': app.config['DEFAULT_ENVIRONMENT']})
18 | @app.route('//nodes')
19 | def nodes(env):
20 | """Fetch all (active) nodes from PuppetDB and stream a table displaying
21 | those nodes.
22 |
23 | Downside of the streaming aproach is that since we've already sent our
24 | headers we can't abort the request if we detect an error. Because of this
25 | we'll end up with an empty table instead because of how yield_or_stop
26 | works. Once pagination is in place we can change this but we'll need to
27 | provide a search feature instead.
28 |
29 | :param env: Search for nodes in this (Catalog and Fact) environment
30 | :type env: :obj:`string`
31 | """
32 | envs = environments()
33 | status_arg = request.args.get('status', '')
34 | check_env(env, envs)
35 |
36 | query = AndOperator()
37 |
38 | if env != '*':
39 | query.add(EqualsOperator("catalog_environment", env))
40 |
41 | if status_arg in ['failed', 'changed', 'unchanged']:
42 | query.add(EqualsOperator('latest_report_status', status_arg))
43 | elif status_arg == 'unreported':
44 | unreported = datetime.now()
45 | unreported = (unreported -
46 | timedelta(hours=app.config['UNRESPONSIVE_HOURS']))
47 | unreported = unreported.replace(microsecond=0).isoformat()
48 |
49 | unrep_query = OrOperator()
50 | unrep_query.add(NullOperator('report_timestamp', True))
51 | unrep_query.add(LessEqualOperator('report_timestamp', unreported))
52 |
53 | query.add(unrep_query)
54 |
55 | if len(query.operations) == 0:
56 | query = None
57 |
58 | nodelist = puppetdb.nodes(
59 | query=query,
60 | unreported=app.config['UNRESPONSIVE_HOURS'],
61 | with_status=True,
62 | with_event_numbers=app.config['WITH_EVENT_NUMBERS'])
63 | nodes = []
64 | for node in yield_or_stop(nodelist):
65 | if status_arg:
66 | if node.status == status_arg:
67 | nodes.append(node)
68 | else:
69 | nodes.append(node)
70 | return Response(stream_with_context(
71 | stream_template('nodes.html',
72 | nodes=nodes,
73 | envs=envs,
74 | current_env=env)))
75 |
76 |
77 | @app.route('/node/', defaults={'env': app.config['DEFAULT_ENVIRONMENT']})
78 | @app.route('//node/')
79 | def node(env, node_name):
80 | """Display a dashboard for a node showing as much data as we have on that
81 | node. This includes facts and reports but not Resources as that is too
82 | heavy to do within a single request.
83 |
84 | :param env: Ensure that the node, facts and reports are in this environment
85 | :type env: :obj:`string`
86 | """
87 | envs = environments()
88 | check_env(env, envs)
89 | query = AndOperator()
90 |
91 | if env != '*':
92 | query.add(EqualsOperator("environment", env))
93 |
94 | query.add(EqualsOperator("certname", node_name))
95 |
96 | node = get_or_abort(puppetdb.node, node_name)
97 |
98 | return render_template(
99 | 'node.html',
100 | node=node,
101 | envs=envs,
102 | current_env=env,
103 | columns=REPORTS_COLUMNS[:2])
104 |
--------------------------------------------------------------------------------
/puppetboard/views/query.py:
--------------------------------------------------------------------------------
1 | import logging
2 |
3 | from flask import (
4 | render_template, abort, session
5 | )
6 | from requests.exceptions import HTTPError
7 |
8 | from puppetboard.core import get_app, get_puppetdb, environments
9 | from puppetboard.forms import ENABLED_QUERY_ENDPOINTS, QueryForm
10 | from puppetboard.utils import (check_env)
11 | from puppetboard.utils import (get_or_abort_except_client_errors)
12 |
13 | app = get_app()
14 | puppetdb = get_puppetdb()
15 |
16 | logging.basicConfig(level=app.config['LOGLEVEL'].upper())
17 | log = logging.getLogger(__name__)
18 |
19 |
20 | @app.route('/query', methods=('GET', 'POST'), defaults={'env': app.config['DEFAULT_ENVIRONMENT']})
21 | @app.route('//query', methods=('GET', 'POST'))
22 | def query(env):
23 | """Allows to execute raw, user created queries against PuppetDB. This will return
24 | the JSON of the response or a message telling you what went wrong why nothing was returned.
25 |
26 | :param env: Serves no purpose for the query data but is required for the select field in
27 | the environment block
28 | :type env: :obj:`string`
29 | """
30 | if not app.config['ENABLE_QUERY']:
31 | log.warning('Access to query interface disabled by administrator.')
32 | abort(403)
33 |
34 | envs = environments()
35 | if env != app.config['DEFAULT_ENVIRONMENT']:
36 | check_env(env, envs)
37 |
38 | form = QueryForm(meta={
39 | 'csrf_secret': app.config['SECRET_KEY'],
40 | 'csrf_context': session}
41 | )
42 |
43 | if form.validate_on_submit():
44 | if form.endpoints.data not in ENABLED_QUERY_ENDPOINTS:
45 | log.warning('Access to query endpoint %s disabled by administrator.',
46 | form.endpoints.data)
47 | abort(403)
48 |
49 | query = form.query.data.strip()
50 |
51 | # automatically wrap AST queries with [], if needed
52 | if form.endpoints.data != 'pql' and not query.startswith('['):
53 | query = f"[{query}]"
54 |
55 | try:
56 | result = get_or_abort_except_client_errors(
57 | puppetdb._query,
58 | form.endpoints.data,
59 | query=query)
60 |
61 | zero_results = (len(result) == 0)
62 | result = result if not zero_results else None
63 |
64 | if form.rawjson.data:
65 | # for JSON view pass the response from PuppetDB as-is
66 | return render_template('query.html',
67 | form=form,
68 | zero_results=zero_results,
69 | result=result,
70 | columns=None,
71 | envs=envs,
72 | current_env=env)
73 | else:
74 | # for table view separate the columns and the rows
75 | rows = []
76 | if not zero_results:
77 | columns = result[0].keys()
78 | for items in result:
79 | rows.append(list(items.values()))
80 | else:
81 | columns = []
82 |
83 | return render_template('query.html',
84 | form=form,
85 | zero_results=zero_results,
86 | result=rows,
87 | columns=columns,
88 | envs=envs,
89 | current_env=env)
90 |
91 | except HTTPError as e:
92 | error_text = e.response.text
93 | return render_template('query.html',
94 | form=form,
95 | error_text=error_text,
96 | envs=envs,
97 | current_env=env)
98 |
99 | return render_template('query.html',
100 | form=form,
101 | envs=envs,
102 | current_env=env)
103 |
--------------------------------------------------------------------------------
/puppetboard/views/radiator.py:
--------------------------------------------------------------------------------
1 | from flask import (
2 | render_template, request, jsonify
3 | )
4 | from pypuppetdb.QueryBuilder import (ExtractOperator, AndOperator,
5 | EqualsOperator, FunctionOperator)
6 |
7 | from puppetboard.core import get_app, get_puppetdb, environments
8 | from puppetboard.utils import get_or_abort, check_env
9 |
10 | app = get_app()
11 | puppetdb = get_puppetdb()
12 |
13 |
14 | @app.route('/radiator', defaults={'env': app.config['DEFAULT_ENVIRONMENT']})
15 | @app.route('//radiator')
16 | def radiator(env):
17 | """This view generates a simplified monitoring page
18 | akin to the radiator view in puppet dashboard
19 | """
20 | envs = environments()
21 | check_env(env, envs)
22 |
23 | # TODO: deduplicate. this already implemented in index().
24 | if env == '*':
25 | query = None
26 | metrics = get_or_abort(
27 | puppetdb.metric,
28 | 'puppetlabs.puppetdb.population:name=num-nodes',
29 | )
30 | num_nodes = metrics['Value']
31 | else:
32 | query = AndOperator()
33 | metric_query = ExtractOperator()
34 |
35 | query.add(EqualsOperator("catalog_environment", env))
36 | metric_query.add_field(FunctionOperator('count'))
37 | metric_query.add_query(query)
38 |
39 | metrics = get_or_abort(
40 | puppetdb._query,
41 | 'nodes',
42 | query=metric_query)
43 | num_nodes = metrics[0]['count']
44 |
45 | nodes = puppetdb.nodes(
46 | query=query,
47 | unreported=app.config['UNRESPONSIVE_HOURS'],
48 | with_status=True
49 | )
50 |
51 | stats = {
52 | 'changed_percent': 0,
53 | 'changed': 0,
54 | 'failed_percent': 0,
55 | 'failed': 0,
56 | 'noop_percent': 0,
57 | 'noop': 0,
58 | 'skipped_percent': 0,
59 | 'skipped': 0,
60 | 'unchanged_percent': 0,
61 | 'unchanged': 0,
62 | 'unreported_percent': 0,
63 | 'unreported': 0,
64 | }
65 |
66 | for node in nodes:
67 | if node.status == 'unreported':
68 | stats['unreported'] += 1
69 | elif node.status == 'changed':
70 | stats['changed'] += 1
71 | elif node.status == 'failed':
72 | stats['failed'] += 1
73 | elif node.status == 'noop':
74 | stats['noop'] += 1
75 | elif node.status == 'skipped':
76 | stats['skipped'] += 1
77 | else:
78 | stats['unchanged'] += 1
79 |
80 | try:
81 | stats['changed_percent'] = int(100 * (stats['changed'] /
82 | float(num_nodes)))
83 | stats['failed_percent'] = int(100 * stats['failed'] / float(num_nodes))
84 | stats['noop_percent'] = int(100 * stats['noop'] / float(num_nodes))
85 | stats['skipped_percent'] = int(100 * (stats['skipped'] /
86 | float(num_nodes)))
87 | stats['unchanged_percent'] = int(100 * (stats['unchanged'] /
88 | float(num_nodes)))
89 | stats['unreported_percent'] = int(100 * (stats['unreported'] /
90 | float(num_nodes)))
91 | except ZeroDivisionError:
92 | stats['changed_percent'] = 0
93 | stats['failed_percent'] = 0
94 | stats['noop_percent'] = 0
95 | stats['skipped_percent'] = 0
96 | stats['unchanged_percent'] = 0
97 | stats['unreported_percent'] = 0
98 |
99 | if ('Accept' in request.headers and
100 | request.headers["Accept"] == 'application/json'):
101 | return jsonify(**stats)
102 |
103 | return render_template(
104 | 'radiator.html',
105 | stats=stats,
106 | total=num_nodes
107 | )
108 |
--------------------------------------------------------------------------------
/pylintrc:
--------------------------------------------------------------------------------
1 | [tool.pylint."messages control"]
2 |
3 | disable = no-member
4 |
5 |
--------------------------------------------------------------------------------
/pyproject.toml:
--------------------------------------------------------------------------------
1 | [tool.poetry]
2 | name = "puppetboard"
3 | version = "6.0.1"
4 | description = "Web frontend for PuppetDB"
5 | authors = ["Vox Pupuli "]
6 | license = "Apache-2.0"
7 | readme = "README.md"
8 | repository = 'https://github.com/voxpupuli/puppetboard'
9 | keywords = ["puppet", "puppetdb", "puppetboard"]
10 | classifiers = [
11 | 'Development Status :: 5 - Production/Stable',
12 | 'Environment :: Web Environment',
13 | 'Framework :: Flask',
14 | 'Intended Audience :: System Administrators',
15 | 'Natural Language :: English',
16 | 'License :: OSI Approved :: Apache Software License',
17 | 'Operating System :: POSIX',
18 | ]
19 | packages = [
20 | { include = "puppetboard" },
21 | ]
22 |
23 |
24 |
25 | [tool.poetry.dependencies]
26 | python = "^3.9"
27 | click = "^8.1.7"
28 | flask = "^3.1.0"
29 | commonmark = "^0.9.1"
30 | flask-apscheduler = "^1.13.1"
31 | flask-caching = "^2.3.0"
32 | flask-wtf = "^1.2.2"
33 | idna = "^3.10"
34 | requests = "^2.32.3"
35 | packaging = ">=24.2,<26.0"
36 | pyparsing = "^3.2.0"
37 | pypuppetdb = "^3.2.0"
38 | typing-extensions = "^4.12.2"
39 | zipp = "^3.21.0"
40 |
41 |
42 | [tool.poetry.group.test.dependencies]
43 | pep8 = "^1.7.1"
44 | coverage = "^7.6.9"
45 | mock = "^5.1.0"
46 | pytest = "^8.3.4"
47 | pylint = "^3.3.2"
48 | pytest-cov = "^6.0.0"
49 | pytest-pylint = "^0.21.0"
50 | pytest-mock = "^3.14.0"
51 | pytest-mypy = ">=0.10.3,<1.1.0"
52 | pytest-randomly = "^3.16.0"
53 | cov-core = "^1.15.0"
54 | bandit = "^1.8.0"
55 | beautifulsoup4 = "^4"
56 | types-requests = "^2.32.0.20241016"
57 | types-setuptools = ">=75.6.0.20241126,<81.0.0.0"
58 | types-toml = "^0.10.8.20240310"
59 |
60 |
61 | [tool.poetry.group.docker.dependencies]
62 | gunicorn = "^23.0.0"
63 |
64 | [build-system]
65 | requires = ["poetry-core"]
66 | build-backend = "poetry.core.masonry.api"
67 |
--------------------------------------------------------------------------------
/pytest.ini:
--------------------------------------------------------------------------------
1 | [pytest]
2 | norecursedirs = docs .tox venv .eggs lib
3 | python_files = test/*.py
4 | usefixtures = mock_puppetdb_version
5 |
--------------------------------------------------------------------------------
/requirements-docker.txt:
--------------------------------------------------------------------------------
1 | -r requirements.txt
2 | gunicorn==23.0.0
3 |
--------------------------------------------------------------------------------
/requirements-test.txt:
--------------------------------------------------------------------------------
1 | -r requirements.txt
2 | pep8==1.7.1
3 | coverage==7.8.0
4 | mock==5.2.0
5 | pytest==8.3.5
6 | pylint==3.3.7
7 | pytest-pylint==0.21.0
8 | pytest-cov==6.1.1
9 | pytest-mock==3.14.0
10 | pytest-mypy==1.0.1
11 | pytest-randomly==3.16.0
12 | cov-core==1.15.0
13 | beautifulsoup4==4.13.4
14 | bandit==1.8.3
15 | mypy==1.15.0
16 | types-requests==2.32.0.20250328
17 | types-setuptools==80.4.0.20250511
18 | types-toml==0.10.8.20240310
19 |
--------------------------------------------------------------------------------
/requirements.txt:
--------------------------------------------------------------------------------
1 | certifi==2025.4.26
2 | charset-normalizer==3.4.2
3 | click==8.1.8
4 | commonmark==0.9.1
5 | Flask==3.1.1
6 | Flask-APScheduler==1.13.1
7 | Flask-Caching==2.3.1
8 | Flask-WTF==1.2.2
9 | idna==3.10
10 | itsdangerous==2.2.0
11 | Jinja2==3.1.6
12 | MarkupSafe==3.0.2
13 | packaging==25.0
14 | pyparsing==3.2.3
15 | pypuppetdb==3.2.0
16 | requests==2.32.3
17 | typing_extensions==4.13.2
18 | urllib3==2.4.0
19 | Werkzeug==3.1.3
20 | WTForms==3.2.1
21 | zipp==3.21.0
22 |
--------------------------------------------------------------------------------
/screenshots/class.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/voxpupuli/puppetboard/6b6e8dfbbd9b35ce540598f6dde4811e544db446/screenshots/class.png
--------------------------------------------------------------------------------
/screenshots/classes.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/voxpupuli/puppetboard/6b6e8dfbbd9b35ce540598f6dde4811e544db446/screenshots/classes.png
--------------------------------------------------------------------------------
/screenshots/fact.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/voxpupuli/puppetboard/6b6e8dfbbd9b35ce540598f6dde4811e544db446/screenshots/fact.png
--------------------------------------------------------------------------------
/screenshots/fact_value.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/voxpupuli/puppetboard/6b6e8dfbbd9b35ce540598f6dde4811e544db446/screenshots/fact_value.png
--------------------------------------------------------------------------------
/screenshots/facts.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/voxpupuli/puppetboard/6b6e8dfbbd9b35ce540598f6dde4811e544db446/screenshots/facts.png
--------------------------------------------------------------------------------
/screenshots/failures.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/voxpupuli/puppetboard/6b6e8dfbbd9b35ce540598f6dde4811e544db446/screenshots/failures.png
--------------------------------------------------------------------------------
/screenshots/inventory.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/voxpupuli/puppetboard/6b6e8dfbbd9b35ce540598f6dde4811e544db446/screenshots/inventory.png
--------------------------------------------------------------------------------
/screenshots/metric.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/voxpupuli/puppetboard/6b6e8dfbbd9b35ce540598f6dde4811e544db446/screenshots/metric.png
--------------------------------------------------------------------------------
/screenshots/metrics.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/voxpupuli/puppetboard/6b6e8dfbbd9b35ce540598f6dde4811e544db446/screenshots/metrics.png
--------------------------------------------------------------------------------
/screenshots/node.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/voxpupuli/puppetboard/6b6e8dfbbd9b35ce540598f6dde4811e544db446/screenshots/node.png
--------------------------------------------------------------------------------
/screenshots/nodes.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/voxpupuli/puppetboard/6b6e8dfbbd9b35ce540598f6dde4811e544db446/screenshots/nodes.png
--------------------------------------------------------------------------------
/screenshots/overview.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/voxpupuli/puppetboard/6b6e8dfbbd9b35ce540598f6dde4811e544db446/screenshots/overview.png
--------------------------------------------------------------------------------
/screenshots/query_result_json.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/voxpupuli/puppetboard/6b6e8dfbbd9b35ce540598f6dde4811e544db446/screenshots/query_result_json.png
--------------------------------------------------------------------------------
/screenshots/query_result_table.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/voxpupuli/puppetboard/6b6e8dfbbd9b35ce540598f6dde4811e544db446/screenshots/query_result_table.png
--------------------------------------------------------------------------------
/screenshots/radiator.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/voxpupuli/puppetboard/6b6e8dfbbd9b35ce540598f6dde4811e544db446/screenshots/radiator.png
--------------------------------------------------------------------------------
/screenshots/report.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/voxpupuli/puppetboard/6b6e8dfbbd9b35ce540598f6dde4811e544db446/screenshots/report.png
--------------------------------------------------------------------------------
/settings.py.sample:
--------------------------------------------------------------------------------
1 | # PuppetBoard configuration file
2 | #
3 | # You can tune PuppetBoard by editing this file and running PuppetBoard with
4 | # the environment variable PUPPETBOARD_SETTINGS containing the path to this
5 | # file.
6 | #
7 | # Please refer to the following URL for a list of available variables:
8 | # https://github.com/voxpupuli/puppetboard#app-settings
9 | #
10 | # If you are not accessing PuppetDB locally, set the following variables:
11 | #
12 | # PUPPETDB_HOST = 'localhost'
13 | # PUPPETDB_PORT = 8080
14 | #
15 | # If you access PuppetDB over TLS, configure the path to the TLS certifcate,
16 | # key and CA certificate bellow:
17 | #
18 | # PUPPETDB_CERT = ''
19 | # PUPPETDB_KEY = ''
20 | # PUPPETDB_SSL_VERIFY = ''
21 |
--------------------------------------------------------------------------------
/setup.cfg:
--------------------------------------------------------------------------------
1 | [bdist_wheel]
2 | universal = 1
3 |
4 | [mypy]
5 | ignore_missing_imports=True
6 | ignore_errors=False
7 | pretty=True
8 |
9 | [puppetboard.docker_settings]
10 | ignore_errors = True
11 |
--------------------------------------------------------------------------------
/setup.py:
--------------------------------------------------------------------------------
1 | import sys
2 | import codecs
3 | from setuptools.command.test import test as TestCommand
4 | from setuptools import setup
5 | from puppetboard.version import __version__
6 |
7 |
8 | with codecs.open('README.md', encoding='utf-8') as f:
9 | README = f.read()
10 |
11 | with codecs.open('CHANGELOG.md', encoding='utf-8') as f:
12 | CHANGELOG = f.read()
13 |
14 |
15 | requirements = None
16 | with open('requirements.txt', 'r') as f:
17 | requirements = [line.rstrip()
18 | for line in f.readlines() if not line.startswith('-')]
19 |
20 | requirements_test = None
21 | with open('requirements-test.txt', 'r') as f:
22 | requirements_test = [line.rstrip() for line in f.readlines()
23 | if not line.startswith('-')]
24 |
25 |
26 | class PyTest(TestCommand):
27 |
28 | user_options = [('pytest-args=', 'a', "Arguments to pass to pytest")]
29 |
30 | def initialize_options(self):
31 | TestCommand.initialize_options(self)
32 | self.pytest_args = '--cov=puppetboard --cov-report=term-missing'
33 |
34 | def run_tests(self):
35 | import shlex
36 | import pytest
37 | errno = pytest.main(shlex.split(self.pytest_args))
38 | sys.exit(errno)
39 |
40 |
41 | setup(
42 | name='puppetboard',
43 | version=__version__,
44 | author='Vox Pupuli',
45 | author_email='voxpupuli@groups.io',
46 | packages=["puppetboard", "puppetboard.views"],
47 | url='https://github.com/voxpupuli/puppetboard',
48 | license='Apache License 2.0',
49 | description='Web frontend for PuppetDB',
50 | include_package_data=True,
51 | long_description='\n'.join((README, CHANGELOG)),
52 | long_description_content_type='text/markdown',
53 | zip_safe=False,
54 | python_requires=">=3.9.0",
55 | install_requires=requirements,
56 | tests_require=requirements_test,
57 | extras_require={'test': requirements_test},
58 | data_files=[('requirements_for_tests', ['requirements-test.txt']),
59 | ('requirements_for_docker', ['requirements-docker.txt'])],
60 | keywords="puppet puppetdb puppetboard",
61 | cmdclass={'test': PyTest},
62 | classifiers=[
63 | 'Development Status :: 5 - Production/Stable',
64 | 'Environment :: Web Environment',
65 | 'Framework :: Flask',
66 | 'Intended Audience :: System Administrators',
67 | 'Natural Language :: English',
68 | 'License :: OSI Approved :: Apache Software License',
69 | 'Operating System :: POSIX',
70 | 'Programming Language :: Python :: 3',
71 | 'Programming Language :: Python :: 3.9',
72 | 'Programming Language :: Python :: 3.10',
73 | 'Programming Language :: Python :: 3.11',
74 | 'Programming Language :: Python :: 3.12',
75 | 'Programming Language :: Python :: 3.13',
76 | ],
77 | )
78 |
--------------------------------------------------------------------------------
/test/__init__.py:
--------------------------------------------------------------------------------
1 | class MockDbQuery(object):
2 | def __init__(self, responses):
3 | self.responses = responses
4 |
5 | def get(self, method, **kws):
6 | resp = None
7 | if method in self.responses:
8 | resp = self.responses[method].pop(0)
9 |
10 | if 'validate' in resp:
11 | checks = resp['validate']['checks']
12 | resp = resp['validate']['data']
13 | for check in checks:
14 | assert check in kws
15 | expected_value = checks[check]
16 | assert expected_value == kws[check]
17 | return resp
18 |
19 |
20 | class MockHTTPResponse(object):
21 | def __init__(self, status_code, text):
22 | self.status_code = status_code
23 | self.text = text
24 |
--------------------------------------------------------------------------------
/test/conftest.py:
--------------------------------------------------------------------------------
1 | import os
2 |
3 | import pytest
4 | from pypuppetdb.types import Node
5 |
6 | from puppetboard import app
7 |
8 |
9 | @pytest.fixture(autouse=True)
10 | def mock_puppetdb_version(mocker):
11 | return mocker.patch.object(app.puppetdb, 'current_version',
12 | return_value='5.9.999')
13 |
14 |
15 | @pytest.fixture
16 | def mock_puppetdb_environments(mocker):
17 | environments = [
18 | {'name': 'production'},
19 | {'name': 'staging'}
20 | ]
21 | return mocker.patch.object(app.puppetdb, 'environments',
22 | return_value=environments)
23 |
24 |
25 | @pytest.fixture
26 | def mock_puppetdb_default_nodes(mocker):
27 | node_list = [
28 | Node('_', 'node-unreported',
29 | report_timestamp='2013-08-01T09:57:00.000Z',
30 | latest_report_hash='1234567',
31 | catalog_timestamp='2013-08-01T09:57:00.000Z',
32 | facts_timestamp='2013-08-01T09:57:00.000Z',
33 | status_report='unreported'),
34 | Node('_', 'node-changed',
35 | report_timestamp='2013-08-01T09:57:00.000Z',
36 | latest_report_hash='1234567',
37 | catalog_timestamp='2013-08-01T09:57:00.000Z',
38 | facts_timestamp='2013-08-01T09:57:00.000Z',
39 | status_report='changed'),
40 | Node('_', 'node-failed',
41 | report_timestamp='2013-08-01T09:57:00.000Z',
42 | latest_report_hash='1234567',
43 | catalog_timestamp='2013-08-01T09:57:00.000Z',
44 | facts_timestamp='2013-08-01T09:57:00.000Z',
45 | status_report='failed'),
46 | Node('_', 'node-noop',
47 | report_timestamp='2013-08-01T09:57:00.000Z',
48 | latest_report_hash='1234567',
49 | catalog_timestamp='2013-08-01T09:57:00.000Z',
50 | facts_timestamp='2013-08-01T09:57:00.000Z',
51 | status_report='noop'),
52 | Node('_', 'node-unchanged',
53 | report_timestamp='2013-08-01T09:57:00.000Z',
54 | latest_report_hash='1234567',
55 | catalog_timestamp='2013-08-01T09:57:00.000Z',
56 | facts_timestamp='2013-08-01T09:57:00.000Z',
57 | status_report='unchanged'),
58 | ]
59 | return mocker.patch.object(app.puppetdb, 'nodes',
60 | return_value=iter(node_list))
61 |
62 |
63 | @pytest.fixture
64 | def input_data(request):
65 | data_path = os.path.join(os.path.dirname(os.path.realpath(__file__)),
66 | 'data')
67 | with open('%s/%s' % (data_path, request.function.__name__), "r") as fp:
68 | data = fp.read()
69 | return data
70 |
71 |
72 | @pytest.fixture
73 | def client():
74 | app.app.config['TESTING'] = True
75 | client = app.app.test_client()
76 | return client
77 |
--------------------------------------------------------------------------------
/test/test_app.py:
--------------------------------------------------------------------------------
1 | from bs4 import BeautifulSoup
2 |
3 | from puppetboard import app
4 | from . import MockDbQuery
5 |
6 |
7 | def test_first_test():
8 | assert app is not None
9 |
10 |
11 | def test_no_env(client, mock_puppetdb_environments):
12 | rv = client.get('/nonexistent/')
13 |
14 | assert rv.status_code == 404
15 |
16 |
17 | def test_offline_mode(client, mocker,
18 | mock_puppetdb_environments,
19 | mock_puppetdb_default_nodes):
20 | app.app.config['OFFLINE_MODE'] = True
21 |
22 | query_data = {
23 | 'nodes': [[{'count': 10}]],
24 | 'resources': [[{'count': 40}]],
25 | }
26 |
27 | dbquery = MockDbQuery(query_data)
28 |
29 | mocker.patch.object(app.puppetdb, '_query', side_effect=dbquery.get)
30 | rv = client.get('/')
31 | soup = BeautifulSoup(rv.data, 'html.parser')
32 | assert soup.title.contents[0] == 'Puppetboard'
33 | for link in soup.find_all('link'):
34 | assert "//" not in link['href']
35 | if 'offline' in link['href']:
36 | rv = client.get(link['href'])
37 | assert rv.status_code == 200
38 |
39 | for script in soup.find_all('script'):
40 | if "src" in script.attrs:
41 | assert "//" not in script['src']
42 |
43 | assert rv.status_code == 200
44 |
45 |
46 | def test_offline_static(client):
47 | offline_statics = [
48 | {
49 | "content_type": 'css',
50 | "assets": [
51 | "static/libs/fomantic-ui/semantic.min.css",
52 | "static/libs/datatables.net-se/dataTables.semanticui.min.css",
53 | "static/libs/datatables.net-buttons-se/buttons.semanticui.min.css",
54 | "static/libs/billboard.js/billboard.min.css",
55 | "static/css/fonts.css",
56 | ]
57 | },
58 | {
59 | "content_type": 'javascript',
60 | "assets": [
61 | "static/libs/moment.js/moment-with-locales.min.js",
62 | "static/libs/jquery/jquery.min.js",
63 | "static/libs/fomantic-ui/semantic.min.js",
64 | "static/libs/datatables.net/jquery.dataTables.min.js",
65 | "static/libs/datatables.net-buttons/dataTables.buttons.min.js",
66 | "static/libs/datatables.net-buttons/buttons.html5.min.js",
67 | "static/libs/datatables.net-buttons/buttons.colVis.min.js",
68 | "static/libs/datatables.net-buttons-se/buttons.semanticui.min.js",
69 | "static/libs/datatables.net-se/dataTables.semanticui.min.js",
70 | "static/libs/billboard.js/billboard.pkgd.min.js",
71 | ]
72 |
73 | }
74 | ]
75 |
76 | for category_statics in offline_statics:
77 | content_type = category_statics.get('content_type')
78 |
79 | for asset in category_statics.get('assets'):
80 | rv = client.get(asset)
81 |
82 | assert 'Content-Type' in rv.headers
83 | assert content_type in rv.headers['Content-Type']
84 | assert rv.status_code == 200
85 |
86 |
87 | def test_health_status(client):
88 | rv = client.get('/status')
89 |
90 | assert rv.status_code == 200
91 | assert rv.data.decode('utf-8') == 'OK'
92 |
93 |
94 | def test_custom_title(client, mocker,
95 | mock_puppetdb_environments,
96 | mock_puppetdb_default_nodes):
97 |
98 | default_title = app.app.config['PAGE_TITLE']
99 |
100 | custom_title = 'Dev - Puppetboard'
101 | app.app.config['PAGE_TITLE'] = custom_title
102 |
103 | query_data = {
104 | 'nodes': [[{'count': 10}]],
105 | 'resources': [[{'count': 40}]],
106 | }
107 |
108 | dbquery = MockDbQuery(query_data)
109 |
110 | mocker.patch.object(app.puppetdb, '_query', side_effect=dbquery.get)
111 | rv = client.get('/')
112 | soup = BeautifulSoup(rv.data, 'html.parser')
113 | assert soup.title.contents[0] == custom_title
114 |
115 | # restore the global state
116 | app.app.config['PAGE_TITLE'] = default_title
117 |
--------------------------------------------------------------------------------
/test/test_app_error.py:
--------------------------------------------------------------------------------
1 | import pytest
2 | from bs4 import BeautifulSoup
3 | from werkzeug.exceptions import InternalServerError
4 |
5 | from puppetboard import app
6 | from puppetboard.errors import (bad_request, forbidden, not_found,
7 | precond_failed, server_error)
8 |
9 |
10 | @pytest.fixture
11 | def mock_server_error(mocker):
12 | def raise_error():
13 | raise InternalServerError('Hello world')
14 |
15 | return mocker.patch('puppetboard.core.environments',
16 | side_effect=raise_error)
17 |
18 |
19 | def test_error_bad_request(mock_puppetdb_environments):
20 | with app.app.test_request_context():
21 | (output, error_code) = bad_request(None)
22 | soup = BeautifulSoup(output, 'html.parser')
23 |
24 | assert 'The request sent to PuppetDB was invalid' in soup.p.text
25 | assert error_code == 400
26 |
27 |
28 | def test_error_forbidden(mock_puppetdb_environments):
29 | with app.app.test_request_context():
30 | (output, error_code) = forbidden(None)
31 | soup = BeautifulSoup(output, 'html.parser')
32 |
33 | long_string = "%s %s" % ('What you were looking for has',
34 | 'been disabled by the administrator')
35 | assert long_string in soup.p.text
36 | assert error_code == 403
37 |
38 |
39 | def test_error_not_found(mock_puppetdb_environments):
40 | with app.app.test_request_context():
41 | (output, error_code) = not_found(None)
42 | soup = BeautifulSoup(output, 'html.parser')
43 |
44 | long_string = "%s %s" % ('What you were looking for could not',
45 | 'be found in PuppetDB.')
46 | assert long_string in soup.p.text
47 | assert error_code == 404
48 |
49 |
50 | def test_error_precond(mock_puppetdb_environments):
51 | with app.app.test_request_context():
52 | (output, error_code) = precond_failed(None)
53 | soup = BeautifulSoup(output, 'html.parser')
54 |
55 | long_string = "%s %s" % ('You\'ve configured Puppetboard with an API',
56 | 'version that does not support this feature.')
57 | assert long_string in soup.p.text
58 | assert error_code == 412
59 |
60 |
61 | def test_error_server(mock_puppetdb_environments):
62 | with app.app.test_request_context():
63 | (output, error_code) = server_error(None)
64 | soup = BeautifulSoup(output, 'html.parser')
65 |
66 | assert 'Internal Server Error' in soup.h2.text
67 | assert error_code == 500
68 |
69 |
70 | def test_early_error_server(mock_server_error):
71 | with app.app.test_request_context():
72 | (output, error_code) = server_error(None)
73 | soup = BeautifulSoup(output, 'html.parser')
74 | assert 'Internal Server Error' in soup.h2.text
75 | assert error_code == 500
76 |
--------------------------------------------------------------------------------
/test/test_core.py:
--------------------------------------------------------------------------------
1 | from textwrap import dedent
2 |
3 | import pytest
4 |
5 | from puppetboard.core import get_friendly_error
6 |
7 |
8 | @pytest.mark.parametrize("raw_message,friendly_message", [
9 | ("Could not retrieve catalog from remote server: Error 500 on SERVER: Server Error: Evaluation "
10 | "Error: Error while evaluating a Resource Statement, Evaluation Error: Error while evaluating "
11 | "a Function Call, This envs has Consul ACLs enabled. Please add the app 'statsproxy' to the "
12 | "'profiles::consul::server::policies' hiera key. (file: "
13 | "/etc/puppetlabs/code/environments/patch/modules/consul_wrapper/functions/service"
14 | "/get_acl_token.pp, line: 22, column: 7) (file: "
15 | "/etc/puppetlabs/code/environments/patch/modules/roles/manifests/tomcat/stats.pp, line: 39) "
16 | "on node foo.bar.com", """
17 | Error while evaluating a Resource Statement:
18 |
19 | Error while evaluating a Function Call:
20 |
21 | This envs has Consul ACLs enabled. Please add the app 'statsproxy' to the 'profiles::consul::server::policies' hiera key. (file: …/consul_wrapper/functions/service/get_acl_token.pp, line: 22, column: 7)
22 |
23 | …in …/roles/manifests/tomcat/stats.pp, line: 39.
24 | """),
25 |
26 | ("Could not retrieve catalog from remote server: Error 500 on SERVER: Server Error: "
27 | "Evaluation Error: Error while evaluating a Method call, Could not find class "
28 | "::profiles::snapshot_restore for foo.bar.com (file: "
29 | "/etc/puppetlabs/code/environments/qa/manifests/site.pp, line: 31, column: 7) on node "
30 | "foo.bar.com", """
31 | Error while evaluating a Method call:
32 |
33 | Could not find class ::profiles::snapshot_restore
34 |
35 | …in …/qa/manifests/site.pp, line: 31, column: 7.
36 | """),
37 | ])
38 | def test_get_friendly_error(raw_message, friendly_message):
39 | raw_message = dedent(raw_message)
40 | friendly_message = dedent(friendly_message).strip()
41 | assert get_friendly_error("Puppet", raw_message, "foo.bar.com") == friendly_message
42 |
--------------------------------------------------------------------------------
/test/test_form.py:
--------------------------------------------------------------------------------
1 | from puppetboard import forms
2 | from puppetboard.core import get_app
3 |
4 | app = get_app()
5 | app.config['SECRET_KEY'] = 'the random string'
6 |
7 |
8 | def test_form_valid(capsys):
9 | for form in [forms.QueryForm]:
10 | with app.test_request_context():
11 | qf = form()
12 | out, err = capsys.readouterr()
13 | assert qf is not None
14 | assert err == ""
15 | assert out == ""
16 |
--------------------------------------------------------------------------------
/test/views/__init__.py:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/voxpupuli/puppetboard/6b6e8dfbbd9b35ce540598f6dde4811e544db446/test/views/__init__.py
--------------------------------------------------------------------------------
/test/views/test_catalogs.py:
--------------------------------------------------------------------------------
1 | import json
2 |
3 | from bs4 import BeautifulSoup
4 |
5 | from puppetboard import app
6 |
7 |
8 | def test_catalogs_disabled(client, mocker,
9 | mock_puppetdb_environments,
10 | mock_puppetdb_default_nodes):
11 | app.app.config['ENABLE_CATALOG'] = False
12 | rv = client.get('/catalogs')
13 | assert rv.status_code == 403
14 |
15 |
16 | def test_catalogs_view(client, mocker,
17 | mock_puppetdb_environments,
18 | mock_puppetdb_default_nodes):
19 | app.app.config['ENABLE_CATALOG'] = True
20 |
21 | # below code checks last_total, which should be set after _query
22 | # so we need to simulate that. the value doesn't matter.
23 | app.puppetdb.last_total = 0
24 | rv = client.get('/catalogs')
25 | assert rv.status_code == 200
26 | soup = BeautifulSoup(rv.data, 'html.parser')
27 | assert soup.title.contents[0] == 'Puppetboard'
28 |
29 |
30 | def test_catalogs_json(client, mocker,
31 | mock_puppetdb_environments,
32 | mock_puppetdb_default_nodes):
33 | app.app.config['ENABLE_CATALOG'] = True
34 |
35 | # below code checks last_total, which should be set after _query
36 | # so we need to simulate that. the value doesn't matter.
37 | app.puppetdb.last_total = 0
38 | rv = client.get('/catalogs/json')
39 | assert rv.status_code == 200
40 |
41 | result_json = json.loads(rv.data.decode('utf-8'))
42 | assert 'data' in result_json
43 |
44 | for line in result_json['data']:
45 | assert len(line) == 3
46 | found_status = None
47 | for status in ['failed', 'changed', 'unchanged', 'noop', 'unreported']:
48 | val = BeautifulSoup(line[0], 'html.parser').find_all(
49 | 'a', {"href": "/node/node-%s" % status})
50 | if len(val) == 1:
51 | found_status = status
52 | break
53 | assert found_status, 'Line does not match any known status'
54 |
55 | val = BeautifulSoup(line[2], 'html.parser').find_all(
56 | 'form', {"method": "GET",
57 | "action": "/catalogs/compare/node-%s" % found_status})
58 | assert len(val) == 1
59 |
60 |
61 | def test_catalogs_json_compare(client, mocker,
62 | mock_puppetdb_environments,
63 | mock_puppetdb_default_nodes):
64 | app.app.config['ENABLE_CATALOG'] = True
65 |
66 | # below code checks last_total, which should be set after _query
67 | # so we need to simulate that. the value doesn't matter.
68 | app.puppetdb.last_total = 0
69 | rv = client.get('/catalogs/compare/node-unreported/json')
70 | assert rv.status_code == 200
71 |
72 | result_json = json.loads(rv.data.decode('utf-8'))
73 | assert 'data' in result_json
74 |
75 | for line in result_json['data']:
76 | assert len(line) == 3
77 | found_status = None
78 | for status in ['failed', 'changed', 'unchanged', 'noop', 'unreported']:
79 | val = BeautifulSoup(line[0], 'html.parser').find_all(
80 | 'a', {"href": "/node/node-%s" % status})
81 | if len(val) == 1:
82 | found_status = status
83 | break
84 | assert found_status, 'Line does not match any known status'
85 |
86 | val = BeautifulSoup(line[2], 'html.parser').find_all(
87 | 'form', {"method": "GET",
88 | "action": "/catalogs/compare/node-unreported...node-%s" %
89 | found_status})
90 | assert len(val) == 1
91 |
--------------------------------------------------------------------------------
/test/views/test_classes.py:
--------------------------------------------------------------------------------
1 | import json
2 |
3 | from bs4 import BeautifulSoup
4 |
5 | from puppetboard import app
6 | from test import MockDbQuery
7 |
8 | import pprint
9 | import logging
10 |
11 |
12 | def test_classes_disabled(client, mocker,
13 | mock_puppetdb_environments,
14 | mock_puppetdb_default_nodes):
15 | app.app.config['ENABLE_CLASS'] = False
16 | rv = client.get('/classes')
17 | assert rv.status_code == 403
18 |
19 |
20 | def test_classes_view(client, mocker,
21 | mock_puppetdb_environments,
22 | mock_puppetdb_default_nodes):
23 | app.app.config['ENABLE_CLASS'] = True
24 |
25 | rv = client.get('/classes')
26 | assert rv.status_code == 200
27 | soup = BeautifulSoup(rv.data, 'html.parser')
28 | assert soup.title.contents[0] == 'Puppetboard'
29 |
30 |
31 | def test_class_resource_view(client, mocker,
32 | mock_puppetdb_environments,
33 | mock_puppetdb_default_nodes):
34 | app.app.config['ENABLE_CLASS'] = True
35 |
36 | rv = client.get('/class_resource/My::Class')
37 | assert rv.status_code == 200
38 |
39 | soup = BeautifulSoup(rv.data, 'html.parser')
40 | assert soup.title.contents[0] == 'Puppetboard'
41 | vals = soup.find_all('h1', {"id": "class_name"})
42 | assert len(vals) == 1
43 | assert 'My::Class' in vals[0].string
44 |
--------------------------------------------------------------------------------
/test/views/test_dailychart.py:
--------------------------------------------------------------------------------
1 | import json
2 | from datetime import datetime
3 |
4 | from puppetboard import app
5 | from test import MockDbQuery
6 |
7 |
8 | def test_json_daily_reports_chart_ok(client, mocker,
9 | mock_puppetdb_environments,
10 | mock_puppetdb_default_nodes):
11 | query_data = {
12 | 'reports': [
13 | [{'status': 'changed', 'count': 1}]
14 | for i in range(app.app.config['DAILY_REPORTS_CHART_DAYS'])
15 | ]
16 | }
17 |
18 | dbquery = MockDbQuery(query_data)
19 |
20 | mocker.patch.object(app.puppetdb, '_query', side_effect=dbquery.get)
21 |
22 | rv = client.get('/daily_reports_chart.json')
23 | result_json = json.loads(rv.data.decode('utf-8'))
24 |
25 | assert 'result' in result_json
26 | assert (len(result_json['result']) ==
27 | app.app.config['DAILY_REPORTS_CHART_DAYS'])
28 | day_format = '%Y-%m-%d'
29 | cur_day = datetime.strptime(result_json['result'][0]['day'], day_format)
30 | for day in result_json['result'][1:]:
31 | next_day = datetime.strptime(day['day'], day_format)
32 | assert cur_day < next_day
33 | cur_day = next_day
34 |
35 | assert rv.status_code == 200
36 |
--------------------------------------------------------------------------------
/test/views/test_index.py:
--------------------------------------------------------------------------------
1 | from bs4 import BeautifulSoup
2 |
3 | from puppetboard import app
4 | from test import MockDbQuery
5 |
6 |
7 | def test_get_index(client, mocker,
8 | mock_puppetdb_environments,
9 | mock_puppetdb_default_nodes):
10 | query_data = {
11 | 'nodes': [[{'count': 10}]],
12 | 'resources': [[{'count': 40}]],
13 | }
14 |
15 | dbquery = MockDbQuery(query_data)
16 |
17 | mocker.patch.object(app.puppetdb, '_query', side_effect=dbquery.get)
18 | rv = client.get('/')
19 | soup = BeautifulSoup(rv.data, 'html.parser')
20 | assert soup.title.contents[0] == 'Puppetboard'
21 | assert rv.status_code == 200
22 |
23 |
24 | def test_index_all(client, mocker,
25 | mock_puppetdb_environments,
26 | mock_puppetdb_default_nodes):
27 | # starting with v6.9.1 they changed the metric API to v2
28 | # and a totally different format
29 | base_str = 'puppetlabs.puppetdb.population:'
30 | query_data = {
31 | 'version': [{'version': '6.9.1'}],
32 | 'metrics': [
33 | {
34 | 'validate': {
35 | 'data': {
36 | 'value': {'Value': 10}
37 | },
38 | 'checks': {
39 | 'path': '%sname=num-nodes' % base_str
40 | }
41 | }
42 | },
43 | {
44 | 'validate': {
45 | 'data': {
46 | 'value': {'Value': 63}
47 | },
48 | 'checks': {
49 | 'path': '%sname=num-resources' % base_str
50 | }
51 | }
52 | },
53 | {
54 | 'validate': {
55 | 'data': {
56 | 'value': {'Value': 6.3}
57 | },
58 | 'checks': {
59 | 'path': '%sname=avg-resources-per-node' % base_str
60 | }
61 | }
62 | }
63 | ]
64 | }
65 | dbquery = MockDbQuery(query_data)
66 | mocker.patch.object(app.puppetdb, '_query', side_effect=dbquery.get)
67 | rv = client.get('/%2A/')
68 |
69 | soup = BeautifulSoup(rv.data, 'html.parser')
70 | assert soup.title.contents[0] == 'Puppetboard'
71 | vals = soup.find_all('h1',
72 | {"class": "ui header darkblue no-margin-bottom"})
73 |
74 | assert len(vals) == 3
75 | assert vals[0].string == '10'
76 | assert vals[1].string == '63'
77 | assert vals[2].string == ' 6'
78 |
79 | assert rv.status_code == 200
80 |
81 |
82 | def test_index_division_by_zero(client, mocker,
83 | mock_puppetdb_environments,
84 | mock_puppetdb_default_nodes):
85 | query_data = {
86 | 'nodes': [[{'count': 0}]],
87 | 'resources': [[{'count': 40}]],
88 | }
89 |
90 | dbquery = MockDbQuery(query_data)
91 |
92 | mocker.patch.object(app.puppetdb, '_query', side_effect=dbquery.get)
93 |
94 | rv = client.get('/')
95 |
96 | assert rv.status_code == 200
97 |
98 | soup = BeautifulSoup(rv.data, 'html.parser')
99 | assert soup.title.contents[0] == 'Puppetboard'
100 |
101 | vals = soup.find_all('h1',
102 | {"class": "ui header darkblue no-margin-bottom"})
103 | assert len(vals) == 3
104 | assert vals[2].string == '0'
105 |
--------------------------------------------------------------------------------
/test/views/test_metrics.py:
--------------------------------------------------------------------------------
1 | from bs4 import BeautifulSoup
2 |
3 | from puppetboard import app
4 | from test import MockDbQuery
5 |
6 |
7 | def test_metrics_v2_api(client, mocker,
8 | mock_puppetdb_environments,
9 | mock_puppetdb_default_nodes):
10 | # starting with v6.9.1 they changed the metric API to v2
11 | # and a totally different format
12 | query_data = {
13 | 'version': [{'version': '6.9.1'}],
14 | 'metrics-list': [
15 | {
16 | 'validate': {
17 | 'data': {
18 | 'value': {
19 | 'java.lang': {
20 | 'type=Memory': {}
21 | },
22 | 'puppetlabs.puppetdb.population': {
23 | 'name=num-nodes': {}
24 | },
25 | }
26 | },
27 | 'checks': {
28 | }
29 | }
30 | }
31 | ]
32 | }
33 | dbquery = MockDbQuery(query_data)
34 | mocker.patch.object(app.puppetdb, '_query', side_effect=dbquery.get)
35 | rv = client.get('/metrics')
36 |
37 | soup = BeautifulSoup(rv.data, 'html.parser')
38 | assert soup.title.contents[0] == 'Puppetboard'
39 | ul_list = soup.find_all('ul', attrs={'class': 'ui list searchable'})
40 | assert len(ul_list) == 1
41 | vals = ul_list[0].find_all('a')
42 |
43 | assert len(vals) == 2
44 | assert vals[0].string == 'java.lang:type=Memory'
45 | assert vals[1].string == 'puppetlabs.puppetdb.population:name=num-nodes'
46 |
47 | assert rv.status_code == 200
48 |
49 |
50 | def test_metric_v2_api(client, mocker,
51 | mock_puppetdb_environments,
52 | mock_puppetdb_default_nodes):
53 | # starting with v6.9.1 they changed the metric API to v2
54 | # and a totally different format
55 | metric_name = 'puppetlabs.puppetdb.population:name=num-nodes'
56 | query_data = {
57 | 'version': [{'version': '6.9.1'}],
58 | 'metrics': [
59 | {
60 | 'validate': {
61 | 'data': {
62 | 'value': {'Value': 50},
63 | },
64 | 'checks': {
65 | 'path': metric_name,
66 | }
67 | }
68 | }
69 | ]
70 | }
71 | dbquery = MockDbQuery(query_data)
72 | mocker.patch.object(app.puppetdb, '_query', side_effect=dbquery.get)
73 | rv = client.get('/metric/' + metric_name)
74 |
75 | soup = BeautifulSoup(rv.data, 'html.parser')
76 | assert soup.title.contents[0] == 'Puppetboard'
77 |
78 | small_list = soup.find_all('small')
79 | assert len(small_list) == 1
80 | assert small_list[0].string == metric_name
81 |
82 | tbody_list = soup.find_all('tbody')
83 | assert len(tbody_list) == 1
84 | rows = tbody_list[0].find_all('tr')
85 |
86 | assert len(rows) == 1
87 | cols = rows[0].find_all('td')
88 | assert len(cols) == 2
89 | assert cols[0].string == 'Value'
90 | assert cols[1].string == '50'
91 |
92 | assert rv.status_code == 200
93 |
--------------------------------------------------------------------------------
/test/views/test_nodes.py:
--------------------------------------------------------------------------------
1 | from bs4 import BeautifulSoup
2 |
3 |
4 | def test_default_node_view(client, mocker,
5 | mock_puppetdb_environments,
6 | mock_puppetdb_default_nodes):
7 | rv = client.get('/nodes')
8 | soup = BeautifulSoup(rv.data, 'html.parser')
9 | assert soup.title.contents[0] == 'Puppetboard'
10 |
11 | for label in ['failed', 'changed', 'unreported', 'noop']:
12 | vals = soup.find_all('a',
13 | {"class": "ui %s label status" % label})
14 | assert len(vals) == 1
15 | assert 'node-%s' % label in vals[0].attrs['href']
16 |
17 | assert rv.status_code == 200
18 |
19 |
20 | def test_node_view(client, mocker,
21 | mock_puppetdb_environments,
22 | mock_puppetdb_default_nodes):
23 | rv = client.get('/node/node-failed')
24 | assert rv.status_code == 200
25 |
26 | soup = BeautifulSoup(rv.data, 'html.parser')
27 | assert soup.title.contents[0] == 'Puppetboard'
28 |
29 | vals = soup.find_all('table', {"id": "facts_table"})
30 | assert len(vals) == 1
31 |
32 | vals = soup.find_all('table', {"id": "reports_table"})
33 | assert len(vals) == 1
34 |
--------------------------------------------------------------------------------
/test/views/test_query.py:
--------------------------------------------------------------------------------
1 | from bs4 import BeautifulSoup
2 | from requests.exceptions import HTTPError
3 |
4 | from puppetboard import app
5 | from test import MockHTTPResponse
6 |
7 |
8 | def test_query_view(client, mocker,
9 | mock_puppetdb_environments,
10 | mock_puppetdb_default_nodes):
11 | rv = client.get('/query')
12 | assert rv.status_code == 200
13 |
14 | soup = BeautifulSoup(rv.data, 'html.parser')
15 | assert soup.title.contents[0] == 'Puppetboard'
16 |
17 | vals = soup.find_all('h2', {"id": "results_header"})
18 | assert len(vals) == 0
19 |
20 |
21 | def test_query__some_response__table(client, mocker,
22 | mock_puppetdb_environments,
23 | mock_puppetdb_default_nodes):
24 | app.app.config['WTF_CSRF_ENABLED'] = False
25 |
26 | result = [
27 | {'certname': 'foobar', 'catalog_environment': 'qa'},
28 | ]
29 | mocker.patch.object(app.puppetdb, '_query', return_value=result)
30 |
31 | query_data = {
32 | 'query': 'nodes[certname] { certname = "foobar" }',
33 | 'endpoints': 'pql',
34 | }
35 | rv = client.post(
36 | '/query',
37 | data=query_data,
38 | content_type='application/x-www-form-urlencoded',
39 | )
40 |
41 | assert rv.status_code == 200
42 |
43 | soup = BeautifulSoup(rv.data, 'html.parser')
44 | assert soup.title.contents[0] == 'Puppetboard'
45 |
46 | vals = soup.find_all('h2', {"id": "results_header"})
47 | assert len(vals) == 1
48 |
49 | vals = soup.find_all('p', {"id": "number_of_results"})
50 | assert len(vals) == 1
51 | assert 'Number of results: 1' in vals[0].string
52 |
53 | vals = soup.find_all('table', {"id": "query_table"})
54 | assert len(vals) == 1
55 | # we can't test more here as the content of this table is generated with JavaScript...
56 |
57 |
58 | def test_query__some_response__json(client, mocker,
59 | mock_puppetdb_environments,
60 | mock_puppetdb_default_nodes):
61 | app.app.config['WTF_CSRF_ENABLED'] = False
62 |
63 | result = [
64 | {'certname': 'foobar', 'catalog_environment': 'qa'},
65 | ]
66 | mocker.patch.object(app.puppetdb, '_query', return_value=result)
67 |
68 | query_data = {
69 | 'query': 'nodes[certname] { certname = "foobar" }',
70 | 'endpoints': 'pql',
71 | 'rawjson': 'y',
72 | }
73 | rv = client.post(
74 | '/query',
75 | data=query_data,
76 | content_type='application/x-www-form-urlencoded',
77 | )
78 |
79 | assert rv.status_code == 200
80 |
81 | soup = BeautifulSoup(rv.data, 'html.parser')
82 | assert soup.title.contents[0] == 'Puppetboard'
83 |
84 | vals = soup.find_all('h2', {"id": "results_header"})
85 | assert len(vals) == 1
86 |
87 | vals = soup.find_all('p', {"id": "number_of_results"})
88 | assert len(vals) == 1
89 | assert 'Number of results: 1' in vals[0].string
90 |
91 | vals = soup.find_all('pre', {"id": "result"})
92 | assert len(vals) == 1
93 | # we can't test more here as the content of this tag is generated with JavaScript...
94 |
95 |
96 | def test_query__empty_response(client, mocker,
97 | mock_puppetdb_environments,
98 | mock_puppetdb_default_nodes):
99 | app.app.config['WTF_CSRF_ENABLED'] = False
100 |
101 | query_data = []
102 | mocker.patch.object(app.puppetdb, '_query', return_value=query_data)
103 |
104 | data = {
105 | 'query': 'nodes { certname = "asdasdasdasdsad" }',
106 | 'endpoints': 'pql',
107 | }
108 | rv = client.post(
109 | '/query',
110 | data=data,
111 | content_type='application/x-www-form-urlencoded',
112 | )
113 |
114 | assert rv.status_code == 200
115 |
116 | soup = BeautifulSoup(rv.data, 'html.parser')
117 | assert soup.title.contents[0] == 'Puppetboard'
118 |
119 | vals = soup.find_all('h2', {"id": "results_header"})
120 | assert len(vals) == 1
121 |
122 | vals = soup.find_all('p', {"id": "zero_results"})
123 | assert len(vals) == 1
124 |
125 |
126 | def test_query__error_response(client, mocker,
127 | mock_puppetdb_environments,
128 | mock_puppetdb_default_nodes):
129 | app.app.config['WTF_CSRF_ENABLED'] = False
130 |
131 | error_message = "Invalid query: (...)"
132 | puppetdb_response = HTTPError('Invalid query')
133 | puppetdb_response.response = MockHTTPResponse(400, error_message)
134 | mocker.patch.object(app.puppetdb, '_query', side_effect=puppetdb_response)
135 |
136 | data = {
137 | 'query': 'foobar',
138 | 'endpoints': 'pql',
139 | }
140 | rv = client.post(
141 | '/query',
142 | data=data,
143 | content_type='application/x-www-form-urlencoded',
144 | )
145 |
146 | assert rv.status_code == 200
147 |
148 | soup = BeautifulSoup(rv.data, 'html.parser')
149 | assert soup.title.contents[0] == 'Puppetboard'
150 |
151 | vals = soup.find_all('h2', {"id": "results_header"})
152 | assert len(vals) == 1
153 |
154 | vals = soup.find_all('pre', {"id": "invalid_query"})
155 | assert len(vals) == 1
156 | assert error_message in vals[0].string
157 |
--------------------------------------------------------------------------------
/test/views/test_radiator.py:
--------------------------------------------------------------------------------
1 | import json
2 |
3 | from bs4 import BeautifulSoup
4 |
5 | from puppetboard import app
6 | from test import MockDbQuery
7 |
8 |
9 | def test_radiator_view(client, mocker,
10 | mock_puppetdb_environments,
11 | mock_puppetdb_default_nodes):
12 | query_data = {
13 | 'nodes': [[{'count': 10}]],
14 | 'resources': [[{'count': 40}]],
15 | }
16 |
17 | dbquery = MockDbQuery(query_data)
18 |
19 | mocker.patch.object(app.puppetdb, '_query', side_effect=dbquery.get)
20 |
21 | rv = client.get('/radiator')
22 |
23 | assert rv.status_code == 200
24 |
25 | soup = BeautifulSoup(rv.data, 'html.parser')
26 | assert soup.title.contents[0] == 'Puppetboard'
27 | assert soup.h1 != 'Not Found'
28 | total = soup.find(class_='total')
29 |
30 | assert '10' in total.text
31 |
32 |
33 | def test_radiator_view_all(client, mocker,
34 | mock_puppetdb_environments,
35 | mock_puppetdb_default_nodes):
36 | # starting with v6.9.1 they changed the metric API to v2
37 | # and a totally different format
38 | base_str = 'puppetlabs.puppetdb.population:'
39 | query_data = {
40 | 'version': [{'version': '6.9.1'}],
41 | 'metrics': [
42 | {
43 | 'validate': {
44 | 'data': {
45 | 'value': {'Value': '50'}
46 | },
47 | 'checks': {
48 | 'path': '%sname=num-nodes' % base_str
49 | }
50 | }
51 | },
52 | {
53 | 'validate': {
54 | 'data': {
55 | 'value': {'Value': '60'}
56 | },
57 | 'checks': {
58 | 'path': '%sname=num-resources' % base_str
59 | }
60 | }
61 | },
62 | {
63 | 'validate': {
64 | 'data': {
65 | 'value': {'Value': 60.3}
66 | },
67 | 'checks': {
68 | 'path': '%sname=avg-resources-per-node' % base_str
69 | }
70 | }
71 | }
72 | ]
73 | }
74 |
75 | dbquery = MockDbQuery(query_data)
76 |
77 | mocker.patch.object(app.puppetdb, '_query', side_effect=dbquery.get)
78 |
79 | rv = client.get('/%2A/radiator')
80 |
81 | assert rv.status_code == 200
82 |
83 | soup = BeautifulSoup(rv.data, 'html.parser')
84 | assert soup.title.contents[0] == 'Puppetboard'
85 | assert soup.h1 != 'Not Found'
86 | total = soup.find(class_='total')
87 |
88 | assert '50' in total.text
89 |
90 |
91 | def test_radiator_view_json(client, mocker,
92 | mock_puppetdb_environments,
93 | mock_puppetdb_default_nodes):
94 | query_data = {
95 | 'nodes': [[{'count': 10}]],
96 | 'resources': [[{'count': 40}]],
97 | }
98 |
99 | dbquery = MockDbQuery(query_data)
100 |
101 | mocker.patch.object(app.puppetdb, '_query', side_effect=dbquery.get)
102 |
103 | rv = client.get('/radiator', headers={'Accept': 'application/json'})
104 |
105 | assert rv.status_code == 200
106 | json_data = json.loads(rv.data.decode('utf-8'))
107 |
108 | assert json_data['unreported'] == 1
109 | assert json_data['noop'] == 1
110 | assert json_data['failed'] == 1
111 | assert json_data['changed'] == 1
112 | assert json_data['unchanged'] == 1
113 |
114 |
115 | def test_radiator_view_bad_env(client, mocker,
116 | mock_puppetdb_environments,
117 | mock_puppetdb_default_nodes):
118 | query_data = {
119 | 'nodes': [[{'count': 10}]],
120 | 'resources': [[{'count': 40}]],
121 | }
122 |
123 | dbquery = MockDbQuery(query_data)
124 |
125 | mocker.patch.object(app.puppetdb, '_query', side_effect=dbquery.get)
126 |
127 | rv = client.get('/nothere/radiator')
128 |
129 | assert rv.status_code == 404
130 | soup = BeautifulSoup(rv.data, 'html.parser')
131 | assert soup.title.contents[0] == 'Puppetboard'
132 | assert soup.h1.text == 'Not Found'
133 |
134 |
135 | def test_radiator_view_division_by_zero(client, mocker,
136 | mock_puppetdb_environments,
137 | mock_puppetdb_default_nodes):
138 | query_data = {
139 | 'nodes': [[{'count': 0}]],
140 | 'resources': [[{'count': 40}]],
141 | }
142 |
143 | dbquery = MockDbQuery(query_data)
144 |
145 | mocker.patch.object(app.puppetdb, '_query', side_effect=dbquery.get)
146 |
147 | rv = client.get('/radiator')
148 |
149 | assert rv.status_code == 200
150 |
151 | soup = BeautifulSoup(rv.data, 'html.parser')
152 | assert soup.title.contents[0] == 'Puppetboard'
153 |
154 | total = soup.find(class_='total')
155 | assert '0' in total.text
156 |
--------------------------------------------------------------------------------
/test/views/test_reports.py:
--------------------------------------------------------------------------------
1 | import json
2 |
3 | from bs4 import BeautifulSoup
4 |
5 | from puppetboard import app
6 | from test import MockDbQuery
7 |
8 |
9 | def test_json_report_ok(client, mocker, input_data,
10 | mock_puppetdb_environments,
11 | mock_puppetdb_default_nodes):
12 | query_response = json.loads(input_data)
13 |
14 | query_data = {
15 | 'reports': [
16 | {
17 | 'validate': {
18 | 'data': query_response[:100],
19 | 'checks': {
20 | 'limit': 100,
21 | 'offset': 0
22 | }
23 | }
24 | }
25 | ]
26 | }
27 |
28 | dbquery = MockDbQuery(query_data)
29 |
30 | mocker.patch.object(app.puppetdb, '_query', side_effect=dbquery.get)
31 | app.puppetdb.last_total = 499
32 |
33 | rv = client.get('/reports/json')
34 |
35 | assert rv.status_code == 200
36 | result_json = json.loads(rv.data.decode('utf-8'))
37 |
38 | assert 'data' in result_json
39 | assert len(result_json['data']) == 100
40 |
41 |
42 | def test_reports__a_report(client, mocker,
43 | mock_puppetdb_environments,
44 | ):
45 | query_data = {
46 | 'reports': [[
47 | {
48 | "hash": '1234567',
49 | "receive_time": '2022-05-11T04:00:00.000Z',
50 | "report_format": 12,
51 | "puppet_version": "1.2.3",
52 | "start_time": '1948-09-07T00:00:01.000Z',
53 | "end_time": '2022-05-11T04:00:00.000Z',
54 | "producer_timestamp": '2022-05-11T04:00:00.000Z',
55 | "producer": 'foobar',
56 | "transaction_uuid": 'foobar',
57 | "status": 'failed',
58 | "noop": False,
59 | "noop_pending": False,
60 | "environment": 'production',
61 | "configuration_version": '123',
62 | "certname": 'node-failed',
63 | "code_id": 'foobar',
64 | "catalog_uuid": 'foobar',
65 | "cached_catalog_status": 'not_used',
66 | "resource_events": [],
67 | "metrics": {"data": []},
68 | "logs": {"data": []},
69 | },
70 | ]],
71 | 'events': [[
72 | ]]
73 | }
74 |
75 | dbquery = MockDbQuery(query_data)
76 |
77 | mocker.patch.object(app.puppetdb, '_query', side_effect=dbquery.get)
78 | app.puppetdb.last_total = 499
79 |
80 | rv = client.get('/report/node-failed/1234567')
81 | assert rv.status_code == 200
82 |
83 | soup = BeautifulSoup(rv.data, 'html.parser')
84 | assert soup.title.contents[0] == 'Puppetboard'
85 |
86 | vals = soup.find_all('table', {"id": "logs_table"})
87 | assert len(vals) == 1
88 |
89 | vals = soup.find_all('table', {"id": "events_table"})
90 | assert len(vals) == 1
91 |
--------------------------------------------------------------------------------
/wsgi.py:
--------------------------------------------------------------------------------
1 | from __future__ import absolute_import
2 | # noinspection PyUnresolvedReferences
3 | import os # noqa: F401
4 | # noinspection PyUnresolvedReferences
5 | from puppetboard.app import app as application # noqa: F401
6 |
--------------------------------------------------------------------------------