├── .editorconfig ├── .eslintignore ├── .eslintrc.cjs ├── .git-blame-ignore-revs ├── .github └── workflows │ ├── javascript.yml │ ├── powa_web.yml │ ├── powa_web_git.yml │ └── python_lint.yml ├── .gitignore ├── .prettierrc.json ├── .ruff.toml ├── CHANGELOG ├── CONTRIBUTING.md ├── MANIFEST.in ├── README.md ├── RELEASE.md ├── package-lock.json ├── package.json ├── powa-web ├── powa-web.conf-dist ├── powa ├── __init__.py ├── collector.py ├── compat.py ├── config.py ├── dashboards.py ├── database.py ├── framework.py ├── function.py ├── io.py ├── io_template.py ├── json.py ├── options.py ├── overview.py ├── powa.wsgi ├── qual.py ├── query.py ├── server.py ├── slru.py ├── sql │ ├── __init__.py │ ├── utils.py │ ├── views.py │ ├── views_graph.py │ └── views_grid.py ├── static │ ├── dist │ │ ├── .vite │ │ │ └── manifest.json │ │ └── assets │ │ │ ├── d3-GY-Jf1db.js │ │ │ ├── highlight-CRyVn9mj.js │ │ │ ├── lodash-CmFMvF3r.js │ │ │ ├── luxon-lqzArHOP.js │ │ │ ├── main-BQBMx7EO.js │ │ │ ├── main-OiFLL3TG.css │ │ │ ├── moment-C5S46NFB.js │ │ │ ├── sqltools-formatter-M1RRnKbr.js │ │ │ ├── vue-SrZHs5Ax.js │ │ │ └── vuetify-Bf0BnRvR.js │ ├── img │ │ ├── favicon │ │ │ ├── apple-touch-icon-114x114.png │ │ │ ├── apple-touch-icon-120x120.png │ │ │ ├── apple-touch-icon-144x144.png │ │ │ ├── apple-touch-icon-152x152.png │ │ │ ├── apple-touch-icon-57x57.png │ │ │ ├── apple-touch-icon-60x60.png │ │ │ ├── apple-touch-icon-72x72.png │ │ │ ├── apple-touch-icon-76x76.png │ │ │ ├── apple-touch-icon-precomposed.png │ │ │ ├── apple-touch-icon.png │ │ │ ├── browserconfig.xml │ │ │ ├── favicon-160x160.png │ │ │ ├── favicon-16x16.png │ │ │ ├── favicon-196x196.png │ │ │ ├── favicon-32x32.png │ │ │ ├── favicon-96x96.png │ │ │ ├── favicon.ico │ │ │ ├── mstile-144x144.png │ │ │ ├── mstile-150x150.png │ │ │ ├── mstile-310x150.png │ │ │ ├── mstile-310x310.png │ │ │ └── mstile-70x70.png │ │ └── powa-logo-white.png │ ├── js │ │ ├── App.vue │ │ ├── components │ │ │ ├── BreadCrumbs.vue │ │ │ ├── DateRangePicker │ │ │ │ ├── DateRangePicker.vue │ │ │ │ └── options.ts │ │ │ ├── GridCell.vue │ │ │ ├── LoginView.vue │ │ │ ├── QueryTooltip.vue │ │ │ └── dynamic │ │ │ │ ├── Dashboard.vue │ │ │ │ ├── DistributionGrid.vue │ │ │ │ ├── Graph.vue │ │ │ │ ├── Grid.vue │ │ │ │ ├── Tabcontainer.vue │ │ │ │ ├── Wizard.vue │ │ │ │ ├── config │ │ │ │ ├── AllCollectorsDetail.vue │ │ │ │ └── ServersErrors.vue │ │ │ │ └── database │ │ │ │ ├── FunctionDetail.vue │ │ │ │ ├── WizardThisDatabase.vue │ │ │ │ └── query │ │ │ │ ├── QualDetail.vue │ │ │ │ ├── QueryDetail.vue │ │ │ │ ├── QueryExplains.vue │ │ │ │ └── QueryIndexes.vue │ │ ├── composables │ │ │ ├── DataLoaderService.js │ │ │ └── MessageService.js │ │ ├── fonts │ │ │ └── Roboto │ │ │ │ ├── KFOkCnqEu92Fr1MmgVxGIzIFKw.woff2 │ │ │ │ ├── KFOkCnqEu92Fr1MmgVxIIzI.woff2 │ │ │ │ ├── KFOlCnqEu92Fr1MmEU9fBBc4.woff2 │ │ │ │ ├── KFOlCnqEu92Fr1MmEU9fChc4EsA.woff2 │ │ │ │ ├── KFOlCnqEu92Fr1MmSU5fBBc4.woff2 │ │ │ │ ├── KFOlCnqEu92Fr1MmSU5fChc4EsA.woff2 │ │ │ │ ├── KFOlCnqEu92Fr1MmWUlfBBc4.woff2 │ │ │ │ ├── KFOlCnqEu92Fr1MmWUlfChc4EsA.woff2 │ │ │ │ ├── KFOlCnqEu92Fr1MmYUtfBBc4.woff2 │ │ │ │ ├── KFOlCnqEu92Fr1MmYUtfChc4EsA.woff2 │ │ │ │ ├── KFOmCnqEu92Fr1Mu4mxK.woff2 │ │ │ │ ├── KFOmCnqEu92Fr1Mu7GxKOzY.woff2 │ │ │ │ └── roboto.css │ │ ├── main.js │ │ ├── plugins │ │ │ ├── powa.js │ │ │ └── vuetify.js │ │ ├── stores │ │ │ ├── dashboard.js │ │ │ └── dateRange.js │ │ └── utils │ │ │ ├── datemath.js │ │ │ ├── dates.js │ │ │ ├── duration.js │ │ │ ├── percentage.js │ │ │ ├── rangeutil.js │ │ │ ├── size.js │ │ │ ├── sql.js │ │ │ └── widget-component.js │ └── styles │ │ ├── highlight.scss │ │ ├── main.scss │ │ └── variables.scss ├── templates │ ├── fullpage_dashboard.html │ ├── layout.html │ ├── login.html │ └── xhr.html ├── ui_methods.py ├── ui_modules.py ├── user.py └── wizard.py ├── readme ├── requirements-dev.txt ├── requirements.txt ├── run_powa.py ├── setup.py └── vite.config.js /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | end_of_line = lf 5 | insert_final_newline = true 6 | 7 | [*.py] 8 | charset = utf-8 9 | indent_style = space 10 | indent_size = 4 11 | 12 | [*.{css,scss,js,jsx,ts,vue,json,yml}] 13 | charset = utf-8 14 | indent_style = space 15 | indent_size = 2 16 | trim_trailing_whitespace = true 17 | -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | powa/static/dist 2 | -------------------------------------------------------------------------------- /.eslintrc.cjs: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | root: true, 3 | env: { 4 | node: true, 5 | }, 6 | extends: [ 7 | "eslint:recommended", 8 | "plugin:vue/vue3-recommended", 9 | "prettier", 10 | "plugin:vue/base", 11 | "plugin:vuetify/base", 12 | ], 13 | rules: { 14 | "vue/multi-word-component-names": [ 15 | "error", 16 | { 17 | ignores: [ 18 | "Content", 19 | "Dashboard", 20 | "Graph", 21 | "Grid", 22 | "Tabcontainer", 23 | "Wizard", 24 | ], 25 | }, 26 | ], 27 | "vue/valid-v-slot": [ 28 | "error", 29 | { 30 | allowModifiers: true, 31 | }, 32 | ], 33 | "vue/no-v-html": "off", 34 | }, 35 | }; 36 | -------------------------------------------------------------------------------- /.git-blame-ignore-revs: -------------------------------------------------------------------------------- 1 | # This requires git 2.23 or later. 2 | # 3 | # You need to configure git to use this file, with something like: 4 | # $ git config blame.ignoreRevsFile .git-blame-ignore-revs 5 | 6 | # Introduction of ruff 7 | ecb8013b5f3287b51d00a4d7bf9e186cf1336b85 8 | -------------------------------------------------------------------------------- /.github/workflows/javascript.yml: -------------------------------------------------------------------------------- 1 | name: Check syntax and build JS 2 | 3 | on: 4 | pull_request: 5 | 6 | jobs: 7 | test-javascript: 8 | name: JavaScript Tests 9 | runs-on: ubuntu-latest 10 | 11 | steps: 12 | - name: Checkout repository 13 | uses: actions/checkout@v4 14 | 15 | - name: Setup Node.js 16 | id: setup-node 17 | uses: actions/setup-node@v4 18 | with: 19 | node-version: 22 20 | cache: npm 21 | 22 | - name: Install Dependencies 23 | id: npm-ci 24 | run: npm ci 25 | 26 | - name: Lint 27 | id: npm-lint 28 | run: npm run lint 29 | 30 | - name: Check Format 31 | id: npm-format-check 32 | run: npm run format 33 | 34 | - name: Check Build 35 | id: npm-build-check 36 | run: npm run build 37 | -------------------------------------------------------------------------------- /.github/workflows/powa_web.yml: -------------------------------------------------------------------------------- 1 | name: Trigger build and push of powa-web image 2 | 3 | on: 4 | push: 5 | # https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#filter-pattern-cheat-sheet 6 | tags-ignore: 7 | - 'debian/*' 8 | 9 | env: 10 | TARGET_REPO: "powa-podman" 11 | EVENT_TYPE: "powa-web" 12 | 13 | jobs: 14 | trigger_build: 15 | name: Trigger build and push of powa-web in powa-podman repo 16 | runs-on: ubuntu-latest 17 | steps: 18 | - name: Trigger the powa-web repository dispatch 19 | run: | 20 | # Set variables 21 | org="${{ github.repository_owner }}" 22 | repo="${{ env.TARGET_REPO }}" 23 | event_type="${{ env.EVENT_TYPE }}" 24 | 25 | curl -L \ 26 | -X POST \ 27 | -H "Accept: application/vnd.github+json" \ 28 | -H "Authorization: Bearer ${{ secrets.DISPATCH_TOKEN }}" \ 29 | -H "X-GitHub-Api-Version: 2022-11-28" \ 30 | https://api.github.com/repos/${org}/${repo}/dispatches \ 31 | -d "{\"event_type\": \"${event_type}\"}" 32 | -------------------------------------------------------------------------------- /.github/workflows/powa_web_git.yml: -------------------------------------------------------------------------------- 1 | name: Trigger build and push of powa-web-git image 2 | 3 | on: 4 | push: 5 | branches: [master] 6 | 7 | env: 8 | TARGET_REPO: "powa-podman" 9 | EVENT_TYPE: "powa-web-git" 10 | 11 | jobs: 12 | trigger_build: 13 | name: Trigger build and push of powa-web-git in powa-podman repo 14 | runs-on: ubuntu-latest 15 | steps: 16 | - name: Trigger the powa-web-git repository dispatch 17 | run: | 18 | # Set variables 19 | org="${{ github.repository_owner }}" 20 | repo="${{ env.TARGET_REPO }}" 21 | event_type="${{ env.EVENT_TYPE }}" 22 | 23 | curl -L \ 24 | -X POST \ 25 | -H "Accept: application/vnd.github+json" \ 26 | -H "Authorization: Bearer ${{ secrets.DISPATCH_TOKEN }}" \ 27 | -H "X-GitHub-Api-Version: 2022-11-28" \ 28 | https://api.github.com/repos/${org}/${repo}/dispatches \ 29 | -d "{\"event_type\": \"${event_type}\"}" 30 | -------------------------------------------------------------------------------- /.github/workflows/python_lint.yml: -------------------------------------------------------------------------------- 1 | name: Check python syntax 2 | 3 | on: 4 | pull_request: 5 | 6 | jobs: 7 | ruff: 8 | runs-on: ubuntu-latest 9 | strategy: 10 | matrix: 11 | python-version: ["3.12"] 12 | steps: 13 | - name: Checkout repository 14 | uses: actions/checkout@v4 15 | 16 | - name: Set Python Version 17 | uses: actions/setup-python@v5 18 | with: 19 | python-version: ${{ matrix.python-version }} 20 | 21 | - name: Install dependencies 22 | run: | 23 | python -m pip install --upgrade pip 24 | pip install -r requirements-dev.txt 25 | 26 | - name: Python Ruff Lint and Format 27 | run: | 28 | ruff check --output-format=github . 29 | ruff format --check --diff 30 | 31 | - name: Check manifest 32 | run: | 33 | check-manifest 34 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | **/.sass-cache/ 3 | powa/static/build/ 4 | .ropeproject 5 | *.pyc 6 | **/__pycache__/ 7 | powa-web.conf 8 | build 9 | powa.egg.info 10 | .*.sw? 11 | .pypirc 12 | -------------------------------------------------------------------------------- /.prettierrc.json: -------------------------------------------------------------------------------- 1 | {} 2 | -------------------------------------------------------------------------------- /.ruff.toml: -------------------------------------------------------------------------------- 1 | line-length = 79 2 | extend-include = ["powa-web", "powa/powa.wsgi"] 3 | 4 | [lint] 5 | # Default `select` is: "E4", "E7", "E9", "F" 6 | # `E` for "pycodestyle" (subset) 7 | # `F` for "Pyflakes" 8 | 9 | # In addition, we also enable: 10 | # `Q` for "flake8-quotes" 11 | # `I` for "isort" 12 | extend-select = ["Q", "I"] 13 | 14 | [lint.per-file-ignores] 15 | "powa/__init__.py" = ["E402"] 16 | 17 | [lint.isort] 18 | no-sections = true 19 | -------------------------------------------------------------------------------- /CHANGELOG: -------------------------------------------------------------------------------- 1 | 5.0.1: 2 | New feature: 3 | - Make the server field in login form required (Pierre Giraud) 4 | Bug fixes: 5 | - Fix alert messages (Pierre Giraud) 6 | - Fix typo in WAL and pg_qualstats related queries (Pierre Giraud) 7 | - Various SQL formatting improvements (Pierre Giraud) 8 | - Fix postgres 13 and below compatibility (Julien Rouhaud) 9 | - Include the missing generated assets in the release (Julien Rouhaud) 10 | - Fix various packaging problems (Matthias Dötsch, Pierre Giraud, Julien 11 | Rouhaud) 12 | 5.0.0: 13 | New feature: 14 | - Complete rewrite of the UI using (currently) modern frameworks and 15 | libraries. It is now built with ViteJS, VueJS, Vuetify and d3. (Marion 16 | Giusti, Pierre-Louis Gonon and Pierre Giraud. Thanks to Dalibo for 17 | sponsoring this work). 18 | - Allow forcing a snapshot or requesting a catalog refresh from the UI 19 | (Julien Rouhaud) 20 | - Handle any extension installed in any schema (Julien Rouhaud) 21 | - Add support for PostgreSQL 17 (Julien Rouhaud) 22 | - Add widgets for pg_stat_user_functions metrics (Julien Rouhaud) 23 | - Add pg_stat_activity graphs on the per-server and per-db pages (Julien 24 | Rouhaud) 25 | - Add table / index bar graphs on the per-server and per-db pages (Julien 26 | Rouhaud) 27 | - Add pg_stat_archiver widgets (Julien Rouhaud) 28 | - Add pg_stat_replication widgets (Julien Rouhaud) 29 | - Add pg_stat_database widgets (Julien Rouhaud) 30 | - Add pg_stat_replication_slots widgets (Julien Rouhaud) 31 | - Add pg_stat_io widgets (Julien Rouhaud) 32 | - Add pg_stat_database_conflicts widgets (Julien Rouhaud) 33 | - Add pg_stat_slru widgets (Julien Rouhaud) 34 | - Add pg_stat_wal widgets (Julien Rouhaud) 35 | - Add pg_stat_wal_receiver widgets (Julien Rouhaud) 36 | - Add pg_stat_subscription(_statistic) widgets (Julien Rouhaud) 37 | - Handle new IO timing and JIT counters added in newer pg_stat_statements 38 | versions (Julien Rouhaud) 39 | Bug fixes: 40 | - Properly handle metric groups that don't return any row (Julien Rouhaud) 41 | - Properly handle SQL error when retrieving execution plans (Julien Rouhaud) 42 | - Ignore query texts truncated by pg_qualstats when getting a sampled query 43 | (Julien Rouhaud) 44 | - Fix the worker status retrieval for local server on the config page 45 | (Julien Rouhaud) 46 | - Detect insufficient privilege when checking for collector processes 47 | (Julien Rouhaud) 48 | Misc 49 | - Assume pg10+ server if no remote connection is available (Julien Rouhaud) 50 | - Remove sqlalchemy dependency (Julien Rouhaud) 51 | - Split the list of extensions in configuration page to multiple categories 52 | depending on their use (Julien Rouhaud) 53 | - Show server alias in the breadcrumb selector (Pierre Giraud) 54 | 4.2.1: 55 | Misc 56 | - sqlalchemy > 1.4 is not supported (thanks to David Mödinger for the report) 57 | 4.2.0: 58 | New feature: 59 | - Add support for pg_stat_statements.toplevel, added in pg_stat_statements 60 | version 1.9 (Julien Rouhaud) 61 | 4.1.4: 62 | New features: 63 | - Add support for HTTPS (Julien Rouhaud, per initial request from github user 64 | hrawulwa, then further requests from github users banlex73 and 65 | guruguruguru. Thanks also to github user guruguruguru for testing the 66 | feature) 67 | Bug fixes: 68 | - Fix explain plan css (Julien Rouhaud, per report from github user 69 | Ravi160492) 70 | - Fix detection of nodes with the same qualid in the global index suggestion 71 | wizard (Pierre Giraud) 72 | - Fix detection of powa bgworker in the config page (Julien Rouhaud) 73 | Misc: 74 | - Improve grid queries performance (Marc Cousin and Julien Rouhaud) 75 | - Improve login form serverlist (Uwe Simon) 76 | 4.1.3: 77 | New features: 78 | - Add a new cookie_expires_days option (Julien Rouhaud, per request from 79 | github user Kamal-Villupuram) 80 | - Use pgsql language set for highlighting (Christoph Dreis) 81 | Bug fixes: 82 | - Allow missing pg_stat_kcache records (Julien Rouhaud, per report from 83 | Christoph Dreis) 84 | - Fix query unjumbling code (Julien Rouhaud, per report from Martin Aparicio 85 | and Frédéric Yhuel) 86 | - Correctly handle multiple queries with the same qual in the wizard (Julien 87 | Rouhaud) 88 | Misc: 89 | - Update build and build dependencies and highlight.js (Christoph Dreis) 90 | 4.1.2: 91 | Bug fixes: 92 | - Restore compatibility with tornado 2.0, thanks to github user hrawulwa for 93 | the report. 94 | - Fix detection of extension in local mode(Julien Rouhaud, thanks to Magnus 95 | Hagander for the report) 96 | 4.1.1: 97 | New features: 98 | - Use locally available info for remote server configuration page when 99 | possible (Julien Rouhaud) 100 | Bug fixes: 101 | - Fix per-query page for queries with identical query identifier executed on 102 | multiple remote servers (Julien Rouhaud) 103 | - Properly handle situation where the UI doesn't know the remote server 104 | PostgreSQL version (Julien Rouhaud) 105 | - Fix local server detection (Julien Rouhaud, thanks to github user 106 | alepaes1975 for the report) 107 | 4.1.0: 108 | New features: 109 | - Add compatibility with pg_stat_statements 1.8, and expose all new counters 110 | (Julien Rouhaud) 111 | - Add compatibility with pg_stat_kcache 2.2 (Julien Rouhaud) 112 | Performance improvements: 113 | - General performance improvement of per-database queries (Adrien Nayrat) 114 | 4.0.2: 115 | New features: 116 | - Add powa_coaslesce setting on config page (Adrien Nayrat) 117 | Performance improvements: 118 | - Fix a cartesian product in the predicate view (Adrien Nayrat) 119 | - Fix multiple queries so they can use the existing indexes (Adrien Nayrat) 120 | - Multiple performance improvements on the per-predicate views (Adrien Nayrat 121 | and Julien Rouhaud) 122 | Bug fixes: 123 | - Properly ignore queries that weren't called on the given interval (Adrien 124 | Nayrat) 125 | - Don't display the last retrieved value on graphs, as the value is known to 126 | always be zero and lead to wrong graphs (Adrien Nayrat) 127 | - Fix links to documentation (Matthias Dötsch) 128 | - Fix collector worker status state when srvid greater than 9 (Julien 129 | Rouhaud, thanks to Adrien Nayrat for the report) 130 | 4.0.1: 131 | Bug fixes: 132 | - Fix per-query hypothetical index checking (Julien Rouhaud, thanks to github 133 | user hrawulwa) 134 | 4.0.0: 135 | New features: 136 | - Make the UI compatible with remote-mode setup (Julien Rouhaud) 137 | - Query and display powa-collector information (Julien Rouhaud) 138 | - Expose system cache hit / disk read metrics on global and per-database 139 | pages (Julien Rouhaud) 140 | - Expose new metrics added in pg_stat_kcache 2.1 (page faults, context 141 | switches...) on global, per-database and per-query pages (Julien Rouhaud) 142 | - Add wait events graphs on global and per-database pages (Julien Rouhaud) 143 | - Add queries per second counter on global and per-database pages (Julien 144 | Rouhaud) 145 | - Display configuration changes and PostgreSQL restart on graphs when 146 | pg_track_settings is configured (Julien Rouhaud) 147 | - Add an url_prefix parameter (Julien Rouhaud and github user rippiedoos) 148 | - Add options to forbid the UI from connecting to databases different than 149 | the dedicated powa one, or to remote server, either globally or per server. 150 | - Provide metric definitions and link to the documentation on the graph 151 | (Julien Rouhaud) 152 | - Add pg_stat_bgwriter graphs (Julien Rouhaud) 153 | - Add database objects (based on pg_stat_all_tables info) graphs (Julien 154 | Rouhaud) 155 | - Move the "other queries" panel to its own grid (Ronan Dunklau) 156 | 157 | Bug fixes: 158 | - Fix longstanding bug in graph hover boxes position (Julien Rouhaud) 159 | - Fix the graph preview selection (Julien Rouhaud) 160 | - Add pg_wait_sampling in the config extension list (Julien Rouhaud, thanks 161 | to Adrien Nayrat for the report) 162 | - Don't try to detect if a hypotetical index would be used if no suitable 163 | index is detected (Julien Rouhaud, thanks to Guillaume Lelarge for the 164 | report) 165 | - Fix lost filter when changing the time range (Pierre Giraud, thanks to Marc 166 | Cousin for the report) 167 | - Fix database wizard query validation for pg11+ (Julien Rouhaud) 168 | - Handle hypopg unsupported access methods in dataabase wizard (Julien 169 | Rouhaud, thanks to Adrien Di Mascio for the report) 170 | - Fix compatibility with SQLAlchemy 1.3+ (Julien Rouhaud, thanks to github 171 | user mchubby and irc user ChOcO-Bn for the report) 172 | - Fix query detail if no data is found on the selected range (Julien Rouhaud, 173 | thanks to irc user ChOcO-Bn for the report) 174 | - Fix some metrics in wait events and general query datasources (Julien 175 | Rouhaud, thanks to Adrien Nayrat for the report) 176 | - Fix per-server graphs having multiple data and pg_stat_kcache enabled 177 | (Alexander Kukushkin) 178 | - Fix #calls and #rows metrics on the per-query page (Julien Rouhaud) 179 | - Fix query detail and performance problems on quals (Ronan Dunklau) 180 | - Fix queries for pg_stat_kcache and pg_qualstats (Ronan Dunklau) 181 | - Fix query details when multiples users run the same query (Julien Rouhaud) 182 | - Fix global databases metrics query (Alexander Kukushkin) 183 | - Fix long standing bug in pg_qualstats aggregation 184 | - Fix data retrieval when a time interval spread over more than 2 aggregated 185 | rows 186 | 187 | Misc: 188 | - Display server's alias instead of hostname:port (Adrien Nayrat) 189 | - Support pg_qualstats 2 190 | 3.2.0: 191 | - Add support for pg_wait_sampling to display wait events statistics and 192 | graphs, require pg9.6+ (Julien Rouhaud) 193 | - Show a sorted list of servers on login screen (meikomeik) 194 | - Add category to the list of column in the config overview page (Julien 195 | Rouhaud, thanks to Nehemiah I. Dacres for the feature request) 196 | - New breadcrumb and many other UI improvements (Pierre Giraud) 197 | - Fix behavior when changing time interval and then changing back to last 198 | hour (Julien Rouhaud, reported by Thomas Reiss) 199 | - Allow both "user" and "username" in configuration files (Julien Rouhaud) 200 | 3.1.4: 201 | - Export all data in csv export (thanks to jdeshayes for the feature request) 202 | - Reword double negation, thanks to Pierre Giraud for noticing 203 | 3.1.3: 204 | - Fix wrong calculation of microseconds difference (thanks to Eric Champigny) 205 | 3.1.2: 206 | - Fix I/O time unit on overview dashboard 207 | - Fix problem with explain queries (thanks to dblugeon) 208 | - Cosmetic changes to CPU time 209 | 3.1.1: 210 | - Detect powa-archivist / powa-web incompatibility 211 | - Handle quals which are not optimizable at all 212 | - Fix grid rendering. 213 | - Change default sorting order to DESC 214 | - Change default sort order on query page to duration desc 215 | - Use the time interval when rendering urls in Grids 216 | - Propagate updatePeriod on zoom to picker 217 | - Add export to CSV feature on grid 218 | - Fix case when quals dont belong to the same rel 219 | - Ignore errors while getting hypoplans 220 | 3.1.0: 221 | - Fix figures in query details, thanks to Eric Champigny for the patch 222 | - Fix graphs to display local time instead of UTC time 223 | - Fix IO timing figures on database overview (thanks to ribbles for the 224 | report) 225 | - Fix query page when pg_qualstats isn't available 226 | - Display dropped databases in the UI (requires at least powa-archvist 3.0.1) 227 | - Notify that server is listening, display socket information (thanks to 228 | Pierre Hilbert for the feature request) 229 | - Always display overall informations on a query, even if not statistics is 230 | present 231 | - Display server and connection information in top-bar 232 | 3.0.2: 233 | - fix regression in period updates 234 | 3.0.1: 235 | - Display installed version of handled extensions 236 | - Display information on pg_track_settings 237 | - Make query detail widget smaller 238 | - Fix qualstat_getstatdata, condition was ignored 239 | - Better hint on fail wizard. 240 | - Fix some syntax erors 241 | - Handle case n_distinct is unkown. 242 | - On per-cluster view, the avg runtime is displayed, not the total runtime. 243 | - Add a "runtime per sec" serie on per-cluster and per-db views. 244 | - Show the initially sorted column in grids. 245 | - Don't display sidebar on login page. 246 | - Add tabbed dashboards 247 | 3.0.0: 248 | - Add indexes suggestion for the whole database workload. 249 | - Fix bug with negative microsecond differences 250 | - Add support for hypopg 251 | - Handle example queries from pg_qualstats 252 | - Handle prepared statements 253 | - Add example on how to specify a client encoding 254 | 2.0.11: 255 | - fix bug on "other queries" panel on qual page 256 | - other bug fixes 257 | 2.0.10: 258 | - add compatibility for wsgi on tornado < 4 259 | 2.0.9: 260 | - add index_url config parameter 261 | 2.0.8: 262 | - Compatibility with sqlalchemy 1.0.0 263 | 2.0.7: 264 | - Add zoom-on-drag behaviour to graphs 265 | - Fix redirect after login 266 | - Add logging for authentification errors 267 | 2.0.6: 268 | - fix bug with python2 relative imports 269 | 2.0.5: 270 | - display cpu / user time as a percentage of query time 271 | - initial changelog 272 | 273 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | **How to contribute to powa-web** 2 | 3 | This documentation describes how to set up an environment for powa-web or how 4 | to maintain it. 5 | 6 | This project relies on a few frameworks / libraries including: 7 | 8 | - VueJS, 9 | - Vuetify, 10 | - D3, 11 | - ViteJS. 12 | 13 | # Set up dev environment 14 | 15 | For the following steps, we assume you use PoWA web in debug mode (for example 16 | by running `./run-powa.py` or using podman dev environment). 17 | 18 | ### Python syntax and formatting 19 | 20 | Python syntax and formatting must conform to ruff. CI checks new code with ruff. 21 | 22 | If not already available, you can create a virtualenv for developpement purpose: 23 | 24 | ```shell 25 | python3 -m venv .venv 26 | source .venv/bin/activate 27 | pip install -r requirements-dev.txt 28 | ``` 29 | 30 | You can then do a syntax check and format check by running the following command: 31 | 32 | ``` shell 33 | ruff check 34 | ruff format --check --diff 35 | ``` 36 | 37 | ## Requirements 38 | 39 | - A recent version of `NodeJS` (16+) and `npm` are required. 40 | 41 | ## Install JS packages 42 | 43 | ```shell 44 | npm ci 45 | ``` 46 | 47 | It installs a clean version of the project by installing packages with versions 48 | declared in the `package-lock.json` file. 49 | 50 | ## Serve files wih ViteJS 51 | 52 | In a new terminal, execute: 53 | 54 | ```shell 55 | npm run dev 56 | ``` 57 | 58 | It launches a `ViteJS` server running on 5173 port. It serves the JS and CSS 59 | files dynamically, and in most cases modifications made on the files will be 60 | taken into account directly without having to reload the page in the browser. 61 | 62 | At this point, you should be able to use powa-web in your browser. 63 | 64 | Beware that the first page load in the browser may take some time. ViteJS needs 65 | to build dependencies at first launch. 66 | 67 | # JS/CSS libraries maintenance 68 | 69 | ## Audit the package vulnerabilities 70 | 71 | Using `npm audit` you can check if packages powa depends on are vulnerable. If 72 | it reports high severity vulnerabilities, it is recommended to update the 73 | involved packages either using `npm audit fix` or `npm install 74 | name-of-package`. 75 | 76 | ## Update packages 77 | 78 | To update libraries, you can run: 79 | 80 | ``` 81 | npm install name-of-package 82 | ``` 83 | 84 | This will take `package.json` settings into account and will only install 85 | versions that match the one declared there if the package is already installed. 86 | 87 | Please refer to https://docs.npmjs.com/about-semantic-versioning. 88 | 89 | Don't forget to commit changes in both `package.json` and `package-lock.json` 90 | if need be. 91 | 92 | # Build for production 93 | 94 | When PoWA runs in production mode, it relies on built assets. Those built 95 | static assets can be found in `powa/static/dist`. 96 | 97 | To build the assets from the current source files, run: 98 | 99 | ```shell 100 | npm run build 101 | ``` 102 | 103 | This is to be done before releasing a new version. Don't forget to commit the 104 | new static files that may have been created in `dist`. 105 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | # Exclude all dot files and dot directories 2 | exclude .* 3 | recursive-exclude .* * 4 | 5 | exclude vite.config.js 6 | exclude *.json 7 | 8 | recursive-exclude powa/static/js * 9 | recursive-exclude powa/static/styles * 10 | recursive-include powa/templates *.html 11 | recursive-include powa/static/css * 12 | recursive-include powa/static/img * 13 | recursive-include powa/static/dist * 14 | include powa/powa.wsgi 15 | include powa-web.conf-dist 16 | include run_powa.py 17 | 18 | include *.md 19 | include *.txt 20 | include CHANGELOG 21 | include readme 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | 2 | ![PostgreSQL Workload Analyzer](https://github.com/powa-team/powa/blob/master/img/powa_logo.410x161.png) 3 | 4 | PoWA Web 5 | ========= 6 | 7 | This project is a User Interface to the 8 | [PoWA](http://powa.readthedocs.io/) project. 9 | 10 | For more information, please read the [PoWA-web 11 | documentation](https://powa.readthedocs.io/en/latest/components/powa-web/index.html): 12 | 13 | https://powa.readthedocs.io/en/latest/components/powa-web/index.html 14 | -------------------------------------------------------------------------------- /RELEASE.md: -------------------------------------------------------------------------------- 1 | ## Releasing a new version of PoWA-web 2 | 3 | - [ ] Ensure there's no blocker issues and pull requests are resolved. 4 | - [ ] Bump the powa-web `__VERSION__` in `powa/__init__.py`. 5 | - [ ] Make sure CI runs successfully. 6 | - [ ] Build and commit assets: 7 | - [ ] Run `npm ci` to install packages. 8 | - [ ] Run `npm run build` to build assets (JS and CSS). 9 | - [ ] Update [the changelog](https://github.com/powa-team/powa-web/blob/master/CHANGELOG) if not already up-to-date. 10 | - [ ] Commit the changes (files in `powa/static/dist`, `powa/__init__.py` and `CHANGELOG`). 11 | - [ ] Create a tag either from the command line or Github web interface. 12 | - [ ] Verify that images are pushed to Docker hub (via the powa-podman repo). 13 | - [ ] Publish to `pypi`. 14 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "powa", 3 | "private": true, 4 | "version": "0.1.0", 5 | "type": "module", 6 | "scripts": { 7 | "dev": "vite", 8 | "build": "vite build", 9 | "preview": "vite preview", 10 | "lint": "eslint --ext .js,.vue --fix powa/static/", 11 | "format": "prettier powa/static/js --check" 12 | }, 13 | "devDependencies": { 14 | "@mdi/js": "^7.4.47", 15 | "@types/d3": "^7.4.3", 16 | "@types/lodash": "^4.17.6", 17 | "@types/luxon": "^3.4.2", 18 | "eslint": "^8.57.0", 19 | "eslint-config-prettier": "^8.10.0", 20 | "eslint-plugin-vue": "^9.26.0", 21 | "eslint-plugin-vuetify": "^2.4.0", 22 | "prettier": "^2.8.8", 23 | "sass": "^1.77.6", 24 | "vite": "^6.2.0" 25 | }, 26 | "dependencies": { 27 | "@sqltools/formatter": "^1.2.5", 28 | "@vitejs/plugin-vue": "^5.2.1", 29 | "d3": "^7.9.0", 30 | "highlight.js": "^11.9.0", 31 | "lodash": "^4.17.21", 32 | "luxon": "^3.4.4", 33 | "moment": "^2.30.1", 34 | "pinia": "^2.3.1", 35 | "vite-plugin-vuetify": "^2.0.3", 36 | "vue": "^3.3.11", 37 | "vue-router": "^4.4.0", 38 | "vuetify": "^3.6.10" 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /powa-web: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | import logging 3 | import os 4 | import sys 5 | import tornado.ioloop 6 | from powa import make_app 7 | from tornado.options import options 8 | 9 | if __name__ == "__main__": 10 | application = make_app(debug=False, gzip=True, compress_response=True) 11 | logger = logging.getLogger("tornado.application") 12 | 13 | if ( 14 | options.certfile 15 | and not options.keyfile 16 | or not options.certfile 17 | and options.keyfile 18 | ): 19 | logger.error( 20 | "Invalid SSL configuration: you need to provide both " 21 | "certfile and keyfile" 22 | ) 23 | sys.exit(1) 24 | 25 | server = application 26 | protocol = "http" 27 | if options.certfile and options.keyfile: 28 | if not os.path.isfile(options.certfile): 29 | logger.error("Certificate file %s not found", options.certfile) 30 | sys.exit(1) 31 | if not os.path.isfile(options.keyfile): 32 | logger.error("Certificate key file %s not found", options.keyfile) 33 | sys.exit(1) 34 | 35 | ssl_options = { 36 | "certfile": options.certfile, 37 | "keyfile": options.keyfile, 38 | } 39 | server = tornado.httpserver.HTTPServer( 40 | application, ssl_options=ssl_options 41 | ) 42 | protocol = "https" 43 | 44 | server.listen(options.port, address=options.address) 45 | logger.info( 46 | "Starting powa-web on %s://%s:%s%s", 47 | protocol, 48 | options.address, 49 | options.port, 50 | options.url_prefix, 51 | ) 52 | tornado.ioloop.IOLoop.instance().start() 53 | -------------------------------------------------------------------------------- /powa-web.conf-dist: -------------------------------------------------------------------------------- 1 | servers={ 2 | 'main': { 3 | 'host': 'localhost', 4 | 'port': '5432', 5 | 'database': 'powa', 6 | 'query': {'client_encoding': 'utf8'} 7 | } 8 | } 9 | cookie_secret="SUPERSECRET_THAT_YOU_SHOULD_CHANGE" 10 | # Retention of the authentication cookies in days. Use 0 for a session cookie. 11 | # cookie_expires_days=30 12 | # Some extra options you can set 13 | # 14 | # port on which the UI should be available. 15 | # port=8888 16 | # Address on which the UI should be available on 17 | # address=0.0.0.0 18 | # path to certificate file, for https server 19 | # certfile=None 20 | # path to certificate private key file, for https server 21 | # keyfile=None 22 | # Forbid UI to connect to databases globally (can be configured per server) 23 | # allow_ui_connection=False 24 | # Custom URL prefix the UI should be available on 25 | # url_prefix="/" 26 | -------------------------------------------------------------------------------- /powa/__init__.py: -------------------------------------------------------------------------------- 1 | from __future__ import print_function 2 | 3 | """ 4 | Powa main application. 5 | """ 6 | 7 | import os 8 | import re 9 | 10 | __VERSION__ = "5.0.1" 11 | 12 | ver_tmp = re.sub("(alpha|beta|dev)[0-9]*", "", __VERSION__) 13 | __VERSION_NUM__ = [int(part) for part in (ver_tmp.split("."))] 14 | 15 | POWA_ROOT = os.path.dirname(__file__) 16 | 17 | # Import from powa.options must go before tornado.options 18 | from powa.options import parse_options # noqa: I001 19 | from tornado.options import options 20 | from tornado.web import Application 21 | from tornado.web import URLSpec as U 22 | 23 | from powa import ui_methods, ui_modules 24 | from powa.collector import ( 25 | CollectorDbCatRefreshHandler, 26 | CollectorForceSnapshotHandler, 27 | CollectorReloadHandler, 28 | ) 29 | from powa.config import ( 30 | RemoteConfigOverview, 31 | RepositoryConfigOverview, 32 | ) 33 | from powa.database import DatabaseOverview, DatabaseSelector 34 | from powa.framework import AuthHandler 35 | from powa.function import FunctionOverview 36 | from powa.io import ( 37 | ByBackendTypeIoOverview, 38 | ByContextIoOverview, 39 | ByObjIoOverview, 40 | ) 41 | from powa.overview import Overview 42 | from powa.qual import QualOverview 43 | from powa.query import QueryOverview 44 | from powa.server import ServerOverview, ServerSelector 45 | from powa.slru import ByNameSlruOverview 46 | from powa.user import LoginHandler, LogoutHandler 47 | from powa.wizard import IndexSuggestionHandler 48 | 49 | 50 | class IndexHandler(AuthHandler): 51 | """ 52 | Handler for the main page. 53 | """ 54 | 55 | def get(self): 56 | return self.redirect(options.index_url) 57 | 58 | 59 | def make_app(**kwargs): 60 | """ 61 | Parse the config file and instantiate a tornado app. 62 | """ 63 | parse_options() 64 | 65 | URLS = [ 66 | U(r"%slogin/" % options.url_prefix, LoginHandler, name="login"), 67 | U(r"%slogout/" % options.url_prefix, LogoutHandler, name="logout"), 68 | U( 69 | r"%sreload_collector/" % options.url_prefix, 70 | CollectorReloadHandler, 71 | name="reload_collector", 72 | ), 73 | U( 74 | r"%sforce_snapshot/(\d+)" % options.url_prefix, 75 | CollectorForceSnapshotHandler, 76 | name="force_snapshot", 77 | ), 78 | U( 79 | r"%srefresh_db_cat/" % options.url_prefix, 80 | CollectorDbCatRefreshHandler, 81 | name="refresh_db_cat", 82 | ), 83 | U( 84 | r"%sserver/select" % options.url_prefix, 85 | ServerSelector, 86 | name="server_selector", 87 | ), 88 | U( 89 | r"%sdatabase/select" % options.url_prefix, 90 | DatabaseSelector, 91 | name="database_selector", 92 | ), 93 | U(r"%s" % options.url_prefix, IndexHandler, name="index"), 94 | U( 95 | r"%sserver/(\d+)/database/([^\/]+)/suggest/" % options.url_prefix, 96 | IndexSuggestionHandler, 97 | name="index_suggestion", 98 | ), 99 | ] 100 | 101 | for dashboard in ( 102 | Overview, 103 | ServerOverview, 104 | DatabaseOverview, 105 | QueryOverview, 106 | QualOverview, 107 | FunctionOverview, 108 | RepositoryConfigOverview, 109 | RemoteConfigOverview, 110 | ByBackendTypeIoOverview, 111 | ByObjIoOverview, 112 | ByContextIoOverview, 113 | ByNameSlruOverview, 114 | ): 115 | URLS.extend(dashboard.url_specs(options.url_prefix)) 116 | 117 | _cls = Application 118 | if "legacy_wsgi" in kwargs: 119 | from tornado.wsgi import WSGIApplication 120 | 121 | _cls = WSGIApplication 122 | 123 | return _cls( 124 | URLS, 125 | ui_modules=ui_modules, 126 | ui_methods=ui_methods, 127 | login_url=("%slogin/" % options.url_prefix), 128 | static_path=os.path.join(POWA_ROOT, "static"), 129 | static_url_prefix=("%sstatic/" % options.url_prefix), 130 | cookie_secret=options.cookie_secret, 131 | template_path=os.path.join(POWA_ROOT, "templates"), 132 | **kwargs, 133 | ) 134 | -------------------------------------------------------------------------------- /powa/collector.py: -------------------------------------------------------------------------------- 1 | """ 2 | Dashboard for the powa-collector summary page, and other infrastructure for the 3 | collector handling. 4 | """ 5 | 6 | from __future__ import absolute_import 7 | 8 | import json 9 | from powa.dashboards import MetricGroupDef 10 | from powa.framework import AuthHandler 11 | 12 | 13 | class CollectorServerDetail(MetricGroupDef): 14 | name = "Server details" 15 | data_url = r"/config/(\d+)/server_details/" 16 | params = ["server"] 17 | 18 | @property 19 | def query(self): 20 | return None 21 | 22 | def post_process(self, data, server, **kwargs): 23 | sql = """SELECT * 24 | FROM {powa}.powa_servers s 25 | JOIN {powa}.powa_snapshot_metas m ON m.srvid = s.id 26 | WHERE s.id = %(server)s""" 27 | 28 | row = self.execute(sql, params={"server": server}) 29 | 30 | # unexisting server, bail out 31 | if len(row) != 1: 32 | data["messages"] = {"alert": ["This server does not exists"]} 33 | return data 34 | 35 | status = "unknown" 36 | if server == "0": 37 | status = self.execute("""SELECT 38 | CASE WHEN count(*) = 1 THEN 'running' 39 | ELSE 'stopped' 40 | END AS status 41 | FROM pg_stat_activity 42 | WHERE application_name LIKE 'PoWA - %%'""")[0]["status"] 43 | else: 44 | raw = self.notify_collector("WORKERS_STATUS", [server], 2) 45 | 46 | status = None 47 | # did we receive a valid answer? 48 | if len(raw) != 0 and "OK" in raw[0]: 49 | # just keep the first one 50 | tmp = raw[0]["OK"] 51 | if server in tmp: 52 | status = json.loads(tmp)[server] 53 | 54 | if status is None: 55 | return { 56 | "messages": { 57 | "warning": ["Could not get status for this instance"] 58 | }, 59 | "data": [], 60 | } 61 | 62 | msg = "Collector status for this instance: " + status 63 | level = "alert" 64 | if status == "running": 65 | level = "success" 66 | 67 | return {"messages": {level: [msg]}, "data": []} 68 | 69 | 70 | class CollectorReloadHandler(AuthHandler): 71 | """Page allowing to choose a server.""" 72 | 73 | def get(self): 74 | res = False 75 | 76 | answers = self.notify_collector("RELOAD") 77 | 78 | # iterate over the results. If at least one OK is received, report 79 | # success, otherwise failure 80 | for a in answers: 81 | if "OK" in a: 82 | res = True 83 | break 84 | 85 | self.render_json(res) 86 | 87 | 88 | class CollectorForceSnapshotHandler(AuthHandler): 89 | """Request an immediate snapshot on the given server.""" 90 | 91 | def get(self, server): 92 | answers = self.notify_collector("FORCE_SNAPSHOT", [server]) 93 | 94 | self.render_json(answers) 95 | 96 | 97 | class CollectorDbCatRefreshHandler(AuthHandler): 98 | """Refresh the catalogs for the given cadatabase(s) on the given server.""" 99 | 100 | def post(self): 101 | payload = json.loads(self.request.body.decode("utf8")) 102 | nb_db = len(payload["dbnames"]) 103 | args = [payload["srvid"], str(nb_db)] 104 | if nb_db > 0: 105 | args.extend(payload["dbnames"]) 106 | 107 | answers = self.notify_collector("REFRESH_DB_CAT", args) 108 | 109 | self.render_json(answers) 110 | -------------------------------------------------------------------------------- /powa/compat.py: -------------------------------------------------------------------------------- 1 | """ 2 | Py2/3 compatibility layer 3 | 4 | Mostly copied straight from six: 5 | http://pypi.python.org/pypi/six/ 6 | 7 | """ 8 | 9 | from __future__ import absolute_import 10 | 11 | import json 12 | import psycopg2 13 | from psycopg2 import extensions 14 | 15 | # If psycopg2 < 2.5, register json type 16 | psycopg2_version = tuple(psycopg2.__version__.split(" ")[0].split(".")) 17 | if psycopg2_version < ("2", "5"): 18 | JSON_OID = 114 19 | newtype = extensions.new_type( 20 | (JSON_OID,), "JSON", lambda data, cursor: json.loads(data) 21 | ) 22 | extensions.register_type(newtype) 23 | 24 | 25 | def with_metaclass(meta, *bases): 26 | """Create a base class with a metaclass.""" 27 | 28 | # This requires a bit of explanation: the basic idea is to make a dummy 29 | # metaclass for one level of class instantiation that replaces itself with 30 | # the actual metaclass. 31 | class metaclass(meta): 32 | """The actual metaclass.""" 33 | 34 | def __new__(cls, name, _, d): 35 | return meta(name, bases, d) 36 | 37 | return type.__new__(metaclass, "temporary_class", (), {}) 38 | 39 | 40 | class classproperty(object): 41 | """ 42 | A descriptor similar to property, but using the class. 43 | """ 44 | 45 | def __init__(self, getter): 46 | self.getter = getter 47 | 48 | def __get__(self, instance, owner): 49 | return self.getter(owner) 50 | -------------------------------------------------------------------------------- /powa/function.py: -------------------------------------------------------------------------------- 1 | """ 2 | Dashboard for the by-function page. 3 | """ 4 | 5 | from powa.dashboards import ContentWidget, Dashboard, DashboardPage 6 | from powa.database import DatabaseOverview 7 | from powa.sql.views_grid import powa_getuserfuncdata_detailed_db 8 | 9 | 10 | class FunctionDetail(ContentWidget): 11 | """ 12 | Detail widget showing summarized information for the function. 13 | """ 14 | 15 | title = "Function Detail" 16 | data_url = r"/server/(\d+)/metrics/database/([^\/]+)/function/(\d+)/detail" 17 | 18 | def get(self, server, database, function): 19 | stmt = powa_getuserfuncdata_detailed_db("%(funcid)s") 20 | 21 | value = self.execute( 22 | stmt, 23 | params={ 24 | "server": server, 25 | "database": database, 26 | "funcid": function, 27 | "from": self.get_argument("from"), 28 | "to": self.get_argument("to"), 29 | }, 30 | ) 31 | if value is None or len(value) < 1: 32 | self.render_json(None) 33 | return 34 | self.render_json(value[0]) 35 | 36 | 37 | class FunctionOverview(DashboardPage): 38 | """ 39 | Dashboard page for a function. 40 | """ 41 | 42 | base_url = r"/server/(\d+)/database/([^\/]+)/function/(\d+)/overview" 43 | params = ["server", "database", "function"] 44 | datasources = [FunctionDetail] 45 | parent = DatabaseOverview 46 | title = "Function overview" 47 | 48 | def dashboard(self): 49 | # This COULD be initialized in the constructor, but tornado < 3 doesn't 50 | # call it 51 | if getattr(self, "_dashboard", None) is not None: 52 | return self._dashboard 53 | 54 | self._dashboard = Dashboard( 55 | "Function %(function)s on database %(database)s", 56 | [[FunctionDetail]], 57 | ) 58 | 59 | return self._dashboard 60 | -------------------------------------------------------------------------------- /powa/io.py: -------------------------------------------------------------------------------- 1 | """ 2 | Dashboards for the various IO pages. 3 | """ 4 | 5 | from powa.config import ConfigChangesGlobal 6 | from powa.dashboards import Dashboard, DashboardPage, Graph, Grid 7 | from powa.io_template import TemplateIoGraph, TemplateIoGrid 8 | from powa.server import ServerOverview 9 | 10 | 11 | class TemplateIoOverview(DashboardPage): 12 | """ 13 | Template dashboard for IO. 14 | """ 15 | 16 | parent = ServerOverview 17 | timeline = ConfigChangesGlobal 18 | timeline_params = ["server"] 19 | 20 | def dashboard(self): 21 | # This COULD be initialized in the constructor, but tornado < 3 doesn't 22 | # call it 23 | if getattr(self, "_dashboard", None) is not None: 24 | return self._dashboard 25 | 26 | io_metrics = self.ds_graph.split( 27 | self, 28 | [ 29 | ["reads", "writes", "writebacks", "extends", "fsyncs"], 30 | ["hits", "evictions", "reuses"], 31 | ], 32 | ) 33 | graphs = [] 34 | graphs.append( 35 | [ 36 | Graph( 37 | "IO blocks", 38 | metrics=io_metrics[1], 39 | ), 40 | Graph( 41 | "IO timing", 42 | metrics=io_metrics[0], 43 | ), 44 | Graph( 45 | "IO misc", 46 | metrics=io_metrics[2], 47 | ), 48 | ] 49 | ) 50 | 51 | graphs.append( 52 | [ 53 | Grid( 54 | "IO summary", 55 | columns=[ 56 | { 57 | "name": "backend_type", 58 | "label": "Backend Type", 59 | "url_attr": "backend_type_url", 60 | }, 61 | { 62 | "name": "obj", 63 | "label": "Object Type", 64 | "url_attr": "obj_url", 65 | }, 66 | { 67 | "name": "context", 68 | "label": "Context", 69 | "url_attr": "context_url", 70 | }, 71 | ], 72 | metrics=self.ds_grid.all(), 73 | ) 74 | ] 75 | ) 76 | 77 | self._dashboard = Dashboard(self.title, graphs) 78 | return self._dashboard 79 | 80 | 81 | class ByBackendTypeIoGraph(TemplateIoGraph): 82 | """ 83 | Metric group used by per backend_type graph. 84 | """ 85 | 86 | name = "backend_type_io_graph" 87 | data_url = r"/server/(\d+)/metrics/backend_type_graph/([a-z0-9%]+)/io/" 88 | 89 | query_qual = "backend_type = %(backend_type)s" 90 | 91 | 92 | class ByBackendTypeIoGrid(TemplateIoGrid): 93 | """ 94 | Metric group used by per backend_type grid. 95 | """ 96 | 97 | xaxis = "backend_type" 98 | name = "backend_type_io_grid" 99 | data_url = r"/server/(\d+)/metrics/backend_type_grid/([a-z0-9%]+)/io/" 100 | 101 | query_qual = "backend_type = %(backend_type)s" 102 | 103 | 104 | class ByBackendTypeIoOverview(TemplateIoOverview): 105 | """ 106 | Per backend-type Dashboard page. 107 | """ 108 | 109 | base_url = r"/server/(\d+)/metrics/backend_type/([a-z0-9%]+)/io/overview/" 110 | params = ["server", "backend_type"] 111 | title = 'IO for "%(backend_type)s" backend type' 112 | 113 | ds_graph = ByBackendTypeIoGraph 114 | ds_grid = ByBackendTypeIoGrid 115 | datasources = [ds_graph, ds_grid] 116 | 117 | 118 | class ByObjIoGraph(TemplateIoGraph): 119 | """ 120 | Metric group used by per object graph. 121 | """ 122 | 123 | name = "obj_io_graph" 124 | data_url = r"/server/(\d+)/metrics/obj_graph/([a-z0-9%]+)/io/" 125 | 126 | query_qual = "object = %(obj)s" 127 | 128 | 129 | class ByObjIoGrid(TemplateIoGrid): 130 | """ 131 | Metric group used by per object grid. 132 | """ 133 | 134 | xaxis = "obj" 135 | name = "obj_io_grid" 136 | data_url = r"/server/(\d+)/metrics/obj_grid/([a-z0-9%]+)/io/" 137 | 138 | query_qual = "object = %(obj)s" 139 | 140 | 141 | class ByObjIoOverview(TemplateIoOverview): 142 | """ 143 | Per object Dashboard page. 144 | """ 145 | 146 | base_url = r"/server/(\d+)/metrics/obj/([a-z0-9%]+)/io/overview/" 147 | params = ["server", "obj"] 148 | title = 'IO for "%(obj)s" object' 149 | 150 | ds_graph = ByObjIoGraph 151 | ds_grid = ByObjIoGrid 152 | datasources = [ds_graph, ds_grid] 153 | 154 | 155 | class ByContextIoGraph(TemplateIoGraph): 156 | """ 157 | Metric group used by per context graph. 158 | """ 159 | 160 | name = "context_io_graph" 161 | data_url = r"/server/(\d+)/metrics/context_graph/([a-z0-9%]+)/io/" 162 | 163 | query_qual = "context = %(context)s" 164 | 165 | 166 | class ByContextIoGrid(TemplateIoGrid): 167 | """ 168 | Metric group used by per context grid. 169 | """ 170 | 171 | xaxis = "context" 172 | name = "context_io_grid" 173 | data_url = r"/server/(\d+)/metrics/context_grid/([a-z0-9%]+)/io/" 174 | 175 | query_qual = "context = %(context)s" 176 | 177 | 178 | class ByContextIoOverview(TemplateIoOverview): 179 | """ 180 | Per context Dashboard page. 181 | """ 182 | 183 | base_url = r"/server/(\d+)/metrics/context/([a-z0-9%]+)/io/overview/" 184 | params = ["server", "context"] 185 | title = 'IO for "%(context)s" context' 186 | 187 | ds_graph = ByContextIoGraph 188 | ds_grid = ByContextIoGrid 189 | datasources = [ds_graph, ds_grid] 190 | -------------------------------------------------------------------------------- /powa/io_template.py: -------------------------------------------------------------------------------- 1 | """ 2 | Datasource template used for the various IO pages. 3 | """ 4 | 5 | from powa.dashboards import MetricDef, MetricGroupDef 6 | from powa.sql.utils import sum_per_sec 7 | from powa.sql.views_graph import powa_get_io_sample 8 | from powa.sql.views_grid import powa_getiodata 9 | 10 | 11 | class TemplateIoGraph(MetricGroupDef): 12 | """ 13 | Template metric group for IO graph. 14 | """ 15 | 16 | xaxis = "ts" 17 | query_qual = None 18 | 19 | reads = MetricDef( 20 | label="Reads", type="sizerate", desc="Amount of data read per second" 21 | ) 22 | read_time = MetricDef( 23 | label="Read time", 24 | type="duration", 25 | desc="Total time spend reading data per second", 26 | ) 27 | writes = MetricDef( 28 | label="Write", 29 | type="sizerate", 30 | desc="Amount of data written per second", 31 | ) 32 | write_time = MetricDef( 33 | label="Write time", 34 | type="duration", 35 | desc="Total time spend writing data per second", 36 | ) 37 | writebacks = MetricDef( 38 | label="Writebacks", 39 | type="sizerate", 40 | desc="Amount of data writeback per second", 41 | ) 42 | writeback_time = MetricDef( 43 | label="Writeback time", 44 | type="duration", 45 | desc="Total time spend doing writeback per second", 46 | ) 47 | extends = MetricDef( 48 | label="Extends", 49 | type="sizerate", 50 | desc="Amount of data extended per second", 51 | ) 52 | extend_time = MetricDef( 53 | label="Extend time", 54 | type="duration", 55 | desc="Total time spend extending relations per second", 56 | ) 57 | hits = MetricDef( 58 | label="Hits", 59 | type="sizerate", 60 | desc="Amount of data found in shared_buffers per second", 61 | ) 62 | evictions = MetricDef( 63 | label="Eviction", 64 | type="sizerate", 65 | desc="Amount of data evicted from shared_buffers per second", 66 | ) 67 | reuses = MetricDef( 68 | label="Reuses", 69 | type="sizerate", 70 | desc="Amount of data reused in shared_buffers per second", 71 | ) 72 | fsyncs = MetricDef( 73 | label="Fsyncs", type="sizerate", desc="Blocks flushed per second" 74 | ) 75 | fsync_time = MetricDef( 76 | label="Fsync time", 77 | type="duration", 78 | desc="Total time spend flushing block per second", 79 | ) 80 | 81 | @classmethod 82 | def _get_metrics(cls, handler, **params): 83 | base = cls.metrics.copy() 84 | 85 | pg_version_num = handler.get_pg_version_num(handler.path_args[0]) 86 | # if we can't connect to the remote server, assume pg15 or less 87 | if pg_version_num is None or pg_version_num < 160000: 88 | return {} 89 | return base 90 | 91 | @property 92 | def query(self): 93 | query = powa_get_io_sample(self.query_qual) 94 | 95 | from_clause = query 96 | 97 | cols = [ 98 | "sub.srvid", 99 | "extract(epoch FROM sub.ts) AS ts", 100 | sum_per_sec("reads"), 101 | sum_per_sec("read_time"), 102 | sum_per_sec("writes"), 103 | sum_per_sec("write_time"), 104 | sum_per_sec("writebacks"), 105 | sum_per_sec("writeback_time"), 106 | sum_per_sec("extends"), 107 | sum_per_sec("extend_time"), 108 | sum_per_sec("hits"), 109 | sum_per_sec("evictions"), 110 | sum_per_sec("reuses"), 111 | sum_per_sec("fsyncs"), 112 | sum_per_sec("fsync_time"), 113 | ] 114 | 115 | return """SELECT {cols} 116 | FROM ( 117 | {from_clause} 118 | ) AS sub 119 | WHERE sub.mesure_interval != '0 s' 120 | GROUP BY sub.srvid, sub.ts, sub.mesure_interval 121 | ORDER BY sub.ts""".format( 122 | cols=", ".join(cols), 123 | from_clause=from_clause, 124 | ) 125 | 126 | 127 | class TemplateIoGrid(MetricGroupDef): 128 | """ 129 | Template metric group for IO grid. 130 | """ 131 | 132 | axis_type = "category" 133 | query_qual = None 134 | 135 | reads = MetricDef( 136 | label="Reads", type="size", desc="Total amount of data read" 137 | ) 138 | read_time = MetricDef( 139 | label="Read time", 140 | type="duration", 141 | desc="Total amount of time reading data", 142 | ) 143 | writes = MetricDef( 144 | label="Writes", type="size", desc="Total amount of data write" 145 | ) 146 | write_time = MetricDef( 147 | label="Write time", 148 | type="duration", 149 | desc="Total amount of time writing data", 150 | ) 151 | writebacks = MetricDef( 152 | label="Writebacks", type="size", desc="Total amount of data writeback" 153 | ) 154 | writeback_time = MetricDef( 155 | label="Writeback time", 156 | type="duration", 157 | desc="Total amount of time doing data writeback", 158 | ) 159 | extends = MetricDef( 160 | label="Extends", type="size", desc="Total amount of data extension" 161 | ) 162 | extend_time = MetricDef( 163 | label="Extend time", 164 | type="duration", 165 | desc="Total amount of time extending data", 166 | ) 167 | hits = MetricDef( 168 | label="Hits", type="size", desc="Total amount of data hit" 169 | ) 170 | evictions = MetricDef( 171 | label="Evictions", type="size", desc="Total amount of data evicted" 172 | ) 173 | reuses = MetricDef( 174 | label="Reuses", type="size", desc="Total amount of data reused" 175 | ) 176 | fsyncs = MetricDef( 177 | label="Fsyncs", type="size", desc="Total amount of data flushed" 178 | ) 179 | fsync_time = MetricDef( 180 | label="Fsync time", 181 | type="duration", 182 | desc="Total amount of time flushing data", 183 | ) 184 | 185 | @property 186 | def query(self): 187 | query = powa_getiodata(self.query_qual) 188 | 189 | return query 190 | 191 | def process(self, val, **kwargs): 192 | val["backend_type_url"] = self.reverse_url( 193 | "ByBackendTypeIoOverview", val["srvid"], val["backend_type"] 194 | ) 195 | 196 | val["obj_url"] = self.reverse_url( 197 | "ByObjIoOverview", val["srvid"], val["object"] 198 | ) 199 | 200 | val["context_url"] = self.reverse_url( 201 | "ByContextIoOverview", val["srvid"], val["context"] 202 | ) 203 | 204 | return val 205 | -------------------------------------------------------------------------------- /powa/json.py: -------------------------------------------------------------------------------- 1 | from __future__ import absolute_import 2 | 3 | from datetime import datetime 4 | from decimal import Decimal 5 | from json import JSONEncoder as BaseJSONEncoder 6 | 7 | 8 | class JSONEncoder(BaseJSONEncoder): 9 | """ 10 | JSONEncoder used throughout the application. 11 | Handle Decimal, datetime and JSONizable objects. 12 | """ 13 | 14 | def default(self, obj): 15 | if isinstance(obj, Decimal): 16 | return float(obj) 17 | if isinstance(obj, datetime): 18 | return obj.strftime("%Y-%m-%d %H:%M:%S%z") 19 | if isinstance(obj, JSONizable): 20 | return obj.to_json() 21 | return BaseJSONEncoder.default(self, obj) 22 | 23 | 24 | class JSONizable(object): 25 | """ 26 | Base class for an object which is serializable to JSON. 27 | """ 28 | 29 | def to_json(self): 30 | """ 31 | Serialize the object to JSON 32 | 33 | Returns: 34 | an object which can be encoded by the BaseJSONEncoder. 35 | """ 36 | return dict( 37 | ( 38 | (key, val) 39 | for key, val in self.__dict__.items() 40 | if not key.startswith("_") 41 | ) 42 | ) 43 | 44 | 45 | def to_json(object): 46 | """ 47 | Shortcut for encoding something to JSON. 48 | 49 | Arguments: 50 | object (object): the object to serialize to JSON 51 | Returns: 52 | a json-encoded representation of the object. 53 | """ 54 | return JSONEncoder().encode(object) 55 | -------------------------------------------------------------------------------- /powa/options.py: -------------------------------------------------------------------------------- 1 | import os 2 | import sys 3 | from tornado.options import ( 4 | Error, 5 | define, 6 | options, 7 | parse_command_line, 8 | parse_config_file, 9 | ) 10 | 11 | SAMPLE_CONFIG_FILE = """ 12 | servers={ 13 | 'main': { 14 | 'host': 'localhost', 15 | 'port': '5432', 16 | 'database': 'powa' 17 | } 18 | } 19 | cookie_secret="SUPERSECRET_THAT_YOU_SHOULD_CHANGE" 20 | """ 21 | 22 | CONF_LOCATIONS = [ 23 | "/etc/powa-web.conf", 24 | os.path.expanduser("~/.config/powa-web.conf"), 25 | os.path.expanduser("~/.powa-web.conf"), 26 | "./powa-web.conf", 27 | ] 28 | 29 | 30 | define("cookie_secret", type=str, help="Secret key for cookies") 31 | define( 32 | "cookie_expires_days", 33 | type=int, 34 | default=30, 35 | help="Cookie retention in days", 36 | ) 37 | define("port", type=int, default=8888, metavar="port", help="Listen on ") 38 | define( 39 | "address", 40 | type=str, 41 | default="0.0.0.0", 42 | metavar="address", 43 | help="Listen on
", 44 | ) 45 | define("config", type=str, help="path to config file") 46 | define("url_prefix", type=str, help="optional prefix URL", default="/") 47 | define( 48 | "allow_ui_connection", 49 | type=bool, 50 | help="Allow UI to connect to databases", 51 | default=True, 52 | ) 53 | define("certfile", type=str, help="Path to certificate file", default=None) 54 | define("keyfile", type=str, help="Path to key file", default=None) 55 | 56 | 57 | def parse_file(filepath): 58 | try: 59 | parse_config_file(filepath) 60 | except IOError: 61 | pass 62 | except Error as e: 63 | print("Error parsing config file %s:" % filepath) 64 | print("\t%s" % e) 65 | sys.exit(-1) 66 | 67 | 68 | def parse_options(): 69 | define("servers", type=dict, help="Not available from the command line.") 70 | for possible_config in CONF_LOCATIONS: 71 | parse_file(possible_config) 72 | try: 73 | parse_command_line() 74 | if options.config: 75 | parse_file(options.config) 76 | except Error as e: 77 | print("Error parsing command line options:") 78 | print("\t%s" % e) 79 | sys.exit(1) 80 | 81 | for key in ("servers", "cookie_secret"): 82 | if getattr(options, key, None) is None: 83 | print( 84 | "You should define a server and cookie_secret in your " 85 | "configuration file." 86 | ) 87 | print( 88 | "Place and adapt the following content in one of those " 89 | "locations:" 90 | "" 91 | ) 92 | print("\n\t".join([""] + CONF_LOCATIONS)) 93 | print(SAMPLE_CONFIG_FILE) 94 | sys.exit(-1) 95 | 96 | if getattr(options, "url_prefix", "") == "": 97 | setattr(options, "url_prefix", "/") 98 | elif getattr(options, "url_prefix", "/") != "/": 99 | prefix = getattr(options, "url_prefix", "/") 100 | if prefix[0] != "/": 101 | prefix = "/" + prefix 102 | if prefix[-1] != "/": 103 | prefix = prefix + "/" 104 | setattr(options, "url_prefix", prefix) 105 | define( 106 | "index_url", 107 | type=str, 108 | default="%sserver/" % getattr(options, "url_prefix", "/"), 109 | ) 110 | 111 | # we used to expect a field named "username", so accept "username" as an 112 | # alias for "user" 113 | for key, conf in getattr(options, "servers", {}).items(): 114 | if "username" in conf.keys(): 115 | conf["user"] = conf["username"] 116 | del conf["username"] 117 | -------------------------------------------------------------------------------- /powa/overview.py: -------------------------------------------------------------------------------- 1 | """ 2 | Index page presenting the list of available servers. 3 | """ 4 | 5 | from powa.dashboards import ( 6 | Dashboard, 7 | DashboardPage, 8 | Grid, 9 | MetricDef, 10 | MetricGroupDef, 11 | ) 12 | 13 | 14 | class OverviewMetricGroup(MetricGroupDef): 15 | """ 16 | Metric group used by the "all servers" grid 17 | """ 18 | 19 | name = "all_servers" 20 | xaxis = "srvid" 21 | axis_type = "category" 22 | data_url = r"/server/all_servers/" 23 | hostname = MetricDef(label="Hostname", type="text") 24 | port = MetricDef(label="Port", type="text") 25 | version = MetricDef(label="Version", type="text") 26 | 27 | @property 28 | def query(self): 29 | sql = """SELECT id AS srvid, 30 | CASE WHEN id = 0 THEN 31 | '' 32 | ELSE 33 | COALESCE(alias, hostname || ':' || port) 34 | END AS alias, 35 | CASE WHEN id = 0 THEN %(host)s ELSE hostname END as hostname, 36 | CASE WHEN id = 0 THEN %(port)s ELSE port END AS port, 37 | CASE WHEN id = 0 THEN set.setting 38 | ELSE s.version::text 39 | END AS version 40 | FROM {powa}.powa_servers s 41 | LEFT JOIN pg_settings set ON set.name = 'server_version' 42 | AND s.id = 0""" 43 | 44 | return (sql, {"host": self.current_host, "port": self.current_port}) 45 | 46 | def process(self, val, **kwargs): 47 | val["url"] = self.reverse_url("ServerOverview", val["srvid"]) 48 | return val 49 | 50 | 51 | class Overview(DashboardPage): 52 | """ 53 | Overview dashboard page. 54 | """ 55 | 56 | base_url = r"/server/" 57 | datasources = [OverviewMetricGroup] 58 | title = "All servers" 59 | 60 | def dashboard(self): 61 | # This COULD be initialized in the constructor, but tornado < 3 doesn't 62 | # call it 63 | if getattr(self, "_dashboard", None) is not None: 64 | return self._dashboard 65 | 66 | dashes = [ 67 | [ 68 | Grid( 69 | "All servers", 70 | columns=[ 71 | { 72 | "name": "alias", 73 | "label": "Instance", 74 | "url_attr": "url", 75 | "direction": "descending", 76 | } 77 | ], 78 | metrics=OverviewMetricGroup.all(), 79 | ) 80 | ] 81 | ] 82 | 83 | self._dashboard = Dashboard("All servers", dashes) 84 | return self._dashboard 85 | 86 | @classmethod 87 | def get_childmenu(cls, handler, params): 88 | from powa.server import ServerOverview 89 | 90 | children = [] 91 | for s in list(handler.servers): 92 | new_params = params.copy() 93 | new_params["server"] = s[0] 94 | entry = ServerOverview.get_selfmenu(handler, new_params) 95 | entry.title = s[2] 96 | children.append(entry) 97 | return children 98 | -------------------------------------------------------------------------------- /powa/powa.wsgi: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | import sys 3 | import tornado 4 | from powa import make_app 5 | 6 | if tornado.version > "4": 7 | from tornado.wsgi import WSGIAdapter 8 | 9 | application = make_app(debug=False, gzip=True, compress_response=True) 10 | application = WSGIAdapter(application) 11 | else: 12 | # Wrap sys.stderr because certain versions of mod_wsgi don't 13 | # implement isatty for the log file, and certain versions 14 | # of tornado don't check for the existence of isatty 15 | if not hasattr(sys.stderr, "isatty"): 16 | 17 | class StdErrWrapper(object): 18 | def __init__(self, wrapped): 19 | super(StdErrWrapper, self).__setattr__("wrapped", wrapped) 20 | 21 | def isatty(self): 22 | if hasattr(self.wrapped, "isatty"): 23 | return self.wrapped.isatty 24 | return False 25 | 26 | def __getattr__(self, att): 27 | return getattr(self.wrapped, att) 28 | 29 | def __setattr__(self, att, value): 30 | return setattr(self.wrapped, att, value) 31 | 32 | sys.stderr = StdErrWrapper(sys.stderr) 33 | application = make_app( 34 | debug=False, gzip=True, compress_response=True, legacy_wsgi=True 35 | ) 36 | -------------------------------------------------------------------------------- /powa/qual.py: -------------------------------------------------------------------------------- 1 | """ 2 | Dashboard for the qual page 3 | """ 4 | 5 | from powa.dashboards import ( 6 | ContentWidget, 7 | Dashboard, 8 | DashboardPage, 9 | Grid, 10 | MetricDef, 11 | MetricGroupDef, 12 | ) 13 | from powa.query import QueryOverview 14 | from powa.sql import qual_constants, resolve_quals 15 | from powa.sql.views import qualstat_getstatdata 16 | from tornado.web import HTTPError 17 | 18 | 19 | class QualConstantsMetricGroup(MetricGroupDef): 20 | """ 21 | Metric group used for the qual charts. 22 | """ 23 | 24 | name = "QualConstants" 25 | data_url = r"/server/(\d+)/metrics/database/([^\/]+)/query/(-?\d+)/qual/(\d+)/constants" 26 | xaxis = "rownumber" 27 | occurences = MetricDef(label="Occurences") 28 | grouper = "constants" 29 | 30 | @property 31 | def query(self): 32 | most_used = qual_constants( 33 | "%(server)s", 34 | "most_used", 35 | """ 36 | datname = %(database)s AND 37 | coalesce_range && tstzrange(%(from)s, %(to)s)""", 38 | "%(query)s", 39 | "%(qual)s", 40 | top=10, 41 | ) 42 | 43 | correlated = qualstat_getstatdata( 44 | extra_where=["qualid = %(qual)s", "queryid = %(query)s"] 45 | ) 46 | sql = """SELECT sub.*, correlated.occurences as total_occurences 47 | FROM ( 48 | SELECT * 49 | FROM ( 50 | {most_used} 51 | ) AS most_used 52 | ) AS sub, ( 53 | {correlated} 54 | ) AS correlated 55 | """.format(most_used=most_used, correlated=correlated) 56 | 57 | return sql 58 | 59 | def add_params(self, params): 60 | params["queryids"] = [int(params["query"])] 61 | return params 62 | 63 | def post_process(self, data, server, database, query, qual, **kwargs): 64 | if not data["data"]: 65 | return data 66 | max_rownumber = 0 67 | total_top10 = 0 68 | total = None 69 | d = {"total_occurences": 0} 70 | for d in data["data"]: 71 | max_rownumber = max(max_rownumber, d["rownumber"]) 72 | total_top10 += d["occurences"] 73 | else: 74 | total = d["total_occurences"] 75 | data["data"].append( 76 | { 77 | "occurences": total - total_top10, 78 | "rownumber": max_rownumber + 1, 79 | "constants": "Others", 80 | } 81 | ) 82 | return data 83 | 84 | 85 | class QualDetail(ContentWidget): 86 | """ 87 | Content widget showing detail for a specific qual. 88 | """ 89 | 90 | title = "Detail for this Qual" 91 | data_url = ( 92 | r"/server/(\d+)/database/([^\/]+)/query/(-?\d+)/qual/(\d+)/detail" 93 | ) 94 | 95 | def get(self, server, database, query, qual): 96 | try: 97 | # Check remote access first 98 | remote_conn = self.connect( 99 | server, database=database, remote_access=True 100 | ) 101 | except Exception as e: 102 | raise HTTPError( 103 | 501, "Could not connect to remote server: %s" % str(e) 104 | ) 105 | stmt = qualstat_getstatdata( 106 | extra_select=["queryid = %(query)s AS is_my_query"], 107 | extra_where=["qualid = %(qualid)s", "occurences > 0"], 108 | extra_groupby=["queryid"], 109 | ) 110 | quals = list( 111 | self.execute( 112 | stmt, 113 | params={ 114 | "server": server, 115 | "query": query, 116 | "from": self.get_argument("from"), 117 | "to": self.get_argument("to"), 118 | "queryids": [query], 119 | "qualid": qual, 120 | }, 121 | ) 122 | ) 123 | 124 | my_qual = None 125 | 126 | for qual in quals: 127 | if qual["is_my_query"]: 128 | my_qual = resolve_quals(remote_conn, [qual])[0] 129 | 130 | if my_qual is None: 131 | self.render_json(None) 132 | return 133 | 134 | self.render_json(my_qual) 135 | 136 | 137 | class OtherQueriesMetricGroup(MetricGroupDef): 138 | """Metric group showing other queries for this qual.""" 139 | 140 | name = "other_queries" 141 | xaxis = "queryid" 142 | axis_type = "category" 143 | data_url = r"/server/(\d+)/metrics/database/([^\/]+)/query/(-?\d+)/qual/(\d+)/other_queries" 144 | query_str = MetricDef(label="Query", type="query", url_attr="url") 145 | 146 | @property 147 | def query(self): 148 | return """ 149 | SELECT distinct queryid, query, 150 | query as query_str, pd.srvid 151 | FROM {powa}.powa_qualstats_quals pqs 152 | JOIN {powa}.powa_statements USING (queryid, dbid, srvid, userid) 153 | JOIN {powa}.powa_databases pd ON pd.oid = pqs.dbid AND pd.srvid = 154 | pqs.srvid 155 | WHERE qualid = %(qual)s 156 | AND pqs.queryid != %(query)s 157 | AND pd.srvid = %(server)s 158 | AND pd.datname = %(database)s""" 159 | 160 | def process(self, val, database=None, **kwargs): 161 | val["url"] = self.reverse_url( 162 | "QueryOverview", val["srvid"], database, val["queryid"] 163 | ) 164 | return val 165 | 166 | 167 | class QualOverview(DashboardPage): 168 | """ 169 | Dashboard page for a specific qual. 170 | """ 171 | 172 | base_url = r"/server/(\d+)/database/([^\/]+)/query/(-?\d+)/qual/(\d+)" 173 | params = ["server", "database", "query", "qual"] 174 | datasources = [ 175 | QualDetail, 176 | OtherQueriesMetricGroup, 177 | QualConstantsMetricGroup, 178 | ] 179 | parent = QueryOverview 180 | title = "Predicate Overview" 181 | 182 | def dashboard(self): 183 | # This COULD be initialized in the constructor, but tornado < 3 doesn't 184 | # call it 185 | if getattr(self, "_dashboard", None) is not None: 186 | return self._dashboard 187 | 188 | self._dashboard = Dashboard( 189 | "Qual %(qual)s", 190 | [ 191 | [QualDetail], 192 | [ 193 | Grid( 194 | "Other queries", 195 | metrics=OtherQueriesMetricGroup.all(), 196 | columns=[], 197 | ) 198 | ], 199 | [ 200 | Grid( 201 | "Most executed values", 202 | metrics=[QualConstantsMetricGroup.occurences], 203 | x_label_attr="constants", 204 | renderer="distribution", 205 | ) 206 | ], 207 | ], 208 | ) 209 | 210 | return self._dashboard 211 | -------------------------------------------------------------------------------- /powa/slru.py: -------------------------------------------------------------------------------- 1 | """ 2 | Dashboards for the SLRU page. 3 | """ 4 | 5 | from powa.config import ConfigChangesGlobal 6 | from powa.dashboards import ( 7 | Dashboard, 8 | DashboardPage, 9 | Graph, 10 | Grid, 11 | MetricDef, 12 | MetricGroupDef, 13 | ) 14 | from powa.server import ByAllSlruMetricGroup, ServerOverview 15 | from powa.sql.utils import sum_per_sec 16 | from powa.sql.views_graph import powa_get_slru_sample 17 | 18 | 19 | class NameSlruMetricGroup(MetricGroupDef): 20 | """ 21 | Metric group used by pg_stat_slru graph. 22 | """ 23 | 24 | name = "slru_name" 25 | xaxis = "name" 26 | data_url = r"/server/(\d+)/metrics/slru/([a-zA-Z]+)" 27 | blks_zeroed = MetricDef( 28 | label="Zeroed", 29 | type="sizerate", 30 | desc="Number of blocks zeroed during initializations", 31 | ) 32 | blks_hit = MetricDef( 33 | label="Hit", 34 | type="sizerate", 35 | desc="Number of times disk blocks were found already" 36 | " in the SLRU, so that a read was not necessary" 37 | " (this only includes hits in the SLRU, not the" 38 | " operating system's file system cache)", 39 | ) 40 | blks_read = MetricDef( 41 | label="Read", 42 | type="sizerate", 43 | desc="Number of disk blocks read for this SLRU", 44 | ) 45 | blks_written = MetricDef( 46 | label="Written", 47 | type="sizerate", 48 | desc="Number of disk blocks written for this SLRU", 49 | ) 50 | blks_exists = MetricDef( 51 | label="Exists", 52 | type="sizerate", 53 | desc="Number of blocks checked for existence for this SLRU", 54 | ) 55 | flushes = MetricDef( 56 | label="Flushes", 57 | type="number", 58 | desc="Number of flushes of dirty data for this SLRU", 59 | ) 60 | truncates = MetricDef( 61 | label="Truncates", 62 | type="number", 63 | desc="Number of truncates for this SLRU", 64 | ) 65 | 66 | @classmethod 67 | def _get_metrics(cls, handler, **params): 68 | base = cls.metrics.copy() 69 | 70 | pg_version_num = handler.get_pg_version_num(handler.path_args[0]) 71 | # if we can't connect to the remote server, assume pg13 or more 72 | if pg_version_num is not None and pg_version_num < 130000: 73 | return {} 74 | return base 75 | 76 | @property 77 | def query(self): 78 | query = powa_get_slru_sample("name = %(slru)s") 79 | 80 | from_clause = query 81 | 82 | cols = [ 83 | "sub.srvid", 84 | "extract(epoch FROM sub.ts) AS ts", 85 | sum_per_sec("blks_zeroed"), 86 | sum_per_sec("blks_hit"), 87 | sum_per_sec("blks_read"), 88 | sum_per_sec("blks_written"), 89 | sum_per_sec("blks_exists"), 90 | sum_per_sec("flushes"), 91 | sum_per_sec("truncates"), 92 | ] 93 | 94 | return """SELECT {cols} 95 | FROM ( 96 | {from_clause} 97 | ) AS sub 98 | WHERE sub.mesure_interval != '0 s' 99 | GROUP BY sub.srvid, sub.ts, sub.mesure_interval 100 | ORDER BY sub.ts""".format( 101 | cols=", ".join(cols), 102 | from_clause=from_clause, 103 | ) 104 | 105 | 106 | class ByAllSlruMetricGroup2(ByAllSlruMetricGroup): 107 | """ 108 | Metric group used by pg_stat_slru grid. 109 | 110 | There isn't a lot of SLRU, so we want to display all lines even in the 111 | per-SLRU view so users can easily navigate to another one. Neither the 112 | backend nor the frontend can handle a metric group used in different 113 | dashboard, so duplicate it with the bare minimum different information to 114 | make it work. It's not a big problem since this is a different page, so we 115 | won't end up running the same query twice. 116 | """ 117 | 118 | name = "slru_by_name2" 119 | data_url = r"/server/(\d+)/metrics/slru_by_name2/([a-zA-Z]+)" 120 | 121 | 122 | class ByNameSlruOverview(DashboardPage): 123 | """ 124 | Per SLRU Dashboard page. 125 | """ 126 | 127 | base_url = r"/server/(\d+)/metrics/slru/([a-zA-Z]+)/overview/" 128 | datasources = [NameSlruMetricGroup, ByAllSlruMetricGroup2] 129 | params = ["server", "slru"] 130 | parent = ServerOverview 131 | title = 'Activity for "%(slru)s" SLRU' 132 | timeline = ConfigChangesGlobal 133 | timeline_params = ["server"] 134 | 135 | def dashboard(self): 136 | # This COULD be initialized in the constructor, but tornado < 3 doesn't 137 | # call it 138 | if getattr(self, "_dashboard", None) is not None: 139 | return self._dashboard 140 | 141 | graphs = [ 142 | [ 143 | Graph( 144 | "%(slru)s SLRU (per second)", 145 | metrics=NameSlruMetricGroup.all(self), 146 | ) 147 | ], 148 | [ 149 | Grid( 150 | "All SLRUs", 151 | columns=[ 152 | { 153 | "name": "name", 154 | "label": "SLRU name", 155 | "url_attr": "url", 156 | } 157 | ], 158 | metrics=ByAllSlruMetricGroup2.all(self), 159 | ) 160 | ], 161 | ] 162 | 163 | self._dashboard = Dashboard(self.title, graphs) 164 | return self._dashboard 165 | -------------------------------------------------------------------------------- /powa/sql/utils.py: -------------------------------------------------------------------------------- 1 | block_size = ( 2 | "(SELECT cast(current_setting('block_size') AS numeric)" 3 | " AS block_size) AS bs" 4 | ) 5 | 6 | 7 | def mulblock(col, alias=None, fn=None): 8 | alias = alias or col 9 | 10 | if fn is None: 11 | sql = "{col}" 12 | else: 13 | sql = "{fn}({col})" 14 | 15 | sql += " * block_size AS {alias}" 16 | 17 | return sql.format(col=col, alias=alias, fn=fn) 18 | 19 | 20 | def total_measure_interval(col): 21 | sql = ( 22 | "extract(epoch FROM " 23 | + " CASE WHEN min({col}) = '0 second' THEN '1 second'" 24 | + " ELSE min({col})" 25 | + "END)" 26 | ) 27 | 28 | return sql.format(col=col) 29 | 30 | 31 | def diff(var, alias=None): 32 | alias = alias or var 33 | return "max({var}) - min({var}) AS {alias}".format(var=var, alias=alias) 34 | 35 | 36 | def diffblk(var, blksize=8192, alias=None): 37 | alias = alias or var 38 | return "(max({var}) - min({var})) * {blksize} AS {alias}".format( 39 | var=var, blksize=blksize, alias=alias 40 | ) 41 | 42 | 43 | def get_ts(): 44 | return "extract(epoch FROM greatest(mesure_interval, '1 second'))" 45 | 46 | 47 | def sum_per_sec(col, prefix=None, alias=None): 48 | alias = alias or col 49 | if prefix is not None: 50 | prefix = prefix + "." 51 | else: 52 | prefix = "" 53 | 54 | return "sum({prefix}{col}) / {ts} AS {alias}".format( 55 | prefix=prefix, col=col, ts=get_ts(), alias=alias 56 | ) 57 | 58 | 59 | def byte_per_sec(col, prefix=None, alias=None): 60 | alias = alias or col 61 | if prefix is not None: 62 | prefix = prefix + "." 63 | else: 64 | prefix = "" 65 | 66 | return "sum({prefix}{col}) * block_size / {ts} AS {alias}".format( 67 | prefix=prefix, col=col, ts=get_ts(), alias=alias 68 | ) 69 | 70 | 71 | def wps(col, do_sum=True): 72 | field = "sub." + col 73 | if do_sum: 74 | field = "sum(" + field + ")" 75 | 76 | return "({field} / {ts}) AS {col}".format( 77 | field=field, col=col, ts=get_ts() 78 | ) 79 | 80 | 81 | def to_epoch(col, prefix=None): 82 | if prefix is not None: 83 | qn = "{prefix}.{col}".format(prefix=prefix, col=col) 84 | else: 85 | qn = col 86 | 87 | return "extract(epoch FROM {qn}) AS {col}".format(qn=qn, col=col) 88 | 89 | 90 | def total_read(prefix, noalias=False): 91 | if noalias: 92 | alias = "" 93 | else: 94 | alias = " AS total_blks_read" 95 | 96 | sql = ( 97 | "sum({prefix}.shared_blks_hit" 98 | + "+ {prefix}.local_blks_read" 99 | + "+ {prefix}.temp_blks_read" 100 | ") * block_size / {total_measure_interval}{alias}" 101 | ) 102 | 103 | return sql.format( 104 | prefix=prefix, 105 | total_measure_interval=total_measure_interval("mesure_interval"), 106 | alias=alias, 107 | ) 108 | 109 | 110 | def total_hit(c): 111 | return ( 112 | "sum(shared_blks_hit + local_blks_hit) * block_size /" 113 | + total_measure_interval("mesure_interval") 114 | + " AS total_blks_hit" 115 | ) 116 | -------------------------------------------------------------------------------- /powa/sql/views.py: -------------------------------------------------------------------------------- 1 | """ 2 | Functions to generate the queries used in components that are neither graphs or 3 | grid. 4 | """ 5 | 6 | QUALSTAT_FILTER_RATIO = """CASE 7 | WHEN sum(execution_count) = 0 THEN 0 8 | ELSE sum(nbfiltered) / sum(execution_count)::numeric * 100 9 | END""" 10 | 11 | 12 | def qualstat_base_statdata(eval_type=None): 13 | if eval_type is not None: 14 | base_cols = ["srvid", "qualid", "queryid", "dbid", "userid"] 15 | 16 | pqnh = """( 17 | SELECT {outer_cols} 18 | FROM ( 19 | SELECT {inner_cols} 20 | FROM {{powa}}.powa_qualstats_quals 21 | ) expanded 22 | WHERE (qual).eval_type = '{eval_type}' 23 | GROUP BY {base_cols} 24 | )""".format( 25 | outer_cols=", ".join(base_cols + ["array_agg(qual) AS quals"]), 26 | inner_cols=", ".join(base_cols + ["unnest(quals) AS qual"]), 27 | base_cols=", ".join(base_cols), 28 | eval_type=eval_type, 29 | ) 30 | else: 31 | pqnh = "{powa}.powa_qualstats_quals" 32 | 33 | base_query = """ 34 | ( 35 | SELECT srvid, qualid, queryid, dbid, userid, (unnested.records).* 36 | FROM ( 37 | SELECT pqnh.srvid, pqnh.qualid, pqnh.queryid, pqnh.dbid, pqnh.userid, 38 | pqnh.coalesce_range, unnest(records) AS records 39 | FROM {{powa}}.powa_qualstats_quals_history pqnh 40 | WHERE coalesce_range && tstzrange(%(from)s, %(to)s, '[]') 41 | AND pqnh.srvid = %(server)s 42 | ) AS unnested 43 | WHERE (records).ts <@ tstzrange(%(from)s, %(to)s, '[]') 44 | UNION ALL 45 | SELECT pqnc.srvid, qualid, queryid, dbid, userid, pqnc.ts, pqnc.occurences, 46 | pqnc.execution_count, pqnc.nbfiltered, 47 | pqnc.mean_err_estimate_ratio, pqnc.mean_err_estimate_num 48 | FROM {{powa}}.powa_qualstats_quals_history_current pqnc 49 | WHERE pqnc.ts <@ tstzrange(%(from)s, %(to)s, '[]') 50 | AND pqnc.srvid = %(server)s 51 | ) h 52 | JOIN {pqnh} AS pqnh USING (srvid, queryid, qualid)""".format(pqnh=pqnh) 53 | 54 | return base_query 55 | 56 | 57 | def qualstat_getstatdata( 58 | eval_type=None, 59 | extra_from="", 60 | extra_join="", 61 | extra_select=[], 62 | extra_where=[], 63 | extra_groupby=[], 64 | extra_having=[], 65 | ): 66 | base_query = qualstat_base_statdata(eval_type) 67 | 68 | # Reformat extra_select, extra_where, extra_groupby and extra_having to be 69 | # plain additional SQL clauses. 70 | if len(extra_select) > 0: 71 | extra_select = ", " + ", ".join(extra_select) 72 | else: 73 | extra_select = "" 74 | 75 | if len(extra_where) > 0: 76 | extra_where = " AND " + " AND ".join(extra_where) 77 | else: 78 | extra_where = "" 79 | 80 | if len(extra_groupby) > 0: 81 | extra_groupby = ", " + ", ".join(extra_groupby) 82 | else: 83 | extra_groupby = "" 84 | 85 | if len(extra_having) > 0: 86 | extra_having = " HAVING " + " AND ".join(extra_having) 87 | else: 88 | extra_having = "" 89 | 90 | return """SELECT 91 | ps.srvid, qualid, ps.queryid, query, ps.dbid, 92 | to_json(quals) AS quals, 93 | sum(execution_count) AS execution_count, 94 | sum(occurences) AS occurences, 95 | (sum(nbfiltered) / sum(occurences)) AS avg_filter, 96 | {filter_ratio} AS filter_ratio 97 | {extra_select} 98 | FROM 99 | {base_query} 100 | JOIN {{powa}}.powa_statements ps USING(queryid, srvid) 101 | {extra_join} 102 | WHERE h.srvid = %(server)s 103 | {extra_where} 104 | GROUP BY ps.srvid, qualid, ps.queryid, ps.dbid, ps.query, quals 105 | {extra_groupby} 106 | {extra_having}""".format( 107 | filter_ratio=QUALSTAT_FILTER_RATIO, 108 | extra_select=extra_select, 109 | base_query=base_query, 110 | extra_join=extra_join, 111 | extra_where=extra_where, 112 | extra_groupby=extra_groupby, 113 | extra_having=extra_having, 114 | ) 115 | 116 | 117 | TEXTUAL_INDEX_QUERY = """ 118 | SELECT 'CREATE INDEX idx_' || q.relid || '_' || array_to_string(attnames, '_') 119 | || ' ON ' || nspname || '.' || q.relid 120 | || ' USING ' || idxtype || ' (' || array_to_string(attnames, ', ') || ')' 121 | AS index_ddl 122 | FROM (SELECT t.nspname, 123 | t.relid, 124 | t.attnames, 125 | unnest(t.possible_types) AS idxtype 126 | FROM ( 127 | SELECT nl.nspname AS nspname, 128 | qs.relid::regclass AS relid, 129 | array_agg(DISTINCT attnames.attnames) AS attnames, 130 | array_agg(DISTINCT pg_am.amname) AS possible_types, 131 | array_agg(DISTINCT attnum.attnum) AS attnums 132 | FROM ( 133 | VALUES (:relid, (:attnums)::smallint[], (:indexam)) 134 | ) as qs(relid, attnums, indexam) 135 | LEFT JOIN ( 136 | pg_class cl 137 | JOIN pg_namespace nl ON nl.oid = cl.relnamespace 138 | ) ON cl.oid = qs.relid 139 | JOIN pg_am ON pg_am.amname = qs.indexam 140 | AND pg_am.amname <> 'hash', 141 | LATERAL ( 142 | SELECT pg_attribute.attname AS attnames 143 | FROM pg_attribute 144 | JOIN unnest(qs.attnums) a(a) ON a.a = pg_attribute.attnum 145 | AND pg_attribute.attrelid = qs.relid 146 | ORDER BY pg_attribute.attnum 147 | ) attnames, 148 | LATERAL unnest(qs.attnums) attnum(attnum) 149 | WHERE NOT (EXISTS ( 150 | SELECT 1 151 | FROM pg_index i 152 | WHERE i.indrelid = qs.relid AND ( 153 | (i.indkey::smallint[])[0:array_length(qs.attnums, 1) - 1] 154 | @> qs.attnums 155 | OR qs.attnums 156 | @> (i.indkey::smallint[])[0:array_length(i.indkey, 1) + 1] 157 | AND i.indisunique)) 158 | ) 159 | GROUP BY nl.nspname, qs.relid 160 | ) t 161 | GROUP BY t.nspname, t.relid, t.attnames, t.possible_types 162 | ) q 163 | """ 164 | 165 | 166 | def get_config_changes(restrict_database=False): 167 | restrict_db = "" 168 | if restrict_database: 169 | restrict_db = "AND (d.datname = %(database)s OR h.setdatabase = 0)" 170 | 171 | sql = """SELECT * FROM 172 | ( 173 | WITH src AS ( 174 | select ts, name, 175 | lag(setting_pretty) OVER (PARTITION BY name ORDER BY ts) AS prev_val, 176 | setting_pretty AS new_val, 177 | lag(is_dropped) OVER (PARTITION BY name ORDER BY ts) AS prev_is_dropped, 178 | is_dropped as is_dropped 179 | FROM {{pg_track_settings}}.pg_track_settings_history h 180 | WHERE srvid = %(server)s 181 | AND ts <= %(to)s 182 | ) 183 | SELECT extract("epoch" FROM ts) AS ts, 'global' AS kind, 184 | json_build_object( 185 | 'name', name, 186 | 'prev_val', prev_val, 187 | 'new_val', new_val, 188 | 'prev_is_dropped', coalesce(prev_is_dropped, true), 189 | 'is_dropped', is_dropped 190 | ) AS data 191 | FROM src 192 | WHERE ts >= %(from)s AND ts <= %(to)s 193 | ) AS global 194 | 195 | UNION ALL 196 | 197 | SELECT * FROM 198 | ( 199 | WITH src AS ( 200 | select ts, name, 201 | lag(setting) OVER (PARTITION BY name, setdatabase, setrole ORDER BY ts) AS prev_val, 202 | setting AS new_val, 203 | lag(is_dropped) OVER (PARTITION BY name, setdatabase, setrole ORDER BY ts) AS prev_is_dropped, 204 | is_dropped as is_dropped, 205 | d.datname, 206 | h.setrole 207 | FROM {{pg_track_settings}}.pg_track_db_role_settings_history h 208 | LEFT JOIN {{powa}}.powa_databases d 209 | ON d.srvid = h.srvid 210 | AND d.oid = h.setdatabase 211 | WHERE h.srvid = %(server)s 212 | {restrict_db} 213 | AND ts <= %(to)s 214 | ) 215 | SELECT extract("epoch" FROM ts) AS ts, 'rds' AS kind, 216 | json_build_object( 217 | 'name', name, 218 | 'prev_val', prev_val, 219 | 'new_val', new_val, 220 | 'prev_is_dropped', coalesce(prev_is_dropped, true), 221 | 'is_dropped', is_dropped, 222 | 'datname', datname, 223 | 'setrole', setrole 224 | ) AS data 225 | FROM src 226 | WHERE ts >= %(from)s AND ts <= %(to)s 227 | ) AS rds 228 | 229 | UNION ALL 230 | 231 | SELECT extract("epoch" FROM ts) AS ts, 'reboot' AS kind, 232 | NULL AS data 233 | FROM {{pg_track_settings}}.pg_reboot AS r 234 | WHERE r.srvid = %(server)s 235 | AND r.ts>= %(from)s 236 | AND r.ts <= %(to)s 237 | ORDER BY ts""".format(restrict_db=restrict_db) 238 | 239 | return sql 240 | -------------------------------------------------------------------------------- /powa/static/dist/.vite/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "_d3-GY-Jf1db.js": { 3 | "file": "assets/d3-GY-Jf1db.js", 4 | "name": "d3" 5 | }, 6 | "_highlight-CRyVn9mj.js": { 7 | "file": "assets/highlight-CRyVn9mj.js", 8 | "name": "highlight", 9 | "imports": [ 10 | "_lodash-CmFMvF3r.js" 11 | ] 12 | }, 13 | "_lodash-CmFMvF3r.js": { 14 | "file": "assets/lodash-CmFMvF3r.js", 15 | "name": "lodash" 16 | }, 17 | "_luxon-lqzArHOP.js": { 18 | "file": "assets/luxon-lqzArHOP.js", 19 | "name": "luxon" 20 | }, 21 | "_moment-C5S46NFB.js": { 22 | "file": "assets/moment-C5S46NFB.js", 23 | "name": "moment" 24 | }, 25 | "_sqltools-formatter-M1RRnKbr.js": { 26 | "file": "assets/sqltools-formatter-M1RRnKbr.js", 27 | "name": "sqltools-formatter", 28 | "imports": [ 29 | "_lodash-CmFMvF3r.js" 30 | ] 31 | }, 32 | "_vue-SrZHs5Ax.js": { 33 | "file": "assets/vue-SrZHs5Ax.js", 34 | "name": "vue" 35 | }, 36 | "_vuetify-Bf0BnRvR.js": { 37 | "file": "assets/vuetify-Bf0BnRvR.js", 38 | "name": "vuetify", 39 | "imports": [ 40 | "_vue-SrZHs5Ax.js" 41 | ] 42 | }, 43 | "powa/static/js/main.js": { 44 | "file": "assets/main-BQBMx7EO.js", 45 | "name": "main", 46 | "src": "powa/static/js/main.js", 47 | "isEntry": true, 48 | "imports": [ 49 | "_vue-SrZHs5Ax.js", 50 | "_vuetify-Bf0BnRvR.js", 51 | "_lodash-CmFMvF3r.js", 52 | "_moment-C5S46NFB.js", 53 | "_d3-GY-Jf1db.js", 54 | "_luxon-lqzArHOP.js", 55 | "_highlight-CRyVn9mj.js", 56 | "_sqltools-formatter-M1RRnKbr.js" 57 | ], 58 | "css": [ 59 | "assets/main-OiFLL3TG.css" 60 | ] 61 | } 62 | } -------------------------------------------------------------------------------- /powa/static/img/favicon/apple-touch-icon-114x114.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/powa-team/powa-web/7dfa07142a2c524752d34a02230fa0f106dcfb8d/powa/static/img/favicon/apple-touch-icon-114x114.png -------------------------------------------------------------------------------- /powa/static/img/favicon/apple-touch-icon-120x120.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/powa-team/powa-web/7dfa07142a2c524752d34a02230fa0f106dcfb8d/powa/static/img/favicon/apple-touch-icon-120x120.png -------------------------------------------------------------------------------- /powa/static/img/favicon/apple-touch-icon-144x144.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/powa-team/powa-web/7dfa07142a2c524752d34a02230fa0f106dcfb8d/powa/static/img/favicon/apple-touch-icon-144x144.png -------------------------------------------------------------------------------- /powa/static/img/favicon/apple-touch-icon-152x152.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/powa-team/powa-web/7dfa07142a2c524752d34a02230fa0f106dcfb8d/powa/static/img/favicon/apple-touch-icon-152x152.png -------------------------------------------------------------------------------- /powa/static/img/favicon/apple-touch-icon-57x57.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/powa-team/powa-web/7dfa07142a2c524752d34a02230fa0f106dcfb8d/powa/static/img/favicon/apple-touch-icon-57x57.png -------------------------------------------------------------------------------- /powa/static/img/favicon/apple-touch-icon-60x60.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/powa-team/powa-web/7dfa07142a2c524752d34a02230fa0f106dcfb8d/powa/static/img/favicon/apple-touch-icon-60x60.png -------------------------------------------------------------------------------- /powa/static/img/favicon/apple-touch-icon-72x72.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/powa-team/powa-web/7dfa07142a2c524752d34a02230fa0f106dcfb8d/powa/static/img/favicon/apple-touch-icon-72x72.png -------------------------------------------------------------------------------- /powa/static/img/favicon/apple-touch-icon-76x76.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/powa-team/powa-web/7dfa07142a2c524752d34a02230fa0f106dcfb8d/powa/static/img/favicon/apple-touch-icon-76x76.png -------------------------------------------------------------------------------- /powa/static/img/favicon/apple-touch-icon-precomposed.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/powa-team/powa-web/7dfa07142a2c524752d34a02230fa0f106dcfb8d/powa/static/img/favicon/apple-touch-icon-precomposed.png -------------------------------------------------------------------------------- /powa/static/img/favicon/apple-touch-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/powa-team/powa-web/7dfa07142a2c524752d34a02230fa0f106dcfb8d/powa/static/img/favicon/apple-touch-icon.png -------------------------------------------------------------------------------- /powa/static/img/favicon/browserconfig.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | #2b5797 10 | 11 | 12 | 13 | -------------------------------------------------------------------------------- /powa/static/img/favicon/favicon-160x160.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/powa-team/powa-web/7dfa07142a2c524752d34a02230fa0f106dcfb8d/powa/static/img/favicon/favicon-160x160.png -------------------------------------------------------------------------------- /powa/static/img/favicon/favicon-16x16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/powa-team/powa-web/7dfa07142a2c524752d34a02230fa0f106dcfb8d/powa/static/img/favicon/favicon-16x16.png -------------------------------------------------------------------------------- /powa/static/img/favicon/favicon-196x196.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/powa-team/powa-web/7dfa07142a2c524752d34a02230fa0f106dcfb8d/powa/static/img/favicon/favicon-196x196.png -------------------------------------------------------------------------------- /powa/static/img/favicon/favicon-32x32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/powa-team/powa-web/7dfa07142a2c524752d34a02230fa0f106dcfb8d/powa/static/img/favicon/favicon-32x32.png -------------------------------------------------------------------------------- /powa/static/img/favicon/favicon-96x96.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/powa-team/powa-web/7dfa07142a2c524752d34a02230fa0f106dcfb8d/powa/static/img/favicon/favicon-96x96.png -------------------------------------------------------------------------------- /powa/static/img/favicon/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/powa-team/powa-web/7dfa07142a2c524752d34a02230fa0f106dcfb8d/powa/static/img/favicon/favicon.ico -------------------------------------------------------------------------------- /powa/static/img/favicon/mstile-144x144.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/powa-team/powa-web/7dfa07142a2c524752d34a02230fa0f106dcfb8d/powa/static/img/favicon/mstile-144x144.png -------------------------------------------------------------------------------- /powa/static/img/favicon/mstile-150x150.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/powa-team/powa-web/7dfa07142a2c524752d34a02230fa0f106dcfb8d/powa/static/img/favicon/mstile-150x150.png -------------------------------------------------------------------------------- /powa/static/img/favicon/mstile-310x150.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/powa-team/powa-web/7dfa07142a2c524752d34a02230fa0f106dcfb8d/powa/static/img/favicon/mstile-310x150.png -------------------------------------------------------------------------------- /powa/static/img/favicon/mstile-310x310.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/powa-team/powa-web/7dfa07142a2c524752d34a02230fa0f106dcfb8d/powa/static/img/favicon/mstile-310x310.png -------------------------------------------------------------------------------- /powa/static/img/favicon/mstile-70x70.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/powa-team/powa-web/7dfa07142a2c524752d34a02230fa0f106dcfb8d/powa/static/img/favicon/mstile-70x70.png -------------------------------------------------------------------------------- /powa/static/img/powa-logo-white.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/powa-team/powa-web/7dfa07142a2c524752d34a02230fa0f106dcfb8d/powa/static/img/powa-logo-white.png -------------------------------------------------------------------------------- /powa/static/js/App.vue: -------------------------------------------------------------------------------- 1 | 153 | 154 | 311 | -------------------------------------------------------------------------------- /powa/static/js/components/BreadCrumbs.vue: -------------------------------------------------------------------------------- 1 | 29 | 30 | 52 | -------------------------------------------------------------------------------- /powa/static/js/components/DateRangePicker/DateRangePicker.vue: -------------------------------------------------------------------------------- 1 | 142 | 143 | 275 | -------------------------------------------------------------------------------- /powa/static/js/components/DateRangePicker/options.ts: -------------------------------------------------------------------------------- 1 | import { TimeOption } from "@grafana/data"; 2 | 3 | export const quickOptions: TimeOption[] = [ 4 | { from: "now-30m", to: "now", display: "Last 30 minutes" }, 5 | { from: "now-1h", to: "now", display: "Last 1 hour" }, 6 | { from: "now-3h", to: "now", display: "Last 3 hours" }, 7 | { from: "now-6h", to: "now", display: "Last 6 hours" }, 8 | { from: "now-12h", to: "now", display: "Last 12 hours" }, 9 | { from: "now-24h", to: "now", display: "Last 24 hours" }, 10 | { from: "now-2d", to: "now", display: "Last 2 days" }, 11 | { from: "now-7d", to: "now", display: "Last 7 days" }, 12 | { from: "now-30d", to: "now", display: "Last 30 days" }, 13 | { from: "now-90d", to: "now", display: "Last 90 days" }, 14 | { from: "now-6M", to: "now", display: "Last 6 months" }, 15 | { from: "now-1y", to: "now", display: "Last 1 year" }, 16 | { from: "now-2y", to: "now", display: "Last 2 years" }, 17 | { from: "now-5y", to: "now", display: "Last 5 years" }, 18 | { from: "now-1d/d", to: "now-1d/d", display: "Yesterday" }, 19 | { from: "now-2d/d", to: "now-2d/d", display: "Day before yesterday" }, 20 | { from: "now-7d/d", to: "now-7d/d", display: "This day last week" }, 21 | { from: "now-1w/w", to: "now-1w/w", display: "Previous week" }, 22 | { from: "now-1M/M", to: "now-1M/M", display: "Previous month" }, 23 | { from: "now-1Q/fQ", to: "now-1Q/fQ", display: "Previous fiscal quarter" }, 24 | { from: "now-1y/y", to: "now-1y/y", display: "Previous year" }, 25 | { from: "now-1y/fy", to: "now-1y/fy", display: "Previous fiscal year" }, 26 | { from: "now/d", to: "now/d", display: "Today" }, 27 | { from: "now/d", to: "now", display: "Today so far" }, 28 | { from: "now/w", to: "now/w", display: "This week" }, 29 | { from: "now/w", to: "now", display: "This week so far" }, 30 | { from: "now/M", to: "now/M", display: "This month" }, 31 | { from: "now/M", to: "now", display: "This month so far" }, 32 | { from: "now/y", to: "now/y", display: "This year" }, 33 | { from: "now/y", to: "now", display: "This year so far" }, 34 | ]; 35 | -------------------------------------------------------------------------------- /powa/static/js/components/GridCell.vue: -------------------------------------------------------------------------------- 1 | 8 | 23 | -------------------------------------------------------------------------------- /powa/static/js/components/LoginView.vue: -------------------------------------------------------------------------------- 1 | 34 | 35 | 49 | -------------------------------------------------------------------------------- /powa/static/js/components/QueryTooltip.vue: -------------------------------------------------------------------------------- 1 | 17 | 18 | 30 | -------------------------------------------------------------------------------- /powa/static/js/components/dynamic/Dashboard.vue: -------------------------------------------------------------------------------- 1 | 13 | 14 | 25 | -------------------------------------------------------------------------------- /powa/static/js/components/dynamic/DistributionGrid.vue: -------------------------------------------------------------------------------- 1 | 39 | 40 | 85 | -------------------------------------------------------------------------------- /powa/static/js/components/dynamic/Grid.vue: -------------------------------------------------------------------------------- 1 | 74 | 75 | 243 | 244 | 252 | -------------------------------------------------------------------------------- /powa/static/js/components/dynamic/Tabcontainer.vue: -------------------------------------------------------------------------------- 1 | 35 | 36 | 51 | -------------------------------------------------------------------------------- /powa/static/js/components/dynamic/config/AllCollectorsDetail.vue: -------------------------------------------------------------------------------- 1 | 76 | 77 | 91 | -------------------------------------------------------------------------------- /powa/static/js/components/dynamic/config/ServersErrors.vue: -------------------------------------------------------------------------------- 1 | 30 | 31 | 45 | -------------------------------------------------------------------------------- /powa/static/js/components/dynamic/database/FunctionDetail.vue: -------------------------------------------------------------------------------- 1 | 52 | 53 | 69 | -------------------------------------------------------------------------------- /powa/static/js/components/dynamic/database/WizardThisDatabase.vue: -------------------------------------------------------------------------------- 1 | 23 | 24 | 38 | -------------------------------------------------------------------------------- /powa/static/js/components/dynamic/database/query/QualDetail.vue: -------------------------------------------------------------------------------- 1 | 57 | 58 | 73 | -------------------------------------------------------------------------------- /powa/static/js/components/dynamic/database/query/QueryDetail.vue: -------------------------------------------------------------------------------- 1 | 46 | 47 | 63 | -------------------------------------------------------------------------------- /powa/static/js/components/dynamic/database/query/QueryExplains.vue: -------------------------------------------------------------------------------- 1 | 37 | 38 | 54 | -------------------------------------------------------------------------------- /powa/static/js/components/dynamic/database/query/QueryIndexes.vue: -------------------------------------------------------------------------------- 1 | 98 | 99 | 114 | -------------------------------------------------------------------------------- /powa/static/js/composables/DataLoaderService.js: -------------------------------------------------------------------------------- 1 | import { computed } from "vue"; 2 | import { storeToRefs } from "pinia"; 3 | import { useDashboardStore } from "@/stores/dashboard.js"; 4 | 5 | export function useDataLoader(metric) { 6 | const { dataSources } = storeToRefs(useDashboardStore()); 7 | const source = computed(() => dataSources.value[metric]); 8 | const loading = computed(() => source.value.isFetching); 9 | const data = computed(() => { 10 | if (!source.value.executed) { 11 | source.value.execute(); 12 | } 13 | return source.value.data; 14 | }); 15 | 16 | return { data, loading, source }; 17 | } 18 | -------------------------------------------------------------------------------- /powa/static/js/composables/MessageService.js: -------------------------------------------------------------------------------- 1 | import { nextTick, reactive } from "vue"; 2 | let messageId = 1; 3 | 4 | let alertMessages = reactive([]); 5 | 6 | function addAlertMessage(level, message) { 7 | const colors = { 8 | alert: "error", 9 | error: "error", 10 | warning: "warning", 11 | info: "info", 12 | success: "success", 13 | }; 14 | let newId = ++messageId; 15 | alertMessages.push({ 16 | id: newId, 17 | color: colors[level], 18 | message: message, 19 | shown: false, 20 | }); 21 | nextTick( 22 | () => 23 | (alertMessages[ 24 | alertMessages.findIndex((n) => n.id == newId) 25 | ].shown = true) 26 | ); 27 | } 28 | 29 | function addAlertMessages(messages) { 30 | for (let level in messages) { 31 | for (let message of messages[level]) { 32 | addAlertMessage(level, message); 33 | } 34 | } 35 | } 36 | function removeAlertMessage(id) { 37 | // Remove message from list with delay to make sure transition is finished 38 | window.setTimeout(() => { 39 | alertMessages.splice( 40 | alertMessages.findIndex((n) => n.id == id), 41 | 1 42 | ); 43 | }, 500); 44 | } 45 | 46 | export function useMessageService() { 47 | return { 48 | alertMessages, 49 | addAlertMessage, 50 | addAlertMessages, 51 | removeAlertMessage, 52 | }; 53 | } 54 | -------------------------------------------------------------------------------- /powa/static/js/fonts/Roboto/KFOkCnqEu92Fr1MmgVxGIzIFKw.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/powa-team/powa-web/7dfa07142a2c524752d34a02230fa0f106dcfb8d/powa/static/js/fonts/Roboto/KFOkCnqEu92Fr1MmgVxGIzIFKw.woff2 -------------------------------------------------------------------------------- /powa/static/js/fonts/Roboto/KFOkCnqEu92Fr1MmgVxIIzI.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/powa-team/powa-web/7dfa07142a2c524752d34a02230fa0f106dcfb8d/powa/static/js/fonts/Roboto/KFOkCnqEu92Fr1MmgVxIIzI.woff2 -------------------------------------------------------------------------------- /powa/static/js/fonts/Roboto/KFOlCnqEu92Fr1MmEU9fBBc4.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/powa-team/powa-web/7dfa07142a2c524752d34a02230fa0f106dcfb8d/powa/static/js/fonts/Roboto/KFOlCnqEu92Fr1MmEU9fBBc4.woff2 -------------------------------------------------------------------------------- /powa/static/js/fonts/Roboto/KFOlCnqEu92Fr1MmEU9fChc4EsA.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/powa-team/powa-web/7dfa07142a2c524752d34a02230fa0f106dcfb8d/powa/static/js/fonts/Roboto/KFOlCnqEu92Fr1MmEU9fChc4EsA.woff2 -------------------------------------------------------------------------------- /powa/static/js/fonts/Roboto/KFOlCnqEu92Fr1MmSU5fBBc4.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/powa-team/powa-web/7dfa07142a2c524752d34a02230fa0f106dcfb8d/powa/static/js/fonts/Roboto/KFOlCnqEu92Fr1MmSU5fBBc4.woff2 -------------------------------------------------------------------------------- /powa/static/js/fonts/Roboto/KFOlCnqEu92Fr1MmSU5fChc4EsA.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/powa-team/powa-web/7dfa07142a2c524752d34a02230fa0f106dcfb8d/powa/static/js/fonts/Roboto/KFOlCnqEu92Fr1MmSU5fChc4EsA.woff2 -------------------------------------------------------------------------------- /powa/static/js/fonts/Roboto/KFOlCnqEu92Fr1MmWUlfBBc4.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/powa-team/powa-web/7dfa07142a2c524752d34a02230fa0f106dcfb8d/powa/static/js/fonts/Roboto/KFOlCnqEu92Fr1MmWUlfBBc4.woff2 -------------------------------------------------------------------------------- /powa/static/js/fonts/Roboto/KFOlCnqEu92Fr1MmWUlfChc4EsA.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/powa-team/powa-web/7dfa07142a2c524752d34a02230fa0f106dcfb8d/powa/static/js/fonts/Roboto/KFOlCnqEu92Fr1MmWUlfChc4EsA.woff2 -------------------------------------------------------------------------------- /powa/static/js/fonts/Roboto/KFOlCnqEu92Fr1MmYUtfBBc4.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/powa-team/powa-web/7dfa07142a2c524752d34a02230fa0f106dcfb8d/powa/static/js/fonts/Roboto/KFOlCnqEu92Fr1MmYUtfBBc4.woff2 -------------------------------------------------------------------------------- /powa/static/js/fonts/Roboto/KFOlCnqEu92Fr1MmYUtfChc4EsA.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/powa-team/powa-web/7dfa07142a2c524752d34a02230fa0f106dcfb8d/powa/static/js/fonts/Roboto/KFOlCnqEu92Fr1MmYUtfChc4EsA.woff2 -------------------------------------------------------------------------------- /powa/static/js/fonts/Roboto/KFOmCnqEu92Fr1Mu4mxK.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/powa-team/powa-web/7dfa07142a2c524752d34a02230fa0f106dcfb8d/powa/static/js/fonts/Roboto/KFOmCnqEu92Fr1Mu4mxK.woff2 -------------------------------------------------------------------------------- /powa/static/js/fonts/Roboto/KFOmCnqEu92Fr1Mu7GxKOzY.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/powa-team/powa-web/7dfa07142a2c524752d34a02230fa0f106dcfb8d/powa/static/js/fonts/Roboto/KFOmCnqEu92Fr1Mu7GxKOzY.woff2 -------------------------------------------------------------------------------- /powa/static/js/fonts/Roboto/roboto.css: -------------------------------------------------------------------------------- 1 | /* latin-ext */ 2 | @font-face { 3 | font-family: "Roboto"; 4 | font-style: normal; 5 | font-weight: 100; 6 | src: url(https://fonts.gstatic.com/s/roboto/v30/KFOkCnqEu92Fr1MmgVxGIzIFKw.woff2) 7 | format("woff2"); 8 | unicode-range: U+0100-02AF, U+0304, U+0308, U+0329, U+1E00-1E9F, U+1EF2-1EFF, 9 | U+2020, U+20A0-20AB, U+20AD-20CF, U+2113, U+2C60-2C7F, U+A720-A7FF; 10 | } 11 | /* latin */ 12 | @font-face { 13 | font-family: "Roboto"; 14 | font-style: normal; 15 | font-weight: 100; 16 | src: url(https://fonts.gstatic.com/s/roboto/v30/KFOkCnqEu92Fr1MmgVxIIzI.woff2) 17 | format("woff2"); 18 | unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, 19 | U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, 20 | U+2193, U+2212, U+2215, U+FEFF, U+FFFD; 21 | } 22 | /* latin-ext */ 23 | @font-face { 24 | font-family: "Roboto"; 25 | font-style: normal; 26 | font-weight: 300; 27 | src: url(https://fonts.gstatic.com/s/roboto/v30/KFOlCnqEu92Fr1MmSU5fChc4EsA.woff2) 28 | format("woff2"); 29 | unicode-range: U+0100-02AF, U+0304, U+0308, U+0329, U+1E00-1E9F, U+1EF2-1EFF, 30 | U+2020, U+20A0-20AB, U+20AD-20CF, U+2113, U+2C60-2C7F, U+A720-A7FF; 31 | } 32 | /* latin */ 33 | @font-face { 34 | font-family: "Roboto"; 35 | font-style: normal; 36 | font-weight: 300; 37 | src: url(https://fonts.gstatic.com/s/roboto/v30/KFOlCnqEu92Fr1MmSU5fBBc4.woff2) 38 | format("woff2"); 39 | unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, 40 | U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, 41 | U+2193, U+2212, U+2215, U+FEFF, U+FFFD; 42 | } 43 | 44 | /* latin-ext */ 45 | @font-face { 46 | font-family: "Roboto"; 47 | font-style: normal; 48 | font-weight: 400; 49 | src: url(https://fonts.gstatic.com/s/roboto/v30/KFOmCnqEu92Fr1Mu7GxKOzY.woff2) 50 | format("woff2"); 51 | unicode-range: U+0100-02AF, U+0304, U+0308, U+0329, U+1E00-1E9F, U+1EF2-1EFF, 52 | U+2020, U+20A0-20AB, U+20AD-20CF, U+2113, U+2C60-2C7F, U+A720-A7FF; 53 | } 54 | /* latin */ 55 | @font-face { 56 | font-family: "Roboto"; 57 | font-style: normal; 58 | font-weight: 400; 59 | src: url(https://fonts.gstatic.com/s/roboto/v30/KFOmCnqEu92Fr1Mu4mxK.woff2) 60 | format("woff2"); 61 | unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, 62 | U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, 63 | U+2193, U+2212, U+2215, U+FEFF, U+FFFD; 64 | } 65 | 66 | /* latin-ext */ 67 | @font-face { 68 | font-family: "Roboto"; 69 | font-style: normal; 70 | font-weight: 500; 71 | src: url(https://fonts.gstatic.com/s/roboto/v30/KFOlCnqEu92Fr1MmEU9fChc4EsA.woff2) 72 | format("woff2"); 73 | unicode-range: U+0100-02AF, U+0304, U+0308, U+0329, U+1E00-1E9F, U+1EF2-1EFF, 74 | U+2020, U+20A0-20AB, U+20AD-20CF, U+2113, U+2C60-2C7F, U+A720-A7FF; 75 | } 76 | /* latin */ 77 | @font-face { 78 | font-family: "Roboto"; 79 | font-style: normal; 80 | font-weight: 500; 81 | src: url(https://fonts.gstatic.com/s/roboto/v30/KFOlCnqEu92Fr1MmEU9fBBc4.woff2) 82 | format("woff2"); 83 | unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, 84 | U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, 85 | U+2193, U+2212, U+2215, U+FEFF, U+FFFD; 86 | } 87 | /* latin-ext */ 88 | @font-face { 89 | font-family: "Roboto"; 90 | font-style: normal; 91 | font-weight: 700; 92 | src: url(https://fonts.gstatic.com/s/roboto/v30/KFOlCnqEu92Fr1MmWUlfChc4EsA.woff2) 93 | format("woff2"); 94 | unicode-range: U+0100-02AF, U+0304, U+0308, U+0329, U+1E00-1E9F, U+1EF2-1EFF, 95 | U+2020, U+20A0-20AB, U+20AD-20CF, U+2113, U+2C60-2C7F, U+A720-A7FF; 96 | } 97 | /* latin */ 98 | @font-face { 99 | font-family: "Roboto"; 100 | font-style: normal; 101 | font-weight: 700; 102 | src: url(https://fonts.gstatic.com/s/roboto/v30/KFOlCnqEu92Fr1MmWUlfBBc4.woff2) 103 | format("woff2"); 104 | unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, 105 | U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, 106 | U+2193, U+2212, U+2215, U+FEFF, U+FFFD; 107 | } 108 | /* latin-ext */ 109 | @font-face { 110 | font-family: "Roboto"; 111 | font-style: normal; 112 | font-weight: 900; 113 | src: url(https://fonts.gstatic.com/s/roboto/v30/KFOlCnqEu92Fr1MmYUtfChc4EsA.woff2) 114 | format("woff2"); 115 | unicode-range: U+0100-02AF, U+0304, U+0308, U+0329, U+1E00-1E9F, U+1EF2-1EFF, 116 | U+2020, U+20A0-20AB, U+20AD-20CF, U+2113, U+2C60-2C7F, U+A720-A7FF; 117 | } 118 | /* latin */ 119 | @font-face { 120 | font-family: "Roboto"; 121 | font-style: normal; 122 | font-weight: 900; 123 | src: url(https://fonts.gstatic.com/s/roboto/v30/KFOlCnqEu92Fr1MmYUtfBBc4.woff2) 124 | format("woff2"); 125 | unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, 126 | U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, 127 | U+2193, U+2212, U+2215, U+FEFF, U+FFFD; 128 | } 129 | -------------------------------------------------------------------------------- /powa/static/js/main.js: -------------------------------------------------------------------------------- 1 | import { createApp } from "vue"; 2 | import vuetify from "@/plugins/vuetify"; 3 | import App from "@/App.vue"; 4 | import dynamicComponents from "@/plugins/powa"; 5 | import { createWebHistory, createRouter } from "vue-router"; 6 | import { useMessageService } from "@/composables/MessageService"; 7 | import { createPinia } from "pinia"; 8 | 9 | import "@/../styles/main.scss"; 10 | import "@/fonts/Roboto/roboto.css"; 11 | 12 | const { addAlertMessages } = useMessageService(); 13 | 14 | document 15 | .querySelectorAll('script[type="text/messages"]') 16 | .forEach(function (el) { 17 | const messages = JSON.parse(el.innerText); 18 | addAlertMessages(messages); 19 | }); 20 | 21 | const NotFound = { template: "" }; 22 | const routerPlugin = createRouter({ 23 | history: createWebHistory(), 24 | routes: [{ path: "/:pathMatch(.*)", name: "NotFound", component: NotFound }], 25 | }); 26 | 27 | createApp(App) 28 | .use(createPinia()) 29 | .use(vuetify) 30 | .use(routerPlugin) 31 | .use(dynamicComponents) 32 | .mount("#app"); 33 | -------------------------------------------------------------------------------- /powa/static/js/plugins/powa.js: -------------------------------------------------------------------------------- 1 | // inspired from https://zerotomastery.io/blog/how-to-auto-register-components-for-vue-with-vite/ 2 | import _ from "lodash"; 3 | 4 | export default { 5 | install(app) { 6 | const componentFiles = import.meta.glob("@/components/dynamic/**/*.vue", { 7 | eager: true, 8 | }); 9 | 10 | Object.entries(componentFiles).forEach(([path, m]) => { 11 | const componentName = _.upperFirst( 12 | _.camelCase( 13 | path 14 | .split("/") 15 | .pop() 16 | .replace(/\.\w+$/, "") 17 | ) 18 | ); 19 | 20 | app.component(`${componentName}`, m.default); 21 | }); 22 | }, 23 | }; 24 | -------------------------------------------------------------------------------- /powa/static/js/plugins/vuetify.js: -------------------------------------------------------------------------------- 1 | import "vuetify/styles"; 2 | import { createVuetify } from "vuetify"; 3 | import { aliases, mdi } from "vuetify/lib/iconsets/mdi-svg"; 4 | 5 | const opts = { 6 | icons: { 7 | defaultSet: "mdi", 8 | aliases, 9 | sets: { 10 | mdi, 11 | }, 12 | }, 13 | theme: { 14 | options: { 15 | customProperties: true, 16 | }, 17 | defaultTheme: "light", 18 | themes: { 19 | light: { 20 | dark: false, 21 | colors: { 22 | primary: "#859145", 23 | secondary: "#b0bec5", 24 | accent: "#8c9eff", 25 | error: "#b71c1c", 26 | surface: "#efefef", 27 | tickstroke: "#ffffff", 28 | axisgridlinestroke: "#d3d3d3", 29 | eventmarkerfill: "#000000", 30 | }, 31 | }, 32 | dark: { 33 | dark: true, 34 | colors: { 35 | "on-surface": "#ddd", 36 | primary: "#859145", 37 | mainbg: "#333333", 38 | tooltipbg: "#313131", 39 | tickstroke: "#333333", 40 | axisgridlinestroke: "#4f4f4f", 41 | eventmarkerfill: "#ffffff", 42 | }, 43 | }, 44 | }, 45 | }, 46 | }; 47 | 48 | export default new createVuetify(opts); 49 | -------------------------------------------------------------------------------- /powa/static/js/stores/dashboard.js: -------------------------------------------------------------------------------- 1 | import * as _ from "lodash"; 2 | import { reactive, ref, watch } from "vue"; 3 | import { defineStore, storeToRefs } from "pinia"; 4 | import { useRoute } from "vue-router"; 5 | import { useDateRangeStore } from "@/stores/dateRange.js"; 6 | import { useMessageService } from "@/composables/MessageService.js"; 7 | const { addAlertMessages } = useMessageService(); 8 | 9 | export const useDashboardStore = defineStore("dashboard", () => { 10 | const route = useRoute(); 11 | const dateRangeStore = useDateRangeStore(); 12 | const { urlSearchParams } = storeToRefs(dateRangeStore); 13 | const isFetching = ref(false); 14 | const dashboardConfig = ref(null); 15 | const handlerConfig = ref({ homeUrl: "" }); 16 | const breadcrumbs = ref([]); 17 | const dataSources = ref({}); 18 | const changesUrl = ref(null); 19 | const changes = ref(null); 20 | const changesFetching = ref(false); 21 | const cursorPosition = ref(null); 22 | let dashboardController; 23 | let changesController; 24 | 25 | function cleanUpDashboard() { 26 | // Force a clean up of all components for the page 27 | dashboardConfig.value = null; 28 | dashboardController && dashboardController.abort(); 29 | } 30 | 31 | function cleanUpDataSources() { 32 | _.each(dataSources.value, (source) => { 33 | source.controller && source.controller.abort(); 34 | }); 35 | } 36 | 37 | function cleanUpChanges() { 38 | changesController && changesController.abort(); 39 | changes.value = null; 40 | } 41 | 42 | function fetchDashboardConfig() { 43 | cleanUpDashboard(); 44 | cleanUpDataSources(); 45 | cleanUpChanges(); 46 | isFetching.value = true; 47 | dashboardController = new AbortController(); 48 | fetch(route.path, { 49 | signal: dashboardController.signal, 50 | headers: { 51 | "Content-type": "application/json", 52 | }, 53 | }) 54 | .then((res) => res.json()) 55 | .then(dashboardConfigFetched) 56 | .catch(() => {}); 57 | } 58 | 59 | function dashboardConfigFetched(config) { 60 | isFetching.value = false; 61 | dataSources.value = {}; 62 | handlerConfig.value = config.handler; 63 | breadcrumbs.value = config.breadcrumbs; 64 | changesUrl.value = config.timeline; 65 | 66 | config.datasources.forEach((config) => { 67 | try { 68 | if (config.type == "metric_group") { 69 | config.metrics = _.keyBy(config.metrics, "name"); 70 | } 71 | } catch (e) { 72 | console.error( 73 | "Could not instantiate metric group. Check the metric group definition" 74 | ); 75 | } 76 | 77 | function executeFn() { 78 | source.isFetching = true; 79 | source.controller = new AbortController(); 80 | fetch(`${source.config.data_url}?${urlSearchParams.value}`, { 81 | signal: source.controller.signal, 82 | }) 83 | .then((res) => res.json()) 84 | .then((json) => { 85 | source.data = json; 86 | addAlertMessages(json.messages); 87 | }) 88 | .catch((err) => (source.error = err)) 89 | .finally(() => { 90 | source.isFetching = false; 91 | }); 92 | source.executed = true; 93 | } 94 | 95 | const source = reactive({ 96 | config, 97 | isFetching: true, 98 | data: null, 99 | error: null, 100 | controller: null, 101 | executed: false, 102 | execute: executeFn, 103 | }); 104 | dataSources.value[config.name] = source; 105 | }); 106 | 107 | // Make sure from/to are up-to-date 108 | dateRangeStore.refresh(); 109 | dashboardConfig.value = config.dashboard; 110 | } 111 | 112 | function fetchDataSources() { 113 | cleanUpDataSources(); 114 | _.each(dataSources.value, (source) => { 115 | source.executed && source.execute(); 116 | }); 117 | } 118 | 119 | function fetchChanges() { 120 | cleanUpChanges(); 121 | if (!changesUrl.value) { 122 | return; 123 | } 124 | changesController = new AbortController(); 125 | changesFetching.value = true; 126 | fetch(`${changesUrl.value}?${urlSearchParams.value}`, { 127 | signal: changesController.signal, 128 | }) 129 | .then((res) => res.json()) 130 | .then((json) => { 131 | changesFetching.value = false; 132 | changes.value = json; 133 | }) 134 | .catch(() => {}) 135 | .finally(() => { 136 | changesFetching.value = false; 137 | }); 138 | } 139 | 140 | watch(() => route.path, fetchDashboardConfig); 141 | watch(() => [dataSources.value, urlSearchParams.value], fetchDataSources); 142 | watch(() => [changesUrl.value, urlSearchParams.value], fetchChanges); 143 | 144 | return { 145 | breadcrumbs, 146 | changes, 147 | changesFetching, 148 | dashboardConfig, 149 | dataSources, 150 | handlerConfig, 151 | isFetching, 152 | cursorPosition, 153 | }; 154 | }); 155 | -------------------------------------------------------------------------------- /powa/static/js/stores/dateRange.js: -------------------------------------------------------------------------------- 1 | import { computed, ref } from "vue"; 2 | import { defineStore } from "pinia"; 3 | import { useRoute } from "vue-router"; 4 | import dateMath from "@/utils/datemath"; 5 | 6 | export const useDateRangeStore = defineStore("daterange", () => { 7 | const route = useRoute(); 8 | const defaultFrom = "now-1h"; 9 | const defaultTo = "now"; 10 | const rawFrom = computed(() => route.query.from || defaultFrom); 11 | const rawTo = computed(() => route.query.to || defaultTo); 12 | const refreshCount = ref(0); 13 | 14 | const from = computed(() => { 15 | refreshCount.value; 16 | return dateMath.parse(rawFrom.value); 17 | }); 18 | const to = computed(() => { 19 | refreshCount.value; 20 | return dateMath.parse(rawTo.value); 21 | }); 22 | 23 | const urlSearchParams = computed(() => 24 | new URLSearchParams({ 25 | from: from.value.format("YYYY-MM-DD HH:mm:ssZZ"), 26 | to: to.value.format("YYYY-MM-DD HH:mm:ssZZ"), 27 | }).toString() 28 | ); 29 | 30 | function refresh() { 31 | // force refresh of computed properties 32 | refreshCount.value++; 33 | } 34 | 35 | function getUrl(url) { 36 | // Utility function to build links url 37 | const query = {}; 38 | if (rawFrom.value != defaultFrom || rawTo.value != defaultTo) { 39 | query.from = rawFrom.value; 40 | query.to = rawTo.value; 41 | } 42 | return { path: url, query }; 43 | } 44 | 45 | return { rawFrom, rawTo, from, to, refresh, getUrl, urlSearchParams }; 46 | }); 47 | -------------------------------------------------------------------------------- /powa/static/js/utils/datemath.js: -------------------------------------------------------------------------------- 1 | /* 2 | * This is a port of the excellent grafana's dateMath utils package. 3 | * From TypeScript to pure Javascript. 4 | * See https://github.com/grafana/grafana/blob/master/public/app/core/utils/datemath.ts 5 | * 6 | * Parses humanized date strings like: 'now-1d', 'now/d', 'now-7d/d'. 7 | * 8 | * Requires `moment` and `loadash`. 9 | */ 10 | import * as _ from "lodash"; 11 | import moment from "moment"; 12 | 13 | var units = ["y", "M", "w", "d", "h", "m", "s"]; 14 | function parse(text, roundUp, timezone) { 15 | if (!text) { 16 | return undefined; 17 | } 18 | if (moment.isMoment(text)) { 19 | return text; 20 | } 21 | if (_.isDate(text)) { 22 | return moment(text); 23 | } 24 | var time; 25 | var mathString = ""; 26 | var index; 27 | var parseString; 28 | if (text.substring(0, 3) === "now") { 29 | if (timezone === "utc") { 30 | time = moment.utc(); 31 | } else { 32 | time = moment(); 33 | } 34 | mathString = text.substring("now".length); 35 | } else { 36 | index = text.indexOf("||"); 37 | if (index === -1) { 38 | parseString = text; 39 | mathString = ""; // nothing else 40 | } else { 41 | parseString = text.substring(0, index); 42 | mathString = text.substring(index + 2); 43 | } 44 | // We're going to just require ISO8601 timestamps, k? 45 | time = moment(parseString, moment.ISO_8601); 46 | } 47 | if (!mathString.length) { 48 | return time; 49 | } 50 | return parseDateMath(mathString, time, roundUp); 51 | } 52 | function isValid(text) { 53 | var date = parse(text); 54 | if (!date) { 55 | return false; 56 | } 57 | if (moment.isMoment(date)) { 58 | return date.isValid(); 59 | } 60 | return false; 61 | } 62 | function parseDateMath(mathString, time, roundUp) { 63 | var dateTime = time; 64 | var i = 0; 65 | var len = mathString.length; 66 | while (i < len) { 67 | var c = mathString.charAt(i++); 68 | var type; 69 | var num; 70 | var unit; 71 | if (c === "/") { 72 | type = 0; 73 | } else if (c === "+") { 74 | type = 1; 75 | } else if (c === "-") { 76 | type = 2; 77 | } else { 78 | return undefined; 79 | } 80 | if (isNaN(mathString.charAt(i))) { 81 | num = 1; 82 | } else if (mathString.length === 2) { 83 | num = mathString.charAt(i); 84 | } else { 85 | var numFrom = i; 86 | while (!isNaN(mathString.charAt(i))) { 87 | i++; 88 | if (i > 10) { 89 | return undefined; 90 | } 91 | } 92 | num = parseInt(mathString.substring(numFrom, i), 10); 93 | } 94 | if (type === 0) { 95 | // rounding is only allowed on whole, single, units (eg M or 1M, not 0.5M or 2M) 96 | if (num !== 1) { 97 | return undefined; 98 | } 99 | } 100 | unit = mathString.charAt(i++); 101 | if (!_.includes(units, unit)) { 102 | return undefined; 103 | } else { 104 | if (type === 0) { 105 | if (roundUp) { 106 | dateTime.endOf(unit); 107 | } else { 108 | dateTime.startOf(unit); 109 | } 110 | } else if (type === 1) { 111 | dateTime.add(num, unit); 112 | } else if (type === 2) { 113 | dateTime.subtract(num, unit); 114 | } 115 | } 116 | } 117 | return dateTime; 118 | } 119 | 120 | export default { 121 | parse: parse, 122 | isValid: isValid, 123 | }; 124 | -------------------------------------------------------------------------------- /powa/static/js/utils/dates.js: -------------------------------------------------------------------------------- 1 | import { DateTime } from "luxon"; 2 | 3 | function toISO(date) { 4 | if (!(date instanceof DateTime)) { 5 | date = DateTime.fromJSDate(date); 6 | } 7 | return date.startOf("minute").toISO({ suppressSeconds: true }); 8 | } 9 | 10 | export { toISO }; 11 | -------------------------------------------------------------------------------- /powa/static/js/utils/duration.js: -------------------------------------------------------------------------------- 1 | import _ from "lodash"; 2 | const durationSecond = 1000; 3 | const durationMinute = durationSecond * 60; 4 | const durationHour = durationMinute * 60; 5 | const durationDay = durationHour * 24; 6 | 7 | function formatDuration(ms, rounded) { 8 | let results = []; 9 | const µs = Math.round((ms - Math.floor(ms)) * 1000); 10 | ms = Math.floor(ms); 11 | const d = Math.trunc(ms / durationDay); 12 | results.push(d + " d"); 13 | ms = ms - d * durationDay; 14 | const h = Math.trunc(ms / durationHour); 15 | results.push(h + " h"); 16 | ms = ms - h * durationHour; 17 | const m = Math.trunc(ms / durationMinute); 18 | results.push(m + " min"); 19 | ms = ms - m * durationMinute; 20 | const s = Math.trunc(ms / durationSecond); 21 | results.push(s + " s"); 22 | ms = ms - s * durationSecond; 23 | results.push(ms + " ms"); 24 | results.push(µs + " µs"); 25 | 26 | results = _.dropWhile(results, (o) => parseInt(o) == 0); 27 | 28 | if (rounded) { 29 | // Only keep the first or two firsts values 30 | const n = parseInt(results[0]) < 3 && parseInt(results[1]) !== 0 ? 2 : 1; 31 | results = results.slice(0, n); 32 | } 33 | return results.length ? results.join(" ") : "0 ms"; 34 | } 35 | 36 | export { formatDuration }; 37 | -------------------------------------------------------------------------------- /powa/static/js/utils/percentage.js: -------------------------------------------------------------------------------- 1 | import * as d3 from "d3"; 2 | 3 | function formatPercentage(value) { 4 | return d3.format(".2%")(value / 100); 5 | } 6 | 7 | export { formatPercentage }; 8 | -------------------------------------------------------------------------------- /powa/static/js/utils/rangeutil.js: -------------------------------------------------------------------------------- 1 | /* 2 | * This is a port of the excellent grafana's RangeUtils utils package. 3 | * From TypeScript to pure Javascript. 4 | * See https://github.com/grafana/grafana/blob/master/public/app/core/utils/rangeutils.ts 5 | */ 6 | import * as _ from "lodash"; 7 | import moment from "moment"; 8 | 9 | import dateMath from "./datemath.js"; 10 | 11 | var spans = { 12 | s: { display: "second" }, 13 | m: { display: "minute" }, 14 | h: { display: "hour" }, 15 | d: { display: "day" }, 16 | w: { display: "week" }, 17 | M: { display: "month" }, 18 | y: { display: "year" }, 19 | }; 20 | var rangeOptions = [ 21 | { from: "now/d", to: "now/d", display: "Today", section: 2 }, 22 | { from: "now/d", to: "now", display: "Today so far", section: 2 }, 23 | { from: "now/w", to: "now/w", display: "This week", section: 2 }, 24 | { from: "now/w", to: "now", display: "This week so far", section: 2 }, 25 | { from: "now/M", to: "now/M", display: "This month", section: 2 }, 26 | { from: "now/M", to: "now", display: "This month so far", section: 2 }, 27 | { from: "now/y", to: "now/y", display: "This year", section: 2 }, 28 | { from: "now/y", to: "now", display: "This year so far", section: 2 }, 29 | { from: "now-1d/d", to: "now-1d/d", display: "Yesterday", section: 1 }, 30 | { 31 | from: "now-2d/d", 32 | to: "now-2d/d", 33 | display: "Day before yesterday", 34 | section: 1, 35 | }, 36 | { 37 | from: "now-7d/d", 38 | to: "now-7d/d", 39 | display: "This day last week", 40 | section: 1, 41 | }, 42 | { from: "now-1w/w", to: "now-1w/w", display: "Previous week", section: 1 }, 43 | { from: "now-1M/M", to: "now-1M/M", display: "Previous month", section: 1 }, 44 | { from: "now-1y/y", to: "now-1y/y", display: "Previous year", section: 1 }, 45 | { from: "now-5m", to: "now", display: "Last 5 minutes", section: 3 }, 46 | { from: "now-15m", to: "now", display: "Last 15 minutes", section: 3 }, 47 | { from: "now-30m", to: "now", display: "Last 30 minutes", section: 3 }, 48 | { from: "now-1h", to: "now", display: "Last 1 hour", section: 3 }, 49 | { from: "now-3h", to: "now", display: "Last 3 hours", section: 3 }, 50 | { from: "now-6h", to: "now", display: "Last 6 hours", section: 3 }, 51 | { from: "now-12h", to: "now", display: "Last 12 hours", section: 3 }, 52 | { from: "now-24h", to: "now", display: "Last 24 hours", section: 3 }, 53 | { from: "now-2d", to: "now", display: "Last 2 days", section: 0 }, 54 | { from: "now-7d", to: "now", display: "Last 7 days", section: 0 }, 55 | { from: "now-30d", to: "now", display: "Last 30 days", section: 0 }, 56 | { from: "now-90d", to: "now", display: "Last 90 days", section: 0 }, 57 | { from: "now-6M", to: "now", display: "Last 6 months", section: 0 }, 58 | { from: "now-1y", to: "now", display: "Last 1 year", section: 0 }, 59 | { from: "now-2y", to: "now", display: "Last 2 years", section: 0 }, 60 | { from: "now-5y", to: "now", display: "Last 5 years", section: 0 }, 61 | ]; 62 | var absoluteFormat = "MMM D, YYYY HH:mm:ss"; 63 | var rangeIndex = {}; 64 | _.each(rangeOptions, function (frame) { 65 | rangeIndex[frame.from + " to " + frame.to] = frame; 66 | }); 67 | function getRelativeTimesList(timepickerSettings, currentDisplay) { 68 | var groups = _.groupBy(rangeOptions, function (option) { 69 | option.active = option.display === currentDisplay; 70 | return option.section; 71 | }); 72 | // _.each(timepickerSettings.time_options, (duration: string) => { 73 | // let info = describeTextRange(duration); 74 | // if (info.section) { 75 | // groups[info.section].push(info); 76 | // } 77 | // }); 78 | return groups; 79 | } 80 | 81 | function formatDate(date) { 82 | return date.format(absoluteFormat); 83 | } 84 | // handles expressions like 85 | // 5m 86 | // 5m to now/d 87 | // now/d to now 88 | // now/d 89 | // if no to then to now is assumed 90 | function describeTextRange(expr) { 91 | var isLast = expr.indexOf("+") !== 0; 92 | if (expr.indexOf("now") === -1) { 93 | expr = (isLast ? "now-" : "now") + expr; 94 | } 95 | var opt = rangeIndex[expr + " to now"]; 96 | if (opt) { 97 | return opt; 98 | } 99 | if (isLast) { 100 | opt = { from: expr, to: "now" }; 101 | } else { 102 | opt = { from: "now", to: expr }; 103 | } 104 | var parts = /^now([-+])(\d+)(\w)/.exec(expr); 105 | if (parts) { 106 | var unit = parts[3]; 107 | var amount = parseInt(parts[2]); 108 | var span = spans[unit]; 109 | if (span) { 110 | opt.display = isLast ? "Last " : "Next "; 111 | opt.display += amount + " " + span.display; 112 | opt.section = span.section; 113 | if (amount > 1) { 114 | opt.display += "s"; 115 | } 116 | } 117 | } else { 118 | opt.display = opt.from + " to " + opt.to; 119 | opt.invalid = true; 120 | } 121 | return opt; 122 | } 123 | 124 | function describeTimeRange(range) { 125 | var option = rangeIndex[range.from.toString() + " to " + range.to.toString()]; 126 | if (option) { 127 | return option.display; 128 | } 129 | if (moment.isMoment(range.from) && moment.isMoment(range.to)) { 130 | return formatDate(range.from) + " to " + formatDate(range.to); 131 | } 132 | if (moment.isMoment(range.from)) { 133 | var toMoment = dateMath.parse(range.to, true); 134 | return formatDate(range.from) + " to " + toMoment.fromNow(); 135 | } 136 | if (moment.isMoment(range.to)) { 137 | var from = dateMath.parse(range.from, false); 138 | return from.fromNow() + " to " + formatDate(range.to); 139 | } 140 | if (range.to.toString() === "now") { 141 | var res = describeTextRange(range.from); 142 | return res.display; 143 | } 144 | return range.from.toString() + " to " + range.to.toString(); 145 | } 146 | 147 | export default { 148 | describeTimeRange: describeTimeRange, 149 | getRelativeTimesList: getRelativeTimesList, 150 | }; 151 | -------------------------------------------------------------------------------- /powa/static/js/utils/size.js: -------------------------------------------------------------------------------- 1 | export default { 2 | SizeFormatter: function (opts) { 3 | opts = opts || {}; 4 | var suffix = opts.suffix; 5 | this.fromRaw = function (val) { 6 | if (val === undefined) { 7 | return "(NA)"; 8 | } 9 | if (val === 0) { 10 | return "-"; 11 | } 12 | if (val <= 1024) { 13 | return val.toFixed(2) + " " + "B"; 14 | } 15 | var scale = [null, "K", "M", "G", "T", "P"]; 16 | let i = 0; 17 | for (i; i < 5 && val > 1024; i++) { 18 | val /= 1024; 19 | } 20 | return val.toFixed(2) + " " + scale[i] + (suffix || ""); 21 | }; 22 | }, 23 | }; 24 | -------------------------------------------------------------------------------- /powa/static/js/utils/sql.js: -------------------------------------------------------------------------------- 1 | import hljs from "highlight.js/lib/core"; 2 | import pgsql from "highlight.js/lib/languages/pgsql"; 3 | import "highlight.js/styles/default.css"; 4 | import sqlFormatter from "@sqltools/formatter"; 5 | 6 | hljs.registerLanguage("sql", pgsql); 7 | 8 | export function formatSql(value) { 9 | try { 10 | value = sqlFormatter.format(value, { language: "postgresql" }); 11 | } catch (error) { 12 | console.error("Could not format SQL:", "\n", value, "\n", error); 13 | } 14 | value = hljs.highlightAuto(value, ["sql"]).value; 15 | return value; 16 | } 17 | -------------------------------------------------------------------------------- /powa/static/js/utils/widget-component.js: -------------------------------------------------------------------------------- 1 | export function widgetComponent(widget) { 2 | if (widget.type === "content") { 3 | // we cannot use reserved HTML tag 4 | return "content-cmp"; 5 | } 6 | if (widget.type == "grid" && widget.renderer == "distribution") { 7 | return "distribution-grid"; 8 | } 9 | return widget.type; 10 | } 11 | -------------------------------------------------------------------------------- /powa/static/styles/highlight.scss: -------------------------------------------------------------------------------- 1 | @mixin light { 2 | pre code.hljs { 3 | display: block; 4 | overflow-x: auto; 5 | padding: 1em 6 | } 7 | 8 | code.hljs { 9 | padding: 3px 5px 10 | } 11 | 12 | /*! 13 | Theme: StackOverflow Light 14 | Description: Light theme as used on stackoverflow.com 15 | Author: stackoverflow.com 16 | Maintainer: @Hirse 17 | Website: https://github.com/StackExchange/Stacks 18 | License: MIT 19 | Updated: 2021-05-15 20 | 21 | Updated for @stackoverflow/stacks v0.64.0 22 | Code Blocks: /blob/v0.64.0/lib/css/components/_stacks-code-blocks.less 23 | Colors: /blob/v0.64.0/lib/css/exports/_stacks-constants-colors.less 24 | */ 25 | .hljs { 26 | color: #2f3337; 27 | background: #f6f6f6 28 | } 29 | 30 | .hljs-subst { 31 | color: #2f3337 32 | } 33 | 34 | .hljs-comment { 35 | color: #656e77 36 | } 37 | 38 | .hljs-attr, 39 | .hljs-doctag, 40 | .hljs-keyword, 41 | .hljs-meta .hljs-keyword, 42 | .hljs-section, 43 | .hljs-selector-tag { 44 | color: #015692 45 | } 46 | 47 | .hljs-attribute { 48 | color: #803378 49 | } 50 | 51 | .hljs-name, 52 | .hljs-number, 53 | .hljs-quote, 54 | .hljs-selector-id, 55 | .hljs-template-tag, 56 | .hljs-type { 57 | color: #b75501 58 | } 59 | 60 | .hljs-selector-class { 61 | color: #015692 62 | } 63 | 64 | .hljs-link, 65 | .hljs-regexp, 66 | .hljs-selector-attr, 67 | .hljs-string, 68 | .hljs-symbol, 69 | .hljs-template-variable, 70 | .hljs-variable { 71 | color: #54790d 72 | } 73 | 74 | .hljs-meta, 75 | .hljs-selector-pseudo { 76 | color: #015692 77 | } 78 | 79 | .hljs-built_in, 80 | .hljs-literal, 81 | .hljs-title { 82 | color: #b75501 83 | } 84 | 85 | .hljs-bullet, 86 | .hljs-code { 87 | color: #535a60 88 | } 89 | 90 | .hljs-meta .hljs-string { 91 | color: #54790d 92 | } 93 | 94 | .hljs-deletion { 95 | color: #c02d2e 96 | } 97 | 98 | .hljs-addition { 99 | color: #2f6f44 100 | } 101 | 102 | .hljs-emphasis { 103 | font-style: italic 104 | } 105 | 106 | .hljs-strong { 107 | font-weight: 700 108 | } 109 | } 110 | 111 | @mixin dark { 112 | pre code.hljs { 113 | display: block; 114 | overflow-x: auto; 115 | padding: 1em 116 | } 117 | 118 | code.hljs { 119 | padding: 3px 5px 120 | } 121 | 122 | /*! 123 | Theme: StackOverflow Dark 124 | Description: Dark theme as used on stackoverflow.com 125 | Author: stackoverflow.com 126 | Maintainer: @Hirse 127 | Website: https://github.com/StackExchange/Stacks 128 | License: MIT 129 | Updated: 2021-05-15 130 | 131 | Updated for @stackoverflow/stacks v0.64.0 132 | Code Blocks: /blob/v0.64.0/lib/css/components/_stacks-code-blocks.less 133 | Colors: /blob/v0.64.0/lib/css/exports/_stacks-constants-colors.less 134 | */ 135 | .hljs { 136 | color: #fff; 137 | background: #1c1b1b 138 | } 139 | 140 | .hljs-subst { 141 | color: #fff 142 | } 143 | 144 | .hljs-comment { 145 | color: #999 146 | } 147 | 148 | .hljs-attr, 149 | .hljs-doctag, 150 | .hljs-keyword, 151 | .hljs-meta .hljs-keyword, 152 | .hljs-section, 153 | .hljs-selector-tag { 154 | color: #88aece 155 | } 156 | 157 | .hljs-attribute { 158 | color: #c59bc1 159 | } 160 | 161 | .hljs-name, 162 | .hljs-number, 163 | .hljs-quote, 164 | .hljs-selector-id, 165 | .hljs-template-tag, 166 | .hljs-type { 167 | color: #f08d49 168 | } 169 | 170 | .hljs-selector-class { 171 | color: #88aece 172 | } 173 | 174 | .hljs-link, 175 | .hljs-regexp, 176 | .hljs-selector-attr, 177 | .hljs-string, 178 | .hljs-symbol, 179 | .hljs-template-variable, 180 | .hljs-variable { 181 | color: #b5bd68 182 | } 183 | 184 | .hljs-meta, 185 | .hljs-selector-pseudo { 186 | color: #88aece 187 | } 188 | 189 | .hljs-built_in, 190 | .hljs-literal, 191 | .hljs-title { 192 | color: #f08d49 193 | } 194 | 195 | .hljs-bullet, 196 | .hljs-code { 197 | color: #ccc 198 | } 199 | 200 | .hljs-meta .hljs-string { 201 | color: #b5bd68 202 | } 203 | 204 | .hljs-deletion { 205 | color: #de7176 206 | } 207 | 208 | .hljs-addition { 209 | color: #76c490 210 | } 211 | 212 | .hljs-emphasis { 213 | font-style: italic 214 | } 215 | 216 | .hljs-strong { 217 | font-weight: 700 218 | } 219 | } 220 | -------------------------------------------------------------------------------- /powa/static/styles/main.scss: -------------------------------------------------------------------------------- 1 | @use "highlight"; 2 | 3 | html { 4 | font-size: 14px; 5 | } 6 | 7 | pre.sql { 8 | max-height: 400px; 9 | overflow: auto; 10 | background-color: rgb(var(--v-theme-surface)); 11 | .v-tooltip & { 12 | line-height: 1.3; 13 | background-color: transparent; 14 | padding: 0; 15 | } 16 | white-space: pre-wrap; 17 | padding: 4px; 18 | code { 19 | display: block; 20 | background-color: initial !important; 21 | font-size: 90%; 22 | overflow: initial !important; 23 | } 24 | } 25 | 26 | .v-application a { 27 | color: rgb(var(--v-theme-primary)); 28 | } 29 | 30 | .v-breadcrumbs-item--disabled .v-breadcrumbs-item--link { 31 | color: rgb(var(--v-theme-on-surface)); 32 | } 33 | 34 | .v-table.superdense > .v-table__wrapper > table { 35 | tbody, 36 | thead, 37 | tfoot { 38 | th, 39 | td { 40 | padding: 0 0.3rem; 41 | } 42 | } 43 | } 44 | 45 | .v-table { 46 | td { 47 | white-space: nowrap; 48 | } 49 | th:not(.v-data-table__th--sorted) .v-data-table-header__sort-icon { 50 | display: none; 51 | } 52 | } 53 | 54 | tr:first-child th { 55 | &:not([rowspan="2"]) { 56 | border-left: thin solid rgba(var(--v-border-color), var(--v-border-opacity)); 57 | } 58 | &[rowspan="1"] { 59 | border-bottom: 0 !important; 60 | } 61 | } 62 | 63 | td { 64 | &:not(.query) { 65 | width: 1%; 66 | } 67 | &.query { 68 | overflow: hidden; 69 | max-width: 0; 70 | pre { 71 | overflow: hidden; 72 | text-overflow: ellipsis !important; 73 | white-space: nowrap; 74 | margin-bottom: 0; 75 | background-color: initial !important; 76 | code { 77 | display: initial; 78 | } 79 | } 80 | } 81 | } 82 | 83 | .event:hover .marker { 84 | fill: rgb(var(--v-theme-eventmarkerfill)); 85 | } 86 | 87 | .v-theme--light { 88 | @include highlight.light; 89 | } 90 | 91 | .v-theme--dark { 92 | @include highlight.dark; 93 | } 94 | 95 | .powa-snackbars .v-overlay { 96 | top: initial; 97 | left: initial; 98 | bottom: 0; 99 | right: 0; 100 | padding-bottom: 0; 101 | padding-top: 0; 102 | position: relative; 103 | 104 | .v-overlay__content { 105 | position: relative; 106 | } 107 | } 108 | 109 | .v-card-item.bg-surface + .v-card-text { 110 | padding-top: 8px; // spacer * 2 111 | } 112 | -------------------------------------------------------------------------------- /powa/static/styles/variables.scss: -------------------------------------------------------------------------------- 1 | @use 'vuetify/settings' with ( 2 | $spacer: 2px, 3 | $card-text-padding: 8px, // spacer * 4 4 | $card-text-font-size: 0.9rem, 5 | $table-font-size: 0.9rem, 6 | $card-item-padding: 4px 8px, // spacer, spacer * 2 7 | $card-title-font-size: 1rem, 8 | $card-title-line-height: 1.5rem, 9 | $button-font-size: 1rem, 10 | $field-font-size: reset, 11 | $color-pack: false, 12 | $button-text-transform: none, 13 | $button-text-letter-spacing: inherit, 14 | $card-background: rgb(var(--v-theme-background)), 15 | $table-background: rgb(var(--v-theme-background)), 16 | $table-density: ('default': 0, 'comfortable': -2, 'compact': -14), 17 | $progress-linear-background: rgb(var(--v-theme-primary)), 18 | $tooltip-background-color: rgb(var(--v-theme-background)), 19 | $tooltip-text-color: rgb(var(--v-theme-surface-variant)), 20 | $tooltip-padding: 4px 8px, 21 | $list-background: rgba(var(--v-theme-background)), 22 | $list-item-title-font-size: 1em, 23 | $input-density: ('default': 0, 'comfortable': -2, 'compact': -14), 24 | $tabs-density: ( 'default': 0, 'comfortable' : -1, 'compact': -9), 25 | ); 26 | -------------------------------------------------------------------------------- /powa/templates/fullpage_dashboard.html: -------------------------------------------------------------------------------- 1 | {% extends "layout.html" %} 2 | -------------------------------------------------------------------------------- /powa/templates/layout.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | {{ title }} 5 | 6 | 7 | 8 | 9 | 10 | 11 | {% block header %} {% end %} 12 | 27 | 28 | 29 |
30 |
31 |
32 |
33 |  PoWA 34 |
35 |
36 | 39 | 45 | Loading… 46 |
47 |
48 |
49 | {% block data %} {% end %} 50 | 53 | {% if current_user %} 54 | 57 | {% end %} 58 | {% if handler.application.settings['debug'] %} 59 | 64 | 65 | 66 | {% else %} 67 | {% for css in manifest('powa/static/js/main.js')['css'] %} 68 | 69 | {% end %} 70 | {% for chunk in manifest('powa/static/js/main.js')['imports'] %} 71 | {% for css in manifest(chunk).get("css", []) %} 72 | 73 | {% end %} 74 | {% end %} 75 | 76 | {% for chunk in manifest('powa/static/js/main.js')['imports'] %} 77 | 78 | {% end %} 79 | {% end %} 80 | 81 | 82 | -------------------------------------------------------------------------------- /powa/templates/login.html: -------------------------------------------------------------------------------- 1 | {% extends "layout.html" %} 2 | 3 | {% block data %} 4 | 7 | {% end %} 8 | -------------------------------------------------------------------------------- /powa/templates/xhr.html: -------------------------------------------------------------------------------- 1 |
2 | {% block content %} 3 | {{ content }} 4 | {% end %} 5 |
6 | -------------------------------------------------------------------------------- /powa/ui_methods.py: -------------------------------------------------------------------------------- 1 | """ 2 | Set of helper functions available from the templates. 3 | """ 4 | 5 | import json 6 | import os 7 | from datetime import datetime 8 | from powa import __VERSION__ 9 | from powa.json import JSONEncoder 10 | from tornado.options import options 11 | from tornado.web import HTTPError 12 | 13 | 14 | def version(_): 15 | """ 16 | Returns: 17 | the current powa version. 18 | """ 19 | return __VERSION__ 20 | 21 | 22 | def year(_): 23 | """ 24 | Returns: 25 | the current year. 26 | """ 27 | return datetime.now().year 28 | 29 | 30 | def servers(_): 31 | """ 32 | Returns: 33 | the servers defined in the configuration file. 34 | """ 35 | return options.servers 36 | 37 | 38 | def field(_, **kwargs): 39 | """ 40 | Returns: 41 | a form field formatted with the given attributes. 42 | """ 43 | kwargs.setdefault("tag", "input") 44 | kwargs.setdefault("type", "text") 45 | kwargs.setdefault("class", "form-control") 46 | attrs = " ".join( 47 | '%s="%s"' % (key, value) 48 | for key, value in kwargs.items() 49 | if key not in ("tag", "label") 50 | ) 51 | kwargs["attrs"] = attrs 52 | 53 | def render(content): 54 | """ 55 | Render the field itself. 56 | """ 57 | kwargs["content"] = content.decode("utf8") 58 | return ( 59 | """ 60 | 61 | 66 | 67 | """ 68 | % kwargs 69 | ) 70 | 71 | return render 72 | 73 | 74 | def flash(self, message, category=""): 75 | """ 76 | Stores a message to be displayed on the next rendered page. 77 | """ 78 | flashes = self.get_pickle_cookie("_flashes") or {} 79 | flashes.setdefault(category, []).append(message) 80 | self.set_pickle_cookie("_flashes", flashes) 81 | self.flashed_messages = flashes 82 | 83 | 84 | def flashed_messages(self): 85 | """ 86 | Returns: 87 | a mapping of flashed message category to their messages 88 | """ 89 | messages = self.get_pickle_cookie("_flashes") or {} 90 | self.set_pickle_cookie("_flashes", None) 91 | for key, my_messages in self.flashed_messages.items(): 92 | messages.setdefault(key, []).extend(my_messages) 93 | self.flashed_messages = {} 94 | return messages 95 | 96 | 97 | def sanitycheck_messages(self): 98 | messages = {"error": []} 99 | 100 | # Check if now collector is running 101 | sql = """SELECT 102 | CASE WHEN val LIKE 'PoWA collector - main thread (%%' 103 | THEN 'Remote collector' 104 | ELSE 'Backgound worker' 105 | END AS powa_kind, 106 | date_trunc('second', backend_start) as start, 107 | datname, usename, 108 | coalesce(host(client_addr), '') AS client_addr, 109 | count(datname) OVER () AS nb_found 110 | FROM ( 111 | SELECT 'PoWA - %%' AS val 112 | UNION ALL 113 | SELECT 'PoWA collector - main thread (%%' 114 | ) n 115 | JOIN pg_stat_activity a ON a.application_name LIKE n.val""" 116 | rows = self.execute(sql) 117 | 118 | if rows is None: 119 | messages["error"].append("No collector is running!") 120 | 121 | sql = """SELECT 122 | CASE WHEN id = 0 THEN 123 | '' 124 | ELSE 125 | COALESCE(alias, hostname || ':' || port) 126 | END AS alias, 127 | error 128 | FROM {powa}.powa_servers s 129 | JOIN (SELECT srvid, unnest(errors) error 130 | FROM {powa}.powa_snapshot_metas 131 | WHERE errors IS NOT NULL 132 | ) m ON m.srvid = s.id""" 133 | rows = self.execute(sql) 134 | 135 | if rows is not None and len(rows) > 0: 136 | for r in rows: 137 | messages["error"].append("%s: %s" % (r["alias"], r["error"])) 138 | return messages 139 | 140 | return {} 141 | 142 | 143 | def to_json(_, value): 144 | """ 145 | Utility function to render json in templates. 146 | """ 147 | return JSONEncoder().encode(value) 148 | 149 | 150 | def manifest(self, entrypoint): 151 | fn = os.path.realpath(__file__ + "../../static/dist/.vite/manifest.json") 152 | try: 153 | f = open(fn) 154 | except FileNotFoundError: 155 | raise HTTPError( 156 | 500, "manifest.json doesn't exist, did you run `npm run build`?" 157 | ) 158 | else: 159 | with f: 160 | entrypoints = json.load(f) 161 | return entrypoints[entrypoint] 162 | -------------------------------------------------------------------------------- /powa/ui_modules.py: -------------------------------------------------------------------------------- 1 | class MenuEntry(object): 2 | def __init__( 3 | self, 4 | title, 5 | url_name, 6 | url_params=None, 7 | children_title=None, 8 | children=None, 9 | ): 10 | self.title = title 11 | self.url_name = url_name 12 | self.url_params = url_params or {} 13 | # Setting children helps showing dropdown menu in breadcrumb 14 | # Useful for listing databases for examples 15 | self.children_title = children_title 16 | self.children = children 17 | -------------------------------------------------------------------------------- /powa/user.py: -------------------------------------------------------------------------------- 1 | from __future__ import unicode_literals 2 | 3 | from powa import __VERSION_NUM__ 4 | from powa.framework import BaseHandler 5 | from tornado.options import options 6 | 7 | 8 | class LoginHandler(BaseHandler): 9 | def get(self): 10 | self._status_code = 403 11 | return self.render("login.html", title="Login") 12 | 13 | def post(self, *args, **kwargs): 14 | user = self.get_argument("user") 15 | password = self.get_argument("password") 16 | server = self.get_argument("server") 17 | expires_days = options.cookie_expires_days 18 | if expires_days == 0: 19 | expires_days = None 20 | 21 | try: 22 | self.connect(user=user, password=password, server=server) 23 | except Exception as e: 24 | self.flash("Auth failed", "alert") 25 | self.logger.error("Error: %r", e) 26 | self.get() 27 | return 28 | # Check that the database is correctly installed 29 | version = self.get_powa_version( 30 | user=user, password=password, server=server 31 | ) 32 | if version is None: 33 | self.flash( 34 | "PoWA is not installed on your target database. " 35 | "You should check your installation.", 36 | "alert", 37 | ) 38 | self.redirect(self.url_prefix) 39 | # Major.Minor version should be the same 40 | if version[0:2] != __VERSION_NUM__[0:2]: 41 | self.flash( 42 | "Unable to connect: powa-archivist version %s.X does not match powa-web version %s.X" 43 | % ( 44 | ".".join(str(x) for x in version[0:2]), 45 | ".".join(str(x) for x in __VERSION_NUM__[0:2]), 46 | ), 47 | "alert", 48 | ) 49 | self.redirect(self.url_prefix) 50 | self.set_secure_cookie("user", user, expires_days=expires_days) 51 | self.set_secure_cookie("password", password, expires_days=expires_days) 52 | self.set_secure_cookie("server", server, expires_days=expires_days) 53 | self.redirect(self.get_argument("next", self.url_prefix)) 54 | 55 | 56 | class LogoutHandler(BaseHandler): 57 | def get(self): 58 | self.clear_all_cookies() 59 | return self.redirect(self.url_prefix) 60 | -------------------------------------------------------------------------------- /powa/wizard.py: -------------------------------------------------------------------------------- 1 | """ 2 | Global optimization widget 3 | """ 4 | 5 | from __future__ import absolute_import 6 | 7 | import json 8 | from powa.dashboards import MetricGroupDef, Widget 9 | from powa.framework import AuthHandler 10 | from powa.sql import ( 11 | HypoIndex, 12 | get_any_sample_query, 13 | get_hypoplans, 14 | resolve_quals, 15 | ) 16 | from powa.sql.views import qualstat_getstatdata 17 | from psycopg2 import Error 18 | from psycopg2.extras import RealDictCursor 19 | from tornado.web import HTTPError 20 | 21 | 22 | class IndexSuggestionHandler(AuthHandler): 23 | def post(self, srvid, database): 24 | try: 25 | # Check remote access first 26 | remote_conn = self.connect( 27 | srvid, database=database, remote_access=True 28 | ) 29 | remote_cur = remote_conn.cursor() 30 | except Exception as e: 31 | raise HTTPError( 32 | 501, "Could not connect to remote server: %s" % str(e) 33 | ) 34 | 35 | payload = json.loads(self.request.body.decode("utf8")) 36 | from_date = payload["from_date"] 37 | to_date = payload["to_date"] 38 | indexes = [] 39 | for ind in payload["indexes"]: 40 | hypoind = HypoIndex(ind["nspname"], ind["relname"], ind["ams"]) 41 | hypoind._ddl = ind["ddl"] 42 | indexes.append(hypoind) 43 | queryids = payload["queryids"] 44 | powa_conn = self.connect(database="powa") 45 | cur = powa_conn.cursor(cursor_factory=RealDictCursor) 46 | cur.execute( 47 | """ 48 | SELECT DISTINCT query, ps.queryid 49 | FROM {powa}.powa_statements ps 50 | WHERE srvid = %(srvid)s 51 | AND queryid IN %(queryids)s 52 | """, 53 | ({"srvid": srvid, "queryids": tuple(queryids)}), 54 | ) 55 | queries = cur.fetchall() 56 | cur.close() 57 | # Create all possible indexes for this qual 58 | hypo_version = self.has_extension_version( 59 | srvid, "hypopg", "0.0.3", database=database 60 | ) 61 | hypoplans = {} 62 | indbyname = {} 63 | inderrors = {} 64 | if hypo_version: 65 | # identify indexes 66 | # create them 67 | for ind in indexes: 68 | try: 69 | remote_cur.execute( 70 | """SELECT * 71 | FROM hypopg_create_index(%(ddl)s) 72 | """, 73 | {"ddl": ind.ddl}, 74 | ) 75 | indname = remote_cur.fetchone()[1] 76 | indbyname[indname] = ind 77 | except Error as e: 78 | inderrors[ind.ddl] = str(e) 79 | continue 80 | except Exception as e: 81 | inderrors[ind.ddl] = str(e) 82 | continue 83 | # Build the query and fetch the plans 84 | for row in queries: 85 | querystr = get_any_sample_query( 86 | self, srvid, database, row["queryid"], from_date, to_date 87 | ) 88 | if querystr: 89 | try: 90 | hypoplans[row["queryid"]] = get_hypoplans( 91 | remote_cur, querystr, indbyname.values() 92 | ) 93 | except Exception: 94 | # TODO: stop ignoring the error 95 | continue 96 | # To value of a link is the the reduction in cost 97 | remote_cur.close() 98 | result = {} 99 | result["plans"] = hypoplans 100 | result["inderrors"] = inderrors 101 | self.render_json(result) 102 | 103 | 104 | class WizardMetricGroup(MetricGroupDef): 105 | """Metric group for the wizard.""" 106 | 107 | name = "wizard" 108 | xaxis = "quals" 109 | axis_type = "category" 110 | data_url = r"/server/(\d+)/metrics/database/([^\/]+)/wizard/" 111 | enabled = False 112 | 113 | @property 114 | def query(self): 115 | pq = qualstat_getstatdata(eval_type="f") 116 | 117 | cols = [ 118 | # queryid in pg11+ is int64, so the value can exceed javascript's 119 | # Number.MAX_SAFE_INTEGER, which means that the value can get 120 | # truncated by the browser, leading to looking for unexisting 121 | # queryid when processing this data. To avoid that, simply cast 122 | # the value to text. 123 | "array_agg(cast(queryid AS text)) AS queryids", 124 | "qualid", 125 | "quals::jsonb AS quals", 126 | "occurences", 127 | "execution_count", 128 | "array_agg(query) AS queries", 129 | "avg_filter", 130 | "filter_ratio", 131 | ] 132 | 133 | query = """SELECT {cols} 134 | FROM ( 135 | {pq} 136 | ) AS sub 137 | JOIN {{powa}}.powa_databases pd ON pd.oid = sub.dbid 138 | AND pd.srvid = sub.srvid 139 | WHERE pd.datname = %(database)s 140 | AND pd.srvid = %(server)s 141 | AND sub.avg_filter > 1000 142 | AND sub.filter_ratio > 0.3 143 | GROUP BY sub.qualid, sub.execution_count, sub.occurences, 144 | sub.quals::jsonb, sub.avg_filter, sub.filter_ratio 145 | ORDER BY sub.occurences DESC 146 | LIMIT 200""".format( 147 | cols=", ".join(cols), 148 | pq=pq, 149 | ) 150 | return query 151 | 152 | def post_process(self, data, server, database, **kwargs): 153 | conn = self.connect(server, database=database, remote_access=True) 154 | data["data"] = resolve_quals(conn, data["data"]) 155 | return data 156 | 157 | 158 | class Wizard(Widget): 159 | def __init__(self, title): 160 | self.title = title 161 | 162 | def parameterized_json(self, handler, **parms): 163 | values = self.__dict__.copy() 164 | values["metrics"] = [] 165 | values["type"] = "wizard" 166 | values["datasource"] = "wizard" 167 | 168 | # First check that we can connect on the remote server, otherwise we 169 | # won't be able to do anything 170 | try: 171 | handler.connect( 172 | parms["server"], database=parms["database"], remote_access=True 173 | ) 174 | except Exception as e: 175 | values["has_remote_conn"] = False 176 | values["conn_error"] = str(e) 177 | return values 178 | 179 | values["has_remote_conn"] = True 180 | 181 | hypover = handler.has_extension_version( 182 | parms["server"], "hypopg", "0.0.3", database=parms["database"] 183 | ) 184 | qsver = handler.has_extension_version( 185 | parms["server"], "pg_qualstats", "0.0.7" 186 | ) 187 | values["has_hypopg"] = hypover 188 | values["has_qualstats"] = qsver 189 | values["server"] = parms["server"] 190 | values["database"] = parms["database"] 191 | return values 192 | -------------------------------------------------------------------------------- /readme: -------------------------------------------------------------------------------- 1 | README.md -------------------------------------------------------------------------------- /requirements-dev.txt: -------------------------------------------------------------------------------- 1 | -r requirements.txt 2 | ruff==0.9.5 --only-binary=ruff 3 | check-manifest 4 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | psycopg2 2 | tornado>=2.0 3 | -------------------------------------------------------------------------------- /run_powa.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | import logging 3 | import tornado.ioloop 4 | from powa import make_app 5 | from tornado.options import options 6 | 7 | if __name__ == "__main__": 8 | application = make_app(debug=True, gzip=True, compress_response=True) 9 | application.listen(options.port, address=options.address) 10 | logger = logging.getLogger("tornado.application") 11 | logger.info( 12 | "Starting powa-web on http://%s:%s%s", 13 | options.address, 14 | options.port, 15 | options.url_prefix, 16 | ) 17 | tornado.ioloop.IOLoop.instance().start() 18 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | import sys 2 | from setuptools import find_packages, setup 3 | 4 | __VERSION__ = None 5 | 6 | with open("powa/__init__.py") as f: 7 | for line in f: 8 | if line.startswith("__VERSION__"): 9 | __VERSION__ = line.split("=")[1].replace('"', "").strip() 10 | 11 | 12 | requires = ["tornado>=2.0", "psycopg2"] 13 | 14 | # include ordereddict for python2.6 15 | if sys.version_info < (2, 7, 0): 16 | requires.append("ordereddict") 17 | 18 | 19 | setup( 20 | name="powa-web", 21 | version=__VERSION__, 22 | author="powa-team", 23 | license="Postgresql", 24 | packages=find_packages(), 25 | install_requires=requires, 26 | include_package_data=True, 27 | url="https://powa.readthedocs.io/", 28 | description="A User Interface for the PoWA project", 29 | long_description="See https://powa.readthedocs.io/", 30 | scripts=["powa-web"], 31 | classifiers=[ 32 | "Development Status :: 5 - Production/Stable", 33 | "Intended Audience :: System Administrators", 34 | "Intended Audience :: End Users/Desktop", 35 | "License :: Other/Proprietary License", 36 | "License :: OSI Approved :: BSD License", 37 | "Operating System :: OS Independent", 38 | "Programming Language :: Python :: 2", 39 | "Programming Language :: Python :: 3", 40 | "Topic :: Database :: Front-Ends", 41 | ], 42 | ) 43 | -------------------------------------------------------------------------------- /vite.config.js: -------------------------------------------------------------------------------- 1 | import { resolve } from "path"; 2 | import { defineConfig } from "vite"; 3 | import { fileURLToPath, URL } from "node:url"; 4 | import vue from "@vitejs/plugin-vue"; 5 | import vuetify from "vite-plugin-vuetify"; 6 | 7 | // https://vitejs.dev/config/ 8 | export default defineConfig({ 9 | plugins: [ 10 | vue(), 11 | vuetify({ 12 | autoImport: true, 13 | styles: { configFile: "./powa/static/styles/variables.scss" }, 14 | }), 15 | ], 16 | resolve: { 17 | alias: { 18 | "@": fileURLToPath(new URL("./powa/static/js", import.meta.url)), 19 | }, 20 | }, 21 | build: { 22 | manifest: true, 23 | outDir: resolve(__dirname, "powa/static/dist"), 24 | emptyOutDir: true, 25 | rollupOptions: { 26 | input: "/powa/static/js/main.js", 27 | output: { 28 | manualChunks: { 29 | // Split external library from transpiled code. 30 | d3: ["d3"], 31 | lodash: ["lodash"], 32 | vue: ["vue", "vue-router"], 33 | vuetify: ["vuetify", "@mdi/js"], 34 | luxon: ["luxon"], 35 | highlight: ["highlight.js"], 36 | moment: ["moment"], 37 | "sqltools-formatter": ["@sqltools/formatter"], 38 | }, 39 | }, 40 | }, 41 | }, 42 | }); 43 | --------------------------------------------------------------------------------