├── .ruff.toml ├── LICENSE ├── docs ├── sc ├── changelog.md ├── CONTRIBUTING.md ├── overrides │ └── main.html ├── assets │ ├── rpm.png │ ├── temboard-logo.png │ ├── temboard-logo-256.png │ ├── temboard-logo-64.png │ ├── temboard-logo-slogan.png │ ├── temboard-logo-blanc-256.png │ ├── rtd-material.js │ └── temboard.css ├── screenshots │ ├── login.png │ ├── add-member.png │ ├── add-user.png │ ├── statements.png │ ├── add-instance.png │ ├── architecture.png │ ├── alerting_check.png │ ├── alerting_checks.png │ ├── alerting_edit.png │ ├── edit-user-admin.png │ ├── instance-list.png │ ├── discover-instance.png │ ├── maintenance_hints.png │ ├── alerting_dashboard.png │ ├── instance-dashboard.png │ ├── maintenance_schemas.png │ ├── maintenance_tables.png │ ├── maintenance_databases.png │ ├── alerting_notification_test.png │ ├── maintenance_schedule_vacuum.png │ ├── alerting_notification_set_user.png │ └── alerting_notification_set_instance.png ├── temboard-howto-statements.md ├── postgres_upgrade.md ├── compatibility_guide.md ├── quickstart.md ├── temboard-howto-maintenance.md └── index.md ├── README.md ├── ui ├── .husky │ ├── .gitignore │ └── pre-commit ├── .npmrc ├── temboardui │ ├── cli │ │ ├── __init__.py │ │ ├── serve.py │ │ ├── web.py │ │ ├── generate_key.py │ │ ├── migratedb.py │ │ └── routes.py │ ├── plugins │ │ ├── __init__.py │ │ ├── monitoring │ │ │ ├── model │ │ │ │ └── __init__.py │ │ │ ├── handlers │ │ │ │ ├── __init__.py │ │ │ │ └── monitoring.py │ │ │ ├── routes.py │ │ │ ├── templates │ │ │ │ ├── alerting.checks.html │ │ │ │ ├── index.html │ │ │ │ └── alerting.check.html │ │ │ └── pivot.py │ │ ├── dashboard │ │ │ ├── __init__.py │ │ │ └── routes.py │ │ ├── pgconf │ │ │ ├── routes.py │ │ │ └── __init__.py │ │ ├── statements │ │ │ └── templates │ │ │ │ └── index.html │ │ ├── activity │ │ │ └── __init__.py │ │ └── maintenance │ │ │ ├── routes.py │ │ │ └── __init__.py │ ├── web │ │ ├── __init__.py │ │ └── routes │ │ │ ├── __init__.py │ │ │ ├── instance.py │ │ │ ├── core.py │ │ │ └── home.py │ ├── handlers │ │ ├── __init__.py │ │ ├── settings │ │ │ ├── __init__.py │ │ │ └── metadata.py │ │ └── notification.py │ ├── model │ │ ├── queries │ │ │ ├── roles-count.sql │ │ │ ├── instances-count.sql │ │ │ ├── apikeys-delete.sql │ │ │ ├── apikeys-purge.sql │ │ │ ├── group-get.sql │ │ │ ├── apikeys-insert.sql │ │ │ ├── apikeys-select-active.sql │ │ │ ├── apikeys-select-secret.sql │ │ │ ├── instance-disable-plugin.sql │ │ │ ├── instance-enable-plugin.sql │ │ │ ├── group-delete-membership.sql │ │ │ ├── group-insert-member.sql │ │ │ ├── roles-select-environments.sql │ │ │ ├── group-select-membership.sql │ │ │ ├── environments-all.sql │ │ │ ├── instances-all.sql │ │ │ ├── monitoring-purge-hosts.sql │ │ │ ├── environments-get.sql │ │ │ ├── environment-memberships.sql │ │ │ ├── instance-has-dba.sql │ │ │ ├── roles-all.sql │ │ │ ├── roles-get.sql │ │ │ ├── roles-select-instances.sql │ │ │ ├── monitoring-purge-instances.sql │ │ │ ├── instance-select-email-and-phone-for-notify.sql │ │ │ ├── instances-insert.sql │ │ │ ├── instance-get.sql │ │ │ └── instances-copy-as-csv.sql │ │ └── versions │ │ │ ├── 011_drop-agent_key.sql │ │ │ ├── 013_alerting-index.sql │ │ │ ├── 000_init.sql │ │ │ ├── 007_drop-alembic.sql │ │ │ ├── 009_apikey.sql │ │ │ ├── 010_discover.sql │ │ │ └── 008_monitoring-archive-wait-lock.sql │ ├── static │ │ └── src │ │ │ ├── public │ │ │ └── images │ │ │ │ ├── heron.png │ │ │ │ ├── favicon.ico │ │ │ │ ├── heron-w.png │ │ │ │ ├── sort_asc.png │ │ │ │ ├── sort_both.png │ │ │ │ ├── sort_desc.png │ │ │ │ ├── temboard-logo.png │ │ │ │ ├── sort_asc_disabled.png │ │ │ │ ├── sort_desc_disabled.png │ │ │ │ ├── temboard-150x32-w.png │ │ │ │ └── ring-alt.svg │ │ │ ├── reset-password.js │ │ │ ├── explain.js │ │ │ ├── settings.about.js │ │ │ ├── activity.js │ │ │ ├── dashboard.js │ │ │ ├── pgconf.js │ │ │ ├── instance.about.js │ │ │ ├── maintenance.js │ │ │ ├── reset-password-form.js │ │ │ ├── settings.members.js │ │ │ ├── alerting.checks.js │ │ │ ├── maintenance.database.js │ │ │ ├── maintenance.table.js │ │ │ ├── maintenance.schema.js │ │ │ ├── notifications.js │ │ │ ├── daterangepicker-override.scss │ │ │ ├── home.js │ │ │ ├── monitoring.js │ │ │ ├── statements.js │ │ │ ├── alerting.check.js │ │ │ ├── login.js │ │ │ ├── components │ │ │ ├── configuration │ │ │ │ └── SettingSwitch.vue │ │ │ ├── settings │ │ │ │ └── InstanceDetails.vue │ │ │ ├── ModalDialog.vue │ │ │ ├── Error.vue │ │ │ └── home │ │ │ │ └── Sparkline.vue │ │ │ ├── _bootstrap-variables-override.scss │ │ │ ├── utils │ │ │ ├── state.js │ │ │ └── duration.js │ │ │ ├── settings.notifications.js │ │ │ ├── settings.environments.js │ │ │ └── settings.users.js │ ├── templates │ │ ├── flask │ │ │ ├── reset-password-form.html │ │ │ ├── reset-password.html │ │ │ ├── explain.html │ │ │ ├── error.html │ │ │ ├── 401.html │ │ │ ├── 404.html │ │ │ ├── home.html │ │ │ ├── configuration.html │ │ │ ├── instance-about.html │ │ │ ├── about.html │ │ │ ├── activity.html │ │ │ ├── maintenance │ │ │ │ ├── index.html │ │ │ │ ├── database.html │ │ │ │ ├── schema.html │ │ │ │ ├── table.html │ │ │ │ └── breadcrumb.html │ │ │ ├── settings │ │ │ │ ├── members.html │ │ │ │ └── menu.html │ │ │ ├── 403.html │ │ │ ├── dashboard.html │ │ │ └── login.html │ │ ├── error.html │ │ ├── unauthorized.html │ │ └── notifications.html │ ├── errors.py │ ├── __init__.py │ ├── version.py │ └── core.py ├── packaging │ ├── .gitignore │ ├── nfpm │ │ ├── Makefile │ │ ├── mkchanges.sh │ │ ├── docker-compose.yml │ │ └── nfpm.yaml │ ├── temboard.service │ └── docker │ │ ├── entrypoint.sh │ │ └── Dockerfile ├── .dockerignore ├── tests │ └── unit │ │ ├── pytest.ini │ │ ├── test_pivot.py │ │ ├── test_main.py │ │ └── test_model.py ├── share │ ├── sql │ │ ├── pg_stat_statements-create-extension.sql │ │ └── upgrade-4-5.sql │ ├── postinst.sh │ ├── preun.sh │ ├── temboard_CHANGEME.pem │ ├── temboard_ca_certs_CHANGEME.pem │ ├── purge.sh │ ├── temboard_CHANGEME.key │ └── temboard.conf ├── vendor.in ├── .prettierignore ├── postcss.config.js ├── .prettierrc.json ├── vendor.txt ├── LICENSE ├── package.json └── pyproject.toml ├── dev ├── prometheus │ ├── import │ │ └── .gitkeep │ ├── targets │ │ └── .gitkeep │ └── prometheus.yml ├── postgres-setup-primary.sql ├── requirements.txt ├── git-blame-ignore-revs ├── grafana │ ├── entrypoint.sh │ └── rootfs │ │ └── etc │ │ └── grafana │ │ └── provisioning │ │ ├── datasources │ │ ├── loki.yaml │ │ └── prometheus.yaml │ │ └── dashboards │ │ └── perf.yaml ├── bin │ ├── mkenv │ ├── checkdocker │ ├── mktargets │ └── switchover.sh ├── postgres-setup-replication.sh ├── loki │ └── loki-config.yaml ├── agent-entrypoint.sh ├── signing-public.pem ├── lnav │ └── formats │ │ └── postgresql_log.json └── docker-compose.massagent.yml ├── agent ├── temboardagent │ ├── cli │ │ ├── __init__.py │ │ ├── web.py │ │ ├── discover.py │ │ ├── runscript.py │ │ ├── runtask.py │ │ ├── serve.py │ │ ├── tasks.py │ │ ├── routes.py │ │ └── fetch_key.py │ ├── plugins │ │ ├── __init__.py │ │ ├── pgconf │ │ │ ├── types.py │ │ │ └── __init__.py │ │ └── monitoring │ │ │ └── output.py │ ├── web │ │ └── __init__.py │ ├── core.py │ ├── version.py │ ├── queries │ │ ├── __init__.py │ │ ├── status.sql │ │ ├── discover.sql │ │ ├── discover-settings.sql │ │ ├── monitoring-bgwriter.sql │ │ ├── pgconf-settings.sql │ │ ├── activity-sessions.sql │ │ └── activity-locks.sql │ ├── errors.py │ ├── __init__.py │ ├── __main__.py │ └── command.py ├── vendor.in ├── packaging │ ├── docker │ │ ├── .gitignore │ │ ├── sudoers │ │ ├── Dockerfile.dev │ │ └── Dockerfile │ ├── nfpm │ │ ├── .env │ │ ├── Makefile │ │ ├── mkchanges.sh │ │ ├── docker-compose.yml │ │ └── nfpm.yaml │ └── temboard-agent@.service ├── .dockerignore ├── tests │ └── unit │ │ ├── pytest.ini │ │ ├── test_core.py │ │ └── test_postgres.py ├── vendor.txt ├── README.md ├── share │ ├── restart-all.sh │ ├── preun.sh │ ├── purge.sh │ ├── temboard-agent_CHANGEME.pem │ ├── temboard-agent_ca_certs_CHANGEME.pem │ └── temboard-agent_CHANGEME.key ├── LICENSE └── pyproject.toml ├── toolkit ├── temboardtoolkit │ ├── __init__.py │ ├── tasklist │ │ └── __init__.py │ ├── errors.py │ ├── logfmt.py │ └── versions.py ├── pyproject.toml └── tests │ ├── test_perf.py │ ├── test_queries.py │ ├── test_versions.py │ └── test_utils.py ├── tests ├── fixtures │ └── __init__.py ├── monitoring-files ├── statements-files ├── psql ├── pytest.ini ├── test_60_alerting.py ├── pytest-ci └── install-all.sh ├── .config ├── .readthedocs.yaml └── temboard.conf ├── .editorconfig ├── .github └── ISSUE_TEMPLATE │ └── bug_report.md ├── .gitignore └── pyproject.toml /.ruff.toml: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | ui/LICENSE -------------------------------------------------------------------------------- /docs/sc: -------------------------------------------------------------------------------- 1 | screenshots -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ui/README.md -------------------------------------------------------------------------------- /ui/.husky/.gitignore: -------------------------------------------------------------------------------- 1 | _ 2 | -------------------------------------------------------------------------------- /dev/prometheus/import/.gitkeep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /dev/prometheus/targets/.gitkeep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /docs/changelog.md: -------------------------------------------------------------------------------- 1 | ../CHANGELOG.md -------------------------------------------------------------------------------- /ui/.npmrc: -------------------------------------------------------------------------------- 1 | engine-strict=true 2 | -------------------------------------------------------------------------------- /ui/temboardui/cli/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /ui/temboardui/plugins/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /ui/temboardui/web/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /agent/temboardagent/cli/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /dev/postgres-setup-primary.sql: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /docs/CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | ../CONTRIBUTING.md -------------------------------------------------------------------------------- /toolkit/temboardtoolkit/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /ui/packaging/.gitignore: -------------------------------------------------------------------------------- 1 | build-*/ 2 | -------------------------------------------------------------------------------- /ui/temboardui/handlers/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /ui/temboardui/web/routes/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /agent/temboardagent/plugins/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /agent/vendor.in: -------------------------------------------------------------------------------- 1 | bottle >= 0.13, <0.14 2 | -------------------------------------------------------------------------------- /ui/temboardui/handlers/settings/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /agent/packaging/docker/.gitignore: -------------------------------------------------------------------------------- 1 | histfile 2 | -------------------------------------------------------------------------------- /toolkit/temboardtoolkit/tasklist/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /ui/temboardui/plugins/monitoring/model/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /agent/packaging/nfpm/.env: -------------------------------------------------------------------------------- 1 | COMPOSE_PROJECT_NAME=tbapkg 2 | -------------------------------------------------------------------------------- /docs/overrides/main.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %} 2 | -------------------------------------------------------------------------------- /agent/.dockerignore: -------------------------------------------------------------------------------- 1 | * 2 | !dist/*bookworm*.deb 3 | !packaging/docker/ 4 | -------------------------------------------------------------------------------- /ui/.dockerignore: -------------------------------------------------------------------------------- 1 | * 2 | !dist/*bookworm*.deb 3 | !packaging/docker/*.sh 4 | -------------------------------------------------------------------------------- /docs/assets/rpm.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dalibo/temboard/HEAD/docs/assets/rpm.png -------------------------------------------------------------------------------- /docs/screenshots/login.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dalibo/temboard/HEAD/docs/screenshots/login.png -------------------------------------------------------------------------------- /ui/temboardui/model/queries/roles-count.sql: -------------------------------------------------------------------------------- 1 | SELECT COUNT(1) AS "count" 2 | FROM application.roles; 3 | -------------------------------------------------------------------------------- /ui/tests/unit/pytest.ini: -------------------------------------------------------------------------------- 1 | [pytest] 2 | addopts = -vvv --showlocals 3 | cache_dir = .pytest_cache 4 | -------------------------------------------------------------------------------- /ui/share/sql/pg_stat_statements-create-extension.sql: -------------------------------------------------------------------------------- 1 | CREATE EXTENSION IF NOT EXISTS pg_stat_statements; 2 | -------------------------------------------------------------------------------- /ui/vendor.in: -------------------------------------------------------------------------------- 1 | flask 2 | jinja2 3 | python-dateutil>=1.5 4 | sqlalchemy>=1.3.2,<2 5 | tornado>=6.0.2,<6.5 6 | -------------------------------------------------------------------------------- /agent/temboardagent/web/__init__.py: -------------------------------------------------------------------------------- 1 | from .service import HTTPDService 2 | 3 | __all__ = ["HTTPDService"] 4 | -------------------------------------------------------------------------------- /docs/assets/temboard-logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dalibo/temboard/HEAD/docs/assets/temboard-logo.png -------------------------------------------------------------------------------- /docs/screenshots/add-member.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dalibo/temboard/HEAD/docs/screenshots/add-member.png -------------------------------------------------------------------------------- /docs/screenshots/add-user.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dalibo/temboard/HEAD/docs/screenshots/add-user.png -------------------------------------------------------------------------------- /docs/screenshots/statements.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dalibo/temboard/HEAD/docs/screenshots/statements.png -------------------------------------------------------------------------------- /ui/temboardui/model/queries/instances-count.sql: -------------------------------------------------------------------------------- 1 | SELECT COUNT(1) AS "count" 2 | FROM application.instances; 3 | -------------------------------------------------------------------------------- /agent/temboardagent/core.py: -------------------------------------------------------------------------------- 1 | from temboardtoolkit import taskmanager 2 | 3 | workers = taskmanager.WorkerSet() 4 | -------------------------------------------------------------------------------- /agent/tests/unit/pytest.ini: -------------------------------------------------------------------------------- 1 | [pytest] 2 | addopts = -vvv --showlocals --fulltrace 3 | cache_dir = .pytest_cache 4 | -------------------------------------------------------------------------------- /docs/assets/temboard-logo-256.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dalibo/temboard/HEAD/docs/assets/temboard-logo-256.png -------------------------------------------------------------------------------- /docs/assets/temboard-logo-64.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dalibo/temboard/HEAD/docs/assets/temboard-logo-64.png -------------------------------------------------------------------------------- /docs/screenshots/add-instance.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dalibo/temboard/HEAD/docs/screenshots/add-instance.png -------------------------------------------------------------------------------- /docs/screenshots/architecture.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dalibo/temboard/HEAD/docs/screenshots/architecture.png -------------------------------------------------------------------------------- /agent/packaging/docker/sudoers: -------------------------------------------------------------------------------- 1 | # Edit with /usr/sbin/visudo -sf 2 | Defaults !env_reset 3 | Defaults always_set_home 4 | -------------------------------------------------------------------------------- /agent/temboardagent/version.py: -------------------------------------------------------------------------------- 1 | from importlib.metadata import version 2 | 3 | __version__ = version("temboard-agent") 4 | -------------------------------------------------------------------------------- /docs/assets/temboard-logo-slogan.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dalibo/temboard/HEAD/docs/assets/temboard-logo-slogan.png -------------------------------------------------------------------------------- /docs/screenshots/alerting_check.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dalibo/temboard/HEAD/docs/screenshots/alerting_check.png -------------------------------------------------------------------------------- /docs/screenshots/alerting_checks.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dalibo/temboard/HEAD/docs/screenshots/alerting_checks.png -------------------------------------------------------------------------------- /docs/screenshots/alerting_edit.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dalibo/temboard/HEAD/docs/screenshots/alerting_edit.png -------------------------------------------------------------------------------- /docs/screenshots/edit-user-admin.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dalibo/temboard/HEAD/docs/screenshots/edit-user-admin.png -------------------------------------------------------------------------------- /docs/screenshots/instance-list.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dalibo/temboard/HEAD/docs/screenshots/instance-list.png -------------------------------------------------------------------------------- /docs/screenshots/discover-instance.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dalibo/temboard/HEAD/docs/screenshots/discover-instance.png -------------------------------------------------------------------------------- /docs/screenshots/maintenance_hints.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dalibo/temboard/HEAD/docs/screenshots/maintenance_hints.png -------------------------------------------------------------------------------- /ui/.prettierignore: -------------------------------------------------------------------------------- 1 | **/*.md 2 | **/*.yml 3 | **/*.yaml 4 | **/*.html 5 | temboardui/_vendor 6 | temboardui/static/src/public 7 | -------------------------------------------------------------------------------- /agent/temboardagent/queries/__init__.py: -------------------------------------------------------------------------------- 1 | from temboardtoolkit.queries import QueryFiler 2 | 3 | QUERIES = QueryFiler(__path__[0]) 4 | -------------------------------------------------------------------------------- /docs/assets/temboard-logo-blanc-256.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dalibo/temboard/HEAD/docs/assets/temboard-logo-blanc-256.png -------------------------------------------------------------------------------- /docs/screenshots/alerting_dashboard.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dalibo/temboard/HEAD/docs/screenshots/alerting_dashboard.png -------------------------------------------------------------------------------- /docs/screenshots/instance-dashboard.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dalibo/temboard/HEAD/docs/screenshots/instance-dashboard.png -------------------------------------------------------------------------------- /docs/screenshots/maintenance_schemas.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dalibo/temboard/HEAD/docs/screenshots/maintenance_schemas.png -------------------------------------------------------------------------------- /docs/screenshots/maintenance_tables.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dalibo/temboard/HEAD/docs/screenshots/maintenance_tables.png -------------------------------------------------------------------------------- /docs/screenshots/maintenance_databases.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dalibo/temboard/HEAD/docs/screenshots/maintenance_databases.png -------------------------------------------------------------------------------- /ui/temboardui/model/queries/apikeys-delete.sql: -------------------------------------------------------------------------------- 1 | DELETE FROM "application"."apikeys" 2 | WHERE "id" = :id 3 | RETURNING "id", "comment"; 4 | -------------------------------------------------------------------------------- /agent/packaging/docker/Dockerfile.dev: -------------------------------------------------------------------------------- 1 | FROM dalibo/temboard-agent:snapshot 2 | 3 | COPY --from=ghcr.io/astral-sh/uv:latest /uv /usr/bin/ 4 | -------------------------------------------------------------------------------- /ui/.husky/pre-commit: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | . "$(dirname "$0")/_/husky.sh" 3 | 4 | cd ui 5 | npx lint-staged 6 | ruff check 7 | ruff format --check 8 | -------------------------------------------------------------------------------- /ui/temboardui/model/queries/apikeys-purge.sql: -------------------------------------------------------------------------------- 1 | DELETE FROM "application"."apikeys" 2 | WHERE "edate" < NOW() 3 | RETURNING "id", "comment"; 4 | -------------------------------------------------------------------------------- /docs/screenshots/alerting_notification_test.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dalibo/temboard/HEAD/docs/screenshots/alerting_notification_test.png -------------------------------------------------------------------------------- /docs/screenshots/maintenance_schedule_vacuum.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dalibo/temboard/HEAD/docs/screenshots/maintenance_schedule_vacuum.png -------------------------------------------------------------------------------- /ui/temboardui/static/src/public/images/heron.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dalibo/temboard/HEAD/ui/temboardui/static/src/public/images/heron.png -------------------------------------------------------------------------------- /docs/screenshots/alerting_notification_set_user.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dalibo/temboard/HEAD/docs/screenshots/alerting_notification_set_user.png -------------------------------------------------------------------------------- /ui/temboardui/model/queries/group-get.sql: -------------------------------------------------------------------------------- 1 | SELECT 2 | id, 3 | name, 4 | description 5 | FROM application.groups 6 | WHERE name = :name 7 | LIMIT 2; 8 | -------------------------------------------------------------------------------- /ui/temboardui/static/src/public/images/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dalibo/temboard/HEAD/ui/temboardui/static/src/public/images/favicon.ico -------------------------------------------------------------------------------- /ui/temboardui/static/src/public/images/heron-w.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dalibo/temboard/HEAD/ui/temboardui/static/src/public/images/heron-w.png -------------------------------------------------------------------------------- /ui/temboardui/static/src/public/images/sort_asc.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dalibo/temboard/HEAD/ui/temboardui/static/src/public/images/sort_asc.png -------------------------------------------------------------------------------- /ui/temboardui/model/queries/apikeys-insert.sql: -------------------------------------------------------------------------------- 1 | INSERT INTO "application"."apikeys"("secret", "comment") 2 | VALUES (:secret, :comment) 3 | RETURNING *; 4 | -------------------------------------------------------------------------------- /ui/temboardui/model/versions/011_drop-agent_key.sql: -------------------------------------------------------------------------------- 1 | BEGIN; 2 | 3 | ALTER TABLE application.instances DROP COLUMN IF EXISTS agent_key; 4 | 5 | COMMIT; 6 | -------------------------------------------------------------------------------- /ui/temboardui/static/src/public/images/sort_both.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dalibo/temboard/HEAD/ui/temboardui/static/src/public/images/sort_both.png -------------------------------------------------------------------------------- /ui/temboardui/static/src/public/images/sort_desc.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dalibo/temboard/HEAD/ui/temboardui/static/src/public/images/sort_desc.png -------------------------------------------------------------------------------- /docs/screenshots/alerting_notification_set_instance.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dalibo/temboard/HEAD/docs/screenshots/alerting_notification_set_instance.png -------------------------------------------------------------------------------- /ui/temboardui/static/src/public/images/temboard-logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dalibo/temboard/HEAD/ui/temboardui/static/src/public/images/temboard-logo.png -------------------------------------------------------------------------------- /ui/temboardui/model/queries/apikeys-select-active.sql: -------------------------------------------------------------------------------- 1 | SELECT 2 | id, secret, comment, cdate, edate 3 | FROM "application"."apikeys" 4 | WHERE "edate" > NOW(); 5 | -------------------------------------------------------------------------------- /ui/temboardui/model/queries/apikeys-select-secret.sql: -------------------------------------------------------------------------------- 1 | SELECT 2 | id, secret, comment, cdate, edate 3 | FROM "application"."apikeys" 4 | WHERE "secret" = :secret; 5 | -------------------------------------------------------------------------------- /ui/postcss.config.js: -------------------------------------------------------------------------------- 1 | export default { 2 | map: true, 3 | plugins: { 4 | autoprefixer: { 5 | browserlist: ["last 2 versions"], 6 | }, 7 | }, 8 | }; 9 | -------------------------------------------------------------------------------- /ui/temboardui/model/queries/instance-disable-plugin.sql: -------------------------------------------------------------------------------- 1 | DELETE FROM application.plugins WHERE agent_address = :address AND agent_port = :port AND plugin_name = :name; 2 | -------------------------------------------------------------------------------- /ui/temboardui/static/src/public/images/sort_asc_disabled.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dalibo/temboard/HEAD/ui/temboardui/static/src/public/images/sort_asc_disabled.png -------------------------------------------------------------------------------- /ui/temboardui/static/src/public/images/sort_desc_disabled.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dalibo/temboard/HEAD/ui/temboardui/static/src/public/images/sort_desc_disabled.png -------------------------------------------------------------------------------- /ui/temboardui/static/src/public/images/temboard-150x32-w.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dalibo/temboard/HEAD/ui/temboardui/static/src/public/images/temboard-150x32-w.png -------------------------------------------------------------------------------- /agent/packaging/nfpm/Makefile: -------------------------------------------------------------------------------- 1 | DISTDIR=../../dist 2 | 3 | default: 4 | 5 | build-%: 6 | mkdir -p $(DISTDIR) 7 | env -u COMPOSE_FILE docker compose run --rm -e CLEAN $* 8 | -------------------------------------------------------------------------------- /ui/packaging/nfpm/Makefile: -------------------------------------------------------------------------------- 1 | DISTDIR=../../dist 2 | 3 | default: 4 | 5 | build-%: 6 | mkdir -p $(DISTDIR) 7 | env -u COMPOSE_FILE docker compose run -e CLEAN -e CI --rm $* 8 | -------------------------------------------------------------------------------- /tests/fixtures/__init__.py: -------------------------------------------------------------------------------- 1 | from fixtures.agent import * # noqa: F401, F403 2 | from fixtures.postgres import * # noqa: F401, F403 3 | from fixtures.ui import * # noqa: F401, F403 4 | -------------------------------------------------------------------------------- /ui/temboardui/static/src/reset-password.js: -------------------------------------------------------------------------------- 1 | import { createApp } from "vue"; 2 | 3 | import ResetPassword from "./views/ResetPassword.vue"; 4 | 5 | createApp(ResetPassword).mount("#app"); 6 | -------------------------------------------------------------------------------- /agent/temboardagent/plugins/pgconf/types.py: -------------------------------------------------------------------------------- 1 | T_PGSETTINGS_CATEGORY = b"(^.{1,128}$)" 2 | T_FILE_VERSION = rb"([0-9]{4}\-[0-9]{2}\-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2})" 3 | T_NEW_VERSION = bool 4 | -------------------------------------------------------------------------------- /ui/temboardui/model/queries/instance-enable-plugin.sql: -------------------------------------------------------------------------------- 1 | INSERT INTO application.plugins (agent_address, agent_port, plugin_name) 2 | VALUES (:agent_address, :agent_port, :name) 3 | RETURNING *; 4 | -------------------------------------------------------------------------------- /ui/temboardui/model/queries/group-delete-membership.sql: -------------------------------------------------------------------------------- 1 | DELETE FROM application.memberships AS ms 2 | USING application.groups AS g 3 | WHERE g.name = :group AND ms.group_id = g.id AND ms.role_name = :role; 4 | -------------------------------------------------------------------------------- /agent/vendor.txt: -------------------------------------------------------------------------------- 1 | # This file was autogenerated by uv via the following command: 2 | # uv pip compile --python-version=3.9 agent/vendor.in -o agent/vendor.txt 3 | bottle==0.13.4 4 | # via -r agent/vendor.in 5 | -------------------------------------------------------------------------------- /ui/temboardui/model/versions/013_alerting-index.sql: -------------------------------------------------------------------------------- 1 | SET search_path TO monitoring, public; 2 | CREATE INDEX IF NOT EXISTS idx_state_changes_key 3 | ON monitoring.state_changes (check_id, key, datetime DESC); 4 | -------------------------------------------------------------------------------- /ui/temboardui/static/src/explain.js: -------------------------------------------------------------------------------- 1 | import { createApp } from "vue"; 2 | 3 | import Explain from "./views/Explain.vue"; 4 | 5 | createApp({ 6 | components: { 7 | explain: Explain, 8 | }, 9 | }).mount("#app"); 10 | -------------------------------------------------------------------------------- /ui/temboardui/static/src/settings.about.js: -------------------------------------------------------------------------------- 1 | import { createApp } from "vue"; 2 | 3 | import About from "./views/About.vue"; 4 | 5 | createApp({ 6 | components: { 7 | about: About, 8 | }, 9 | }).mount("#app"); 10 | -------------------------------------------------------------------------------- /agent/README.md: -------------------------------------------------------------------------------- 1 | # temBoard Agent 2 | 3 | This project contains the service monitoring and managing one PostgreSQL instance. 4 | 5 | Please refer to [temBoard](https://labs.dalibo.com/temboard) main project to see the full picture. 6 | -------------------------------------------------------------------------------- /docs/temboard-howto-statements.md: -------------------------------------------------------------------------------- 1 | The *statements* feature gives user performance statistics of the SQL queries executed on the instance. 2 | It relies on the `pg_stat_statements` extension. 3 | 4 | ![Statements](sc/statements.png) 5 | -------------------------------------------------------------------------------- /ui/temboardui/static/src/activity.js: -------------------------------------------------------------------------------- 1 | import { createApp } from "vue"; 2 | 3 | import Activity from "./views/Activity.vue"; 4 | 5 | createApp({ 6 | components: { 7 | activity: Activity, 8 | }, 9 | }).mount("#app"); 10 | -------------------------------------------------------------------------------- /ui/temboardui/static/src/dashboard.js: -------------------------------------------------------------------------------- 1 | import { createApp } from "vue"; 2 | 3 | import Dashboard from "./views/Dashboard.vue"; 4 | 5 | createApp({ 6 | components: { 7 | dashboard: Dashboard, 8 | }, 9 | }).mount("#app"); 10 | -------------------------------------------------------------------------------- /ui/packaging/nfpm/mkchanges.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash -eu 2 | 3 | srcdir=$(readlink -m "$0/..") 4 | DEB=$1 5 | CODENAME=$2 6 | CHANGES=${DEB/.deb/_${CODENAME}.changes} 7 | CODENAME=$CODENAME "$srcdir/simplechanges" $DEB > $CHANGES 8 | debsign $CHANGES 9 | -------------------------------------------------------------------------------- /agent/packaging/nfpm/mkchanges.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash -eu 2 | 3 | srcdir=$(readlink -m "$0/..") 4 | DEB=$1 5 | CODENAME=$2 6 | CHANGES=${DEB/.deb/_${CODENAME}.changes} 7 | CODENAME=$CODENAME "$srcdir/simplechanges" "$DEB" > "$CHANGES" 8 | debsign $CHANGES 9 | -------------------------------------------------------------------------------- /agent/tests/unit/test_core.py: -------------------------------------------------------------------------------- 1 | def test_version(): 2 | from packaging.version import parse 3 | from temboardagent.version import __version__ 4 | 5 | version = parse(__version__) 6 | 7 | assert "Version" == version.__class__.__name__ 8 | -------------------------------------------------------------------------------- /ui/temboardui/model/queries/group-insert-member.sql: -------------------------------------------------------------------------------- 1 | WITH "group" AS ( 2 | SELECT id, description FROM application.groups WHERE name = :group 3 | ) 4 | INSERT INTO application.memberships (role_name, group_id) 5 | SELECT :role, id FROM "group" AS g; 6 | -------------------------------------------------------------------------------- /ui/share/postinst.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash -eu 2 | 3 | if ! readlink /proc/1/exe | grep -q systemd ; then 4 | echo You must restart manually temboard service. >&2 5 | exit 0 6 | fi 7 | 8 | systemctl daemon-reload 9 | systemctl try-restart temboard 10 | -------------------------------------------------------------------------------- /ui/temboardui/static/src/pgconf.js: -------------------------------------------------------------------------------- 1 | import { createApp } from "vue"; 2 | 3 | import Configuration from "./views/Configuration.vue"; 4 | 5 | createApp({ 6 | components: { 7 | configuration: Configuration, 8 | }, 9 | }).mount("#app"); 10 | -------------------------------------------------------------------------------- /ui/temboardui/model/versions/000_init.sql: -------------------------------------------------------------------------------- 1 | CREATE SCHEMA IF NOT EXISTS application; 2 | CREATE TABLE IF NOT EXISTS application.schema_migration_log ( 3 | version TEXT UNIQUE NOT NULL PRIMARY KEY, 4 | comment TEXT, 5 | cdate TIMESTAMP DEFAULT NOW() 6 | ); 7 | -------------------------------------------------------------------------------- /ui/temboardui/static/src/instance.about.js: -------------------------------------------------------------------------------- 1 | import { createApp } from "vue"; 2 | 3 | import InstanceAbout from "./views/InstanceAbout.vue"; 4 | 5 | createApp({ 6 | components: { 7 | InstanceAbout: InstanceAbout, 8 | }, 9 | }).mount("#app"); 10 | -------------------------------------------------------------------------------- /ui/temboardui/plugins/monitoring/handlers/__init__.py: -------------------------------------------------------------------------------- 1 | from os.path import realpath 2 | 3 | from ....web.tornado import Blueprint, TemplateRenderer 4 | 5 | blueprint = Blueprint() 6 | render_template = TemplateRenderer(realpath(__file__ + "/../../templates")) 7 | -------------------------------------------------------------------------------- /ui/temboardui/static/src/maintenance.js: -------------------------------------------------------------------------------- 1 | import { createApp } from "vue"; 2 | 3 | import MaintenanceIndex from "./views/maintenance/Index.vue"; 4 | 5 | createApp({ 6 | components: { 7 | maintenanceindex: MaintenanceIndex, 8 | }, 9 | }).mount("#app"); 10 | -------------------------------------------------------------------------------- /ui/temboardui/static/src/reset-password-form.js: -------------------------------------------------------------------------------- 1 | import { createApp } from "vue"; 2 | 3 | import ResetPasswordForm from "./views/ResetPasswordForm.vue"; 4 | 5 | createApp(ResetPasswordForm, { 6 | token: document.getElementById("app").dataset.token, 7 | }).mount("#app"); 8 | -------------------------------------------------------------------------------- /agent/share/restart-all.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash -eu 2 | 3 | if ! readlink /proc/1/exe | grep -q systemd ; then 4 | echo You must restart manually temboard-agent services. >&2 5 | exit 0 6 | fi 7 | 8 | systemctl daemon-reload 9 | 10 | systemctl try-restart temboard-agent@* 11 | -------------------------------------------------------------------------------- /dev/requirements.txt: -------------------------------------------------------------------------------- 1 | check-manifest 2 | hupper 3 | pytest 4 | pytest-mock 5 | pytest-tornado 6 | pytz # for dev/perfui/ 7 | mock 8 | docutils 9 | pep440deb 10 | wheel 11 | ruff==0.14.4 12 | # For integration tests 13 | httpx 14 | selenium 15 | sh<2 16 | tenacity<8.4 17 | -------------------------------------------------------------------------------- /ui/temboardui/static/src/settings.members.js: -------------------------------------------------------------------------------- 1 | import { createApp } from "vue"; 2 | 3 | import MembersPage from "./components/settings/MembersPage.vue"; 4 | 5 | createApp({ 6 | components: { 7 | memberspage: MembersPage, 8 | }, 9 | }).mount("#members-app"); 10 | -------------------------------------------------------------------------------- /ui/temboardui/model/queries/roles-select-environments.sql: -------------------------------------------------------------------------------- 1 | SELECT 2 | e.id, 3 | e.name 4 | FROM application.environments AS e 5 | JOIN application.groups AS g ON g.id = e.dba_group_id 6 | JOIN application.memberships AS ms ON ms.group_id = g.id 7 | WHERE ms.role_name = :role_name; 8 | -------------------------------------------------------------------------------- /ui/temboardui/model/versions/007_drop-alembic.sql: -------------------------------------------------------------------------------- 1 | DROP TABLE IF EXISTS "application"."alembic_version"; 2 | CREATE TABLE IF NOT EXISTS "application"."schema_migration_log" ( 3 | version TEXT UNIQUE NOT NULL PRIMARY KEY, 4 | comment TEXT, 5 | cdate TIMESTAMP DEFAULT NOW() 6 | ); 7 | -------------------------------------------------------------------------------- /dev/git-blame-ignore-revs: -------------------------------------------------------------------------------- 1 | # Format with ruff 2 | 6d6a1edb1ff01f64e9b622134e94a9404a801a59 3 | d60e1e9077f94c10cb9c5849a0e933ef13c01f08 4 | fc61a4d1ea20c8f8a0b60eed785a1e7554ead89e 5 | c85b2e01c6b526a3308d47bbeb3a308872667182 6 | # Sort imports 7 | 4b23838e985f7c7f1de24fedd6f8170f20f62447 8 | -------------------------------------------------------------------------------- /docs/assets/rtd-material.js: -------------------------------------------------------------------------------- 1 | document.addEventListener('readystatechange', function() { 2 | if ('complete' != document.readyState) { 3 | return 4 | } 5 | 6 | var rtd = document.querySelector('div.injected') 7 | if (null !== rtd) { 8 | rtd.remove() 9 | } 10 | }) 11 | -------------------------------------------------------------------------------- /ui/temboardui/static/src/alerting.checks.js: -------------------------------------------------------------------------------- 1 | import { createApp } from "vue"; 2 | 3 | import AlertingChecks from "./views/AlertingChecks.vue"; 4 | 5 | createApp({ 6 | el: "#app", 7 | components: { 8 | "alerting-checks": AlertingChecks, 9 | }, 10 | }).mount("#app"); 11 | -------------------------------------------------------------------------------- /ui/temboardui/static/src/maintenance.database.js: -------------------------------------------------------------------------------- 1 | import { createApp } from "vue"; 2 | 3 | import MaintenanceDatabase from "./views/maintenance/Database.vue"; 4 | 5 | createApp({ 6 | components: { 7 | maintenancedatabase: MaintenanceDatabase, 8 | }, 9 | }).mount("#app"); 10 | -------------------------------------------------------------------------------- /ui/temboardui/static/src/maintenance.table.js: -------------------------------------------------------------------------------- 1 | import { createApp } from "vue"; 2 | 3 | import MaintenanceTable from "./views/maintenance/Table.vue"; 4 | 5 | createApp({ 6 | el: "#app", 7 | components: { 8 | maintenancetable: MaintenanceTable, 9 | }, 10 | }).mount("#app"); 11 | -------------------------------------------------------------------------------- /ui/temboardui/static/src/maintenance.schema.js: -------------------------------------------------------------------------------- 1 | import { createApp } from "vue"; 2 | 3 | import MaintenanceSchema from "./views/maintenance/Schema.vue"; 4 | 5 | createApp({ 6 | el: "#app", 7 | components: { 8 | maintenanceschema: MaintenanceSchema, 9 | }, 10 | }).mount("#app"); 11 | -------------------------------------------------------------------------------- /ui/temboardui/static/src/notifications.js: -------------------------------------------------------------------------------- 1 | import DataTablesLib from "datatables.net-bs5"; 2 | import DataTable from "datatables.net-vue3"; 3 | 4 | DataTable.use(DataTablesLib); 5 | 6 | new DataTablesLib("#tableNotifications", { 7 | order: [[0, "desc"]], 8 | pageLength: 25, 9 | }); 10 | -------------------------------------------------------------------------------- /ui/temboardui/model/queries/group-select-membership.sql: -------------------------------------------------------------------------------- 1 | SELECT 2 | ms.role_name AS username, 3 | g.name, 4 | g.description AS profile 5 | FROM application.groups AS g 6 | JOIN application.memberships AS ms ON ms.group_id = g.id 7 | WHERE g.name = :group AND ms.role_name = :role 8 | ORDER BY 1, 2; 9 | -------------------------------------------------------------------------------- /ui/temboardui/model/versions/009_apikey.sql: -------------------------------------------------------------------------------- 1 | CREATE TABLE "application"."apikeys" ( 2 | id SERIAL PRIMARY KEY, 3 | secret TEXT NOT NULL UNIQUE, 4 | comment TEXT, 5 | cdate TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(), 6 | edate TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW() + '6 months'::interval 7 | ); 8 | -------------------------------------------------------------------------------- /ui/temboardui/plugins/monitoring/routes.py: -------------------------------------------------------------------------------- 1 | # Flask routes 2 | from flask import current_app 3 | 4 | from ...web.flask import apikey_allowed, instance_proxy 5 | 6 | 7 | @instance_proxy.route("/monitoring/metrics") 8 | @apikey_allowed 9 | def get_metrics(): 10 | return current_app.instance.proxy() 11 | -------------------------------------------------------------------------------- /agent/temboardagent/queries/status.sql: -------------------------------------------------------------------------------- 1 | SELECT 2 | pg_is_in_recovery() AS is_standby, 3 | ( 4 | SELECT setting 5 | FROM pg_settings 6 | WHERE name = 'primary_conninfo') AS primary_conninfo, 7 | ( 8 | SELECT bool_or(pending_restart) 9 | FROM pg_settings 10 | ) AS pending_restart 11 | ; 12 | -------------------------------------------------------------------------------- /tests/monitoring-files: -------------------------------------------------------------------------------- 1 | # Sources files requiring monitoring tests on CI 2 | .circleci/ 3 | agent/temboardagent/plugins/monitoring/ 4 | tests/fixtures/ 5 | tests/monitoring-files 6 | tests/test_40_monitoring.py 7 | ui/temboardui/plugins/monitoring/ 8 | ui/temboardui/toolkit/taskmanager/ 9 | pyproject.toml 10 | -------------------------------------------------------------------------------- /tests/statements-files: -------------------------------------------------------------------------------- 1 | # Sources files requiring statements tests on CI 2 | .circleci/ 3 | agent/temboardagent/plugins/statements/ 4 | tests/fixtures/ 5 | tests/statements-files 6 | tests/test_50_statements.py 7 | ui/temboardui/plugins/statements/ 8 | ui/temboardui/toolkit/taskmanager/ 9 | pyproject.toml 10 | -------------------------------------------------------------------------------- /toolkit/pyproject.toml: -------------------------------------------------------------------------------- 1 | [project] 2 | name = "temboard-toolkit" 3 | version = "0.0.0" 4 | classifiers = ["Private :: Do Not Upload"] 5 | 6 | [build-system] 7 | requires = ["uv_build"] 8 | build-backend = "uv_build" 9 | 10 | [tool.uv.build-backend] 11 | module-name = "temboardtoolkit" 12 | module-root = "" 13 | -------------------------------------------------------------------------------- /agent/tests/unit/test_postgres.py: -------------------------------------------------------------------------------- 1 | def test_pickle(): 2 | from pickle import dumps as pickle 3 | from pickle import loads as unpickle 4 | 5 | from temboardagent.postgres import Postgres 6 | 7 | orig = Postgres(host="myhost") 8 | copy = unpickle(pickle(orig)) 9 | assert "myhost" == copy.host 10 | -------------------------------------------------------------------------------- /ui/temboardui/plugins/dashboard/__init__.py: -------------------------------------------------------------------------------- 1 | from ...web.flask import instance_proxy 2 | 3 | 4 | class DashboardPlugin: 5 | def __init__(self, app, **kw): 6 | self.app = app 7 | 8 | def load(self): 9 | __import__(__name__ + ".routes") 10 | instance_proxy.generic_proxy("/dashboard") 11 | -------------------------------------------------------------------------------- /agent/temboardagent/errors.py: -------------------------------------------------------------------------------- 1 | from temboardtoolkit.errors import UserError 2 | 3 | __all__ = ["UserError"] 4 | 5 | 6 | class NotificationError(Exception): 7 | """Notification errors""" 8 | 9 | def __init__(self, message): 10 | Exception.__init__(self, message) 11 | self.message = str(message) 12 | -------------------------------------------------------------------------------- /toolkit/temboardtoolkit/errors.py: -------------------------------------------------------------------------------- 1 | class TemboardError(Exception): 2 | """An internal temBoard error.""" 3 | 4 | 5 | class UserError(Exception): 6 | def __init__(self, message, retcode=1): 7 | super().__init__(message) 8 | self.retcode = retcode 9 | 10 | 11 | class StorageEngineError(Exception): 12 | pass 13 | -------------------------------------------------------------------------------- /agent/temboardagent/plugins/pgconf/__init__.py: -------------------------------------------------------------------------------- 1 | from bottle import default_app 2 | 3 | 4 | class PgConfPlugin: 5 | PG_MIN_VERSION = (90500, 9.5) 6 | 7 | def __init__(self, app, **kw): 8 | self.app = app 9 | 10 | def load(self): 11 | from .routes import bottle 12 | 13 | default_app().mount("/pgconf/", bottle) 14 | -------------------------------------------------------------------------------- /dev/grafana/entrypoint.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash -eux 2 | 3 | # Copie avant de changer parternité et droits. 4 | cp -arv /usr/local/lib/grafana/rootfs/* / 5 | 6 | chown -R root:root /etc/grafana 7 | chmod -R a+r /etc/grafana 8 | chown -R grafana: /var/lib/grafana 9 | chown -R grafana: /usr/share/grafana 10 | 11 | exec su -s "$SHELL" grafana -c /run.sh 12 | -------------------------------------------------------------------------------- /tests/psql: -------------------------------------------------------------------------------- 1 | #!/bin/bash -eu 2 | # 3 | # Helper to enter monitored psql. 4 | # 5 | # Keep it sync with conftest.py 6 | # 7 | 8 | # shellcheck disable=2086 9 | unset ${!PG*} 10 | 11 | export PGHOST=$PWD/tests/workdir/run/postgresql/ 12 | export PGPASSWORD=S3cret_postgres 13 | export PGPORT=55432 14 | export PGUSER=postgres 15 | 16 | exec "psql" "$@" 17 | -------------------------------------------------------------------------------- /ui/packaging/temboard.service: -------------------------------------------------------------------------------- 1 | [Unit] 2 | Description=temBoard Server 3 | After=network.target 4 | 5 | [Service] 6 | Type=simple 7 | User=temboard 8 | Group=temboard 9 | Environment="SYSTEMD=1" 10 | ExecStart=/usr/bin/temboard -c /etc/temboard/temboard.conf serve 11 | ExecReload=/bin/kill -HUP $MAINPID 12 | 13 | [Install] 14 | WantedBy=multi-user.target 15 | -------------------------------------------------------------------------------- /ui/temboardui/model/queries/environments-all.sql: -------------------------------------------------------------------------------- 1 | SELECT 2 | e.id, 3 | e.name, 4 | e.description, 5 | e.color, 6 | e.dba_group_id, 7 | g.id AS g_id, 8 | g.name AS g_name, 9 | g.description AS g_description 10 | FROM application.environments AS e 11 | JOIN application.groups AS g ON g.id = e.dba_group_id 12 | ORDER BY 2 13 | -------------------------------------------------------------------------------- /agent/temboardagent/queries/discover.sql: -------------------------------------------------------------------------------- 1 | SELECT 2 | version() AS version, 3 | (SELECT split_part(version(), ' ', 1) || ' ' || setting FROM pg_catalog.pg_settings WHERE name = 'server_version') AS version_summary, 4 | (SELECT setting::BIGINT FROM pg_catalog.pg_settings WHERE name = 'server_version_num') AS version_num, 5 | pg_postmaster_start_time() as start_time 6 | ; 7 | -------------------------------------------------------------------------------- /dev/prometheus/prometheus.yml: -------------------------------------------------------------------------------- 1 | scrape_configs: 2 | - job_name: temboard 3 | scrape_interval: 60s 4 | scheme: https 5 | authorization: 6 | type: Bearer 7 | credentials: UNSECURE_DEV_APIKEY 8 | tls_config: 9 | insecure_skip_verify: true 10 | file_sd_configs: 11 | - files: 12 | - /targets/temboard-dev.yaml 13 | - /targets/custom.yaml 14 | -------------------------------------------------------------------------------- /ui/temboardui/templates/flask/reset-password-form.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %} 2 | 3 | {% block content %} 4 |
5 | 6 |
7 | 8 | 9 | {% endblock %} 10 | -------------------------------------------------------------------------------- /tests/pytest.ini: -------------------------------------------------------------------------------- 1 | [pytest] 2 | addopts = -v --strict-markers -p no:anyio -p no:cov -p no:logging -p no:mock -p no:tornado 3 | cache_dir = .pytest_cache 4 | markers = 5 | slowmonitoring: (slow) tests of monitoring plugin 6 | slowstatements: (slow) tests of statements plugin 7 | 8 | junit_suite_name = e2e 9 | junit_duration_report = call 10 | junit_logging = all 11 | -------------------------------------------------------------------------------- /agent/temboardagent/plugins/monitoring/output.py: -------------------------------------------------------------------------------- 1 | def remove_passwords(instances): 2 | clean_instances = [] 3 | for instance in instances: 4 | clean_instance = {} 5 | for k in instance.keys(): 6 | if k != "password": 7 | clean_instance[k] = instance[k] 8 | clean_instances.append(clean_instance) 9 | return clean_instances 10 | -------------------------------------------------------------------------------- /ui/temboardui/templates/flask/reset-password.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %} 2 | 3 | {% block title %}temBoard / Reset password{% endblock %} 4 | 5 | {% block content %} 6 |
7 | 8 |
9 | 10 | 11 | {% endblock %} 12 | -------------------------------------------------------------------------------- /ui/temboardui/model/queries/instances-all.sql: -------------------------------------------------------------------------------- 1 | SELECT 2 | agent_address, 3 | agent_port, 4 | hostname, 5 | pg_port, 6 | notify, 7 | comment, 8 | discover, 9 | discover_date, 10 | discover_etag, 11 | environment_id, 12 | e.id AS e_id, 13 | e.name AS e_name 14 | FROM application.instances AS i 15 | JOIN application.environments AS e ON e.id = i.environment_id 16 | ORDER BY 1, 2; 17 | -------------------------------------------------------------------------------- /ui/temboardui/model/queries/monitoring-purge-hosts.sql: -------------------------------------------------------------------------------- 1 | WITH deleted AS ( 2 | SELECT 3 | h.host_id, 4 | h.hostname 5 | FROM monitoring.hosts AS h 6 | LEFT OUTER JOIN monitoring.instances AS i USING (host_id) 7 | WHERE i.instance_id IS NULL 8 | ) 9 | DELETE FROM monitoring.hosts AS h 10 | USING deleted 11 | WHERE h.host_id = deleted.host_id 12 | RETURNING deleted.hostname; 13 | -------------------------------------------------------------------------------- /ui/temboardui/model/queries/environments-get.sql: -------------------------------------------------------------------------------- 1 | SELECT 2 | e.id AS e_id, 3 | e.name AS e_name, 4 | e.description AS e_description, 5 | e.color, 6 | e.dba_group_id, 7 | g.id AS g_id, 8 | g.name AS g_name, 9 | g.description AS g_description 10 | FROM application.environments AS e 11 | JOIN application.groups AS g ON g.id = e.dba_group_id 12 | WHERE e.name = :name 13 | -------------------------------------------------------------------------------- /ui/temboardui/errors.py: -------------------------------------------------------------------------------- 1 | class CLIError(Exception): 2 | """CLI errors""" 3 | 4 | def __init__(self, message): 5 | Exception.__init__(self, message) 6 | self.message = str(message) 7 | 8 | 9 | class TemboardUIError(Exception): 10 | def __init__(self, code, message): 11 | Exception.__init__(self, message) 12 | self.code = int(code) 13 | self.message = str(message) 14 | -------------------------------------------------------------------------------- /ui/temboardui/model/queries/environment-memberships.sql: -------------------------------------------------------------------------------- 1 | SELECT 2 | r.role_name AS username, 3 | g.name AS groupname, 4 | g.description AS profile 5 | FROM application.environments AS e 6 | JOIN application.groups AS g ON g.id = e.dba_group_id 7 | JOIN application.memberships AS ms ON ms.group_id = g.id 8 | JOIN application.roles AS r on r.role_name = ms.role_name 9 | WHERE e.name = :name 10 | ORDER BY 1, 2; 11 | -------------------------------------------------------------------------------- /ui/temboardui/plugins/pgconf/routes.py: -------------------------------------------------------------------------------- 1 | from flask import current_app, render_template 2 | 3 | from ...web.flask import instance_routes 4 | 5 | 6 | @instance_routes.route("/pgconf/configuration", methods=["GET"]) 7 | def pgconf_configuration(): 8 | current_app.instance.check_active_plugin("pgconf") 9 | current_app.instance.fetch_status() 10 | 11 | return render_template("configuration.html", plugin="pgconf") 12 | -------------------------------------------------------------------------------- /ui/.prettierrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "printWidth": 120, 3 | "plugins": ["@trivago/prettier-plugin-sort-imports", "prettier-plugin-jinja-template"], 4 | "importOrder": ["", "^[./]"], 5 | "importOrderSeparation": true, 6 | "importOrderSortSpecifiers": true, 7 | "overrides": [ 8 | { 9 | "files": "*.html", 10 | "options": { 11 | "parser": "jinja-template" 12 | } 13 | } 14 | ] 15 | } 16 | -------------------------------------------------------------------------------- /ui/temboardui/templates/flask/explain.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %} 2 | 3 | {% block title %}temBoard / PEV2{% endblock %} 4 | 5 | {% block head %} 6 | {% for link in vitejs.css_links_for('explain.js') %}{{ link|safe }}{% endfor %} 7 | {% endblock %} 8 | 9 | {% block content %} 10 |
11 | 12 |
13 | 14 | {% endblock %} 15 | -------------------------------------------------------------------------------- /ui/tests/unit/test_pivot.py: -------------------------------------------------------------------------------- 1 | from io import StringIO 2 | 3 | 4 | def test_pivot(): 5 | from temboardui.plugins.monitoring.pivot import pivot_timeserie 6 | 7 | in_ = StringIO("i,k,v\n1,a,1\n1,b,2\n2,a,3\n4,b,5\n100,z,100\n") 8 | expected = "i,a,b,z\n1,1,2,\n2,3,,\n4,,5,\n100,,,100\n" 9 | out_ = StringIO() 10 | pivot_timeserie(in_, index="i", key="k", value="v", output=out_) 11 | assert out_.getvalue() == expected 12 | -------------------------------------------------------------------------------- /ui/temboardui/model/queries/instance-has-dba.sql: -------------------------------------------------------------------------------- 1 | SELECT EXISTS ( 2 | SELECT COUNT(1) 3 | FROM application.instances AS i 4 | JOIN application.environments AS e ON e.id = i.environment_id 5 | JOIN application.groups AS g ON g.id = e.dba_group_id 6 | JOIN application.memberships AS ms ON ms.group_id = g.id 7 | WHERE i.agent_address = :agent_address AND i.agent_port = :agent_port 8 | AND ms.role_name = :role_name 9 | ) AS has_dba; 10 | -------------------------------------------------------------------------------- /dev/bin/mkenv: -------------------------------------------------------------------------------- 1 | #!/bin/bash -eu 2 | # 3 | # Generate environment variables for development. 4 | # 5 | 6 | # Configure a routable UI address to configure agent-to-UI communication from 7 | # Docker container. 8 | 9 | mapfile -t hostaddr < <(hostname --all-ip-addresses | tr ' ' '\n') 10 | test -n "${hostaddr[*]}" 11 | TEMBOARD_UI_URL="http://${hostaddr[0]}:8888" 12 | 13 | cat <<-EOF 14 | # Generated by $0 at $(date). 15 | TEMBOARD_UI_URL=${TEMBOARD_UI_URL@Q} 16 | EOF 17 | -------------------------------------------------------------------------------- /dev/postgres-setup-replication.sh: -------------------------------------------------------------------------------- 1 | mkdir -p "/var/lib/postgresql/archive" 2 | 3 | docker_process_sql <> "$PGDATA/pg_hba.conf" < 7 | 8 | 9 | 10 | 11 | 14 | 15 | {% end %} 16 | -------------------------------------------------------------------------------- /toolkit/tests/test_perf.py: -------------------------------------------------------------------------------- 1 | def test_format(): 2 | from temboardtoolkit.perf import PerfCounters 3 | 4 | p = PerfCounters() 5 | 6 | p["a"] = 1 7 | p["bc"] = 1 8 | p["ba"] = 1 9 | 10 | assert "a=1 ba=1 bc=1" in str(p) 11 | 12 | 13 | def test_stat(): 14 | from temboardtoolkit.perf import PerfCounters 15 | 16 | p = PerfCounters() 17 | 18 | p.snapshot() 19 | 20 | assert "io_rchar" in p 21 | assert "io_wchar" in p 22 | assert 0 != p["load1"] 23 | -------------------------------------------------------------------------------- /ui/share/sql/upgrade-4-5.sql: -------------------------------------------------------------------------------- 1 | BEGIN; 2 | 3 | CREATE TABLE IF NOT EXISTS monitoring.collector_status ( 4 | instance_id INTEGER PRIMARY KEY REFERENCES monitoring.instances(instance_id) ON DELETE CASCADE, 5 | last_pull TIMESTAMP WITHOUT TIME ZONE, 6 | last_push TIMESTAMP WITHOUT TIME ZONE, 7 | last_insert TIMESTAMP WITHOUT TIME ZONE, 8 | status CHAR(12) CHECK (status = 'OK' OR status = 'FAIL') 9 | ); 10 | 11 | GRANT ALL ON TABLE monitoring.collector_status TO temboard; 12 | 13 | COMMIT; 14 | -------------------------------------------------------------------------------- /ui/temboardui/model/queries/roles-all.sql: -------------------------------------------------------------------------------- 1 | SELECT 2 | r.role_name, 3 | role_email, 4 | role_phone, 5 | is_active, 6 | is_admin, 7 | g.id AS g_id, 8 | g.name AS g_name, 9 | e.id AS e_id, 10 | e.name AS e_name 11 | FROM application.roles AS r 12 | LEFT OUTER JOIN application.memberships AS ms ON ms.role_name = r.role_name 13 | LEFT OUTER JOIN application.groups AS g ON ms.group_id = g.id 14 | LEFT OUTER JOIN application.environments AS e ON e.dba_group_id = g.id 15 | ORDER BY 1, 9; 16 | -------------------------------------------------------------------------------- /ui/temboardui/model/queries/roles-get.sql: -------------------------------------------------------------------------------- 1 | SELECT 2 | r.role_name, 3 | role_email, 4 | role_phone, 5 | is_active, 6 | is_admin, 7 | g.id AS g_id, 8 | g.name, 9 | e.id AS e_id, 10 | e.name AS e_name 11 | FROM application.roles AS r 12 | LEFT OUTER JOIN application.memberships AS ms ON ms.role_name = r.role_name 13 | LEFT OUTER JOIN application.groups AS g ON g.id = ms.group_id 14 | LEFT OUTER JOIN application.environments AS e ON e.dba_group_id = g.id 15 | WHERE r.role_name = :name; 16 | -------------------------------------------------------------------------------- /agent/temboardagent/queries/discover-settings.sql: -------------------------------------------------------------------------------- 1 | SELECT 2 | "name", "vartype", "unit", "setting" 3 | FROM pg_catalog.pg_settings 4 | WHERE "name" IN ( 5 | 'config_file' 6 | ,'cluster_name' 7 | ,'data_directory' 8 | ,'external_pid_file' 9 | ,'hba_file' 10 | ,'ident_file' 11 | ,'lc_collate' 12 | ,'lc_ctype' 13 | ,'listen_addresses' 14 | ,'max_connections' 15 | ,'port' 16 | ,'server_encoding' 17 | ,'syslog_ident' 18 | ,'unix_socket_directories' 19 | ) OR context = 'internal'; 20 | -------------------------------------------------------------------------------- /ui/temboardui/handlers/notification.py: -------------------------------------------------------------------------------- 1 | from ..web.tornado import app, render_template 2 | 3 | 4 | @app.instance_route(r"/notifications") 5 | def notifications(request): 6 | notifications = request.instance.get("/notifications") 7 | request.instance.fetch_status() 8 | return render_template( 9 | "notifications.html", 10 | instance=request.instance, 11 | notifications=notifications, 12 | plugin="notifications", 13 | role=request.current_user, 14 | ) 15 | -------------------------------------------------------------------------------- /ui/temboardui/static/src/daterangepicker-override.scss: -------------------------------------------------------------------------------- 1 | $daterangepicker-active-bg-color: $primary; 2 | $daterangepicker-ranges-hover-bg-color: $primary; 3 | $daterangepicker-control-active-border-color: $primary; 4 | $daterangepicker-ranges-color: $primary; 5 | $daterangepicker-cell-hover-bg-color: lighten($primary, 50%); 6 | $daterangepicker-in-range-bg-color: lighten($primary, 60%); 7 | $daterangepicker-z-index: $zindex-dropdown !default; 8 | 9 | .daterangepicker { 10 | z-index: 1151 !important; 11 | } 12 | -------------------------------------------------------------------------------- /agent/temboardagent/cli/web.py: -------------------------------------------------------------------------------- 1 | from temboardtoolkit import services 2 | from temboardtoolkit.app import SubCommand 3 | 4 | from .app import app 5 | 6 | 7 | @app.command 8 | class Web(SubCommand): 9 | """Standalone web server 10 | 11 | For testing purpose only. Some feature won't work without combined task 12 | manager processes. See serve command for production. 13 | 14 | """ 15 | 16 | is_service = True 17 | 18 | def main(self, args): 19 | return services.run(self.app.httpd) 20 | -------------------------------------------------------------------------------- /dev/grafana/rootfs/etc/grafana/provisioning/datasources/loki.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: 1 2 | 3 | datasources: 4 | - "id": 1 5 | "uid": "j1tzJKb7k" 6 | "orgId": 1 7 | "name": "Loki" 8 | "type": "loki" 9 | "typeName": "Loki" 10 | "typeLogoUrl": "public/app/plugins/datasource/loki/img/loki_icon.svg" 11 | "access": "proxy" 12 | "url": "http://loki:3100" 13 | "password": "" 14 | "user": "" 15 | "database": "" 16 | "basicAuth": false 17 | "isDefault": false 18 | "jsonData": {} 19 | "readOnly": false 20 | -------------------------------------------------------------------------------- /ui/temboardui/templates/error.html: -------------------------------------------------------------------------------- 1 | {% extends base.html %} 2 | 3 | {% block title %}temBoard{% end %} 4 | 5 | {% block content %} 6 |
7 |
8 | 14 |
15 |
16 | {% end %} 17 | -------------------------------------------------------------------------------- /ui/temboardui/templates/flask/error.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %} 2 | 3 | {% block title %}{{ super() }}/ Error{% endblock %} 4 | 5 | {% block content %} 6 |
7 |
8 | 13 |
14 |
15 | {% endblock %} 16 | -------------------------------------------------------------------------------- /toolkit/temboardtoolkit/logfmt.py: -------------------------------------------------------------------------------- 1 | def format(**kw): 2 | return " ".join([format_attribute(*i) for i in sorted(flatten(**kw))]) 3 | 4 | 5 | def format_attribute(key, value): 6 | if " " in str(value): 7 | value = '"%s"' % value 8 | return "%s=%s" % (key, value) 9 | 10 | 11 | def flatten(**kw): 12 | for k, v in kw.items(): 13 | if isinstance(v, dict): 14 | for kk, vv in flatten(**v): 15 | yield "%s.%s" % (k, kk), vv 16 | else: 17 | yield k, v 18 | -------------------------------------------------------------------------------- /ui/temboardui/cli/serve.py: -------------------------------------------------------------------------------- 1 | from temboardtoolkit import services 2 | from temboardtoolkit.app import SubCommand 3 | 4 | from ..model import check_schema 5 | from .app import app 6 | 7 | 8 | @app.command 9 | class Serve(SubCommand): 10 | """Combined web server and background workers.""" 11 | 12 | is_service = True 13 | 14 | def main(self, args): 15 | check_schema() 16 | self.app.config.load_signing_key() 17 | 18 | services.run(self.app.webservice, self.app.scheduler, self.app.worker_pool) 19 | -------------------------------------------------------------------------------- /ui/temboardui/model/queries/roles-select-instances.sql: -------------------------------------------------------------------------------- 1 | SELECT 2 | i.agent_address, 3 | i.agent_port, 4 | i.hostname, 5 | i.pg_port, 6 | i.notify, 7 | i.comment, 8 | i.cdate, 9 | i.discover, 10 | i.discover_date, 11 | i.discover_etag, 12 | i.environment_id 13 | FROM application.instances AS i 14 | JOIN application.environment AS e ON e.id = i.environment_id 15 | JOIN application.groups AS g ON g.id = e.group_id 16 | JOIN application.memberships AS m ON m.group_id = g.id 17 | WHERE m.role_name = :role_name; 18 | -------------------------------------------------------------------------------- /agent/packaging/temboard-agent@.service: -------------------------------------------------------------------------------- 1 | [Unit] 2 | Description=PostgreSQL Remote Control Agent %I 3 | After=network.target postgresql@%i.service 4 | AssertPathExists=/etc/temboard-agent/%I/temboard-agent.conf 5 | 6 | [Service] 7 | Type=simple 8 | User=postgres 9 | Group=postgres 10 | Environment="SYSTEMD=1" 11 | ExecStart=/usr/bin/temboard-agent -c /etc/temboard-agent/%I/temboard-agent.conf serve 12 | # Increase OOM Score to ensure agent is killed before Postgres. 13 | OOMScoreAdjust=15 14 | 15 | [Install] 16 | WantedBy=multi-user.target 17 | -------------------------------------------------------------------------------- /.config/.readthedocs.yaml: -------------------------------------------------------------------------------- 1 | version: 2 2 | 3 | build: 4 | os: ubuntu-22.04 5 | tools: 6 | python: "3.9" 7 | jobs: 8 | pre_create_environment: 9 | - asdf plugin add uv 10 | - asdf install uv latest 11 | - asdf global uv latest 12 | create_environment: 13 | - uv venv "${READTHEDOCS_VIRTUALENV_PATH}" 14 | install: 15 | - UV_PROJECT_ENVIRONMENT="${READTHEDOCS_VIRTUALENV_PATH}" uv sync --frozen --no-default-groups --group=docs 16 | 17 | mkdocs: 18 | configuration: mkdocs.yml 19 | fail_on_warning: true 20 | -------------------------------------------------------------------------------- /dev/bin/checkdocker: -------------------------------------------------------------------------------- 1 | #!/bin/bash -eu 2 | # Usage: dev/bin/checkdocker 3 | 4 | max="$1" 5 | current="$(docker version --format "{{ .Server.Version }}")" 6 | lowest="$(echo -e "$max\n$current" | sort --version-sort | head -1)" 7 | if [ "$lowest" == "$max" ]; then 8 | echo "Unsupported Docker version $current. Please fix develop for this version of Docker." >&2 9 | exit 1 10 | fi 11 | if ! docker compose version >/dev/null; then 12 | echo "docker compose not found. Please install docker compose v2." >&2 13 | exit 1 14 | fi 15 | -------------------------------------------------------------------------------- /ui/temboardui/templates/flask/401.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %} 2 | 3 | {% block title %}{{ super() }}/ Authentication required{% endblock %} 4 | 5 | {% block content %} 6 |
7 |
8 | 12 |
13 |
14 | {% endblock %} 15 | -------------------------------------------------------------------------------- /ui/temboardui/templates/flask/404.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %} 2 | 3 | {% block title %}{{ super() }}/ Not Found{% endblock %} 4 | 5 | {% block content %} 6 |
7 |
8 | 12 |
13 |
14 | {% endblock %} 15 | -------------------------------------------------------------------------------- /ui/temboardui/templates/flask/home.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %} 2 | 3 | {% block title %}temBoard{% endblock %} 4 | 5 | {% block head %} 6 | {% for link in vitejs.css_links_for('home.js') %} 7 | {{ link | safe}} 8 | {% endfor %} 9 | {% endblock %} 10 | 11 | {% block content %} 12 |
13 | 16 | 17 |
18 | 19 | {% endblock %} 20 | -------------------------------------------------------------------------------- /toolkit/tests/test_queries.py: -------------------------------------------------------------------------------- 1 | def test_filter_pg_version(): 2 | from temboardtoolkit.queries import filter_pragma_version 3 | 4 | assert filter_pragma_version("pouet", pg_version=150000) 5 | 6 | sql = "pouet -- pragma:pg_version_max 140000" 7 | assert not filter_pragma_version(sql, pg_version=150000) 8 | assert filter_pragma_version(sql, pg_version=100000) 9 | 10 | sql = "pouet -- pragma:pg_version_min 140000" 11 | assert not filter_pragma_version(sql, pg_version=100000) 12 | assert filter_pragma_version(sql, pg_version=150000) 13 | -------------------------------------------------------------------------------- /ui/temboardui/model/queries/monitoring-purge-instances.sql: -------------------------------------------------------------------------------- 1 | WITH deleted AS ( 2 | SELECT 3 | i.instance_id, 4 | h.hostname, 5 | i.port 6 | FROM monitoring.instances AS i 7 | JOIN monitoring.hosts AS h USING (host_id) 8 | LEFT OUTER JOIN application.instances AS agent 9 | ON agent.hostname = h.hostname AND agent.pg_port = i.port 10 | WHERE agent.agent_address IS NULL 11 | ) 12 | DELETE FROM monitoring.instances AS i 13 | USING deleted 14 | WHERE i.instance_id = deleted.instance_id 15 | RETURNING deleted.hostname, deleted.port; 16 | -------------------------------------------------------------------------------- /ui/temboardui/templates/flask/configuration.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %} 2 | 3 | {% block title %}{{super()}} / Configuration{% endblock %} 4 | 5 | {% block head %} 6 | {% for link in vitejs.css_links_for('home.js') %} 7 | {{ link | safe}} 8 | {% endfor %} 9 | {% endblock %} 10 | 11 | {% block content %} 12 |
13 | 17 |
18 | 19 | {% endblock %} 20 | -------------------------------------------------------------------------------- /ui/temboardui/templates/flask/instance-about.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %} 2 | 3 | {% block title %}{{ super() }}/ About{% endblock %} 4 | 5 | {% block content %} 6 |
7 | 14 | 15 |
16 | 17 | {% endblock %} 18 | -------------------------------------------------------------------------------- /ui/temboardui/static/src/home.js: -------------------------------------------------------------------------------- 1 | import { createApp } from "vue"; 2 | import { createRouter, createWebHistory } from "vue-router"; 3 | 4 | import Home from "./views/Home.vue"; 5 | 6 | const NotFound = { template: "" }; 7 | const router = createRouter({ 8 | history: createWebHistory(), 9 | // at least one route is required, we use a fake one 10 | routes: [{ path: "/:pathMatch(.*)*", name: "not-found", component: NotFound }], 11 | }); 12 | 13 | createApp({ 14 | components: { 15 | home: Home, 16 | }, 17 | }) 18 | .use(router) 19 | .mount("#app"); 20 | -------------------------------------------------------------------------------- /ui/temboardui/templates/flask/about.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %} 2 | 3 | {% block title %}temBoard / About{% endblock %} 4 | 5 | {% block head %} 6 | {% for link in vitejs.css_links_for('settings.about.js') %} 7 | {{ link | safe}} 8 | {% endfor %} 9 | {% endblock %} 10 | 11 | {% block content %} 12 | 13 |
14 | 17 | 18 |
19 | 20 | {% endblock %} 21 | -------------------------------------------------------------------------------- /agent/temboardagent/cli/discover.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import sys 3 | 4 | from temboardtoolkit.app import SubCommand 5 | 6 | from .app import app 7 | 8 | logger = logging.getLogger(__name__) 9 | 10 | 11 | @app.command 12 | class Discover(SubCommand): 13 | """Introspect system and PostgreSQL instance.""" 14 | 15 | def main(self, args): 16 | self.app.discover.refresh() 17 | self.app.discover.write() 18 | self.app.discover.write(sys.stdout) 19 | logger.debug("Discover etag is %s.", self.app.discover.etag) 20 | return 0 21 | -------------------------------------------------------------------------------- /ui/temboardui/templates/unauthorized.html: -------------------------------------------------------------------------------- 1 | {% extends base.html %} 2 | 3 | {% block title %}temBoard{% end %} 4 | 5 | {% block content %} 6 |
7 |
8 | 13 |
14 |
15 |
16 | {% end %} 17 | -------------------------------------------------------------------------------- /ui/share/preun.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | set -eu 3 | mode="$1" 4 | 5 | # rpm uses an int. deb uses a keyword. 6 | case "$mode" in 7 | remove|0) 8 | if ! readlink /proc/1/exe | grep -q systemd ; then 9 | echo You must disable manually temboard service. >&2 10 | exit 0 11 | fi 12 | 13 | systemctl disable --now temboard 14 | if systemctl -q is-failed temboard ; then 15 | systemctl reset-failed temboard 16 | fi 17 | exit 0 18 | ;; 19 | upgrade|1) 20 | exit 0 21 | ;; 22 | *) 23 | echo "Unknown scriptlet execution mode: '$mode'. Skipping" >&2 24 | exit 0 25 | ;; 26 | esac 27 | -------------------------------------------------------------------------------- /ui/temboardui/static/src/monitoring.js: -------------------------------------------------------------------------------- 1 | import { createApp } from "vue"; 2 | import { createRouter, createWebHistory } from "vue-router"; 3 | 4 | import Monitoring from "./views/Monitoring.vue"; 5 | 6 | const NotFound = { template: "" }; 7 | const router = createRouter({ 8 | history: createWebHistory(), 9 | // at least one route is required, we use a fake one 10 | routes: [{ path: "/:pathMatch(.*)*", name: "not-found", component: NotFound }], 11 | }); 12 | 13 | createApp({ 14 | components: { 15 | monitoring: Monitoring, 16 | }, 17 | }) 18 | .use(router) 19 | .mount("#app"); 20 | -------------------------------------------------------------------------------- /ui/temboardui/static/src/statements.js: -------------------------------------------------------------------------------- 1 | import { createApp } from "vue"; 2 | import { createRouter, createWebHistory } from "vue-router"; 3 | 4 | import Statements from "./views/Statements.vue"; 5 | 6 | const NotFound = { template: "" }; 7 | const router = createRouter({ 8 | history: createWebHistory(), 9 | // at least one route is required, we use a fake one 10 | routes: [{ path: "/:pathMatch(.*)*", name: "not-found", component: NotFound }], 11 | }); 12 | 13 | createApp({ 14 | components: { 15 | statements: Statements, 16 | }, 17 | }) 18 | .use(router) 19 | .mount("#app"); 20 | -------------------------------------------------------------------------------- /ui/temboardui/templates/flask/activity.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %} 2 | {% block title %}temBoard / {{instance}} / Activity{% endblock %} 3 | 4 | {% block head %} 5 | {% for link in vitejs.css_links_for('activity.js') %}{{ link | safe }}{% endfor %} 6 | {% endblock %} 7 | 8 | {% block content %} 9 |
10 | 11 |
12 | 13 | 14 | 18 | 19 | {% endblock %} 20 | -------------------------------------------------------------------------------- /ui/temboardui/__init__.py: -------------------------------------------------------------------------------- 1 | import os 2 | import sys 3 | 4 | if True: # Vendoring 5 | parent_dir = os.path.abspath(os.path.dirname(__file__)) 6 | vendor_dir = os.path.join(parent_dir, "_vendor") 7 | sys.path[:0] = [vendor_dir] 8 | 9 | import warnings 10 | 11 | import temboardtoolkit.log 12 | 13 | from .version import __version__ # noqa 14 | 15 | if "DEBUG" not in os.environ and "CI" not in os.environ: 16 | warnings.filterwarnings("ignore") 17 | 18 | # Configure toolkit root logger name before importing toolkit's getLogger. 19 | temboardtoolkit.log.LastnameFilter.root = __name__ 20 | -------------------------------------------------------------------------------- /agent/temboardagent/__init__.py: -------------------------------------------------------------------------------- 1 | import os 2 | import sys 3 | import warnings 4 | 5 | if True: # Vendoring 6 | parent_dir = os.path.abspath(os.path.dirname(__file__)) 7 | vendor_dir = os.path.join(parent_dir, "_vendor") 8 | sys.path[:0] = [vendor_dir] 9 | 10 | import temboardtoolkit.log 11 | 12 | from .version import __version__ # noqa 13 | 14 | if "DEBUG" not in os.environ and "CI" not in os.environ: 15 | warnings.filterwarnings("ignore") 16 | 17 | 18 | # Configure toolkit root logger name before importing toolkit's getLogger. 19 | temboardtoolkit.log.LastnameFilter.root = __name__ 20 | -------------------------------------------------------------------------------- /dev/grafana/rootfs/etc/grafana/provisioning/dashboards/perf.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: 1 2 | 3 | providers: 4 | - name: "temBoard Performance Provisionned" 5 | type: file 6 | folder: '' 7 | allowUiUpdates: true 8 | options: 9 | path: /usr/share/temboard/perf.json 10 | - name: "Postgres from temBoard agent" 11 | type: file 12 | folder: '' 13 | allowUiUpdates: true 14 | options: 15 | path: /usr/share/temboard/postgres.json 16 | - name: "temBoard Prometheus" 17 | type: file 18 | folder: '' 19 | allowUiUpdates: true 20 | options: 21 | path: /usr/share/temboard/temboard-prometheus.json 22 | -------------------------------------------------------------------------------- /ui/temboardui/static/src/alerting.check.js: -------------------------------------------------------------------------------- 1 | import { createApp } from "vue"; 2 | import { createRouter, createWebHistory } from "vue-router"; 3 | 4 | import AlertingCheck from "./views/AlertingCheck.vue"; 5 | 6 | const NotFound = { template: "" }; 7 | const router = createRouter({ 8 | history: createWebHistory(), 9 | // at least one route is required, we use a fake one 10 | routes: [{ path: "/:pathMatch(.*)*", name: "not-found", component: NotFound }], 11 | }); 12 | 13 | createApp({ 14 | components: { 15 | alertingCheck: AlertingCheck, 16 | }, 17 | }) 18 | .use(router) 19 | .mount("#app"); 20 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | charset = utf-8 5 | indent_style = space 6 | indent_size = 4 7 | # Unix-style newlines 8 | end_of_line = lf 9 | # Remove any whitespace characters preceding newline characters 10 | trim_trailing_whitespace = true 11 | # Newline ending every file 12 | insert_final_newline = true 13 | # Sync it with default ruff value. 14 | max_line_length = 88 15 | 16 | # 2 space indentation for UI 17 | [*.{css,html,js,json,scss,yml,yaml,sql,vue}] 18 | indent_size = 2 19 | 20 | [{*.sh,Makefile}] 21 | indent_size = 8 22 | indent_style = tab 23 | 24 | [dev/lnav/**.json] 25 | indent_size = 4 26 | -------------------------------------------------------------------------------- /ui/temboardui/cli/web.py: -------------------------------------------------------------------------------- 1 | from temboardtoolkit import services 2 | from temboardtoolkit.app import SubCommand 3 | 4 | from ..model import check_schema 5 | from .app import app 6 | 7 | 8 | @app.command 9 | class web(SubCommand): 10 | """Standalone web server. 11 | 12 | For testing purpose only. Some feature won't work without combined task 13 | manager processes. See serve command for production. 14 | 15 | """ 16 | 17 | is_service = True 18 | 19 | def main(self, args): 20 | check_schema() 21 | self.app.config.load_signing_key() 22 | services.run(self.app.webservice) 23 | -------------------------------------------------------------------------------- /ui/temboardui/templates/flask/maintenance/index.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %} 2 | 3 | {% block title %}{{ super() }}/ Maintenance{% endblock %} 4 | 5 | {% block content %} 6 | 7 | {% include "maintenance/breadcrumb.html" %} 8 |
9 | 16 |
17 | 18 | 19 | {% endblock %} 20 | -------------------------------------------------------------------------------- /ui/temboardui/plugins/pgconf/__init__.py: -------------------------------------------------------------------------------- 1 | from ...web.flask import instance_proxy 2 | 3 | 4 | class PGConfPlugin: 5 | def __init__(self, app): 6 | self.app = app 7 | 8 | def load(self): 9 | __import__(__name__ + ".routes") 10 | instance_proxy.generic_proxy("/pgconf/configuration") 11 | instance_proxy.generic_proxy("/pgconf/configuration", method="POST") 12 | instance_proxy.generic_proxy("/pgconf/configuration/categories") 13 | instance_proxy.generic_proxy("/pgconf/configuration/status") 14 | instance_proxy.generic_proxy("/pgconf/configuration/category/") 15 | -------------------------------------------------------------------------------- /dev/bin/mktargets: -------------------------------------------------------------------------------- 1 | #!/bin/bash -eu 2 | 3 | # shellcheck source=/dev/null 4 | . .env 5 | 6 | target="${TEMBOARD_UI_URL#https://}" 7 | 8 | cat <<-EOF 9 | - targets: 10 | - $target 11 | labels: 12 | __metrics_path__: /proxy/0.0.0.0/2345/monitoring/metrics 13 | instance: "postgres0.dev:5432" 14 | # For PostgreSQL Database dashboard #9628 15 | kubernetes_namespace: nok8s 16 | release: norelease 17 | - targets: 18 | - $target 19 | labels: 20 | __metrics_path__: /proxy/0.0.0.0/2346/monitoring/metrics 21 | instance: "postgres1.dev:5432" 22 | kubernetes_namespace: nok8s 23 | release: norelease 24 | EOF 25 | -------------------------------------------------------------------------------- /ui/temboardui/templates/flask/settings/members.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %} 2 | 3 | {% block title %}temBoard / Settings / Environments / {{ environment }} / Members{% endblock %} 4 | {% block head %} 5 | {% for link in vitejs.css_links_for('settings.members.js') %}{{ link|safe }}{% endfor %} 6 | {% endblock %} 7 | 8 | {% block sidebar %} 9 | {% include "settings/menu.html" %} 10 | {% endblock %} 11 | 12 | {% block content %} 13 |
14 | 15 |
16 | 17 | {% endblock %} 18 | -------------------------------------------------------------------------------- /ui/temboardui/static/src/public/images/ring-alt.svg: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /ui/temboardui/templates/flask/403.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %} 2 | 3 | {% block title %}{{ super() }}/ Restricted area{% endblock %} 4 | 5 | {% block content %} 6 |
7 |
8 | 14 |
15 |
16 | {% endblock %} 17 | -------------------------------------------------------------------------------- /ui/temboardui/model/queries/instance-select-email-and-phone-for-notify.sql: -------------------------------------------------------------------------------- 1 | SELECT 2 | array_agg(DISTINCT r.role_email) FILTER (WHERE r.role_email IS NOT NULL AND r.role_email <> '') AS emails, 3 | array_agg(DISTINCT r.role_phone) FILTER (WHERE r.role_phone IS NOT NULL AND r.role_phone <> '') AS phones 4 | FROM application.instances AS i 5 | JOIN application.environments AS e ON i.environment_id = e.id 6 | JOIN application.memberships AS m ON e.dba_group_id = m.group_id 7 | JOIN application.roles AS r ON m.role_name = r.role_name 8 | WHERE i.agent_address = :agent_address AND i.agent_port = :agent_port 9 | AND r.is_active AND i.notify; 10 | -------------------------------------------------------------------------------- /ui/temboardui/static/src/login.js: -------------------------------------------------------------------------------- 1 | import $ from "jquery"; 2 | 3 | function login(e) { 4 | e.preventDefault(); 5 | $.ajax({ 6 | url: "/json/login", 7 | type: "post", 8 | data: JSON.stringify({ 9 | username: $("#inputUsername").val(), 10 | password: $("#inputPassword").val(), 11 | }), 12 | headers: { 13 | "Content-Type": "application/json", 14 | }, 15 | success: function (data) { 16 | window.location.href = document.referrer ? document.referrer : "/"; 17 | }, 18 | error: function (xhr) { 19 | console.log("error", xhr); 20 | showError(xhr); 21 | }, 22 | }); 23 | } 24 | 25 | window.login = login; 26 | -------------------------------------------------------------------------------- /ui/temboardui/static/src/components/configuration/SettingSwitch.vue: -------------------------------------------------------------------------------- 1 | 10 | 11 | 17 | -------------------------------------------------------------------------------- /dev/loki/loki-config.yaml: -------------------------------------------------------------------------------- 1 | auth_enabled: false 2 | 3 | server: 4 | http_listen_port: 3100 5 | grpc_listen_port: 9096 6 | 7 | common: 8 | path_prefix: /tmp/loki 9 | storage: 10 | filesystem: 11 | chunks_directory: /tmp/loki/chunks 12 | rules_directory: /tmp/loki/rules 13 | replication_factor: 1 14 | ring: 15 | instance_addr: 127.0.0.1 16 | kvstore: 17 | store: inmemory 18 | 19 | schema_config: 20 | configs: 21 | - from: 2020-10-24 22 | store: boltdb-shipper 23 | object_store: filesystem 24 | schema: v11 25 | index: 26 | prefix: index_ 27 | period: 24h 28 | 29 | ruler: 30 | alertmanager_url: http://localhost:9093 31 | -------------------------------------------------------------------------------- /ui/temboardui/model/queries/instances-insert.sql: -------------------------------------------------------------------------------- 1 | WITH environment AS ( 2 | SELECT id FROM application.environments WHERE name = :environment 3 | ) 4 | INSERT INTO application.instances (agent_address, agent_port, hostname, pg_port, notify, comment, discover, discover_date, discover_etag, environment_id) 5 | SELECT 6 | :agent_address AS agent_address, 7 | :agent_port AS agent_port, 8 | :hostname AS hostname, 9 | :pg_port AS pg_port, 10 | :notify AS notify, 11 | :comment AS "comment", 12 | :discover AS discover, 13 | :discover_date AS discover_date, 14 | :discover_etag AS discover_etag, 15 | environment.id AS environment_id 16 | FROM environment 17 | RETURNING *; 18 | -------------------------------------------------------------------------------- /ui/temboardui/static/src/components/settings/InstanceDetails.vue: -------------------------------------------------------------------------------- 1 | 4 | 5 | 16 | -------------------------------------------------------------------------------- /ui/packaging/docker/entrypoint.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash -eu 2 | 3 | export PGHOST=${PGHOST-localhost} 4 | export PGPORT=${PGPORT-5432} 5 | export PGUSER=${PGUSER-postgres} 6 | PGPASSWORD=${PGPASSWORD-} 7 | export PGDATABASE=${PGDATABASE-$PGUSER} 8 | 9 | wait-for-it "${PGHOST}:${PGPORT}" 10 | if [ ! -f /etc/temboard/temboard.conf ] ; then 11 | if ! DEBUG=y PGPASSWORD="$PGPASSWORD" /usr/share/temboard/auto_configure.sh; then 12 | cat /var/log/temboard-auto-configure.log >&2 13 | exit 1 14 | fi 15 | fi 16 | 17 | # Clean PG* used for setup. libpq for temBoard is configured only by config 18 | # file. 19 | # shellcheck disable=2086 20 | unset ${!PG*} 21 | 22 | set -x 23 | exec sudo -EHu temboard "${@-temboard}" 24 | -------------------------------------------------------------------------------- /agent/temboardagent/cli/runscript.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | from temboardtoolkit.app import SubCommand 4 | 5 | from .app import app 6 | 7 | logger = logging.getLogger(__name__) 8 | 9 | 10 | @app.command 11 | class RunScript(SubCommand): 12 | """Run a Python script.""" 13 | 14 | def define_arguments(self, parser): 15 | parser.add_argument("script", metavar="PATH", help="Path to script file.") 16 | 17 | super().define_arguments(parser) 18 | 19 | def main(self, args): 20 | with open(args.script) as fo: 21 | source = fo.read() 22 | code = compile(source, args.script, "exec") 23 | 24 | g = globals() 25 | g["app"] = self.app 26 | 27 | exec(code, g, g) 28 | -------------------------------------------------------------------------------- /ui/temboardui/plugins/monitoring/templates/index.html: -------------------------------------------------------------------------------- 1 | {% extends ../../../templates/base.html %} 2 | 3 | {% block title %}temBoard / {{instance}} / Monitoring{% end %} 4 | 5 | {% block head %} 6 | 7 | {% end %} 8 | 9 | {% block content %} 10 | 11 |
12 | 13 |
14 | 15 | 16 | 17 | 21 | 22 | {% end %} 23 | -------------------------------------------------------------------------------- /agent/temboardagent/queries/monitoring-bgwriter.sql: -------------------------------------------------------------------------------- 1 | WITH backends AS ( 2 | SELECT SUM(writes) AS buffers_backend, 3 | SUM(fsyncs) AS buffers_backend_fsync 4 | FROM pg_stat_io 5 | WHERE backend_type = 'client backend' 6 | ) 7 | SELECT cp.num_timed AS checkpoints_timed, 8 | cp.num_requested AS checkpoints_req, 9 | cp.write_time AS checkpoint_write_time, 10 | cp.sync_time AS checkpoint_sync_time, 11 | cp.buffers_written AS buffers_checkpoint, 12 | bg.buffers_clean, 13 | bg.maxwritten_clean, 14 | backends.buffers_backend, 15 | backends.buffers_backend_fsync, 16 | bg.buffers_alloc, 17 | bg.stats_reset 18 | FROM pg_stat_bgwriter AS bg, 19 | pg_stat_checkpointer AS cp, 20 | backends; 21 | -------------------------------------------------------------------------------- /dev/agent-entrypoint.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash -eux 2 | 3 | chown -R "$(stat -c "%u:%g" "$0")" /usr/local/src/temboard/agent 4 | 5 | if ! [ -f /etc/temboard-agent/postgres0/signing-public.pem ] ; then 6 | # Copy dev signing key to prefetch UI key. Don't bother whether we are 7 | # managing first or second instance. 8 | mkdir -p /etc/temboard-agent/postgres{0,1} 9 | cp -fv /usr/local/src/temboard/dev/signing-public.pem /etc/temboard-agent/postgres0 10 | cp -fv /usr/local/src/temboard/dev/signing-public.pem /etc/temboard-agent/postgres1 11 | fi 12 | 13 | # Presere uv PATH when sudoing as Postgres. 14 | sed -i /secure_path/d /etc/sudoers 15 | visudo -cf /etc/sudoers 16 | 17 | uv sync 18 | exec uv run /usr/local/src/temboard/agent/packaging/docker/entrypoint.sh "$@" 19 | -------------------------------------------------------------------------------- /agent/share/preun.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | set -eu 3 | mode="$1" 4 | 5 | # rpm uses an int. deb uses a keyword. 6 | case "$mode" in 7 | 0|remove) 8 | if ! readlink /proc/1/exe | grep -q systemd ; then 9 | echo You must disable manually temboard-agent services. >&2 10 | exit 0 11 | fi 12 | 13 | while read -r service ; do 14 | systemctl disable --now "$service" 15 | if systemctl -q is-failed "$service" ; then 16 | systemctl reset-failed "$service" 17 | fi 18 | done < <(systemctl list-units --full --no-legend --no-pager --plain --type service temboard-agent@* | grep -Po '^[^.]+') 19 | exit 0 20 | ;; 21 | 1|upgrade) 22 | exit 0 23 | ;; 24 | *) 25 | echo "Unknown scriptlet execution mode: '$mode'. Skipping" >&2 26 | exit 0 27 | ;; 28 | esac 29 | -------------------------------------------------------------------------------- /agent/temboardagent/queries/pgconf-settings.sql: -------------------------------------------------------------------------------- 1 | SELECT 2 | category, "s"."name", 3 | "s".setting, 4 | current_setting("name") AS current_setting, 5 | unit, 6 | vartype, 7 | min_val, max_val, enumvals, 8 | context, 9 | short_desc || ' ' || coalesce(extra_desc, '') AS "desc", 10 | boot_val, reset_val, 11 | pending_restart, 12 | COALESCE(json_agg( 13 | json_build_object( 14 | 'file', f.sourcefile, 15 | 'line', f.sourceline, 16 | 'seqno', f.seqno, 17 | 'setting', f.setting, 18 | 'error', f.error, 19 | 'applied', f.applied 20 | ) 21 | ) FILTER (WHERE f.sourcefile IS NOT NULL), '[]'::JSON) AS sources 22 | FROM pg_settings AS "s" 23 | LEFT OUTER JOIN pg_file_settings AS "f" USING("name") 24 | GROUP BY 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14 25 | ORDER BY 1, 2; 26 | -------------------------------------------------------------------------------- /ui/temboardui/plugins/statements/templates/index.html: -------------------------------------------------------------------------------- 1 | {% extends ../../../templates/base.html %} 2 | 3 | {% block title %}temBoard / {{instance}} / Statements{% end %} 4 | 5 | {% block head %} 6 | {% for link in vitejs.css_links_for('statements.js') %}{% raw link %}{% end %} 7 | 8 | {% end %} 9 | 10 | {% block content %} 11 | 12 |
13 | 14 |
15 | 16 | 17 | 18 | 22 | {% end %} 23 | -------------------------------------------------------------------------------- /agent/temboardagent/__main__.py: -------------------------------------------------------------------------------- 1 | import sys 2 | 3 | from bottle import default_app 4 | 5 | from .cli.app import app 6 | from .web.app import create_app 7 | 8 | 9 | def main(): 10 | default_app.push(create_app(app)) 11 | 12 | # Import core HTTP routes 13 | __import__(__package__ + ".web.core") 14 | 15 | # Import commands 16 | __import__(__package__ + ".cli.discover") 17 | __import__(__package__ + ".cli.fetch_key") 18 | __import__(__package__ + ".cli.register") 19 | __import__(__package__ + ".cli.routes") 20 | __import__(__package__ + ".cli.runscript") 21 | __import__(__package__ + ".cli.serve") 22 | __import__(__package__ + ".cli.tasks") 23 | __import__(__package__ + ".cli.web") 24 | return app() 25 | 26 | 27 | if "__main__" == __name__: 28 | sys.exit(main()) 29 | -------------------------------------------------------------------------------- /ui/temboardui/templates/flask/maintenance/database.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %} 2 | 3 | {% block title %}{{ super() }}/ Maintenance / {{database}}{% endblock %} 4 | 5 | {% block content %} 6 | 7 | {% include "maintenance/breadcrumb.html" %} 8 | 9 | 10 |
11 | 20 |
21 | 22 | 23 | {% endblock %} 24 | -------------------------------------------------------------------------------- /ui/tests/unit/test_main.py: -------------------------------------------------------------------------------- 1 | def test_pgvar_map(): 2 | from temboardui.cli.app import map_pgvars 3 | 4 | env = dict( 5 | PGHOST="localhost", 6 | PGPORT="5433", 7 | PGUSER="temboard", 8 | PGPASSWORD="étagère", 9 | PGDATABASE="temboarddb", 10 | ) 11 | mapped = map_pgvars(env) 12 | assert "localhost" == mapped["TEMBOARD_REPOSITORY_HOST"] 13 | assert "5433" == mapped["TEMBOARD_REPOSITORY_PORT"] 14 | assert "temboard" == mapped["TEMBOARD_REPOSITORY_USER"] 15 | assert "étagère" == mapped["TEMBOARD_REPOSITORY_PASSWORD"] 16 | assert "temboarddb" == mapped["TEMBOARD_REPOSITORY_DBNAME"] 17 | 18 | env = dict(PGHOST="pg", TEMBOARD_REPOSITORY_HOST="temboard") 19 | mapped = map_pgvars(env) 20 | assert "temboard" == mapped["TEMBOARD_REPOSITORY_HOST"] 21 | -------------------------------------------------------------------------------- /ui/temboardui/web/routes/instance.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | from flask import current_app as app 4 | from flask import g, render_template 5 | 6 | from ..flask import instance_routes 7 | 8 | logger = logging.getLogger(__name__) 9 | 10 | 11 | @instance_routes.route("/about") 12 | def instance_about(): 13 | app.instance.fetch_status() 14 | return render_template( 15 | "instance-about.html", 16 | instance_name=g.instance.__str__(), 17 | pg_data=g.instance.pg_data, 18 | pg_version_summary=g.instance.pg_version_summary, 19 | discover=g.instance.discover, 20 | environment=g.instance.environment.name, 21 | ) 22 | 23 | 24 | @app.route("/explain") 25 | def explain(): 26 | return render_template( 27 | "explain.html", nav=True, role=g.current_user, vitejs=app.vitejs 28 | ) 29 | -------------------------------------------------------------------------------- /dev/signing-public.pem: -------------------------------------------------------------------------------- 1 | -----BEGIN PUBLIC KEY----- 2 | MIICIjANBgkqhkiG9w0BAQEFAAOCAg8AMIICCgKCAgEA4TVNVsTR3IaL9hdV69XY 3 | 6FEFnZpxfdJ5HuKtku+Gy+ezw4swTNLolSXn1OYDlLd688aE0p3xER+dtim5bWxj 4 | p2iTnN2hS8w6hPMA3o5noBG1UWJy5jjKeVF40h92MAB6KU4SA84IJNz+zBQTIFkx 5 | AqRaOJUPqyJwXeHhkiH3p5zuPHw+FPZZkNcmL4FuoPjZ5KGvPalUvmUM7HZevSdf 6 | 4Ycsn5xEHB8mutpwheNhRJDRuUfzZxoj6Iru7hPxWOSrQdgkIxYymZV0YrRGB0OI 7 | AV5Go1MwWCAyK8D55JO9F3fZMwyVbSHAdOEcvCVqf8YBrjT3BzvgxM1qDx+k5Mlb 8 | 3RcMED9EXNGwKUHKxsc9jEFunNeM6t8mpndki2gdxrmZsovQ2MDAmGcFNTffSO5h 9 | S5hm4nw44Vv2SG7FBOp29WmGbOdBnNRwAt1f5VvvyYLwwAX3bk8ljIYplMBWJN5D 10 | sl0kT9YPYphbWtWESLX9X/LgpgD7t299m4Fi0OEyamL0jF4pRgvTha3vUIEtcgaJ 11 | 3WNGVFQRBHgu8lIUmI6hO7swCMnIG+CcjCRo0ez/e0JyK6ONVFuuwt38FSam+Nrk 12 | vDqvhmKaNFwJpkWuSW3p+j5fjWVsf39arIodLU4XxNr32LaqKncld24bRYhg35CG 13 | /saax0girQznwhje0AH0tLMCAwEAAQ== 14 | -----END PUBLIC KEY----- 15 | -------------------------------------------------------------------------------- /agent/share/purge.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash -eu 2 | 3 | if [ -n "${DEBUG-}" ] ; then 4 | set -x 5 | fi 6 | 7 | ETCDIR=${ETCDIR-/etc/temboard-agent} 8 | VARDIR=${VARDIR-/var/lib/temboard-agent} 9 | LOGDIR=${LOGDIR-/var/log/temboard-agent} 10 | 11 | instance_path="$1" 12 | instance_name="${instance_path//\//-}" 13 | 14 | if type -p systemctl >/dev/null && systemctl is-system-running && [ -w /etc/systemd/system ]; then 15 | echo "Stopping and disabling systemd service." >&2 16 | systemctl disable --now "temboard-agent@$instance_name" || true 17 | fi 18 | 19 | echo "Cleaning files and directories..." >&2 20 | rm -rvf \ 21 | "${ETCDIR:?}/$instance_path/" \ 22 | "${LOGDIR:?}/$instance_path.log" \ 23 | "${VARDIR:?}/$instance_path/" \ 24 | "/etc/systemd/system/temboard-agent@${instance_path}.service.d" \ 25 | ; 26 | 27 | echo "temBoard agent ${instance_name} stopped and cleaned." >&2 28 | -------------------------------------------------------------------------------- /ui/temboardui/templates/flask/dashboard.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %} 2 | 3 | {% block title %}{{ super() }}/ Dashboard{% endblock %} 4 | 5 | {% block content %} 6 |
7 | 20 |
21 | 22 | 23 | 24 | 25 | {% endblock %} 26 | -------------------------------------------------------------------------------- /ui/vendor.txt: -------------------------------------------------------------------------------- 1 | # This file was autogenerated by uv via the following command: 2 | # uv pip compile --python-version=3.9 ui/vendor.in -o ui/vendor.txt 3 | blinker==1.9.0 4 | # via flask 5 | click==8.1.8 6 | # via flask 7 | flask==3.1.2 8 | # via -r ui/vendor.in 9 | greenlet==3.2.4 10 | # via sqlalchemy 11 | importlib-metadata==8.7.0 12 | # via flask 13 | itsdangerous==2.2.0 14 | # via flask 15 | jinja2==3.1.6 16 | # via 17 | # -r ui/vendor.in 18 | # flask 19 | markupsafe==3.0.3 20 | # via 21 | # flask 22 | # jinja2 23 | # werkzeug 24 | python-dateutil==2.9.0.post0 25 | # via -r ui/vendor.in 26 | six==1.17.0 27 | # via python-dateutil 28 | sqlalchemy==1.4.54 29 | # via -r ui/vendor.in 30 | tornado==6.4.2 31 | # via -r ui/vendor.in 32 | werkzeug==3.1.3 33 | # via flask 34 | zipp==3.23.0 35 | # via importlib-metadata 36 | -------------------------------------------------------------------------------- /docs/assets/temboard.css: -------------------------------------------------------------------------------- 1 | :root { 2 | --md-primary-fg-color: #d65916; 3 | --md-primary-fg-color--dark: #622a11; 4 | --md-accent-fg-color: #d65916; 5 | } 6 | 7 | .md-grid { 8 | max-width: 1200px; 9 | } 10 | 11 | .md-sidebar[hidden] + .md-content { 12 | max-width: 990px; 13 | margin: 0 auto; 14 | } 15 | 16 | .md-container .md-main h2.doc-hide { 17 | display: none; 18 | } 19 | 20 | .md-container .md-main img.logo { 21 | display: block; 22 | margin: 3rem auto; 23 | max-width: 75%; 24 | } 25 | 26 | .display-none 27 | { 28 | display: none !important; 29 | } 30 | 31 | .md-container .md-main article > p img { 32 | display: block; 33 | max-width: 90%; 34 | margin: 1em auto; 35 | } 36 | 37 | /* Hide RTD injected code. */ 38 | div.injected { 39 | display: none !important; 40 | } 41 | 42 | .md-header__option { 43 | height: 2.4rem; 44 | line-height: 2.4rem; 45 | } 46 | -------------------------------------------------------------------------------- /agent/LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2016, Dalibo 2 | 3 | Permission to use, copy, modify, and distribute this software and its documentation for any purpose, without fee, and without a written agreement is hereby granted, provided that the above copyright notice and this paragraph and the following two paragraphs appear in all copies. 4 | 5 | IN NO EVENT SHALL DALIBO BE LIABLE TO ANY PARTY FOR DIRECT, INDIRECT, SPECIAL, INCIDENTAL, OR CONSEQUENTIAL DAMAGES, INCLUDING LOST PROFITS, ARISING OUT OF THE USE OF THIS SOFTWARE AND ITS DOCUMENTATION, EVEN IF DALIBO HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 6 | 7 | DALIBO SPECIFICALLY DISCLAIMS ANY WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE. THE SOFTWARE PROVIDED HEREUNDER IS ON AN "AS IS" BASIS, AND DALIBO HAS NO OBLIGATIONS TO PROVIDE MAINTENANCE, SUPPORT, UPDATES, ENHANCEMENTS, OR MODIFICATIONS. 8 | -------------------------------------------------------------------------------- /ui/temboardui/static/src/_bootstrap-variables-override.scss: -------------------------------------------------------------------------------- 1 | $primary: #3c424c; 2 | $undef: #ccc; 3 | $cat1: #78b8fa; 4 | $cat2: #fa9e78; 5 | $navbar-inverse-bg: $primary; 6 | $link-color: lighten(saturate($primary, 15%), 15%); 7 | 8 | @import "../node_modules/bootstrap/scss/functions"; 9 | @import "../node_modules/bootstrap/scss/variables"; 10 | 11 | $input-border-focus: $link-color; 12 | $grid-gutter-width: 20px; 13 | $spacer: $grid-gutter-width; 14 | $small-font-size: 85%; 15 | 16 | // Additional xxl grid tiers 17 | $grid-breakpoints: ( 18 | xs: 0, 19 | sm: 576px, 20 | md: 768px, 21 | lg: 992px, 22 | xl: 1200px, 23 | xxl: 1500px, 24 | ) !default; 25 | 26 | $custom-colors: ( 27 | "ok": $success, 28 | "critical": $danger, 29 | "undef": $undef, 30 | "cat1": $cat1, 31 | "cat2": $cat2, 32 | ); 33 | $theme-colors: map-merge($theme-colors, $custom-colors); 34 | $popover-font-size: 1rem !default; 35 | -------------------------------------------------------------------------------- /ui/temboardui/model/versions/010_discover.sql: -------------------------------------------------------------------------------- 1 | ALTER TABLE "application"."instances" 2 | ADD COLUMN "cdate" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(), 3 | ADD COLUMN "discover" JSONB, 4 | ADD COLUMN "discover_date" TIMESTAMP WITH TIME ZONE, 5 | ADD COLUMN "discover_etag" TEXT; 6 | 7 | UPDATE "application"."instances" 8 | SET "discover" = json_build_object( 9 | 'system', json_build_object( 10 | 'cpu_count', "cpu", 11 | 'memory', "memory_size", 12 | 'fqdn', "hostname" 13 | ), 14 | 'postgres', json_build_object( 15 | 'version', "pg_version", 16 | 'version_summary', "pg_version_summary", 17 | 'port', "pg_port", 18 | 'data_directory', "pg_data" 19 | ), 20 | 'temboard', json_build_object() 21 | ); 22 | 23 | ALTER TABLE "application"."instances" 24 | DROP COLUMN "cpu", 25 | DROP COLUMN "memory_size", 26 | DROP COLUMN "pg_data", 27 | DROP COLUMN "pg_version", 28 | DROP COLUMN "pg_version_summary"; 29 | -------------------------------------------------------------------------------- /ui/packaging/nfpm/docker-compose.yml: -------------------------------------------------------------------------------- 1 | services: 2 | rhel10: &rhel 3 | image: dalibo/buildpack-pkg:rockylinux10 4 | volumes: 5 | - ../..:/workspace 6 | working_dir: /workspace 7 | entrypoint: ["/usr/bin/tini", "--"] 8 | command: /workspace/packaging/nfpm/mkrpm.sh 9 | 10 | rhel9: 11 | <<: *rhel 12 | image: dalibo/buildpack-pkg:rockylinux9 13 | 14 | rhel8: 15 | <<: *rhel 16 | image: dalibo/buildpack-pkg:rockylinux8 17 | 18 | trixie: &mkdeb 19 | <<: *rhel 20 | image: dalibo/buildpack-pkg:trixie 21 | command: /workspace/packaging/nfpm/mkdeb.sh 22 | 23 | bookworm: 24 | <<: *mkdeb 25 | image: dalibo/buildpack-pkg:bookworm 26 | 27 | bullseye: 28 | <<: *mkdeb 29 | image: dalibo/buildpack-pkg:bullseye 30 | 31 | jammy: 32 | <<: *mkdeb 33 | image: dalibo/buildpack-pkg:jammy 34 | 35 | noble: 36 | <<: *mkdeb 37 | image: dalibo/buildpack-pkg:noble 38 | -------------------------------------------------------------------------------- /agent/packaging/nfpm/docker-compose.yml: -------------------------------------------------------------------------------- 1 | services: 2 | rhel10: &rhel 3 | image: dalibo/buildpack-pkg:rockylinux10 4 | volumes: 5 | - ../..:/workspace 6 | working_dir: /workspace 7 | entrypoint: ["/usr/bin/tini", "--"] 8 | command: /workspace/packaging/nfpm/mkrpm.sh 9 | 10 | rhel9: 11 | <<: *rhel 12 | image: dalibo/buildpack-pkg:rockylinux9 13 | 14 | rhel8: 15 | <<: *rhel 16 | image: dalibo/buildpack-pkg:rockylinux8 17 | 18 | trixie: &mkdeb 19 | <<: *rhel 20 | image: dalibo/buildpack-pkg:trixie 21 | command: /workspace/packaging/nfpm/mkdeb.sh 22 | 23 | bookworm: 24 | <<: *mkdeb 25 | image: dalibo/buildpack-pkg:bookworm 26 | 27 | bullseye: 28 | <<: *mkdeb 29 | image: dalibo/buildpack-pkg:bullseye 30 | 31 | jammy: 32 | <<: *mkdeb 33 | image: dalibo/buildpack-pkg:jammy 34 | 35 | noble: 36 | <<: *mkdeb 37 | image: dalibo/buildpack-pkg:noble 38 | -------------------------------------------------------------------------------- /ui/temboardui/plugins/monitoring/templates/alerting.check.html: -------------------------------------------------------------------------------- 1 | {% extends ../../../templates/base.html %} 2 | 3 | {% block title %}temBoard / {{instance}} / Monitoring{% end %} 4 | 5 | {% block head %} 6 | 7 | {% end %} 8 | 9 | {% block content %} 10 | 11 |
12 | 13 |
14 | 15 | 16 | 17 | 28 | {% end %} 29 | -------------------------------------------------------------------------------- /ui/temboardui/web/routes/core.py: -------------------------------------------------------------------------------- 1 | from flask import current_app as app 2 | from flask import g, jsonify, redirect 3 | 4 | from ...model.orm import Instance 5 | from ..flask import admin_required, anonymous_allowed 6 | 7 | 8 | @app.route("/") 9 | @anonymous_allowed 10 | def index(): 11 | if g.current_user: 12 | return redirect("/home") 13 | return redirect("/login") 14 | 15 | 16 | @app.route("/json/instances/home") 17 | def get_instance_home(): 18 | """Data for InstanceCards.vue component.""" 19 | return jsonify( 20 | [ 21 | {k: getattr(row, k) for k in row.keys()} 22 | for row in g.db_session.execute( 23 | Instance.select_for_home(g.current_user.role_name) 24 | ) 25 | ] 26 | ) 27 | 28 | 29 | @app.route("/json/plugins") 30 | @admin_required 31 | def get_plugins(): 32 | """List plugins.""" 33 | return jsonify(sorted(app.temboard.config.temboard.plugins)) 34 | -------------------------------------------------------------------------------- /ui/temboardui/static/src/utils/state.js: -------------------------------------------------------------------------------- 1 | function stateBorderClass(state) { 2 | const classes = ["border"]; 3 | if (state != "OK" && state != "UNDEF") { 4 | classes.push("border-" + state.toLowerCase()); 5 | } 6 | if (state == "CRITICAL") { 7 | classes.push("border-2"); 8 | } 9 | return classes; 10 | } 11 | 12 | function stateBgClass(state) { 13 | const classes = []; 14 | if (state == "UNDEF") { 15 | classes.push("text-bg-light"); 16 | } else if (state == "OK") { 17 | classes.push("text-success"); 18 | } else { 19 | classes.push("text-bg-" + state.toLowerCase()); 20 | } 21 | return classes; 22 | } 23 | 24 | function stateIcon(state) { 25 | if (state == "WARNING") { 26 | return "fa-warning"; 27 | } 28 | if (state == "CRITICAL") { 29 | return "fa-burst"; 30 | } 31 | if (state == "OK") { 32 | return "fa-check"; 33 | } 34 | return ""; 35 | } 36 | 37 | export { stateBgClass, stateBorderClass, stateIcon }; 38 | -------------------------------------------------------------------------------- /ui/packaging/nfpm/nfpm.yaml: -------------------------------------------------------------------------------- 1 | name: temboard 2 | arch: amd64 3 | # Accept PEP440 4 | version_schema: none 5 | version: ${VERSION} 6 | release: ${RELEASE} 7 | depends: 8 | - "${PYTHONPKG}" 9 | maintainer: Dalibo Labs 10 | description: PostgreSQL Remote Control UI 11 | homepage: https://labs.dalibo.com/temboard/ 12 | license: PostgreSQL 13 | contents: 14 | - type: tree 15 | src: packaging/nfpm/build/destdir/ 16 | dst: / 17 | - type: tree 18 | src: share/ 19 | dst: /usr/share/temboard 20 | - type: tree 21 | src: packaging/temboard.service 22 | dst: /usr/lib/systemd/system/temboard.service 23 | scripts: 24 | postinstall: share/postinst.sh 25 | preremove: share/preun.sh 26 | overrides: 27 | deb: 28 | depends: 29 | - ssl-cert 30 | - python3-cryptography 31 | - "python3-psycopg2 (>>2.8)" 32 | rpm: 33 | depends: 34 | - openssl 35 | - ${PYTHONPKG}-psycopg2 36 | - ${PYTHONPKG}-cryptography 37 | -------------------------------------------------------------------------------- /agent/temboardagent/cli/runtask.py: -------------------------------------------------------------------------------- 1 | # Note that our home-made background task implementation, we use a different 2 | # semantic that state of the art background task implemtation: 3 | # 4 | # a task function is called a worker 5 | # a worker process is called workerpool 6 | # a message is called a task 7 | # the message broker is called taskmanager 8 | # 9 | 10 | import logging.config 11 | 12 | from temboardtoolkit.app import SubCommand 13 | from temboardtoolkit.taskmanager import RunTaskMixin 14 | 15 | from .app import app 16 | 17 | logger = logging.getLogger(__name__) 18 | 19 | 20 | @app.command 21 | class RunTask(RunTaskMixin, SubCommand): 22 | """Run a task foreground.""" 23 | 24 | def main(self, args): 25 | workers = self.iter_workers() 26 | 27 | if "?" == args.worker_name: 28 | self.print_workers(workers) 29 | else: 30 | worker, worker_args = self.compute_worker_args(workers, args) 31 | worker(*worker_args) 32 | return 0 33 | -------------------------------------------------------------------------------- /ui/temboardui/model/queries/instance-get.sql: -------------------------------------------------------------------------------- 1 | SELECT DISTINCT 2 | i.agent_address, 3 | i.agent_port, 4 | i.hostname, 5 | i.pg_port, 6 | i.notify, 7 | i.comment, 8 | i.discover, 9 | i.discover_date, 10 | i.discover_etag, 11 | i.environment_id, 12 | -- Instance.plugins eager load. 13 | p.agent_address AS p_agent_address, 14 | p.agent_port AS p_agent_port, 15 | p.plugin_name AS p_name, 16 | -- Instance.environment eager load. 17 | e.id AS e_id, 18 | e.name AS e_name, 19 | e.description AS e_description, 20 | e.color AS e_color, 21 | e.dba_group_id AS e_dba_group_id, 22 | -- Environment.dba_group eager load. 23 | g.id AS g_id, 24 | g.name AS g_name, 25 | g.description AS g_description 26 | FROM application.instances AS i 27 | LEFT OUTER JOIN application.plugins AS p ON p.agent_address = i.agent_address AND p.agent_port = i.agent_port 28 | JOIN application.environments AS e ON i.environment_id = e.id 29 | JOIN application.groups AS g ON g.id = e.dba_group_id 30 | WHERE i.agent_address = :address AND i.agent_port = :port; 31 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | 32 | 33 | -------------------------------------------------------------------------------- /agent/packaging/nfpm/nfpm.yaml: -------------------------------------------------------------------------------- 1 | # template file for nfpm configuration 2 | name: temboard-agent 3 | arch: all 4 | # Accept PEP440 5 | version_schema: none 6 | version: ${VERSION} 7 | release: ${RELEASE} 8 | maintainer: Dalibo Labs 9 | description: PostgreSQL Remote Control Agent 10 | homepage: https://labs.dalibo.com/temboard/ 11 | license: PostgreSQL 12 | depends: 13 | - ${PYTHONPKG} 14 | contents: 15 | - type: tree 16 | src: packaging/nfpm/build/destdir/ 17 | dst: / 18 | - type: tree 19 | src: share/ 20 | dst: /usr/share/temboard-agent 21 | - type: tree 22 | src: packaging/temboard-agent@.service 23 | dst: /usr/lib/systemd/system/temboard-agent@.service 24 | scripts: 25 | postinstall: share/restart-all.sh 26 | preremove: share/preun.sh 27 | overrides: 28 | deb: 29 | depends: 30 | - python3-cryptography 31 | - python3-psycopg2 (>= 2.7) 32 | - ssl-cert 33 | rpm: 34 | depends: 35 | - openssl 36 | - ${PYTHONPKG}-cryptography 37 | - ${PYTHONPKG}-psycopg2 38 | -------------------------------------------------------------------------------- /ui/temboardui/templates/flask/settings/menu.html: -------------------------------------------------------------------------------- 1 | 33 | -------------------------------------------------------------------------------- /ui/temboardui/templates/notifications.html: -------------------------------------------------------------------------------- 1 | {% extends base.html %} 2 | 3 | {% block title %}temBoard / {{instance}} / Notifications{% end %} 4 | 5 | {% block head %} 6 | {% for link in vitejs.css_links_for('settings.users.js') %}{% raw link %}{% end %} 7 | {% end %} 8 | 9 | {% block content %} 10 |
11 |
12 |
13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | {% for notification in notifications %} 23 | 24 | 25 | 26 | 27 | 28 | {% end %} 29 | 30 |
DateUsernameContent
{{notification['date']}}{{notification['username']}}{{notification['message']}}
31 |
32 |
33 |
34 | 35 | {% end %} 36 | -------------------------------------------------------------------------------- /ui/temboardui/plugins/dashboard/routes.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | from flask import current_app, render_template 4 | 5 | from ...agentclient import TemboardAgentClient 6 | from ...web.flask import instance_routes 7 | 8 | logger = logging.getLogger(__name__) 9 | 10 | 11 | @instance_routes.route("/dashboard") 12 | def dashboard(): 13 | current_app.instance.check_active_plugin("dashboard") 14 | 15 | try: 16 | config = current_app.instance.request("/dashboard/config").json() 17 | except TemboardAgentClient.Error as e: 18 | if 404 != e.code: 19 | raise 20 | logger.debug("Fallback dashboard config.") 21 | config = {"history_length": 150, "scheduler_interval": 2} 22 | 23 | history = current_app.instance.request("/dashboard/history").json() 24 | 25 | current_app.instance.fetch_status() 26 | dashboard = history[-1] if history else {} 27 | return render_template( 28 | "dashboard.html", 29 | plugin="dashboard", 30 | config=config, 31 | dashboard=dashboard, 32 | history=history or "", 33 | ) 34 | -------------------------------------------------------------------------------- /ui/LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2020, Dalibo 2 | 3 | Permission to use, copy, modify, and distribute this software and its documentation for any purpose, without fee, and without a written agreement is hereby granted, provided that the above copyright notice and this paragraph and the following two paragraphs appear in all copies. 4 | 5 | IN NO EVENT SHALL DALIBO BE LIABLE TO ANY PARTY FOR DIRECT, INDIRECT, SPECIAL, INCIDENTAL, OR CONSEQUENTIAL DAMAGES, INCLUDING LOST PROFITS, ARISING OUT OF THE USE OF THIS SOFTWARE AND ITS DOCUMENTATION, EVEN IF DALIBO HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 6 | 7 | DALIBO SPECIFICALLY DISCLAIMS ANY WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE. THE SOFTWARE PROVIDED HEREUNDER IS ON AN "AS IS" BASIS, AND DALIBO HAS NO OBLIGATIONS TO PROVIDE MAINTENANCE, SUPPORT, UPDATES, ENHANCEMENTS, OR MODIFICATIONS. 8 | 9 | A modified version of code chunks of PoWA (powa-archivist and powa-web) is embedded in temBoard with copyright (c) Dalibo, The PoWA-team under the terms of the PostgreSQL License. 10 | -------------------------------------------------------------------------------- /ui/temboardui/model/versions/008_monitoring-archive-wait-lock.sql: -------------------------------------------------------------------------------- 1 | CREATE OR REPLACE FUNCTION monitoring.archive_current_metrics(table_name TEXT, record_type TEXT, query TEXT) 2 | RETURNS TABLE(tblname TEXT, nb_rows INTEGER) 3 | LANGUAGE plpgsql 4 | AS $$ 5 | DECLARE 6 | v_table_current TEXT; 7 | v_table_history TEXT; 8 | v_query TEXT; 9 | i INTEGER; 10 | BEGIN 11 | v_table_current := table_name || '_current'; 12 | v_table_history := table_name || '_history'; 13 | -- Lock _current table to prevent concurrent updates 14 | EXECUTE 'LOCK TABLE ' || v_table_current || ' IN SHARE MODE'; 15 | v_query := replace(query, '#history_table#', v_table_history); 16 | v_query := replace(v_query, '#current_table#', v_table_current); 17 | v_query := replace(v_query, '#record_type#', record_type); 18 | -- Move data into _history table 19 | EXECUTE v_query; 20 | GET DIAGNOSTICS i = ROW_COUNT; 21 | -- Truncate _current table 22 | EXECUTE 'TRUNCATE '||v_table_current; 23 | -- Return each history table name and the number of rows inserted 24 | RETURN QUERY SELECT v_table_history, i; 25 | END; 26 | $$; 27 | -------------------------------------------------------------------------------- /ui/temboardui/plugins/activity/__init__.py: -------------------------------------------------------------------------------- 1 | from flask import current_app, jsonify 2 | from flask import render_template as flask_render_template 3 | 4 | from ...web.flask import instance_proxy, instance_routes 5 | 6 | 7 | class ActivityPlugin: 8 | def __init__(self, app, **kw): 9 | self.app = app 10 | 11 | def load(self): 12 | instance_proxy.generic_proxy("/activity/kill", method="POST") 13 | 14 | 15 | @instance_routes.route("/activity") 16 | def activity(): 17 | current_app.instance.check_active_plugin("activity") 18 | current_app.instance.fetch_status() 19 | return flask_render_template("activity.html", plugin="activity") 20 | 21 | 22 | @instance_proxy.route("/activity") 23 | def activity_proxy(): 24 | # DEPRECATED: move to new agent endpoints for activity. 25 | return jsonify( 26 | dict( 27 | blocking=current_app.instance.request("/activity/blocking").json(), 28 | running=current_app.instance.request("/activity").json(), 29 | waiting=current_app.instance.request("/activity/waiting").json(), 30 | ) 31 | ) 32 | -------------------------------------------------------------------------------- /tests/test_60_alerting.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | 4 | def test_quick_alerting(browser, browse_alerting): 5 | # Quick smoketest of status page. 6 | browser.select("#checks-container") 7 | 8 | 9 | @pytest.mark.slowmonitoring 10 | def test_alerts(browser, browse_alerting): 11 | checks = [ 12 | "btree_bloat", 13 | "cpu_core", 14 | "heap_bloat", 15 | "hitreadratio_db", 16 | "load1", 17 | "memory_usage", 18 | "rollback_db", 19 | "sessions_usage", 20 | "temp_files_size_delta", 21 | "waiting_sessions_db", 22 | "wal_files_archive", 23 | "wal_files_total", 24 | ] 25 | 26 | wanted = {"text-success", "text-bg-warning", "text-bg-critical"} 27 | 28 | for check in checks: 29 | el = browser.refresh_until(f"#status-{check} .badge") 30 | classes = set(el.get_attribute("class").split()) 31 | assert wanted & classes 32 | 33 | 34 | @pytest.fixture 35 | def browse_alerting(browse_instance, browser): 36 | """Go to Monitoring tab of current instance.""" 37 | browser.select("div.sidebar a.alerting").click() 38 | -------------------------------------------------------------------------------- /ui/temboardui/templates/flask/maintenance/schema.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %} 2 | 3 | {% block title %}{{ super() }}/ Maintenance / {{database}} / {{schema}}{% endblock %} 4 | 5 | {% block head %} 6 | {% for link in vitejs.css_links_for('maintenance.schema.js') %}{{ link | safe }}{% endfor %} 7 | {% endblock %} 8 | 9 | {% block content %} 10 | 11 | {% include "maintenance/breadcrumb.html" %} 12 | 13 |
14 | 25 |
26 | 27 | 28 | {% endblock %} 29 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Python runtime files 2 | *.egg-info/ 3 | *.pyc 4 | .venv/ 5 | 6 | dev/downloads/ 7 | dev/bin/prometheus 8 | dev/bin/promtool 9 | 10 | # Buildtime files 11 | agent/temboardagent/_vendor/ 12 | ui/temboardui/_vendor/ 13 | build/ 14 | dist/ 15 | 16 | # ViteJS assets 17 | ui/temboardui/static/*.css 18 | ui/temboardui/static/css/ 19 | ui/temboardui/static/images/ 20 | ui/temboardui/static/*.js 21 | ui/temboardui/static/js/ 22 | ui/temboardui/static/fontawesome-webfont* 23 | ui/temboardui/static/manifest.json 24 | ui/temboardui/static/.vite/ 25 | 26 | # From dev compose project 27 | .env 28 | dev/agent-bash_history 29 | dev/agent-psql_history 30 | dev/pip-cache 31 | dev/prometheus/targets/*.yaml 32 | 33 | # Test runtime files 34 | # from pytest-log 35 | test_log.log 36 | tests/downloads/ 37 | tests/logs/ 38 | tests/screenshots/ 39 | tests/workdir/ 40 | tests/results.xml 41 | 42 | # Nodejs buildtime files 43 | node_modules/ 44 | 45 | # temBoard runtime files 46 | dev/temboard/ 47 | 48 | # Help developer customize their development environment 49 | docker-compose.override.yml 50 | temboard.conf.d/ 51 | *.log 52 | dev/prometheus/import/*.txt 53 | -------------------------------------------------------------------------------- /agent/temboardagent/cli/serve.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import os 3 | 4 | from temboardtoolkit import services 5 | from temboardtoolkit.app import SubCommand 6 | 7 | from ..plugins.monitoring import db 8 | from .app import app 9 | 10 | logger = logging.getLogger(__name__) 11 | 12 | 13 | @app.command 14 | class Serve(SubCommand): 15 | """Combined web and worker services.""" 16 | 17 | is_service = True 18 | 19 | def main(self, args): 20 | # Purge all legacy data queues 21 | home = self.app.config.temboard.home 22 | if os.path.exists(home): 23 | [ 24 | os.remove(os.path.join(home, f)) 25 | for f in os.listdir(home) 26 | if f.endswith(".q") 27 | ] 28 | 29 | if "monitoring" in self.app.config.temboard.plugins: 30 | logger.info("Resetting monitoring data.") 31 | db.bootstrap(self.app.config.temboard.home, "monitoring.db") 32 | 33 | self.app.config.load_signing_key() 34 | self.app.discover.refresh() 35 | self.app.discover.write() 36 | 37 | return services.run(self.app.httpd, self.app.scheduler, self.app.worker_pool) 38 | -------------------------------------------------------------------------------- /ui/temboardui/handlers/settings/metadata.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | from temboardui.web.tornado import admin_required, app, render_template 4 | 5 | from ...version import inspect_versions 6 | 7 | logger = logging.getLogger(__name__) 8 | 9 | 10 | @app.route(r"/settings/metadata") 11 | @admin_required 12 | def metadata(request): 13 | versions_info = inspect_versions() 14 | infos = { 15 | "Version": "%(temboard)s (%(temboardbin)s)" % versions_info, 16 | "Uptime": app.start_time, 17 | "OS": "%(distname)s %(distversion)s" % versions_info, 18 | "Python": "%(python)s (%(pythonbin)s)" % versions_info, 19 | "cryptography": "%(cryptography)s" % versions_info, 20 | "Tornado": "%(tornado)s" % versions_info, 21 | "libpq": "%(libpq)s" % versions_info, 22 | "psycopg2": "%(psycopg2)s" % versions_info, 23 | "SQLAlchemy": "%(sqlalchemy)s" % versions_info, 24 | } 25 | temboard_version = "%(temboard)s" % versions_info 26 | 27 | return render_template( 28 | "settings/metadata.html", 29 | role=request.current_user, 30 | infos=infos, 31 | temboard_version=temboard_version, 32 | ) 33 | -------------------------------------------------------------------------------- /agent/temboardagent/queries/activity-sessions.sql: -------------------------------------------------------------------------------- 1 | WITH blockages AS ( 2 | SELECT 3 | pid AS waiting_pid, 4 | -- Requires pg_blocking_pids from PostgreSQL 9.6+ 5 | unnest(pg_blocking_pids(pid)) AS blocking_pid 6 | FROM pg_stat_activity 7 | ) 8 | SELECT 9 | sessions.pid AS pid, 10 | sessions.client_addr AS client_addr, 11 | sessions.usename AS "username", 12 | sessions.application_name AS application_name, 13 | sessions.datname AS database, 14 | sessions."state" AS "state", 15 | sessions.backend_start AS backend_start, 16 | sessions.query_start AS query_start, 17 | round(EXTRACT(epoch FROM (NOW() - sessions.query_start))::numeric, 2)::FLOAT AS duration, 18 | sessions.query AS query, 19 | waiting.waiting_pid IS NOT NULL AS waiting, 20 | blocking.blocking_pid IS NOT NULL AS blocking 21 | FROM pg_catalog.pg_stat_activity AS sessions 22 | LEFT OUTER JOIN blockages AS waiting ON waiting.waiting_pid = pid 23 | LEFT OUTER JOIN blockages AS blocking ON blocking.blocking_pid = pid 24 | WHERE sessions.pid <> pg_backend_pid() 25 | AND sessions.backend_type = 'client backend' -- pragma:pg_version_min 100000 26 | ORDER BY EXTRACT(epoch FROM (NOW() - sessions.query_start)) DESC 27 | -------------------------------------------------------------------------------- /docs/postgres_upgrade.md: -------------------------------------------------------------------------------- 1 | This section explains how to upgrade a PostgreSQL instance monitored by a temBoard agent. 2 | 3 | To upgrade to PostgreSQL instance hosting the *repository* database, 4 | please go to the [Server Upgrade] section. 5 | 6 | [Server Upgrade]: server_upgrade.md 7 | 8 | 9 | ## Minor Upgrade 10 | 11 | PostgreSQL minor upgrades (e.g. from `10.3` to `10.4`) are very important 12 | but they include only backward-compatible changes. 13 | 14 | Therefore, you can simply upgrade your instance using the usual update process, 15 | depending on which distribution you are using. 16 | 17 | You dont need to restart temBoard agent nor edit its configuration. 18 | 19 | 20 | ## Major Upgrade 21 | 22 | PostgreSQL major upgrades (e.g. from 13 to 14) will bring new features and improvements. 23 | 24 | If you choose to migrate your data from your current instance to a new one listening to another port (or on another host), 25 | then you can simply add a temboard-agent to this new instance. 26 | However you will lose the history of the former one. 27 | 28 | If you choose the *in-place upgrade*, you can keep the same agent. 29 | You don't need to restart agent nor edit its configuration. 30 | -------------------------------------------------------------------------------- /ui/temboardui/templates/flask/maintenance/table.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %} 2 | 3 | {% block title %}{{ super() }}/ Maintenance / {{database}} / {{schema}} / {{table}}{% endblock %} 4 | 5 | {% block head %} 6 | {% for link in vitejs.css_links_for('maintenance.table.js') %}{{ link | safe }}{% endfor %} 7 | {% endblock %} 8 | 9 | {% block content %} 10 | 11 | {% include "maintenance/breadcrumb.html" %} 12 | 13 |
14 | 26 |
27 | 28 | 29 | {% endblock %} 30 | -------------------------------------------------------------------------------- /ui/share/temboard_CHANGEME.pem: -------------------------------------------------------------------------------- 1 | -----BEGIN CERTIFICATE----- 2 | MIIDETCCAfkCFCfsZSk1gg872dzAFpy/8yk3B2GCMA0GCSqGSIb3DQEBCwUAMEUx 3 | CzAJBgNVBAYTAkFVMRMwEQYDVQQIDApTb21lLVN0YXRlMSEwHwYDVQQKDBhJbnRl 4 | cm5ldCBXaWRnaXRzIFB0eSBMdGQwHhcNMjIwNDI2MTcwNzI3WhcNMjUwNDI1MTcw 5 | NzI3WjBFMQswCQYDVQQGEwJBVTETMBEGA1UECAwKU29tZS1TdGF0ZTEhMB8GA1UE 6 | CgwYSW50ZXJuZXQgV2lkZ2l0cyBQdHkgTHRkMIIBIjANBgkqhkiG9w0BAQEFAAOC 7 | AQ8AMIIBCgKCAQEAsJYf3R8GOrtF0wn6cxLn4OxnClccKzvVV25zQIR+QM0SeIFk 8 | lxs7zxc5bT2TsVmr2dGnHhqrgueQL/UFfwovZUpkktZnUaNm6El2XSSIQJ+X0m6i 9 | 8K6NKn7Yjgqkj36tMyMNerGcXjfA9+QfXjnkTIXty19GqYtzslYFZRWXDfoKLhQf 10 | qjRdIMPsxeXzrROYfAuT1dxBULfLH+oa2RWL/gaVt+L7+ld06r/a6dFGCuHebbTN 11 | FaqqdmMwnMBCqYkrgPPhsysJF07ENGBIeFzYX54gOtpG+Ya5ayWvQsQLSlGlhYkC 12 | tNqFcxT5jtWvXHVDSkaQ4g8F+2SJApUeQNqHHQIDAQABMA0GCSqGSIb3DQEBCwUA 13 | A4IBAQBhT5DnbJFDrt9fCP3bZbD3X9iAv3YA8dEp46BfyTd4jh8HALiHttmUEjG+ 14 | ImB7y/U/84vrMRgdAfVpkogMIO9fHG9joLQxuCtKkeikKI5BBw+BM5wZyC8m3bpW 15 | 9wvAsle5Suc0scx6Mpwiu5/n+nYEj25UE8vkCZ7R/wRH1/xLfk1tWFYhyvz/exUm 16 | KmcdbUWxSkxrWCEeHHTIwSlf/yty5VNz4OzzSy2919kkcs9iB5vjgKp0zbC7SDXA 17 | Inm78yDQpcZXfhnt7cRTu2d4Vm+wsdpOX5nf7qBuGAT/VvqJg9qaLqNVqNNtcj/x 18 | e8dTtauaSnZ4MvZvU8rwFZ4ATsTA 19 | -----END CERTIFICATE----- 20 | -------------------------------------------------------------------------------- /agent/share/temboard-agent_CHANGEME.pem: -------------------------------------------------------------------------------- 1 | -----BEGIN CERTIFICATE----- 2 | MIIDETCCAfkCFGOIzLQKtvCulsO6905Nd3d95aOOMA0GCSqGSIb3DQEBCwUAMEUx 3 | CzAJBgNVBAYTAkFVMRMwEQYDVQQIDApTb21lLVN0YXRlMSEwHwYDVQQKDBhJbnRl 4 | cm5ldCBXaWRnaXRzIFB0eSBMdGQwHhcNMjIwNDI2MTcwNzI3WhcNMjUwNDI1MTcw 5 | NzI3WjBFMQswCQYDVQQGEwJBVTETMBEGA1UECAwKU29tZS1TdGF0ZTEhMB8GA1UE 6 | CgwYSW50ZXJuZXQgV2lkZ2l0cyBQdHkgTHRkMIIBIjANBgkqhkiG9w0BAQEFAAOC 7 | AQ8AMIIBCgKCAQEA0HwkI8cz7KnBf+2arktqO3TvaVi8+307rJGkkfmeDZYnjuWv 8 | BiQSF7N97thMPLaB7W2J2nzrIJCKTTtnagqVOn/TLOboSxDAzMWaJh9jfXqOwgX1 9 | e4khjw7gXemOQR/tCPVX00fIorKtX0vqS1oBDlbpW/Yxxu2gI/Lwld+787957iy5 10 | uZq8kIqa19tOW+0Kp8288nQbqnCrzb3yOFS57T6UdRHqtwY12vuZV/orjwL6ToVR 11 | zJXGt9Nqqa6dU4toXCEan3AcOQQ8oYmRaNaW0HzZI+jXJs8Y+GS+K1FtFibA2eYU 12 | Rql2DpiHcsg+l6UKQwM340nVGJLbMF6ZbJWrrwIDAQABMA0GCSqGSIb3DQEBCwUA 13 | A4IBAQBu7zj8LR/XMMS+pI/fbDxd5BkXTwAOZ12aDfA+UpEHv6+94+NhFy6XCTKX 14 | JJDYavNdijTtTZjmjdfq6Ryza2R2xhj7DZmgi8COg31foRU+TE6/th4EUxb8bQwg 15 | E/tvMAhNF55RZitIOyFSkylXAK4sT0XqbGlRTo343ME/YzSTqtCMv28iUhLHuDRn 16 | KZCS+6dk930dnCuUvT2gnsd6+MgS61jhgYIYLeyhO6MdDs4VFiH8z0mdudPbcTru 17 | YuEjVwTw9g/3dAuq59oJduRn1l7RLCSMG7ylpQotnjpD+gwDMM4+sQzbvdX4/VP7 18 | ukZMXodW9KeqWGMbUAdBnO99+/qm 19 | -----END CERTIFICATE----- 20 | -------------------------------------------------------------------------------- /docs/compatibility_guide.md: -------------------------------------------------------------------------------- 1 | temBoard is designed to interact with many components: systemd, PostgreSQL, 2 | agents, etc. 3 | 4 | As we are trying to find the right balance between innovation and backward 5 | compatibility, we define a comprehensive list of platforms and software that 6 | we support for each version. 7 | 8 | Temboard 10 is the current release. 9 | 10 | Each minor release (e.g. `10.1`, `10.2`, etc.) is compatible with the following 11 | components: 12 | 13 | | | | 14 | | -------------- | -------------------------------------------| 15 | | Linux | Debian 12 and 13, Ubuntu 22.04 and 24.04, RHEL 8, 9 and 10 | 16 | | Python | 3.9+ | 17 | | Postgres | 13 to 18 | 18 | | Temboard Agent | 9.x and 10.x | 19 | 20 | Additional notes: 21 | 22 | * On RHEL8, the default python version is 3.6. Be sure to install `python39`. 23 | * RHEL7 et Debian 11 (bullseye) are unsupported 24 | * All PostgreSQL versions below 13.x are obsolete and unsupported 25 | * It is highly recommended to upgrade PostgreSQL to the latest minor version of your 26 | current major version 27 | -------------------------------------------------------------------------------- /ui/temboardui/static/src/settings.notifications.js: -------------------------------------------------------------------------------- 1 | import $ from "jquery"; 2 | 3 | $("#sendEmailForm").on("submit", function (e) { 4 | e.preventDefault(); 5 | sendEmail(); 6 | }); 7 | 8 | $("#sendSmsForm").on("submit", function (e) { 9 | e.preventDefault(); 10 | sendSms(); 11 | }); 12 | 13 | function sendEmail() { 14 | clearError(); 15 | $.ajax({ 16 | url: "/json/test_email", 17 | type: "post", 18 | contentType: "application/json", 19 | data: JSON.stringify({ 20 | email: $("#inputTestEmail").val(), 21 | }), 22 | success: function (data) { 23 | var msg = "Test email successfully sent"; 24 | msg += "\nIf the email is not received, please have a look at your SMTP server logs"; 25 | alert(msg); 26 | }, 27 | error: function (xhr) { 28 | console.log("error", xhr); 29 | showError(xhr); 30 | }, 31 | }); 32 | } 33 | 34 | function sendSms() { 35 | clearError(); 36 | $.ajax({ 37 | url: "/json/test_sms", 38 | type: "post", 39 | contentType: "application/json", 40 | data: JSON.stringify({ 41 | phone: $("#inputTestPhone").val(), 42 | }), 43 | success: function (data) { 44 | alert("Test SMS sent"); 45 | }, 46 | error: showError, 47 | }); 48 | } 49 | -------------------------------------------------------------------------------- /ui/temboardui/static/src/utils/duration.js: -------------------------------------------------------------------------------- 1 | import _ from "lodash"; 2 | 3 | const durationSecond = 1000; 4 | const durationMinute = durationSecond * 60; 5 | const durationHour = durationMinute * 60; 6 | const durationDay = durationHour * 24; 7 | 8 | function formatDuration(ms, rounded) { 9 | let results = []; 10 | const µs = Math.round((ms - Math.floor(ms)) * 1000); 11 | ms = Math.floor(ms); 12 | const d = Math.trunc(ms / durationDay); 13 | results.push(d + " d"); 14 | ms = ms - d * durationDay; 15 | const h = Math.trunc(ms / durationHour); 16 | results.push(h + " h"); 17 | ms = ms - h * durationHour; 18 | const m = Math.trunc(ms / durationMinute); 19 | results.push(m + " min"); 20 | ms = ms - m * durationMinute; 21 | const s = Math.trunc(ms / durationSecond); 22 | results.push(s + " s"); 23 | ms = ms - s * durationSecond; 24 | results.push(ms + " ms"); 25 | results.push(µs + " µs"); 26 | 27 | results = _.dropWhile(results, (o) => parseInt(o) == 0); 28 | 29 | if (rounded) { 30 | // Only keep the first or two firsts values 31 | const n = parseInt(results[0]) < 3 && parseInt(results[1]) !== 0 ? 2 : 1; 32 | results = results.slice(0, n); 33 | } 34 | return results.length ? results.join(" ") : "0 ms"; 35 | } 36 | 37 | export { formatDuration }; 38 | -------------------------------------------------------------------------------- /dev/grafana/rootfs/etc/grafana/provisioning/datasources/prometheus.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: 1 2 | 3 | datasources: 4 | - "uid": "01lWBBhnk" 5 | "orgId": 1 6 | "name": "Prometheus" 7 | "type": "prometheus" 8 | "typeName": "Prometheus" 9 | "typeLogoUrl": "public/app/plugins/datasource/prometheus/img/prometheus_logo.svg" 10 | "access": "proxy" 11 | "url": "http://prometheus:9090/" 12 | "password": "" 13 | "user": "" 14 | "database": "" 15 | "basicAuth": false 16 | "isDefault": true 17 | "jsonData": 18 | "httpMethod": "POST" 19 | "timeInterval": "60s" 20 | "readOnly": false 21 | 22 | - "uid": "Kme6NfMSz" 23 | "orgId": 1 24 | "name": "temBoard prometheus" 25 | "type": "prometheus" 26 | "typeName": "Prometheus" 27 | "typeLogoUrl": "public/app/plugins/datasource/prometheus/img/prometheus_logo.svg" 28 | "access": "proxy" 29 | # This IP is the default docker0 host IP. 30 | # Binding to 0.0.0.0 on docker host exposes to container through this IP at least. 31 | # You may need to update if docker0 host IP is different. 32 | # And then recreate Grafana container. 33 | "url": "http://172.17.0.1:8890/" 34 | "user": "" 35 | "database": "" 36 | "basicAuth": false 37 | "isDefault": false 38 | "jsonData": 39 | "httpMethod": "POST" 40 | "readOnly": false 41 | -------------------------------------------------------------------------------- /ui/temboardui/static/src/components/ModalDialog.vue: -------------------------------------------------------------------------------- 1 | 32 | 33 | 47 | -------------------------------------------------------------------------------- /ui/share/temboard_ca_certs_CHANGEME.pem: -------------------------------------------------------------------------------- 1 | -----BEGIN CERTIFICATE----- 2 | MIIDXTCCAkWgAwIBAgIJALXncntr5W52MA0GCSqGSIb3DQEBCwUAMEUxCzAJBgNV 3 | BAYTAkFVMRMwEQYDVQQIDApTb21lLVN0YXRlMSEwHwYDVQQKDBhJbnRlcm5ldCBX 4 | aWRnaXRzIFB0eSBMdGQwHhcNMTgxMDIzMDk0MzA3WhcNMjExMDIyMDk0MzA3WjBF 5 | MQswCQYDVQQGEwJBVTETMBEGA1UECAwKU29tZS1TdGF0ZTEhMB8GA1UECgwYSW50 6 | ZXJuZXQgV2lkZ2l0cyBQdHkgTHRkMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIB 7 | CgKCAQEAsJYf3R8GOrtF0wn6cxLn4OxnClccKzvVV25zQIR+QM0SeIFklxs7zxc5 8 | bT2TsVmr2dGnHhqrgueQL/UFfwovZUpkktZnUaNm6El2XSSIQJ+X0m6i8K6NKn7Y 9 | jgqkj36tMyMNerGcXjfA9+QfXjnkTIXty19GqYtzslYFZRWXDfoKLhQfqjRdIMPs 10 | xeXzrROYfAuT1dxBULfLH+oa2RWL/gaVt+L7+ld06r/a6dFGCuHebbTNFaqqdmMw 11 | nMBCqYkrgPPhsysJF07ENGBIeFzYX54gOtpG+Ya5ayWvQsQLSlGlhYkCtNqFcxT5 12 | jtWvXHVDSkaQ4g8F+2SJApUeQNqHHQIDAQABo1AwTjAdBgNVHQ4EFgQUtlIGs0QI 13 | XlQtg7bkbzBH35j57howHwYDVR0jBBgwFoAUtlIGs0QIXlQtg7bkbzBH35j57how 14 | DAYDVR0TBAUwAwEB/zANBgkqhkiG9w0BAQsFAAOCAQEAgxWfdbITjrWOMJEzhY3q 15 | KKz+B/N4QjUq9hpceQtmyT21ntbqkto1yaeksws9Z/81JBYkowW5U6J6DzOziIpI 16 | PptEz845AKa8rljPv7o8Tu/Z+9MdP4Pdl3eVayMIF/WR7drR6vCIRycw25yiY0UT 17 | RvgsGcW6Tf7sz8LWGzlvwcgATXLu5hwGC0iEWRfGESd0wsMEu0FcGDnJceScOVBu 18 | ssp2k7K4g1V+bHEQftTlQ/wYfFgy/o4BoVV0rQx4jlSq9hTx9nTQcbBLtw9stHkr 19 | gNoaIx+Fdqm/OT/h1WDmJkb/grd75uMTZKhJKKMxcvYxtYxpdtWTDSoPqgnVXFBc 20 | hQ== 21 | -----END CERTIFICATE----- 22 | -------------------------------------------------------------------------------- /agent/share/temboard-agent_ca_certs_CHANGEME.pem: -------------------------------------------------------------------------------- 1 | -----BEGIN CERTIFICATE----- 2 | MIIDYDCCAkigAwIBAgIJALYNYoXM3oe6MA0GCSqGSIb3DQEBCwUAMEUxCzAJBgNV 3 | BAYTAkFVMRMwEQYDVQQIDApTb21lLVN0YXRlMSEwHwYDVQQKDBhJbnRlcm5ldCBX 4 | aWRnaXRzIFB0eSBMdGQwHhcNMTgwNjI2MTQzNzA1WhcNMjEwNjI1MTQzNzA1WjBF 5 | MQswCQYDVQQGEwJBVTETMBEGA1UECAwKU29tZS1TdGF0ZTEhMB8GA1UECgwYSW50 6 | ZXJuZXQgV2lkZ2l0cyBQdHkgTHRkMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIB 7 | CgKCAQEA0HwkI8cz7KnBf+2arktqO3TvaVi8+307rJGkkfmeDZYnjuWvBiQSF7N9 8 | 7thMPLaB7W2J2nzrIJCKTTtnagqVOn/TLOboSxDAzMWaJh9jfXqOwgX1e4khjw7g 9 | XemOQR/tCPVX00fIorKtX0vqS1oBDlbpW/Yxxu2gI/Lwld+787957iy5uZq8kIqa 10 | 19tOW+0Kp8288nQbqnCrzb3yOFS57T6UdRHqtwY12vuZV/orjwL6ToVRzJXGt9Nq 11 | qa6dU4toXCEan3AcOQQ8oYmRaNaW0HzZI+jXJs8Y+GS+K1FtFibA2eYURql2DpiH 12 | csg+l6UKQwM340nVGJLbMF6ZbJWrrwIDAQABo1MwUTAdBgNVHQ4EFgQUN/ZOXDwy 13 | QBxz7r5DQGjOUAxFFGIwHwYDVR0jBBgwFoAUN/ZOXDwyQBxz7r5DQGjOUAxFFGIw 14 | DwYDVR0TAQH/BAUwAwEB/zANBgkqhkiG9w0BAQsFAAOCAQEAXOoFGi8FpSyXQkwx 15 | VZ6rSQRk8dCorwP3STQsw9p+pXU543y+uZgm1MRJa7LrVAn/5dkBMlEk8LGUzIZt 16 | ScQHWgRn3AtiR5Fz3EOsT24+/JyBqVmIz6WbeeyXAsmoNY3aU5RiRudOGplUubz2 17 | c+pbQCaLsj47Xgu2bgIsJsDzOEy9E5AXcfukBSJIZEO2SzeNGRtSRQpe0pHp5VC1 18 | bj14HVlAD73xarRwatByMj9Nd2PLBVYZcXCWbBYnELG3NBvXzptaK2yCeB8l7H37 19 | V3rS0q+gSDcJMxyFyG4jVCcI/AeUHndxRozDgLWE6y2nxSHHQpcZ7fy24IZfTKMc 20 | DmdQAw== 21 | -----END CERTIFICATE----- 22 | -------------------------------------------------------------------------------- /ui/temboardui/plugins/monitoring/pivot.py: -------------------------------------------------------------------------------- 1 | import csv 2 | import operator 3 | 4 | 5 | def get_csv_data(fd): 6 | # CSV parser as generator 7 | yield from csv.DictReader(fd) 8 | 9 | 10 | def pivot_timeserie(fd, index, key, value, output): 11 | # Simple pivot table implementation. 12 | # Beware, input data *MUST* be ordered by index value. 13 | fd.seek(0) 14 | keys = {} 15 | p = 1 16 | # We need to get the keys first 17 | for r in get_csv_data(fd): 18 | if r[key] not in keys: 19 | keys[r[key]] = p 20 | p += 1 21 | sk = sorted(list(keys.items()), key=operator.itemgetter(1)) 22 | # CSV Header 23 | line = [index] + [x[0] for x in sk] 24 | p_index = "" 25 | fd.seek(0) 26 | for r in get_csv_data(fd): 27 | if r[index] != p_index: 28 | # As data are ordered if we meet a new index value then the current 29 | # line is complete. 30 | output.write(",".join(line) + "\n") 31 | # And start a new line 32 | line = [""] * (len(keys) + 1) 33 | line[0] = r[index] 34 | # Append value to the current line 35 | line[keys[r[key]]] = r[value] 36 | # Keep a track of the index value 37 | p_index = r[index] 38 | # Write the last line 39 | output.write(",".join(line) + "\n") 40 | -------------------------------------------------------------------------------- /agent/temboardagent/command.py: -------------------------------------------------------------------------------- 1 | import errno 2 | from shlex import split as shlex_split 3 | from subprocess import PIPE, CalledProcessError, Popen, check_call 4 | 5 | 6 | def exec_command(command_args, **kwargs): 7 | """ 8 | Execute a system command with Popen. 9 | """ 10 | kwargs.setdefault("stdout", PIPE) 11 | kwargs.setdefault("stderr", PIPE) 12 | kwargs.setdefault("stdin", PIPE) 13 | kwargs.setdefault("close_fds", True) 14 | try: 15 | process = Popen(command_args, **kwargs) 16 | except OSError as err: 17 | return (err.errno, None, err.strerror) 18 | 19 | (stdout, stderrout) = process.communicate() 20 | 21 | return (process.returncode, stdout, stderrout) 22 | 23 | 24 | def exec_script(script_args, **kwargs): 25 | """ 26 | Execute an external script. 27 | """ 28 | kwargs.setdefault("stderr", PIPE) 29 | kwargs.setdefault("stdout", PIPE) 30 | try: 31 | check_call(script_args, **kwargs) 32 | except CalledProcessError as err: 33 | return (err.returncode, None, err.output) 34 | except OSError as err: 35 | if err.errno == errno.EPIPE: 36 | pass 37 | 38 | return (0, None, None) 39 | 40 | 41 | def oneline_cmd_to_array(command_line): 42 | """ 43 | Split a command line using shlex module. 44 | """ 45 | return shlex_split(command_line) 46 | -------------------------------------------------------------------------------- /agent/pyproject.toml: -------------------------------------------------------------------------------- 1 | [project] 2 | name = "temboard-agent" 3 | version = "10.0.0" 4 | description = "PostgreSQL Remote Control Agent" 5 | readme = "README.md" 6 | requires-python = ">=3.9,<3.14" 7 | license = "PostgreSQL" 8 | authors = [ 9 | {name = "Dalibo", email = "contact@dalibo.com"}, 10 | ] 11 | classifiers = ["Private :: Do Not Upload"] 12 | dependencies = [ 13 | "cryptography", 14 | ] 15 | 16 | [project.urls] 17 | Homepage = "https://labs.dalibo.com/temboard" 18 | Documentation = "https://temboard.readthedocs.io" 19 | Repository = "https://github.com/dalibo/temboard" 20 | Changelog = "https://github.com/dalibo/temboard/blob/master/CHANGELOG.md" 21 | 22 | [project.scripts] 23 | temboard-agent = "temboardagent.__main__:main" 24 | 25 | [project.entry-points."temboardagent.plugins"] 26 | activity = "temboardagent.plugins.activity:ActivityPlugin" 27 | dashboard = "temboardagent.plugins.dashboard:DashboardPlugin" 28 | maintenance = "temboardagent.plugins.maintenance:MaintenancePlugin" 29 | monitoring = "temboardagent.plugins.monitoring:MonitoringPlugin" 30 | pgconf = "temboardagent.plugins.pgconf:PgConfPlugin" 31 | statements = "temboardagent.plugins.statements:StatementsPlugin" 32 | 33 | [build-system] 34 | requires = ["uv_build>=0.9.10,<0.10.0"] 35 | build-backend = "uv_build" 36 | 37 | [tool.uv.build-backend] 38 | module-name = "temboardagent" 39 | module-root = "" 40 | -------------------------------------------------------------------------------- /agent/temboardagent/cli/tasks.py: -------------------------------------------------------------------------------- 1 | # Note that our home-made background task implementation uses a different 2 | # semantic that state of the art background task implemtation: 3 | # 4 | # a task function is called a worker 5 | # a worker process is called workerpool 6 | # a message is called a task 7 | # the message broker is called taskmanager 8 | # 9 | 10 | import logging.config 11 | 12 | from temboardtoolkit.app import SubCommand 13 | from temboardtoolkit.errors import UserError 14 | from temboardtoolkit.taskmanager import FlushTasksMixin, RunTaskMixin 15 | 16 | from .app import app 17 | 18 | logger = logging.getLogger(__name__) 19 | 20 | 21 | @app.command 22 | class Tasks(SubCommand): 23 | """Manage background tasks.""" 24 | 25 | def main(self, args): 26 | raise UserError("Missing sub-command. See --help for details.") 27 | 28 | 29 | @Tasks.command 30 | class Flush(FlushTasksMixin, SubCommand): 31 | """Flush all tasks.""" 32 | 33 | 34 | @Tasks.command 35 | class Run(RunTaskMixin, SubCommand): 36 | """Run a task foreground.""" 37 | 38 | def main(self, args): 39 | workers = self.iter_workers() 40 | 41 | if "?" == args.worker_name: 42 | self.print_workers(workers) 43 | else: 44 | worker, worker_args = self.compute_worker_args(workers, args) 45 | worker(*worker_args) 46 | return 0 47 | -------------------------------------------------------------------------------- /ui/temboardui/templates/flask/login.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %} 2 | 3 | {% block title %}{{ super() }}/ Login{% endblock %} 4 | 5 | {% block content %} 6 |
7 |
8 |
9 |
10 | 11 |
12 |
13 |
14 |
15 | 16 | 17 |
18 |
19 | 20 | 21 | Forgot password? 22 |
23 |
24 | 25 |
26 |
27 |
28 |
29 |
30 |
31 | 32 | {% endblock %} 33 | -------------------------------------------------------------------------------- /ui/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "temboard", 3 | "private": true, 4 | "type": "module", 5 | "engines": { 6 | "node": ">=20.0.0" 7 | }, 8 | "scripts": { 9 | "dev": "vite", 10 | "build": "vite build", 11 | "install-husky": "cd .. && husky install ui/.husky" 12 | }, 13 | "dependencies": { 14 | "@fortawesome/fontawesome-free": "^7.1.0", 15 | "@vueuse/components": "^10.11.0", 16 | "@vueuse/core": "^10.9.0", 17 | "bootstrap": "^5.3.3", 18 | "bootstrap-vue-next": "^0.16.6", 19 | "datatables.net-bs5": "^2.0.8", 20 | "datatables.net-buttons-bs5": "^3.0.2", 21 | "datatables.net-vue3": "^3.0.1", 22 | "daterangepicker": "^2.1.27", 23 | "dygraphs": "^2.2.1", 24 | "filesize": "^10.1.2", 25 | "highlight.js": "^11.9.0", 26 | "jquery": "^3.7.1", 27 | "lodash": "^4.17.21", 28 | "moment": "^2.30.1", 29 | "pev2": "^1.12.1", 30 | "vue": "^3.4.21", 31 | "vue-multiselect": "^3.0.0", 32 | "vue-router": "^4.4.0" 33 | }, 34 | "devDependencies": { 35 | "@trivago/prettier-plugin-sort-imports": "^4.3.0", 36 | "@vitejs/plugin-vue": "^5.0.5", 37 | "autoprefixer": "^10.4.19", 38 | "husky": "^8.0.3", 39 | "lint-staged": "^14.0.1", 40 | "postcss": "^8.4.38", 41 | "prettier": "3.3.3", 42 | "prettier-plugin-jinja-template": "1.4.1", 43 | "sass": "^1.77.6", 44 | "vite": "^5.3.1" 45 | }, 46 | "lint-staged": { 47 | "*.{js,css,vue}": "prettier --write" 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /toolkit/tests/test_versions.py: -------------------------------------------------------------------------------- 1 | import sys 2 | from textwrap import dedent 3 | 4 | 5 | def test_os_release(): 6 | from temboardtoolkit.versions import parse_lsb_release 7 | 8 | distinfos = parse_lsb_release( 9 | dedent("""\ 10 | PRETTY_NAME="Debian GNU/Linux 10 (buster)" 11 | NAME="Debian GNU/Linux" 12 | VERSION_ID="10" 13 | VERSION="10 (buster)" 14 | VERSION_CODENAME=buster 15 | ID=debian 16 | HOME_URL="https://www.debian.org/" 17 | SUPPORT_URL="https://www.debian.org/support" 18 | BUG_REPORT_URL="https://bugs.debian.org/" 19 | """).splitlines(True) 20 | ) 21 | 22 | assert "10" == distinfos["VERSION_ID"] 23 | assert "10 (buster)" == distinfos["VERSION"] 24 | 25 | 26 | def test_format_pq_version(): 27 | from temboardtoolkit.versions import format_pq_version 28 | 29 | assert "14.1" == format_pq_version(140001) 30 | assert "13.5" == format_pq_version(130005) 31 | assert "12.9" == format_pq_version(120009) 32 | assert "11.14" == format_pq_version(110014) 33 | assert "10.19" == format_pq_version(100019) 34 | assert "9.6.24" == format_pq_version(90624) 35 | 36 | 37 | def test_read_libpq_version_from_ctypes(mocker): 38 | __import__("psycopg2.extensions") 39 | # Remove __libpq_version__ if any. 40 | mocker.patch.dict(sys.modules, [("psycopg2.extensions", object())]) 41 | 42 | from temboardtoolkit.versions import read_libpq_version 43 | 44 | assert read_libpq_version() > 90000 45 | -------------------------------------------------------------------------------- /tests/pytest-ci: -------------------------------------------------------------------------------- 1 | #!/bin/bash -eu 2 | # 3 | # Wrapper around pytest to configure marker expr from git diff. 4 | # 5 | 6 | main() { 7 | upstream="$1"; shift 8 | marker_expr=() 9 | 10 | if [ "$CIRCLE_BRANCH" = "$upstream" ] ; then 11 | log "Run all tests on $upstream." 12 | else 13 | if ! git diff --name-only "origin/$upstream"..HEAD | grep -f tests/monitoring-files ; then 14 | log "Skipping slow monitoring tests." 15 | marker_expr+=("not slowmonitoring") 16 | fi 17 | 18 | if ! git diff --name-only "origin/$upstream"..HEAD | grep -f tests/statements-files ; then 19 | log "Skipping slow statements tests." 20 | marker_expr+=("not slowstatements") 21 | fi 22 | 23 | if [ "${#marker_expr[@]}" -eq 0 ] ; then 24 | log "Running all test for modifications." 25 | fi 26 | fi 27 | 28 | marker_expr_s="$(join_array " and " "${marker_expr[@]-}")" 29 | 30 | if [ "${marker_expr_s}" ] ; then 31 | set -- "-m" "$marker_expr_s" "$@" 32 | fi 33 | 34 | args=("$@") # Hack for CentOS 7 bash. 35 | log "Running pytest" "${args[@]@Q}" 36 | 37 | exec pytest "$@" 38 | } 39 | 40 | 41 | log() { 42 | echo "$*" >&2 43 | } 44 | 45 | 46 | # From https://stackoverflow.com/questions/1527049/how-can-i-join-elements-of-an-array-in-bash 47 | join_array() { 48 | local d="${1-}" f="${2-}" 49 | if shift 2; then 50 | printf %s "$f" "${@/#/$d}" 51 | fi 52 | } 53 | 54 | 55 | main "$@" 56 | -------------------------------------------------------------------------------- /ui/temboardui/plugins/maintenance/routes.py: -------------------------------------------------------------------------------- 1 | from flask import current_app, render_template 2 | 3 | from ...web.flask import instance_routes 4 | 5 | PLUGIN_NAME = "maintenance" 6 | 7 | 8 | @instance_routes.route("/maintenance") 9 | def maintenance(): 10 | current_app.instance.check_active_plugin(PLUGIN_NAME) 11 | current_app.instance.fetch_status() 12 | return render_template("maintenance/index.html", plugin=PLUGIN_NAME) 13 | 14 | 15 | @instance_routes.route("/maintenance//schema//table/") 16 | def table(database, schema, table): 17 | current_app.instance.check_active_plugin(PLUGIN_NAME) 18 | current_app.instance.fetch_status() 19 | return render_template( 20 | "maintenance/table.html", 21 | plugin=PLUGIN_NAME, 22 | database=database, 23 | schema=schema, 24 | table=table, 25 | ) 26 | 27 | 28 | @instance_routes.route("/maintenance//schema/") 29 | def schema(database, schema): 30 | current_app.instance.check_active_plugin(PLUGIN_NAME) 31 | current_app.instance.fetch_status() 32 | return render_template( 33 | "maintenance/schema.html", plugin=PLUGIN_NAME, database=database, schema=schema 34 | ) 35 | 36 | 37 | @instance_routes.route("/maintenance/") 38 | def database(database): 39 | current_app.instance.check_active_plugin(PLUGIN_NAME) 40 | current_app.instance.fetch_status() 41 | return render_template( 42 | "maintenance/database.html", plugin=PLUGIN_NAME, database=database 43 | ) 44 | -------------------------------------------------------------------------------- /ui/temboardui/version.py: -------------------------------------------------------------------------------- 1 | import sys 2 | from importlib.metadata import version 3 | from platform import python_version 4 | 5 | __version__ = version("temboard") 6 | 7 | 8 | # This output is parsed by tests/conftest.py::pytest_report_header. 9 | VERSION_FMT = """\ 10 | temBoard %(temboard)s (%(temboardbin)s) 11 | System %(distname)s %(distversion)s 12 | Python %(python)s (%(pythonbin)s) 13 | cryptography %(cryptography)s 14 | Tornado %(tornado)s 15 | Flask %(flask)s 16 | libpq %(libpq)s 17 | psycopg2 %(psycopg2)s 18 | SQLAlchemy %(sqlalchemy)s 19 | """ 20 | 21 | 22 | def format_version(): 23 | return VERSION_FMT % inspect_versions() 24 | 25 | 26 | def inspect_versions(): 27 | import cryptography 28 | import flask 29 | import psycopg2 30 | import sqlalchemy 31 | import tornado 32 | from temboardtoolkit.versions import ( 33 | format_pq_version, 34 | read_distinfo, 35 | read_libpq_version, 36 | ) 37 | 38 | distinfos = read_distinfo() 39 | 40 | return dict( 41 | cryptography=cryptography.__version__, 42 | distname=distinfos["NAME"], 43 | distversion=distinfos.get("VERSION", "n/a"), 44 | flask=flask.__version__, 45 | libpq=format_pq_version(read_libpq_version()), 46 | psycopg2=psycopg2.__version__, 47 | python=python_version(), 48 | pythonbin=sys.executable, 49 | sqlalchemy=sqlalchemy.__version__, 50 | temboard=__version__, 51 | temboardbin=sys.argv[0], 52 | tornado=tornado.version, 53 | ) 54 | -------------------------------------------------------------------------------- /agent/temboardagent/cli/routes.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | from bottle import default_app 4 | from temboardtoolkit.app import SubCommand 5 | 6 | from .app import app 7 | 8 | logger = logging.getLogger(__name__) 9 | 10 | 11 | @app.command 12 | class Routes(SubCommand): 13 | """Show HTTP routing table.""" 14 | 15 | def main(self, args): 16 | for path, method, callback in sorted(iter_bottle_routes()): 17 | doc = callback.__doc__ or "" 18 | try: 19 | title = doc.splitlines()[0] 20 | except IndexError: 21 | title = "" 22 | print(" %6.6s %-64s %s" % (method, path, title)) 23 | 24 | return 0 25 | 26 | 27 | def iter_bottle_routes(bottle=None): 28 | if bottle is None: 29 | bottle = default_app() 30 | for route in bottle.routes: 31 | if "PROXY" == route.method: # Gateway to a plugin. 32 | prefix = route.config["mountpoint.prefix"].rstrip("/") 33 | subapp = route.config["mountpoint.target"] 34 | dynamic = "<:re:" in route.rule 35 | for rule, method, callback in iter_bottle_routes(subapp): 36 | # Show root for static PROXY only. 37 | if dynamic: 38 | if "/" == rule: 39 | continue 40 | elif "/" != rule: 41 | continue 42 | yield prefix + ("" if "/" == rule else rule), method, callback 43 | else: 44 | yield route.rule, route.method, route.callback 45 | -------------------------------------------------------------------------------- /ui/temboardui/static/src/settings.environments.js: -------------------------------------------------------------------------------- 1 | import { Tooltip } from "bootstrap"; 2 | import DataTablesLib from "datatables.net-bs5"; 3 | import "datatables.net-buttons-bs5"; 4 | import DataTable from "datatables.net-vue3"; 5 | import { createApp } from "vue"; 6 | 7 | import DeleteDialog from "./components/DeleteDialog.vue"; 8 | import EnvironmentDialog from "./components/EnvironmentDialog.vue"; 9 | 10 | DataTable.use(DataTablesLib); 11 | 12 | createApp({ 13 | components: { 14 | deletedialog: DeleteDialog, 15 | environmentdialog: EnvironmentDialog, 16 | }, 17 | created() { 18 | this.$nextTick(() => { 19 | new DataTablesLib("#tableEnvironments", { 20 | stateSave: true, 21 | autoWidth: false, 22 | // name, description, actions 23 | columns: [{ width: "32rem" }, { width: "auto" }, { width: "8rem", orderable: false }], 24 | layout: { 25 | topStart: "search", 26 | topEnd: { 27 | buttons: [ 28 | { 29 | text: "New environment", 30 | className: "btn btn-sm btn-success", 31 | attr: { 32 | "data-bs-toggle": "modal", 33 | "data-bs-target": "#modalEditEnvironment", 34 | "data-testid": "new", 35 | }, 36 | }, 37 | ], 38 | }, 39 | }, 40 | }); 41 | 42 | document.querySelectorAll('[data-bs-toggle="tooltip"]').forEach((el) => new Tooltip(el)); 43 | }); 44 | }, 45 | }).mount("#vue-app"); 46 | -------------------------------------------------------------------------------- /ui/temboardui/core.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | from temboardtoolkit.taskmanager import WorkerSet 4 | from temboardtoolkit.utils import utcnow 5 | 6 | from .agentclient import TemboardAgentClient 7 | from .model import Session, worker_engine 8 | from .model.orm import Instance 9 | 10 | logger = logging.getLogger(__name__) 11 | workers = WorkerSet() 12 | 13 | 14 | @workers.register(pool_size=1) 15 | def refresh_discover(app, address, port): 16 | session = Session(bind=worker_engine(app.config.repository)) 17 | instance = Instance.get(address, port).with_session(session).one() 18 | client = TemboardAgentClient.factory(app.config, address, port) 19 | try: 20 | logger.info("Discovering %s.", instance) 21 | response = client.get("/discover") 22 | response.raise_for_status() 23 | except (OSError, ConnectionError, client.Error) as e: 24 | logger.error("Failed to discover %s: %s", instance, e) 25 | logger.error("Agent or host may be down or misconfigured.") 26 | return 27 | 28 | data = response.json() 29 | 30 | discover_etag = response.headers.get("ETag") 31 | if discover_etag == instance.discover_etag: 32 | logger.info("Discover data up to date for %s.", instance) 33 | if session.is_modified(instance): 34 | session.commit() 35 | return 36 | 37 | instance.discover = data 38 | instance.discover_etag = discover_etag 39 | instance.discover_date = utcnow() 40 | session.commit() 41 | 42 | logger.info("Updated discover data for %s.", instance) 43 | -------------------------------------------------------------------------------- /ui/packaging/docker/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM debian:bookworm-slim AS copier 2 | # RUN --mount syntax does not accept arg. Thus first stage copy with arg in 3 | # static path for future RUN --mount. 4 | ARG VERSION 5 | COPY dist/temboard_${VERSION}-0dlb1bookworm1_amd64.deb /tmp/temboard.deb 6 | 7 | FROM debian:bookworm-slim 8 | 9 | ADD https://raw.githubusercontent.com/vishnubob/wait-for-it/master/wait-for-it.sh /usr/local/bin/wait-for-it 10 | RUN set -ex; \ 11 | chmod 0755 /usr/local/bin/wait-for-it; \ 12 | : 13 | 14 | RUN set -ex; \ 15 | apt-get update -y ; \ 16 | mkdir -p /usr/share/man/man1 /usr/share/man/man7 ; \ 17 | apt-get install -y --no-install-recommends \ 18 | openssl \ 19 | postgresql-client \ 20 | ssl-cert \ 21 | sudo \ 22 | ; \ 23 | apt-get clean ; \ 24 | rm -rf /var/lib/apt/lists/* /usr/share/man/man*/*; \ 25 | : 26 | 27 | ADD packaging/docker/entrypoint.sh /usr/local/bin/docker-entrypoint.sh 28 | 29 | ARG VERSION 30 | RUN --mount=type=bind,from=copier,source=/tmp/temboard.deb,target=/tmp/temboard.deb \ 31 | set -ex; \ 32 | apt-get update -y ; \ 33 | mkdir -p /usr/share/man/man1 /usr/share/man/man7 ; \ 34 | apt-get install -y --no-install-recommends /tmp/temboard.deb ; \ 35 | apt-get clean ; \ 36 | rm -rf /var/lib/apt/lists/* /usr/share/man/man*/*; \ 37 | temboard --version ; \ 38 | : 39 | 40 | VOLUME /var/lib/temboard 41 | VOLUME /etc/temboard 42 | 43 | WORKDIR /var/lib/temboard 44 | ENTRYPOINT ["docker-entrypoint.sh"] 45 | CMD ["temboard"] 46 | 47 | EXPOSE 8888 48 | -------------------------------------------------------------------------------- /ui/temboardui/static/src/settings.users.js: -------------------------------------------------------------------------------- 1 | import DataTablesLib from "datatables.net-bs5"; 2 | import "datatables.net-buttons-bs5"; 3 | import DataTable from "datatables.net-vue3"; 4 | import { createApp } from "vue"; 5 | 6 | import DeleteDialog from "./components/DeleteDialog.vue"; 7 | import EditUserDialog from "./components/settings/EditUserDialog.vue"; 8 | 9 | DataTable.use(DataTablesLib); 10 | 11 | createApp({ 12 | components: { 13 | edituserdialog: EditUserDialog, 14 | deletedialog: DeleteDialog, 15 | }, 16 | created() { 17 | this.$nextTick(() => { 18 | new DataTablesLib("#tableUsers", { 19 | pageLength: 50, 20 | stateSave: true, 21 | columns: [ 22 | { width: "auto" }, // Username 23 | { width: "auto" }, // Email 24 | { width: "12rem" }, // Phone 25 | { width: "6rem" }, // Active 26 | { width: "6rem" }, // Admin 27 | { width: "auto" }, // Environments 28 | { width: "6rem", orderable: false }, // Actions 29 | ], 30 | layout: { 31 | topStart: "search", 32 | topEnd: { 33 | buttons: [ 34 | { 35 | text: "New user", 36 | className: "btn btn-sm btn-success", 37 | attr: { 38 | id: "buttonNewUser", 39 | "data-bs-toggle": "modal", 40 | "data-bs-target": "#modalEditUser", 41 | }, 42 | }, 43 | ], 44 | }, 45 | }, 46 | }); 47 | }); 48 | }, 49 | }).mount("#vueapp"); 50 | -------------------------------------------------------------------------------- /tests/install-all.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash -eux 2 | # 3 | # Install UI & agent from packages. 4 | # 5 | 6 | main() { 7 | local disttag pkgtype 8 | 9 | disttag="$(guess_disttag)" 10 | pkgtype="$(guess_pkg_type "$disttag")" 11 | 12 | mapfile -t pkgs < <(readlink -e agent/dist/temboard-agent*"$disttag"*".$pkgtype" ui/dist/temboard*"$disttag"*".$pkgtype") 13 | test "${#pkgs[@]}" -eq 2 14 | 15 | "install_$pkgtype" "${pkgs[@]}" 16 | 17 | temboard --version 18 | temboard-agent --version 19 | } 20 | 21 | guess_disttag() { 22 | . /etc/os-release 23 | 24 | case "$ID" in 25 | centos) 26 | echo "el${VERSION_ID}" 27 | ;; 28 | rocky) 29 | echo "${PLATFORM_ID#platform:}" 30 | ;; 31 | debian) 32 | # Debian testing does not have VERSION* vars. 33 | if ! grep -Po '(.+(?=/))' /etc/debian_version ; then 34 | echo "$VERSION_CODENAME" 35 | fi 36 | ;; 37 | *) 38 | echo "Unsupported distribution $ID." >&2 39 | return 1 40 | ;; 41 | esac 42 | } 43 | 44 | guess_pkg_type() { 45 | local disttag="$1" 46 | 47 | case "$disttag" in 48 | trixie|bookworm|bullseye) 49 | echo deb 50 | ;; 51 | el*) 52 | echo rpm 53 | ;; 54 | *) 55 | echo "Unsupported distribution tag: $disttag." >&2 56 | return 1 57 | ;; 58 | esac 59 | } 60 | 61 | 62 | install_rpm() { 63 | "$(type -p retry)" yum --quiet --assumeyes --disablerepo='pgdg*' --disablerepo='powertools*' --disablerepo='extras' install "$@" 64 | } 65 | 66 | 67 | install_deb() { 68 | apt-get --assume-yes --quiet update 69 | apt install --no-install-recommends --quiet --yes "$@" 70 | } 71 | 72 | 73 | main "$@" 74 | -------------------------------------------------------------------------------- /ui/pyproject.toml: -------------------------------------------------------------------------------- 1 | [project] 2 | name = "temboard" 3 | version = "10.0.0" 4 | description = "temBoard User Interface." 5 | readme = "README.md" 6 | requires-python = ">=3.9,<3.14" 7 | license = "PostgreSQL" 8 | authors = [ 9 | {name = "Dalibo", email = "contact@dalibo.com"}, 10 | ] 11 | classifiers = ["Private :: Do Not Upload"] 12 | dependencies = [ 13 | "cryptography", 14 | # There is no hard dependency on psycopg2 to allow using 15 | # psycopg2-binary instead. psycopg2 is not provided by psycopg2-binary 16 | # and there is no way to state an OR dependency in Python. It's up to 17 | # the user or package manager to ensure psycopg2 dependency. See 18 | # documentation. 19 | ] 20 | 21 | [project.urls] 22 | Homepage = "https://labs.dalibo.com/temboard" 23 | Documentation = "https://temboard.readthedocs.io" 24 | Repository = "https://github.com/dalibo/temboard" 25 | Changelog = "https://github.com/dalibo/temboard/blob/master/CHANGELOG.md" 26 | 27 | [project.scripts] 28 | temboard = "temboardui.__main__:main" 29 | 30 | [project.entry-points."temboardui.plugins"] 31 | activity = "temboardui.plugins.activity:ActivityPlugin" 32 | dashboard = "temboardui.plugins.dashboard:DashboardPlugin" 33 | maintenance = "temboardui.plugins.maintenance:MaintenancePlugin" 34 | monitoring = "temboardui.plugins.monitoring:MonitoringPlugin" 35 | pgconf = "temboardui.plugins.pgconf:PGConfPlugin" 36 | statements = "temboardui.plugins.statements:StatementsPlugin" 37 | 38 | [build-system] 39 | requires = ["uv_build>=0.9.10,<0.10"] 40 | build-backend = "uv_build" 41 | 42 | [tool.uv.build-backend] 43 | module-name = "temboardui" 44 | module-root = "" 45 | -------------------------------------------------------------------------------- /ui/temboardui/web/routes/home.py: -------------------------------------------------------------------------------- 1 | from flask import current_app as app 2 | from flask import g, render_template, request 3 | 4 | from ...model import orm 5 | from ...version import inspect_versions 6 | 7 | 8 | @app.route("/home") 9 | def home(): 10 | role = g.current_user 11 | environments = [ 12 | e.name for e in role.select_environments().with_session(g.db_session).all() 13 | ] 14 | return render_template("home.html", environments=environments) 15 | 16 | 17 | @app.route("/about") 18 | def about(): 19 | versions_info = inspect_versions() 20 | instances = g.db_session.scalar(orm.Instance.count()) 21 | roles = g.db_session.scalar(orm.Role.count()) 22 | infos = { 23 | "Browser": request.headers.get("User-Agent", "Unknown"), 24 | "Version": "%(temboard)s (%(temboardbin)s)" % versions_info, 25 | "Uptime": app.start_time, 26 | "OS": "%(distname)s %(distversion)s" % versions_info, 27 | "Python": "%(python)s (%(pythonbin)s)" % versions_info, 28 | "cryptography": versions_info["cryptography"], 29 | "Tornado": versions_info["tornado"], 30 | "libpq": versions_info["libpq"], 31 | "psycopg2": versions_info["psycopg2"], 32 | "SQLAlchemy": versions_info["sqlalchemy"], 33 | "Instances": instances, 34 | "Users": roles, 35 | "SMTP": app.temboard.config.notifications.smtp_host is not None, 36 | "Twilio": app.temboard.config.notifications.twilio_account_sid is not None, 37 | } 38 | temboard_version = versions_info["temboard"] 39 | 40 | return render_template("about.html", infos=infos, temboard_version=temboard_version) 41 | -------------------------------------------------------------------------------- /ui/share/purge.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash -eu 2 | # 3 | # purge.sh purges all temboard files and configuration, except database. 4 | 5 | ETCDIR=${ETCDIR-/etc/temboard} 6 | VARDIR=${VARDIR-/var/lib/temboard} 7 | LOGDIR=${LOGDIR-/var/log/temboard} 8 | LOGFILE=${LOGFILE-/var/log/temboard-purge.log} 9 | SYSUSER=${SYSUSER-temboard} 10 | 11 | catchall() { 12 | # shellcheck disable=SC2181 13 | if [ $? -gt 0 ] ; then 14 | fatal "Failure. See ${LOGFILE} for details." 15 | else 16 | rm -f "${LOGFILE}" 17 | fi 18 | trap - INT EXIT TERM 19 | } 20 | 21 | fatal() { 22 | echo -e "\\e[1;31m$*\\e[0m" | tee -a /dev/fd/3 >&2 23 | exit 1 24 | } 25 | 26 | log() { 27 | echo "$@" | tee -a /dev/fd/3 >&2 28 | } 29 | 30 | if [ -n "${DEBUG-}" ] ; then 31 | exec 3>/dev/null 32 | else 33 | exec 3>&2 2>"${LOGFILE}" 1>&2 34 | chmod 0600 "${LOGFILE}" 35 | trap 'catchall' INT EXIT TERM 36 | fi 37 | 38 | # Now, log everything. 39 | set -x 40 | 41 | if systemctl is-system-running && systemctl cat temboard &>/dev/null ; then 42 | systemctl disable --now temboard 43 | systemctl reset-failed temboard || true 44 | fi 45 | 46 | if getent passwd "$SYSUSER" && [ "$(whoami)" != "$SYSUSER" ]; then 47 | userdel "$SYSUSER" 48 | fi 49 | 50 | if [ -d "${PGHOST-/var/run/postgresql/}" ] ; then 51 | # If local, sudo to PGUSER. 52 | run_as_postgres=(sudo -nEHu "${PGUSER-postgres}") 53 | else 54 | run_as_postgres=(env) 55 | fi 56 | 57 | "${run_as_postgres[@]}" dropdb --if-exists "${TEMBOARD_DATABASE-temboard}" 58 | "${run_as_postgres[@]}" dropuser --if-exists temboard || : 59 | 60 | rm -rf "${ETCDIR}" "${VARDIR}" "${LOGDIR}" "/etc/pki/tls/*/temboard-auto.*" 61 | 62 | log "temBoard UI unconfigured." 63 | -------------------------------------------------------------------------------- /ui/temboardui/model/queries/instances-copy-as-csv.sql: -------------------------------------------------------------------------------- 1 | COPY ( 2 | WITH 3 | inventory AS (SELECT DISTINCT 4 | i.hostname AS "Hostname", 5 | port AS "Port", 6 | discover->'postgres'->'data_directory'#>>'{}' AS "PGDATA", 7 | discover->'postgres'->'version_summary'#>>'{}' AS "Version", 8 | e.name AS "Environment", 9 | app.agent_address AS "Agent Address", 10 | app.agent_port AS "Agent Port", 11 | string_agg(DISTINCT plugins.plugin_name, ',') AS "Plugins", 12 | app.comment AS "Comment", 13 | string_agg(DISTINCT db.dbname, ',') AS "Databases" 14 | FROM monitoring.hosts AS i 15 | LEFT OUTER JOIN monitoring.instances AS monit 16 | ON monit.host_id = i.host_id 17 | LEFT OUTER JOIN application.instances AS app 18 | ON app.hostname = i.hostname 19 | LEFT OUTER JOIN application.plugins 20 | ON plugins.agent_address = app.agent_address 21 | AND plugins.agent_port = app.agent_port 22 | LEFT OUTER JOIN application.environments AS e 23 | ON e.id = app.environment_id 24 | LEFT OUTER JOIN monitoring.metric_db_size_current AS db 25 | ON db.instance_id = monit.instance_id 26 | AND db.dbname NOT IN ('postgres', 'template1') 27 | AND datetime > (now() - interval '5 minutes') 28 | GROUP BY 1, 2, 3, 4, 5, 6, 7 29 | ORDER BY 5, 1, 2 30 | ) 31 | SELECT * FROM inventory 32 | WHERE concat_ws( 33 | ' ', 34 | "Hostname", "Port", "PGDATA", "Version", "Environment", 35 | "Agent Address", "Agent Port" 36 | ) LIKE %s 37 | ) TO STDOUT WITH ( 38 | DELIMITER ';', 39 | ENCODING 'UTF-8', 40 | FORCE_QUOTE *, 41 | FORMAT CSV, 42 | HEADER 43 | ) ; 44 | -------------------------------------------------------------------------------- /ui/temboardui/cli/generate_key.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import os.path 3 | from subprocess import check_call 4 | 5 | from cryptography.hazmat.primitives import serialization 6 | from temboardtoolkit.app import SubCommand 7 | from temboardtoolkit.errors import UserError 8 | from temboardtoolkit.signing import load_private_key 9 | 10 | from .app import app 11 | 12 | logger = logging.getLogger(__name__) 13 | 14 | 15 | @app.command 16 | class GenerateKey(SubCommand): 17 | """Generate signing key.""" 18 | 19 | name = "generate-key" 20 | 21 | def define_arguments(self, parser): 22 | parser.add_argument( 23 | "--force", 24 | action="store_true", 25 | default=False, 26 | help="Force overwriting existing files.", 27 | ) 28 | 29 | def main(self, args): 30 | priv = self.app.config.temboard.signing_private_key 31 | if os.path.exists(priv) and not args.force: 32 | raise UserError("%s exists. Use --force to overwrite." % priv) 33 | logger.info("Generating RSA key with openssl at %s.", priv) 34 | check_call(["openssl", "genrsa", "-out", priv, "4096"]) 35 | 36 | with open(priv, "rb") as fo: 37 | privkey = load_private_key(fo.read()) 38 | 39 | pub = self.app.config.temboard.signing_public_key 40 | logger.info("Exporting public key at %s.", pub) 41 | pem = privkey.public_key().public_bytes( 42 | encoding=serialization.Encoding.PEM, 43 | format=serialization.PublicFormat.SubjectPublicKeyInfo, 44 | ) 45 | with open(pub, "wb") as fo: 46 | fo.write(pem) 47 | 48 | return 0 49 | -------------------------------------------------------------------------------- /docs/quickstart.md: -------------------------------------------------------------------------------- 1 | --- 2 | hide: 3 | - navigation 4 | --- 5 | 6 |

Quickstart

7 | 8 | We provide a `docker-compose.yml` file to give temBoard a try. 9 | 10 | You'll need docker compose 1.10+ and docker engine 1.10+. 11 | 12 | ``` console 13 | wget https://raw.githubusercontent.com/dalibo/temboard/master/docker/docker-compose.yml 14 | docker compose up 15 | ``` 16 | 17 | `docker compose` will launch: 18 | 19 | - a PostgreSQL instance for temBoard own data 20 | - the temBoard UI 21 | - 4 PostgreSQL instances (with different versions), exposed on ports 5432, 5433, 5434 and 5435, 22 | - a temBoard agent for each instance. 23 | 24 | temBoard UI is available on with `admin` / `admin` 25 | credentials. 26 | 27 | You can access the PostgreSQL instances. For example to run some pgbench tasks: 28 | 29 | ``` console 30 | $ export PGHOST=0.0.0.0 PGPORT=5432 PGUSER=postgres PGPASSWORD=postgres 31 | $ psql -c 'CREATE EXTENSION IF NOT EXISTS pg_stat_statements' 32 | $ createdb pgbench 33 | $ pgbench -i pgbench 34 | $ pgbench -c 8 -T 60 pgbench 35 | ``` 36 | 37 | !!! danger 38 | 39 | **DO NOT USE THIS IN PRODUCTION !** 40 | 41 | temBoard docker images are designed for *testing* and *demo*. 42 | 43 | - The SSL certificate is *self-signed*. 44 | - Default passwords are dumb and public. 45 | - temBoard agent is designed to run on same host as PostgreSQL which is incompatible with Docker service-minded architecture. 46 | - temBoard agent image requires *access to docker socket* to restart PostgreSQL, which you do not want in production. 47 | 48 | **To deploy temBoard in a production environment, follow [installation documentation](server_install.md).** 49 | -------------------------------------------------------------------------------- /toolkit/temboardtoolkit/versions.py: -------------------------------------------------------------------------------- 1 | # Various functions to expose components version 2 | 3 | import ctypes 4 | import re 5 | 6 | 7 | def read_distinfo(): # pragma: nocover 8 | with open("/etc/os-release") as fo: 9 | distinfos = parse_lsb_release(fo) 10 | return distinfos 11 | 12 | 13 | def parse_lsb_release(lines): 14 | _assignement_re = re.compile(r"""(?P[A-Z_]+)="(?P[^"]+)"$""") 15 | infos = dict() 16 | for line in lines: 17 | m = _assignement_re.match(line) 18 | if not m: 19 | continue 20 | infos[m.group("variable")] = m.group("value") 21 | return infos 22 | 23 | 24 | def load_libpq(): 25 | __import__("psycopg2") 26 | 27 | # Search for libpq.so path in loaded libraries. 28 | with open("/proc/self/maps") as fo: 29 | for line in fo: 30 | values = line.split() 31 | path = values[-1] 32 | if "/libpq" in path: 33 | break 34 | else: # pragma: nocover 35 | raise Exception("libpq.so not loaded") 36 | 37 | return ctypes.cdll.LoadLibrary(path) 38 | 39 | 40 | def read_libpq_version(): 41 | # Search libpq version bound to this process. 42 | 43 | try: 44 | # For psycopg2 2.7+ 45 | from psycopg2.extensions import libpq_version 46 | 47 | return libpq_version() 48 | except ImportError: 49 | libpq = load_libpq() 50 | return libpq.PQlibVersion() 51 | 52 | 53 | def format_pq_version(version): 54 | pqnums = [version / 10000, version % 100] 55 | if version <= 100000: 56 | pqnums[1:1] = [(version % 10000) / 100] 57 | return ".".join(str(int(n)) for n in pqnums) 58 | -------------------------------------------------------------------------------- /toolkit/tests/test_utils.py: -------------------------------------------------------------------------------- 1 | def test_dict_factory(): 2 | from temboardtoolkit.utils import dict_factory 3 | 4 | my = dict_factory() 5 | assert isinstance(my, dict) 6 | assert 0 == len(my) 7 | 8 | my = dict_factory(dict(a=True)) 9 | assert isinstance(my, dict) 10 | assert 1 == len(my) 11 | assert my["a"] is True 12 | 13 | my = dict_factory([("a", True)]) 14 | assert isinstance(my, dict) 15 | assert 1 == len(my) 16 | assert my["a"] is True 17 | 18 | original = dict(a=True) 19 | my = dict_factory(original) 20 | original["a"] = False 21 | assert my["a"] is False 22 | 23 | 24 | def test_dotdict(): 25 | from temboardtoolkit.utils import DotDict 26 | 27 | my = DotDict(dict(a=1, b=dict(c=2))) 28 | 29 | assert 1 == my.a 30 | assert 1 == my["a"] 31 | assert 2 == my.b.c 32 | assert 2 == my["b"].c 33 | 34 | keys = list(iter(my)) 35 | assert "a" in keys 36 | assert "b" in keys 37 | 38 | my.a = 3 39 | my.b.c = 4 40 | 41 | assert 3 == my.a 42 | assert 4 == my["b"]["c"] 43 | 44 | my["a"] = 5 45 | assert 5 == my.a 46 | 47 | d = my.setdefault("d", dict(e=True)) 48 | assert d.e is True 49 | 50 | 51 | def test_pickle_dotdict(): 52 | from pickle import dumps as pickle 53 | from pickle import loads as unpickle 54 | 55 | from temboardtoolkit.utils import DotDict 56 | 57 | orig = DotDict(dict(a=1, b=dict(c=2))) 58 | copy = unpickle(pickle(orig)) 59 | assert 2 == copy.b.c 60 | 61 | 62 | def test_ensure_str(): 63 | from temboardtoolkit.taskmanager import ensure_str 64 | 65 | assert type(ensure_str("toto")) is str 66 | assert type(ensure_str(b"toto")) is str 67 | -------------------------------------------------------------------------------- /ui/temboardui/static/src/components/Error.vue: -------------------------------------------------------------------------------- 1 | 43 | 44 | 56 | -------------------------------------------------------------------------------- /agent/packaging/docker/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM debian:bookworm-slim AS copier 2 | # RUN --mount syntax does not accept arg. Thus first stage copy with arg in 3 | # static path for future RUN --mount. 4 | # Ensure package is not ignored by .dockerignore. 5 | ARG VERSION 6 | COPY dist/temboard-agent_${VERSION}-0dlb1bookworm1_all.deb /tmp/temboard-agent.deb 7 | 8 | FROM debian:bookworm-slim 9 | 10 | ADD https://raw.githubusercontent.com/vishnubob/wait-for-it/master/wait-for-it.sh /usr/local/bin/wait-for-it 11 | RUN set -ex; \ 12 | chmod 0755 /usr/local/bin/wait-for-it; \ 13 | apt-get update -y ; \ 14 | mkdir -p /usr/share/man/man1 /usr/share/man/man7 ; \ 15 | apt-get install -y --no-install-recommends \ 16 | iproute2 \ 17 | openssl \ 18 | postgresql-client \ 19 | ssl-cert \ 20 | sudo \ 21 | ; \ 22 | apt-get clean ; \ 23 | rm -rf /var/lib/apt/lists/* /usr/share/man/man*/*.7*; \ 24 | : 25 | 26 | ADD packaging/docker/sudoers /etc/sudoers.d/temboard-agent 27 | ADD packaging/docker/entrypoint.sh /usr/local/lib/docker-entrypoint.sh 28 | 29 | RUN --mount=type=bind,from=copier,source=/tmp/temboard-agent.deb,target=/tmp/temboard-agent.deb \ 30 | set -ex; \ 31 | apt-get update -y ; \ 32 | mkdir -p /usr/share/man/man1 /usr/share/man/man7 ; \ 33 | apt-get install -y --no-install-recommends /tmp/temboard-agent.deb tini ; \ 34 | apt-get clean ; \ 35 | rm -rf /var/lib/apt/lists/* /usr/share/man/man*/*.7*; \ 36 | temboard-agent --version ; \ 37 | : 38 | 39 | VOLUME /etc/temboard-agent 40 | VOLUME /var/lib/temboard-agent 41 | WORKDIR /var/lib/temboard-agent 42 | ENTRYPOINT ["/usr/bin/tini", "/usr/local/lib/docker-entrypoint.sh"] 43 | CMD ["temboard-agent"] 44 | -------------------------------------------------------------------------------- /ui/temboardui/templates/flask/maintenance/breadcrumb.html: -------------------------------------------------------------------------------- 1 | 47 | -------------------------------------------------------------------------------- /ui/tests/unit/test_model.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | 4 | @pytest.fixture 5 | def engine(mocker): 6 | engine = mocker.Mock(name="engine") 7 | conn = mocker.MagicMock(name="conn") 8 | engine.pool._invoke_creator.return_value = conn 9 | engine.conn = conn 10 | cur = conn.cursor.return_value.__enter__.return_value 11 | cur.fetchone.return_value = ("PostgreSQL 15 Debian",) 12 | engine.cur = cur 13 | return engine 14 | 15 | 16 | def test_check_connectivity_ok(engine, mocker): 17 | sleep = mocker.patch("temboardui.model.sleep") 18 | sleep.side_effect = Exception("Must not sleep") 19 | from temboardui.model import check_connectivity 20 | 21 | check_connectivity(engine) 22 | 23 | assert engine.conn.close.called is True 24 | 25 | 26 | def test_check_connectivity_sleep(engine, mocker): 27 | sleep = mocker.patch("temboardui.model.sleep") 28 | from temboardui.model import check_connectivity 29 | 30 | engine.pool._invoke_creator.side_effect = [Exception(), engine.conn] 31 | 32 | check_connectivity(engine) 33 | 34 | assert sleep.called is True 35 | 36 | 37 | def test_check_connectivity_fail(engine, mocker): 38 | sleep = mocker.patch("temboardui.model.sleep") 39 | from temboardui.model import check_connectivity 40 | 41 | engine.pool._invoke_creator.side_effect = Exception() 42 | 43 | with pytest.raises(Exception): 44 | check_connectivity(engine) 45 | 46 | assert sleep.called is True 47 | 48 | 49 | def test_configure(mocker): 50 | mod = "temboardui.model" 51 | Session = mocker.patch(mod + ".Session") 52 | 53 | from temboardui.model import configure 54 | 55 | configure(dsn="sqlite://") # LOL 56 | assert Session.configure.called is True 57 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | # This is a virtual project to manage a single development env 2 | # 3 | # Never build nor release temboard-workspace project. 4 | # 5 | 6 | [project] 7 | requires-python = ">=3.9,<3.14" 8 | name = "temboard-workspace" 9 | version = "0.0.0" # never change this. 10 | classifiers = ["Private :: Do Not Upload"] 11 | 12 | [dependency-groups] 13 | dev = [ 14 | "hupper", 15 | "pep440deb", 16 | "pytest", 17 | "psycopg2-binary", 18 | "pytz>=2025.2", # for dev/perfui/ 19 | # Installed as editable according to uv settings below. 20 | "temboard", 21 | "temboard-agent", 22 | "temboard-toolkit", 23 | "wheel>=0.45.1", 24 | ] 25 | docs = [ 26 | "mkdocs-git-revision-date-localized-plugin", 27 | "mkdocs-glightbox", 28 | "mkdocs-material", 29 | ] 30 | lint = [ 31 | "docutils", 32 | "ruff", 33 | ] 34 | e2e = [ 35 | "httpx", 36 | "pytest", 37 | "pytest-mock", 38 | "pytest-tornado", 39 | "selenium", 40 | "sh<2", 41 | "tenacity<8.4", 42 | ] 43 | 44 | [tool.uv] 45 | default-groups = "all" 46 | 47 | [tool.uv.workspace] 48 | members = ["agent", "ui", "toolkit"] 49 | 50 | [tool.uv.sources] 51 | temboard = { workspace = true } 52 | temboard-agent = { workspace = true } 53 | temboard-toolkit = { workspace = true } 54 | 55 | [tool.ruff] 56 | target-version = "py39" 57 | force-exclude = true 58 | extend-exclude = ["_vendor"] 59 | 60 | [tool.ruff.lint] 61 | extend-select = [ 62 | "I", # Sort imports 63 | ] 64 | ignore = [ 65 | "E721", # Do not compare types, use `isinstance()` 66 | ] 67 | 68 | [tool.ruff.lint.isort] 69 | split-on-trailing-comma = false 70 | 71 | [tool.ruff.format] 72 | skip-magic-trailing-comma = true 73 | line-ending = "lf" 74 | docstring-code-format = true 75 | -------------------------------------------------------------------------------- /dev/lnav/formats/postgresql_log.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://lnav.org/schemas/format-v1.schema.json", 3 | "postgresql_log": { 4 | "description": "Format file generated from regex101 entry -- https://regex101.com/r/2ofpAY", 5 | "regex": { 6 | "std": { 7 | "pattern": "(?.+) \\[(?\\d+)\\]:(?: \\[(?\\d+)-\\d+\\])? (?:(?:app=(?[^,]*)|db=(?[^,]*)|client=(?[^,]*)|user=(?[^, ]*)),?)+ (?[A-Z]+): (?.+)" 8 | } 9 | }, 10 | "value": { 11 | "level": { 12 | "kind": "string", 13 | "identifier": true 14 | }, 15 | "user": { 16 | "kind": "string", 17 | "identifier": true 18 | }, 19 | "client": { 20 | "kind": "string", 21 | "collate": "ipaddress" 22 | }, 23 | "app": { 24 | "kind": "string" 25 | }, 26 | "db": { 27 | "kind": "string", 28 | "identifier": true 29 | }, 30 | "pid": { 31 | "kind": "string", 32 | "identifier": true 33 | }, 34 | "line": { 35 | "kind": "integer" 36 | } 37 | }, 38 | "sample": [ 39 | { 40 | "line": "2024-06-25 14:54:20 UTC [551]: user=,db=,app=,client= LOG: starting PostgreSQL 13.15 on x86_64-pc-linux-gnu, compiled by gcc (GCC) 8.5.0 20210514 (Red Hat 8.5.0-20), 64-bit\n2024-06-25 14:54:20 UTC [551]: user=,db=,app=,client= LOG: listening on IPv4 address \"127.0.0.1\", port 55432" 41 | } 42 | ] 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /ui/temboardui/cli/migratedb.py: -------------------------------------------------------------------------------- 1 | import logging 2 | from contextlib import closing 3 | 4 | from psycopg2 import connect 5 | from temboardtoolkit.app import SubCommand 6 | from temboardtoolkit.errors import UserError 7 | 8 | from ..model import format_dsn 9 | from ..model.migrator import Migrator 10 | from .app import app 11 | 12 | logger = logging.getLogger(__name__) 13 | 14 | 15 | @app.command 16 | class MigrateDB(SubCommand): 17 | """Manage temBoard own database.""" 18 | 19 | def main(self, args): 20 | raise UserError("Missing sub-command. See --help for details.") 21 | 22 | def init_migrator(self): 23 | migrator = Migrator() 24 | migrator.inspect_available_versions() 25 | return migrator 26 | 27 | def make_conn(self): 28 | return closing(connect(format_dsn(self.app.config.repository))) 29 | 30 | 31 | @MigrateDB.command 32 | class Check(SubCommand): 33 | """Check schema synchronisation status only.""" 34 | 35 | def main(self, args): 36 | migrator = self.parent.init_migrator() 37 | 38 | with self.parent.make_conn() as conn: 39 | migrator.inspect_current_version(conn) 40 | migrator.check() 41 | 42 | 43 | @MigrateDB.command 44 | class Upgrade(SubCommand): 45 | """Upgrade temBoard database to latest revision.""" 46 | 47 | def main(self, args): 48 | migrator = self.parent.init_migrator() 49 | 50 | with self.parent.make_conn() as conn: 51 | migrator.inspect_current_version(conn) 52 | for version in migrator.missing_versions: 53 | logger.info("Upgrading database to version %s.", version) 54 | migrator.apply(conn, version) 55 | 56 | logger.info("Database up to date.") 57 | -------------------------------------------------------------------------------- /ui/temboardui/cli/routes.py: -------------------------------------------------------------------------------- 1 | import logging 2 | from itertools import chain 3 | 4 | from flask import current_app 5 | from temboardtoolkit.app import SubCommand 6 | 7 | from .app import app 8 | 9 | logger = logging.getLogger(__name__) 10 | 11 | 12 | @app.command 13 | class Routes(SubCommand): 14 | """List HTTP routes map.""" 15 | 16 | def define_arguments(self, parser): 17 | parser.add_argument( 18 | "--sort", 19 | action="store_true", 20 | default=False, 21 | help="Sort routes alphabetically", 22 | ) 23 | 24 | def main(self, args): 25 | rules = self.app.tornado_app.wildcard_router.rules 26 | routes = chain(iter_tornado_routes(rules), iter_flask_routes()) 27 | 28 | if args.sort: 29 | logger.debug("Sorting routes alphabetically.") 30 | routes = sorted(routes, key=lambda x: (x[1], x[0])) 31 | else: 32 | logger.debug("Listing routes by matching order.") 33 | 34 | for method, path in routes: 35 | print(" %6.6s %-64s" % (method, path)) 36 | 37 | return 0 38 | 39 | 40 | def iter_flask_routes(): 41 | for rule in current_app.url_map.iter_rules(): 42 | for method in rule.methods: 43 | if method in ("OPTIONS", "HEAD"): 44 | continue 45 | yield method, rule.rule 46 | 47 | 48 | def iter_tornado_routes(rules): 49 | for rule in rules: 50 | if "fallback" in rule.target_kwargs: 51 | # Skip fallback to Flask routes. 52 | continue 53 | 54 | # Fallback for static handlers 55 | methods = rule.target_kwargs.get("methods", ["GET"]) 56 | path = rule.matcher.regex.pattern 57 | for method in methods: 58 | yield method, path 59 | -------------------------------------------------------------------------------- /ui/share/temboard_CHANGEME.key: -------------------------------------------------------------------------------- 1 | -----BEGIN PRIVATE KEY----- 2 | MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQCwlh/dHwY6u0XT 3 | CfpzEufg7GcKVxwrO9VXbnNAhH5AzRJ4gWSXGzvPFzltPZOxWavZ0aceGquC55Av 4 | 9QV/Ci9lSmSS1mdRo2boSXZdJIhAn5fSbqLwro0qftiOCqSPfq0zIw16sZxeN8D3 5 | 5B9eOeRMhe3LX0api3OyVgVlFZcN+gouFB+qNF0gw+zF5fOtE5h8C5PV3EFQt8sf 6 | 6hrZFYv+BpW34vv6V3Tqv9rp0UYK4d5ttM0Vqqp2YzCcwEKpiSuA8+GzKwkXTsQ0 7 | YEh4XNhfniA62kb5hrlrJa9CxAtKUaWFiQK02oVzFPmO1a9cdUNKRpDiDwX7ZIkC 8 | lR5A2ocdAgMBAAECggEATD1UnnxRjTPjjp0FQ3+LG2IVjrJTWBsqHehy3A0YEVQ4 9 | wExlKJQ6e0u0oIRwaqajepR4yZeMKyVc7EemStYT5nB7AaaNzwJ0YJ+u+cHXDceZ 10 | neHkeXNzQUCosJLJv6ZakvR0Ul+yej8qEhysqlrmRV+hbifBc1vg5MMc6yqqH/73 11 | nU17a6VCZwNFfaWfv34x/SEZIlGr1NzfIrqs/42KgriifTp5dKR0C47d94+ykpit 12 | PIgPmYtXDvVdzkuxWlRsXhGjBw6/bvDW7yQaQb5V3GnDr4vZQK5+5MtcGDD1zL7y 13 | ztdqx42OmHsCv6loZbfdneRdkBHcWgnwKKRKCgJ0xQKBgQDYRsnYcPWmDG/5GUQZ 14 | Q7s2U0Fk0MHvdDv8W041xU3TW6yCw+vvWDpKALTrhCkCwWLBRPyrrPMImOt/G5Bs 15 | VnlYnop4IoMz91+7MSj0TJKAjlOcTql41EZ+SUwynEIElgJO5RMNKKiTAHZtiRfH 16 | VldVpgws3QbQnHBCuBdghRlBawKBgQDRBSDHmRIAdiTPRI6yKsFCEucrak0ezRsC 17 | FIcNQVtUyIEFkqsNi8bB6I9JaovFXGBhhTWjAB6K5AXuithZyPG1Dy3Oa7LNgB/p 18 | PzEgrINsSQwx7AgxVC7IOdUQTRvVmNNmdj4xj2m5SPoiEdiT8/u3bk4QojYHylPl 19 | bD5Ucc4TlwKBgHOZ8s3EIylsQHWvMZ0nfOveuHeAtgid4mpTC4zmN2EgASesPXIj 20 | gJSJqCqy65DTeUvA6iWBPi58PnQkcZ/W4OmjZHQuTl76fKr77d4XB7+7U6mayi8R 21 | l9RsrVPn/cmhvP4ap4JDF0fr2WFXA+TCm8/l/2ADjF75H3AqIiSvP/6dAoGAPJdV 22 | 90ZiN4wIA6WGEBPgzfyY0rcQABvI9oNo2ujVRvCQpkLsHxMj3NZDoy6lseKjdeGd 23 | uNCyCeUr6wiIyw47MxdhWfNSc8vudDkDTstzlZJfXKFlhpc2sIhDQWR46yRQM+WX 24 | Bdri9Pk4uWOe+tTBZV0ueBftvbhjNaB5ORV8faUCgYEAtYpg3CFTAJmTuZB34BPv 25 | qjk/f3Uc4YuAFmxhrvYrGpJuIRyTHm8VKmDMa2A+sbXHZQgVgKH/bNcTukytFAeU 26 | nzwlIYGmbD9/daqOoybz2SRxB+oBFJxXRcYy8cwOW5HkTZKbkczg07oG18vTJFmW 27 | x6yBaegnkVr9QAb/2o1Of14= 28 | -----END PRIVATE KEY----- 29 | -------------------------------------------------------------------------------- /docs/temboard-howto-maintenance.md: -------------------------------------------------------------------------------- 1 | *maintenance* feature gives user an overview on the 2 | **databases**, **schemas**, **tables** or **indexes** respective **size**. 3 | 4 | It's very useful to get information about **bloat** and **toast**. 5 | This can help users determine potential issues and understand or prevent performance issues due to 6 | unaccordingly used space. 7 | 8 | The *maintenance* feature also provides easy access to administrative actions such as **VACUUM**, 9 | **ANALYZE** or **REINDEX** in order to fix space or performances problems. 10 | 11 | 12 | !!! note 13 | 14 | Please beware that the values for bloat, toast, etc… are estimated. 15 | They may not perfectly reflect the reality especially if there hasn't been 16 | any analyze performed recently. 17 | 18 | 19 | ## Views 20 | 21 | The maintenance plugin lets you navigate through databases, schemas and tables 22 | of the instance. 23 | 24 | ![Maintenance databases page](sc/maintenance_databases.png) 25 | 26 | ![Maintenance schema page](sc/maintenance_schemas.png) 27 | 28 | The page for one table shows more detailed information. 29 | 30 | ![Maintenance table page](sc/maintenance_tables.png) 31 | 32 | ## Hints 33 | 34 | Once in a while temBoard may be able to provide some hints on actions to 35 | perform on tables or indexes. 36 | 37 | ![Maintenance hints](sc/maintenance_hints.png) 38 | 39 | 40 | ## Actions 41 | 42 | If you're logged onto the agent, you may then be able to perform actions by 43 | clicking on the dedicated buttons. 44 | 45 | temBoard can let you launch different actions such as **ANALYZE**, **VACUUM** or 46 | **REINDEX** 47 | 48 | You can also choose either to launch the action immediately or in the future 49 | at the date and time you decide. 50 | 51 | ![Maintenance VACUUM schedule](sc/maintenance_schedule_vacuum.png) 52 | -------------------------------------------------------------------------------- /agent/share/temboard-agent_CHANGEME.key: -------------------------------------------------------------------------------- 1 | -----BEGIN PRIVATE KEY----- 2 | MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQDQfCQjxzPsqcF/ 3 | 7ZquS2o7dO9pWLz7fTuskaSR+Z4NlieO5a8GJBIXs33u2Ew8toHtbYnafOsgkIpN 4 | O2dqCpU6f9Ms5uhLEMDMxZomH2N9eo7CBfV7iSGPDuBd6Y5BH+0I9VfTR8iisq1f 5 | S+pLWgEOVulb9jHG7aAj8vCV37vzv3nuLLm5mryQiprX205b7QqnzbzydBuqcKvN 6 | vfI4VLntPpR1Eeq3BjXa+5lX+iuPAvpOhVHMlca302qprp1Ti2hcIRqfcBw5BDyh 7 | iZFo1pbQfNkj6Ncmzxj4ZL4rUW0WJsDZ5hRGqXYOmIdyyD6XpQpDAzfjSdUYktsw 8 | XplslauvAgMBAAECggEAcKLRJ59E11SzXPkyu321DCBWBFVT7I8xQT+CaPcyQ+be 9 | wl4m3Ct6tuFbZUHolliIA41NkAQzR+mvPqCcc3b6Ppu2rKin0X5gm4EakgQdTTp8 10 | jCiKAs8ImXCRGUvIdjjYaCGc7GH47KWZ80VhdIpQzb144B03sWDKRwHGp0I0GjBb 11 | khdCqB6QsPJhxbh07M8qoAaq1TZDisa9EFznQ1ay0jy0PuZyry01M5dukM/suq+r 12 | US02vUpoFp3LpSjh+ydyrhgQH1o4SWLRiKgtHwTFuhGaMwqCQDXh+Td9hq2q7EXk 13 | Ixg+EWCJAt60TBy3Kx/9zDgC9uvY1WZmPOeGMCZIoQKBgQD08FEzMJzmktl3Vkge 14 | iFvhwCRD+FOcVipYCji7StwDjTIAha+O9Y1fVPeOyVXSsCyEo8R7ulCE/nQK2ARf 15 | 7RQq0gOyREjGgGeKLOs1jVwCrOSl0iP1NpF7fUqArzeanjFRTvJDBXHBePittF/E 16 | k0a3+o4R1aN+SxtMHDkBOk8VUQKBgQDZ5mO1VWF2WnKg2wSpCB9s6wlRNjdtGCVy 17 | A6NaT33jWPlkg9gkOv+G3M15RYimyxFFJ0Z64V1F0s8sUYySwoSH3LUYie49kV98 18 | mfyE+WBiracqQVvNyFueg5oUUJLYIVlyvvEtXPZ4voypDo/mozyV+IHhNS+XhpDf 19 | 3eou57xw/wKBgQDbd+Ep/ur3ZqlYVoU0ZnX9p05XYNB6CnLShAYlO4Q74m3lLeQK 20 | MldEDjvrQteVeqnJB3xsaJrxL5YGiVwSH7msTJVnS+vxgOhFVM5EI69H7mbJdasm 21 | coiUn8T73QPzlL8X3acRCnXNJ3mbGz2cQ2JgQy69KDHgXafN4JPrV7W4oQKBgDyT 22 | QsbHXJfVXyZ+nJYNDwdtc6KjCteGLeq7Pi8+CAYq1vHtgSnZSO4J9gkvnmSX8U4j 23 | NAG3IwHlL/jnFsg50TQf1CxlM9jj0ALIoB2rYfMsyVsC3m2ftHClrzDUkW4KH165 24 | 3Dw7Kr24Y0wgIzr/yDj848Zizb83BpFllNPDUmyrAoGAO6PSTycIUnXvyB6h7bkx 25 | rkKQbWvIfZCq1CjBPXwHw3GeF988ymd3whGcRCyI+6EXDbj8LRO6m6J6xHTZqNrX 26 | QkTgVo8ERr++lVa7+z0A66AfzhhEAGGKiJit0BbUAXV68c88huNhzhY+6g9A1NEv 27 | k1WXeEYMCZJMKVUgIzsyR/I= 28 | -----END PRIVATE KEY----- 29 | -------------------------------------------------------------------------------- /ui/temboardui/static/src/components/home/Sparkline.vue: -------------------------------------------------------------------------------- 1 | 63 | 64 | 69 | -------------------------------------------------------------------------------- /agent/temboardagent/queries/activity-locks.sql: -------------------------------------------------------------------------------- 1 | WITH blockages AS ( 2 | SELECT 3 | pid AS waiting_pid, 4 | -- Requires pg_blocking_pids from PostgreSQL 9.6+ 5 | unnest(pg_blocking_pids(pid)) AS blocking_pid 6 | FROM pg_catalog.pg_stat_activity 7 | ) 8 | SELECT 9 | lock.pid, 10 | lock.locktype, 11 | dat.datname AS "database", 12 | nsp.nspname AS "schema", 13 | rel.relname AS "relation", 14 | lock.mode, 15 | lock."granted", 16 | waiting.blocking_pid AS blocking_pid, 17 | min(lock."waitstart"), -- pragma:pg_version_min 140000 18 | array_remove(array_agg(blocking.waiting_pid), NULL) AS waiting_pids 19 | FROM pg_catalog.pg_locks AS lock 20 | LEFT OUTER JOIN pg_catalog.pg_database AS dat ON dat.oid = lock.database 21 | LEFT OUTER JOIN pg_catalog.pg_class AS rel ON rel.oid = lock.relation 22 | LEFT OUTER JOIN pg_catalog.pg_namespace AS nsp ON nsp.oid = rel.relnamespace 23 | LEFT OUTER JOIN blockages AS waiting ON waiting.waiting_pid = pid 24 | LEFT OUTER JOIN blockages AS blocking ON blocking.blocking_pid = pid 25 | LEFT OUTER JOIN pg_catalog.pg_locks AS waiting_lock 26 | ON lock."granted" AND waiting_lock.pid = blocking.waiting_pid 27 | AND waiting_lock.locktype = lock.locktype 28 | AND CASE lock.locktype 29 | WHEN 'advisory' THEN waiting_lock.objid = lock.objid AND waiting_lock.objsubid = lock.objsubid 30 | WHEN 'relation' THEN waiting_lock.database = lock.database AND waiting_lock.relation = lock.relation 31 | WHEN 'transactionid' THEN waiting_lock.transactionid = lock.transactionid 32 | WHEN 'virtualxid' THEN waiting_lock.virtualxid = lock.virtualxid 33 | END 34 | WHERE NOT lock."granted" 35 | OR (blocking.blocking_pid IS NOT NULL AND waiting_lock.pid IS NOT NULL AND "lock"."mode" LIKE '%Exclusive%') 36 | GROUP BY 1, 2, 3, 4, 5, 6, 7, 8 37 | ORDER BY min("lock"."waitstart") ASC -- pragma:pg_version_min 140000 38 | -------------------------------------------------------------------------------- /agent/temboardagent/cli/fetch_key.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import os.path 3 | from urllib.parse import urlparse 4 | 5 | from temboardtoolkit.app import SubCommand 6 | from temboardtoolkit.errors import UserError 7 | from temboardtoolkit.http import TemboardClient 8 | from temboardtoolkit.signing import load_public_key 9 | 10 | from .app import app 11 | 12 | logger = logging.getLogger(__name__) 13 | 14 | 15 | @app.command 16 | class FetchKey(SubCommand): 17 | """Fetch signing public key.""" 18 | 19 | name = "fetch-key" 20 | 21 | def define_arguments(self, parser): 22 | parser.add_argument( 23 | "--force", 24 | action="store_true", 25 | default=False, 26 | help="Force overwriting existing files.", 27 | ) 28 | 29 | def main(self, args): 30 | pub = self.app.config.temboard.signing_public_key 31 | if os.path.exists(pub) and not args.force: 32 | raise UserError("%s exists. Use --force to overwrite." % pub) 33 | 34 | ui_url_raw = self.app.config.temboard.ui_url.rstrip("/") 35 | ui_url = urlparse(ui_url_raw) 36 | ui_client = TemboardClient.factory( 37 | self.app.config, 38 | scheme=ui_url.scheme, 39 | host=ui_url.hostname, 40 | port=ui_url.port, 41 | ) 42 | 43 | logger.info("Requesting public key from %s.", ui_url_raw) 44 | response = ui_client.get("/signing.key") 45 | response.raise_for_status() 46 | pem = response.read() 47 | 48 | logger.info("Validating PEM data.") 49 | load_public_key(pem) 50 | 51 | with open(pub, "wb") as fo: 52 | fo.write(pem) 53 | 54 | logger.info("%s updated from %s.", pub, ui_url_raw) 55 | logger.info("Please reload agent service if running.") 56 | 57 | return 0 58 | -------------------------------------------------------------------------------- /ui/temboardui/plugins/maintenance/__init__.py: -------------------------------------------------------------------------------- 1 | from ...web.flask import instance_proxy 2 | 3 | 4 | class MaintenancePlugin: 5 | def __init__(self, app): 6 | self.app = app 7 | 8 | def load(self): 9 | __import__(__name__ + ".routes") 10 | instance_proxy.generic_proxy("/maintenance") 11 | instance_proxy.generic_proxy("/maintenance/") 12 | instance_proxy.generic_proxy("/maintenance/", method="POST") 13 | instance_proxy.generic_proxy( 14 | "/maintenance//", 15 | method="DELETE", 16 | ) 17 | instance_proxy.generic_proxy( 18 | "/maintenance//", 19 | method="POST", 20 | ) 21 | instance_proxy.generic_proxy( 22 | "/maintenance///scheduled" 23 | ) 24 | instance_proxy.generic_proxy("/maintenance//schema/") 25 | instance_proxy.generic_proxy( 26 | "/maintenance//schema///scheduled" 27 | ) 28 | instance_proxy.generic_proxy( 29 | "/maintenance//schema//table/
" 30 | ) 31 | instance_proxy.generic_proxy( 32 | "/maintenance//schema////reindex", 33 | method="POST", 34 | ) 35 | instance_proxy.generic_proxy( 36 | "/maintenance//schema//table/
/", 37 | method="POST", 38 | ) 39 | instance_proxy.generic_proxy( 40 | "/maintenance//schema//table/
//scheduled" 41 | ) 42 | -------------------------------------------------------------------------------- /docs/index.md: -------------------------------------------------------------------------------- 1 | --- 2 | hide: 3 | - navigation 4 | --- 5 | 6 | 7 |

8 | 9 | ![temBoard](assets/temboard-logo-slogan.png){ .no-lightbox .logo } 10 | 11 | temBoard is a powerful management tool for PostgreSQL. 12 | You can use it to monitor, optimize or configure multiple PostgreSQL instances. 13 | 14 | 15 | # Features 16 | 17 | - Manage hundreds of instances in one interface. 18 | - Fleet-wide and per-instance dashboards. 19 | - Monitor PostgreSQL with advanced metrics. 20 | - Manage running sessions. 21 | - Track bloat and schedule vacuum on tables and indexes. 22 | - Track slow queries. 23 | - Tweak PostgreSQL configuration. 24 | - Visualize Query Plan. 25 | 26 | All of this from a web interface. 27 | 28 | ![Dashboard](screenshots/instance-dashboard.png){ loading=lazy } 29 | 30 | 31 | # Quickstart 32 | 33 | You can run a complete testing environment based on Docker Compose, 34 | follow the [quickstart](quickstart.md) guide for more details. 35 | 36 | 37 | # Install 38 | 39 | temBoard requires Python 3 and supports PostgreSQL 13 to 17. 40 | temBoard is composed of 2 services: 41 | 42 | - A lightweight agent to install on every PostgreSQL server to monitor and manage. 43 | - A central server controlling the agents, collecting metrics and presenting it on a web UI. 44 | 45 | Dalibo ships packages for RHEL and Debian systems. 46 | For a regular installation, follow the [Installation guide](server_install.md). 47 | 48 | 49 | # About 50 | 51 | temBoard is open source software, developed by [Dalibo Labs] and availabled under the [PostgreSQL license]. 52 | 53 | [PostgreSQL license]: https://github.com/dalibo/temboard/blob/master/LICENSE 54 | [Dalibo Labs]: https://labs.dalibo.com/ 55 | -------------------------------------------------------------------------------- /dev/bin/switchover.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash -eux 2 | # 3 | # Switch over replication of development instances postgres0 and postgres1. 4 | # 5 | # Tested with postgres-15. 6 | # It's designed to work only on latest supported Postgres. 7 | # 8 | 9 | _psql() 10 | { 11 | # Usage: psql SERVICE COMMAND [ARGS...] 12 | local num=${1#postgres}; shift 13 | PGUSER=postgres PGPASSWORD=postgres PGHOST=0.0.0.0 PGPORT=$((1+num))5432 PGDATABASE=postgres psql -AqtX "$@" 14 | } 15 | 16 | 17 | # Guess topology 18 | for server in postgres0 postgres1 ; do 19 | if ! replications="$(_psql $server -c 'SELECT * FROM pg_stat_replication;')" ; then 20 | : "Is $server down ?. restart it before running this script." 21 | exit 1 22 | fi 23 | 24 | mapfile -t replications < <(echo -n "$replications") 25 | 26 | case "${#replications[@]}" in 27 | 0) 28 | secondary=$server 29 | ;; 30 | 1) 31 | primary=$server 32 | ;; 33 | *) 34 | : "Unhandled replication state. Please advise." 35 | exit 1 36 | ;; 37 | esac 38 | done 39 | 40 | : "Current Primary is $primary" 41 | : "Current secondary is $secondary" 42 | 43 | docker compose stop $primary 44 | _psql "$secondary" -c 'SELECT pg_promote();' 45 | # Swap variables 46 | servers=("$secondary" "$primary") 47 | primary="${servers[0]}" 48 | secondary="${servers[1]}" 49 | # failback. The entrypoint takes care of rebuilding the data with pg_rewind. 50 | docker compose up -d "$secondary" 51 | 52 | sleep 3 53 | 54 | # Check primary has a replication client. 55 | _psql "$primary" -c "SELECT * FROM pg_stat_replication;" | grep "$secondary" 56 | # Check secondary is in recovery. 57 | _psql "$secondary" -c "SELECT pg_is_in_recovery();" | grep t 58 | 59 | : "New Primary is $primary (postgres://postgres:postgres@0.0.0.0:$((1 + ${primary#postgres}))5432/postgres)" 60 | : "New Secondary is $secondary (postgres://postgres:postgres@0.0.0.0:$((1 + ${secondary#postgres}))5432/postgres)" 61 | -------------------------------------------------------------------------------- /ui/share/temboard.conf: -------------------------------------------------------------------------------- 1 | [temboard] 2 | # Bind port 3 | port = 8888 4 | # Bind address 5 | address = 0.0.0.0 6 | # SSL: certificat file path (.pem) 7 | ssl_cert_file = temboard_CHANGEME.pem 8 | # SSL: private key file path (.key) 9 | ssl_key_file = temboard_CHANGEME.key 10 | # SSL: CA cert file 11 | # This file must contains SSL cert of each agent that the UI can establish a connexion to. 12 | # ssl_ca_cert_file = temboard_ca_certs_CHANGEME.pem 13 | # Cookie secret key 14 | cookie_secret = SECRETKEYTOBECHANGED 15 | # Plugins 16 | plugins = ["dashboard", "pgconf", "activity", "monitoring", "maintenance", "statements"] 17 | # Working dir 18 | home = /tmp 19 | 20 | [repository] 21 | # Unix socket path. 22 | # host = /var/run/postgresql 23 | # PG port number. 24 | # port = 5432 25 | # User name. 26 | # user = temboard 27 | # User password. 28 | # password = 29 | # Database name. 30 | # dbname = temboard 31 | 32 | [logging] 33 | # Available methods for logging: stderr, syslog or file 34 | method = stderr 35 | # Syslog facility. 36 | # facility = local0 37 | # Log destination, should be /dev/log for syslog on Linux. 38 | # When using file logging method, this is referencing the log file path. 39 | # destination = /var/log/temboard.log 40 | # Default log level. 41 | level = DEBUG 42 | 43 | [notifications] 44 | # SMTP host 45 | # smtp_host = localhost 46 | # SMTP port 47 | # smtp_port = 48 | # SMTP TLS 49 | # smtp_tls = False 50 | # SMTP login / password 51 | # smtp_login = 52 | # smtp_password = 53 | # SMTP from address 54 | # smtp_from_addr = 55 | # Twilio SMS service configuration 56 | # twilio_account_sid = ACCOUNTSIDTOBECHANGED 57 | # twilio_auth_token = AUTHTOKENTOBECHANGED 58 | # twilio_from = FROMNUMBERTOBECHANGED 59 | 60 | [monitoring] 61 | # Set the amount of data to keep, expressed in days 62 | # purge_after = 365 63 | 64 | [statements] 65 | # Set the amount of data to keep, expressed in days 66 | # purge_after = 7 67 | -------------------------------------------------------------------------------- /dev/docker-compose.massagent.yml: -------------------------------------------------------------------------------- 1 | # This compose file requires a few environment variables, see root Makefile for 2 | # details. 3 | networks: 4 | # By default, hook containers in network managed by root docker-compose.yml 5 | default: 6 | name: "${NETWORK}" 7 | external: true 8 | 9 | volumes: 10 | home: 11 | run: 12 | 13 | services: 14 | postgres: 15 | image: postgres:18-alpine 16 | environment: 17 | POSTGRES_PASSWORD: confinment 18 | command: [ 19 | postgres, 20 | -c, shared_preload_libraries=pg_stat_statements, 21 | -c, "cluster_name=postgres-${TEMBOARD_REGISTER_PORT}", 22 | ] 23 | volumes: 24 | - home:/var/lib/postgresql 25 | - run:/var/run/postgresql 26 | - type: bind 27 | source: ../ui/share/sql/pg_stat_statements-create-extension.sql 28 | target: /docker-entrypoint-initdb.d/pg_stat_statements-create-extension.sql 29 | healthcheck: 30 | test: ["CMD-SHELL", "psql -h localhost -U postgres -c \"SELECT 'HAS_STATEMENTS' FROM information_schema.tables WHERE table_schema = 'public' AND table_name = 'pg_stat_statements';\""] 31 | interval: 2s 32 | timeout: 2s 33 | retries: 5 34 | 35 | agent: 36 | image: dalibo/temboard-agent:snapshot 37 | ports: 38 | - ${TEMBOARD_REGISTER_PORT}:2345 39 | depends_on: 40 | postgres: 41 | condition: service_healthy 42 | volumes: 43 | - home:/var/lib/postgresql 44 | - run:/var/run/postgresql 45 | links: 46 | - "postgres:postgres-${TEMBOARD_REGISTER_PORT}.dev" 47 | environment: 48 | TEMBOARD_HOSTNAME: "postgres-${TEMBOARD_REGISTER_PORT}.dev" 49 | TEMBOARD_LOGGING_LEVEL: DEBUG 50 | TEMBOARD_UI_URL: http://172.17.0.1:8888 51 | TEMBOARD_UI_USER: admin 52 | TEMBOARD_UI_PASSWORD: admin 53 | TEMBOARD_REGISTER_HOST: 172.17.0.1 54 | TEMBOARD_REGISTER_PORT: "${TEMBOARD_REGISTER_PORT}" 55 | TEMBOARD_ENVIRONMENT: mass 56 | -------------------------------------------------------------------------------- /.config/temboard.conf: -------------------------------------------------------------------------------- 1 | # Pathes are relative to .config/ parent. 2 | 3 | [temboard] 4 | # Bind port 5 | port = 8888 6 | # Bind address 7 | address = 0.0.0.0 8 | # SSL: certificat file path (.pem) 9 | ssl_cert_file = 10 | # SSL: private key file path (.key) 11 | ssl_key_file = 12 | # Sign with development key. THIS IS TOTALLY UNSECURE. Use this only for 13 | # testing! 14 | signing_private_key = dev/signing-private.pem 15 | signing_public_key = dev/signing-public.pem 16 | # Cookie secret key 17 | cookie_secret = UNSECURE_DEV_COOKIE_SECRET 18 | # Plugins 19 | plugins = ["dashboard", "pgconf", "activity", "monitoring", "maintenance", "statements"] 20 | # Working dir 21 | home = dev/temboard/ 22 | 23 | [auth] 24 | # localhost and docker default subnets. 25 | allowed_ip = 127.0.0.0/8,172.16.0.0/12 26 | 27 | [repository] 28 | # Unix socket path. 29 | host = 0.0.0.0 30 | # PG port number. 31 | port = 5432 32 | # User name. 33 | user = temboard 34 | # User password. 35 | password = temboard 36 | # Database name. 37 | dbname = temboard 38 | 39 | [logging] 40 | # Available methods for logging: stderr, syslog or file 41 | # method = syslog 42 | method = stderr 43 | # Syslog facility. 44 | # facility = local0 45 | # Log destination, should be /dev/log for syslog on Linux. 46 | # When using file logging method, this is referencing the log file path. 47 | #destination = /var/log/temboard/temboard.log 48 | # Default log level. 49 | level = DEBUG 50 | 51 | [monitoring] 52 | prometheus = ui/build/bin/prometheus 53 | 54 | [notifications] 55 | # SMTP host 56 | smtp_host = localhost 57 | # SMTP port 58 | smtp_port = 1025 59 | # SMTP TLS 60 | # smtp_tls = False 61 | # SMTP login / password 62 | # smtp_login = 63 | # smtp_password = 64 | # SMTP from address 65 | smtp_from_addr = temBoard Dev 66 | # 67 | # Twilio SMS service configuration 68 | # twilio_account_sid = ACCOUNTSIDTOBECHANGED 69 | # twilio_auth_token = AUTHTOKENTOBECHANGED 70 | # twilio_from = FROMNUMBERTOBECHANGED 71 | -------------------------------------------------------------------------------- /ui/temboardui/plugins/monitoring/handlers/monitoring.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | from temboardui.web.tornado import HTTPError, csvify 4 | 5 | from ..chartdata import get_metric_data_csv, get_unavailability_csv 6 | from ..tools import get_request_ids, parse_start_end 7 | from . import blueprint, render_template 8 | 9 | logger = logging.getLogger(__name__) 10 | 11 | 12 | @blueprint.instance_route("/monitoring") 13 | def index(request): 14 | request.instance.check_active_plugin("monitoring") 15 | request.instance.fetch_status() 16 | return render_template( 17 | "index.html", 18 | role=request.current_user, 19 | instance=request.instance, 20 | plugin="monitoring", 21 | ) 22 | 23 | 24 | @blueprint.instance_route("/monitoring/unavailability") 25 | def unavailability(request): 26 | try: 27 | host_id, instance_id = get_request_ids(request) 28 | except NameError as e: 29 | logger.info("%s. No data.", e) 30 | return csvify(data=[]) 31 | 32 | start, end = parse_start_end(request) 33 | data = get_unavailability_csv(request.db_session, start, end, host_id, instance_id) 34 | return csvify(data) 35 | 36 | 37 | @blueprint.instance_route(r"/monitoring/data/([a-z\-_.0-9]{1,64})$") 38 | def data_metric(request, metric_name): 39 | key = request.handler.get_argument("key", default=None) 40 | try: 41 | host_id, instance_id = get_request_ids(request) 42 | except NameError as e: 43 | logger.info("%s. No data.", e) 44 | return csvify(data=[]) 45 | 46 | start, end = parse_start_end(request) 47 | try: 48 | data = get_metric_data_csv( 49 | request.db_session, 50 | metric_name, 51 | start, 52 | end, 53 | host_id=host_id, 54 | instance_id=instance_id, 55 | key=key, 56 | ) 57 | except IndexError: 58 | raise HTTPError(404, "Unknown metric.") 59 | 60 | return csvify(data=data) 61 | --------------------------------------------------------------------------------