├── .dockerignore ├── .gitattributes ├── .github ├── ISSUE_TEMPLATE │ ├── bug_report.md │ └── feature_request.md ├── dependabot.yml └── workflows │ ├── build.yml │ ├── release.yml │ └── stale.yml ├── .gitignore ├── .golangci.yml ├── .goreleaser.yml ├── CODE_OF_CONDUCT.md ├── LICENSE ├── README.md ├── api ├── doc.go └── types.go ├── cmd ├── convert_metrics │ ├── convert_metrics.go │ ├── convert_metrics_test.go │ └── doc.go └── pgwatch │ ├── doc.go │ ├── main.go │ ├── main_integration_test.go │ └── version.go ├── contrib ├── sample.sources.yaml └── sample.systemd.service ├── docker ├── Dockerfile ├── Dockerfile.postgres-plpython3u ├── bootstrap │ ├── create_role_db.sql │ ├── init_dbs.sh │ ├── init_persistent_config.sh │ ├── init_replication.sh │ ├── init_supervisor.sh │ ├── init_test_monitored_db.sh │ ├── pgadmin_pass │ ├── pgadmin_servers.json │ └── prometheus.yml ├── build-docker-demo.sh ├── build-docker.sh ├── compose.add-test-db.sh ├── compose.grafana.yml ├── compose.pgactivity.sh ├── compose.pgadmin.yml ├── compose.pgbench.sh ├── compose.pgbouncer.yml ├── compose.pgpool.yml ├── compose.pgwatch.yml ├── compose.postgres.yml ├── compose.prometheus.yml ├── compose.timescaledb.yml ├── demo │ ├── Dockerfile │ ├── grafana.ini │ ├── postgresql.conf │ └── supervisord.conf ├── docker-compose.timescaledb.yml ├── docker-compose.yml └── test │ ├── launch_all_pg_versions │ ├── README_all_versions_testing.md │ ├── add_all_to_monitoring.sql │ ├── get-all-pg-versions.sh │ ├── launch-all-pg-version-primaries.sh │ ├── launch-all-pg-version-replicas.sh │ ├── pause-all-pg-versions.sh │ ├── pgbench_on_all.sh │ ├── purge-all-pg-versions.sh │ ├── stop-all-pg-versions.sh │ └── unpause-all-pg-versions.sh │ └── smoke_test_docker_image.sh ├── docs ├── CNAME ├── _overrides │ └── main.html ├── concept │ ├── components.md │ ├── installation_options.md │ ├── kubernetes.md │ ├── long_term_installations.md │ ├── security.md │ └── web_ui.md ├── developer │ ├── CODE_OF_CONDUCT.md │ ├── LICENSE.md │ └── contributing.md ├── gallery │ ├── biggest_relations_treemap.png │ ├── change_events.png │ ├── checkpointer_bgwriter.png │ ├── dashboards.md │ ├── db_overview_time_lag_comparison.png │ ├── global_health.png │ ├── health_check.png │ ├── index_overview.png │ ├── isoflow-architecture-diagram-no-config.json │ ├── isoflow-architecture-diagram.json │ ├── overview.png │ ├── overview_developer.png │ ├── pgbouncer_stats.png │ ├── pgpool_status.png │ ├── pgwatch_architecture.jpg │ ├── pgwatch_architecture_no_config.png │ ├── postres_versions_overview.png │ ├── recommendations.png │ ├── replication_lag.png │ ├── server_log_events.png │ ├── show_plans_realtime.png │ ├── stat_activity_realtime.png │ ├── stat_statements_sql_search.png │ ├── stat_statements_top.png │ ├── stat_statements_top_visual.png │ ├── system_stats_psutil.png │ ├── tables_top.png │ ├── webui.md │ ├── webui_logs.png │ ├── webui_metrics_grid.png │ ├── webui_presets_grid.png │ ├── webui_sources_grid.png │ └── what-how-where.svg ├── howto │ ├── config_db_bootstrap.md │ ├── dashboarding_alerting.md │ ├── metrics_db_bootstrap.md │ ├── migrate_v2_to_v3.md │ ├── sizing_recommendations.md │ └── using_managed_services.md ├── index.md ├── intro │ ├── features.md │ └── project_background.md ├── reference │ ├── advanced_features.md │ ├── cli_env.md │ ├── env_variables.md │ └── metric_definitions.md └── tutorial │ ├── custom_installation.md │ ├── docker_installation.md │ ├── preparing_databases.md │ └── upgrading.md ├── go.mod ├── go.sum ├── grafana ├── dashboards.yml ├── postgres │ ├── v10 │ │ ├── 1-global-db-overview.json │ │ ├── aws-cloudwatch.json │ │ ├── biggest-relations.json │ │ ├── change-events.json │ │ ├── checkpointer-bgwriter-stats.json │ │ ├── db-overview-developer.json │ │ ├── db-overview-time-lag.json │ │ ├── db-overview.json │ │ ├── documentation.json │ │ ├── global-health.json │ │ ├── health-check.json │ │ ├── index-overview.json │ │ ├── lock-details.json │ │ ├── pgbouncer-stats.json │ │ ├── pgpool-stats.json │ │ ├── postgres-version-overview.json │ │ ├── recommendations.json │ │ ├── replication.json │ │ ├── server-log-events.json │ │ ├── sessions-overview.json │ │ ├── show-plans-realtime.json │ │ ├── single-query-details.json │ │ ├── sproc-details.json │ │ ├── sprocs-top.json │ │ ├── stat-activity-realtime.json │ │ ├── stat-activity.json │ │ ├── stat-statements-sql-search.json │ │ ├── stat-statements-top-fast.json │ │ ├── stat-statements-top-visual.json │ │ ├── stat-statements-top.json │ │ ├── system-stats-time-lag.json │ │ ├── system-stats.json │ │ ├── table-details-time-lag.json │ │ ├── table-details.json │ │ └── tables-top.json │ └── v11 │ │ ├── 1-global-db-overview.json │ │ ├── aws-cloudwatch.json │ │ ├── biggest-relations.json │ │ ├── change-events.json │ │ ├── checkpointer-bgwriter-stats.json │ │ ├── db-overview-developer.json │ │ ├── db-overview-time-lag.json │ │ ├── db-overview.json │ │ ├── documentation.json │ │ ├── global-health.json │ │ ├── health-check.json │ │ ├── index-overview.json │ │ ├── lock-details.json │ │ ├── pgbouncer-stats.json │ │ ├── pgpool-stats.json │ │ ├── postgres-version-overview.json │ │ ├── recommendations.json │ │ ├── replication.json │ │ ├── server-log-events.json │ │ ├── sessions-overview.json │ │ ├── show-plans-realtime.json │ │ ├── single-query-details.json │ │ ├── sproc-details.json │ │ ├── sprocs-top.json │ │ ├── stat-activity-realtime.json │ │ ├── stat-activity.json │ │ ├── stat-statements-sql-search.json │ │ ├── stat-statements-top-fast.json │ │ ├── stat-statements-top-visual.json │ │ ├── stat-statements-top.json │ │ ├── system-stats-time-lag.json │ │ ├── system-stats.json │ │ ├── table-details-time-lag.json │ │ ├── table-details.json │ │ └── tables-top.json ├── postgres_datasource.yml ├── prometheus │ ├── v10 │ │ ├── db-overview.json │ │ ├── health-check.json │ │ ├── sessions-overview.json │ │ ├── table-details.json │ │ └── tables-top.json │ └── v11 │ │ ├── 1-global-db-overview.json │ │ ├── db-overview.json │ │ ├── health-check.json │ │ ├── sessions-overview.json │ │ ├── table-details.json │ │ └── tables-top.json └── prometheus_datasource.yml ├── internal ├── build.sh ├── cmdopts │ ├── cmdconfig.go │ ├── cmdconfig_test.go │ ├── cmdmetric.go │ ├── cmdmetric_test.go │ ├── cmdoptions.go │ ├── cmdoptions_test.go │ ├── cmdsource.go │ ├── cmdsource_test.go │ └── doc.go ├── db │ ├── bootstrap.go │ ├── bootstrap_test.go │ ├── conn.go │ ├── conn_test.go │ └── doc.go ├── log │ ├── cmdopts.go │ ├── doc.go │ ├── formatter.go │ ├── formatter_test.go │ ├── log.go │ ├── log_broker_hook.go │ ├── log_broker_hook_test.go │ ├── log_file_test.go │ └── log_test.go ├── metrics │ ├── cmdopts.go │ ├── cmdopts_test.go │ ├── default.go │ ├── default_test.go │ ├── doc.go │ ├── logparse.go │ ├── logparse_test.go │ ├── metrics.yaml │ ├── postgres.go │ ├── postgres_schema.go │ ├── postgres_schema.sql │ ├── postgres_schema_test.go │ ├── postgres_test.go │ ├── types.go │ ├── types_test.go │ ├── yaml.go │ └── yaml_test.go ├── reaper │ ├── cache.go │ ├── cache_test.go │ ├── database.go │ ├── database_test.go │ ├── doc.go │ ├── file.go │ ├── metric.go │ ├── metric_test.go │ ├── psutil.go │ ├── psutil_darwin.go │ ├── psutil_linux.go │ ├── psutil_test.go │ ├── psutil_windows.go │ ├── reaper.go │ └── recommendations.go ├── sinks │ ├── cmdopts.go │ ├── doc.go │ ├── json.go │ ├── json_test.go │ ├── multiwriter.go │ ├── multiwriter_test.go │ ├── postgres.go │ ├── postgres_test.go │ ├── prometheus.go │ ├── rpc.go │ ├── rpc_test.go │ └── sql │ │ ├── README.md │ │ ├── admin_functions.sql │ │ ├── admin_schema.sql │ │ ├── change_chunk_interval.sql │ │ ├── change_compression_interval.sql │ │ ├── ensure_partition_postgres.sql │ │ └── ensure_partition_timescale.sql ├── sources │ ├── cmdopts.go │ ├── conn.go │ ├── conn_test.go │ ├── doc.go │ ├── postgres.go │ ├── postgres_test.go │ ├── resolver.go │ ├── resolver_test.go │ ├── types.go │ ├── types_test.go │ ├── yaml.go │ └── yaml_test.go ├── webserver │ ├── cmdoptions_test.go │ ├── cmdopts.go │ ├── doc.go │ ├── jwt.go │ ├── jwt_test.go │ ├── metric.go │ ├── metric_test.go │ ├── server_test.go │ ├── source.go │ ├── source_test.go │ ├── webserver.go │ ├── webserver_test.go │ ├── wslog.go │ └── wslog_test.go └── webui │ ├── .env │ ├── .eslintignore │ ├── .eslintrc.js │ ├── .gitignore │ ├── .prettierignore │ ├── .prettierrc.json │ ├── README.md │ ├── doc.go │ ├── embed.go │ ├── embed_test.go │ ├── package.json │ ├── public │ ├── favicon.ico │ ├── index.html │ ├── logo.png │ ├── logo192.png │ ├── manifest.json │ └── robots.txt │ ├── src │ ├── App.test.tsx │ ├── App.tsx │ ├── QueryClient.tsx │ ├── api.ts │ ├── components │ │ ├── Alert │ │ │ ├── Alert.styles.ts │ │ │ └── Alert.tsx │ │ ├── Autocomplete │ │ │ └── Autocomplete.tsx │ │ ├── Error │ │ │ ├── Error.styles.ts │ │ │ └── Error.tsx │ │ ├── GridActions │ │ │ ├── GridActions.styles.ts │ │ │ └── GridActions.tsx │ │ ├── GridToolbar │ │ │ └── GridToolbar.tsx │ │ ├── Loading │ │ │ ├── Loading.styles.ts │ │ │ └── Loading.tsx │ │ ├── MetricPopUp │ │ │ ├── MetricPopUp.consts.tsx │ │ │ └── MetricPopUp.tsx │ │ ├── PasswordInput │ │ │ └── PasswordInput.tsx │ │ └── WarningDialog │ │ │ └── WarningDialog.tsx │ ├── consts │ │ └── queryKeys.ts │ ├── containers │ │ ├── MetricFormDialog │ │ │ ├── MetricFormDialog.consts.ts │ │ │ ├── MetricFormDialog.tsx │ │ │ └── components │ │ │ │ └── MetricForm │ │ │ │ ├── MetricForm.consts.ts │ │ │ │ ├── MetricForm.tsx │ │ │ │ ├── MetricForm.types.ts │ │ │ │ └── components │ │ │ │ ├── MetricFormStepGeneral.tsx │ │ │ │ ├── MetricFormStepSQL.tsx │ │ │ │ ├── MetricFormStepSettings.tsx │ │ │ │ └── StepButtons │ │ │ │ ├── StepButtons.consts.ts │ │ │ │ └── StepButtons.tsx │ │ ├── PresetFormDialog │ │ │ ├── PresetFormDialog.consts.ts │ │ │ ├── PresetFormDialog.tsx │ │ │ └── components │ │ │ │ └── PresetForm │ │ │ │ ├── PresetForm.consts.ts │ │ │ │ ├── PresetForm.tsx │ │ │ │ ├── PresetForm.types.ts │ │ │ │ └── components │ │ │ │ ├── PresetFormStepGeneral.tsx │ │ │ │ ├── PresetFormStepMetrics.tsx │ │ │ │ └── StepButtons │ │ │ │ ├── StepButtons.consts.ts │ │ │ │ └── StepButtons.tsx │ │ └── SourceFormDialog │ │ │ ├── SourceFormDialog.consts.ts │ │ │ ├── SourceFormDialog.tsx │ │ │ └── components │ │ │ └── SourceForm │ │ │ ├── SourceForm.consts.ts │ │ │ ├── SourceForm.tsx │ │ │ ├── SourceForm.types.ts │ │ │ └── components │ │ │ ├── SourceFormStepGeneral.tsx │ │ │ ├── SourceFormStepMetrics.tsx │ │ │ ├── SourceFormStepTags.tsx │ │ │ ├── StepButtons │ │ │ ├── StepButtons.consts.ts │ │ │ └── StepButtons.tsx │ │ │ └── TestConnection │ │ │ └── TestConnection.tsx │ ├── contexts │ │ ├── MetricForm │ │ │ ├── MetricForm.consts.ts │ │ │ ├── MetricForm.context.ts │ │ │ ├── MetricForm.provider.tsx │ │ │ └── MetricForm.types.ts │ │ ├── PresetForm │ │ │ ├── PresetForm.consts.ts │ │ │ ├── PresetForm.context.ts │ │ │ ├── PresetForm.provider.tsx │ │ │ └── PresetForm.types.ts │ │ └── SourceForm │ │ │ ├── SourceForm.consts.ts │ │ │ ├── SourceForm.context.ts │ │ │ ├── SourceForm.provider.tsx │ │ │ └── SourceForm.types.ts │ ├── hooks │ │ └── useGridColumnVisibility.ts │ ├── index.tsx │ ├── layout │ │ ├── AppBar.tsx │ │ ├── PrivateRoute.tsx │ │ ├── Routes.ts │ │ └── const.ts │ ├── logo.svg │ ├── pages │ │ ├── LoginPage │ │ │ ├── LoginPage.styles.ts │ │ │ ├── LoginPage.tsx │ │ │ └── components │ │ │ │ └── LoginForm │ │ │ │ ├── LoginForm.consts.ts │ │ │ │ ├── LoginForm.styles.ts │ │ │ │ ├── LoginForm.tsx │ │ │ │ └── LoginForm.types.ts │ │ ├── LogsPage │ │ │ ├── LogsPage.tsx │ │ │ └── components │ │ │ │ ├── Logs.styles.ts │ │ │ │ ├── Logs.tsx │ │ │ │ └── components │ │ │ │ ├── LogOutput.consts.ts │ │ │ │ └── LogOutput.tsx │ │ ├── MetricsPage │ │ │ ├── MetricsPage.tsx │ │ │ └── components │ │ │ │ └── MetricsGrid │ │ │ │ ├── MetricsGrid.consts.tsx │ │ │ │ ├── MetricsGrid.tsx │ │ │ │ ├── MetricsGrid.types.ts │ │ │ │ └── components │ │ │ │ ├── MetricsGridActions │ │ │ │ └── MetricsGridActions.tsx │ │ │ │ ├── MetricsGridToolbar │ │ │ │ └── MetricsGridToolbar.tsx │ │ │ │ └── SqlPopUp │ │ │ │ ├── SqlPopUp.consts.tsx │ │ │ │ └── SqlPopUp.tsx │ │ ├── PresetsPage │ │ │ ├── PresetsPage.tsx │ │ │ └── components │ │ │ │ └── PresetsGrid │ │ │ │ ├── PresetsGrid.consts.tsx │ │ │ │ ├── PresetsGrid.tsx │ │ │ │ ├── PresetsGrid.types.ts │ │ │ │ └── components │ │ │ │ ├── PresetsGridActions │ │ │ │ └── PresetsGridActions.tsx │ │ │ │ └── PresetsGridToolbar │ │ │ │ └── PresetsGridToolbar.tsx │ │ └── SourcesPage │ │ │ ├── SourcesPage.tsx │ │ │ └── components │ │ │ └── SourcesGrid │ │ │ ├── SourcesGrid.consts.tsx │ │ │ ├── SourcesGrid.tsx │ │ │ └── components │ │ │ ├── CustomTagsPopUp │ │ │ ├── CustomTagsPopUp.consts.ts │ │ │ └── CustomTagsPopUp.tsx │ │ │ ├── EnabledSourceSwitch.tsx │ │ │ ├── HostConfigPopUp │ │ │ ├── HostConfigPopUp.consts.ts │ │ │ ├── HostConfigPopUp.tsx │ │ │ └── HostConfigPopUp.types.ts │ │ │ ├── MaskConnectionString.tsx │ │ │ ├── SourcesGridActions.tsx │ │ │ └── SourcesGridToolbar.tsx │ ├── queries │ │ ├── Auth │ │ │ └── index.ts │ │ ├── Log │ │ │ └── index.ts │ │ ├── Metric │ │ │ └── index.ts │ │ ├── Preset │ │ │ └── index.ts │ │ └── Source │ │ │ └── index.ts │ ├── react-app-env.d.ts │ ├── reportWebVitals.ts │ ├── services │ │ ├── Auth │ │ │ └── index.ts │ │ ├── Metric │ │ │ └── index.ts │ │ ├── Preset │ │ │ └── index.ts │ │ ├── Source │ │ │ └── index.ts │ │ └── Token │ │ │ └── index.ts │ ├── setupTests.ts │ ├── styles │ │ ├── form.ts │ │ └── page.ts │ ├── types │ │ ├── Metric │ │ │ ├── Metric.ts │ │ │ └── MetricRequestBody.ts │ │ ├── Preset │ │ │ ├── Preset.ts │ │ │ └── PresetRequestBody.ts │ │ └── Source │ │ │ ├── Source.ts │ │ │ └── SourceRequestBody.ts │ └── utils │ │ ├── AlertContext.tsx │ │ ├── toArrayFromRecord.ts │ │ └── toRecordFromArray.ts │ ├── tsconfig.json │ ├── web-ui.postman_collection.json │ └── yarn.lock └── mkdocs.yml /.dockerignore: -------------------------------------------------------------------------------- 1 | **/node_modules 2 | **/build 3 | docs 4 | contrib -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | *.sql linguist-language=PLpgSQL 2 | *.py linguist-vendored 3 | *.sh linguist-vendored 4 | *.js linguist-language=TypeScript 5 | *.ts linguist-language=TypeScript 6 | *.tsx linguist-language=TypeScript 7 | -------------------------------------------------------------------------------- /.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 | **Describe the bug** 11 | A clear and concise description of what the bug is. 12 | 13 | **To Reproduce** 14 | Steps to reproduce the behavior: 15 | 1. Go to '...' 16 | 2. Click on '....' 17 | 3. Scroll down to '....' 18 | 4. See error 19 | 20 | **Expected behavior** 21 | A clear and concise description of what you expected to happen. 22 | 23 | **Screenshots** 24 | If applicable, add screenshots to help explain your problem. 25 | 26 | **Desktop (please complete the following information):** 27 | - OS: [e.g. iOS] 28 | - Browser [e.g. chrome, safari] 29 | - Version [e.g. 22] 30 | 31 | **Smartphone (please complete the following information):** 32 | - Device: [e.g. iPhone6] 33 | - OS: [e.g. iOS8.1] 34 | - Browser [e.g. stock browser, safari] 35 | - Version [e.g. 22] 36 | 37 | **Additional context** 38 | Add any other context about the problem here. 39 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Is your feature request related to a problem? Please describe.** 11 | A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] 12 | 13 | **Describe the solution you'd like** 14 | A clear and concise description of what you want to happen. 15 | 16 | **Describe alternatives you've considered** 17 | A clear and concise description of any alternative solutions or features you've considered. 18 | 19 | **Additional context** 20 | Add any other context or screenshots about the feature request here. 21 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | 4 | # Maintain dependencies for Go modules 5 | - package-ecosystem: gomod 6 | directory: "/" 7 | schedule: 8 | interval: daily 9 | time: "04:00" 10 | open-pull-requests-limit: 10 11 | 12 | # Maintain dependencies for GitHub Actions 13 | - package-ecosystem: "github-actions" 14 | directory: "/" 15 | schedule: 16 | interval: "daily" 17 | -------------------------------------------------------------------------------- /.github/workflows/stale.yml: -------------------------------------------------------------------------------- 1 | name: Close Stale Issues and PRs 2 | 3 | on: 4 | schedule: 5 | - cron: '0 0 * * *' 6 | 7 | workflow_dispatch: 8 | 9 | 10 | jobs: 11 | stale: 12 | if: false # false to skip job during debug 13 | runs-on: ubuntu-latest 14 | steps: 15 | - uses: actions/stale@v9 16 | with: 17 | repo-token: ${{ secrets.GITHUB_TOKEN }} 18 | stale-issue-label: 'stale' 19 | stale-pr-label: 'stale' 20 | stale-issue-message: | 21 | 📅 This issue has been automatically marked as stale because lack of recent activity. It will be closed if no further activity occurs. 22 | ♻️ If you think there is new information allowing us to address the issue, please reopen it and provide us with updated details. 23 | 🤝 Thank you for your contributions. 24 | stale-pr-message: | 25 | 📅 This PR has been automatically marked as stale because lack of recent activity. It will be closed if no further activity occurs. 26 | ♻️ If you think there is new information allowing us to address this PR, please reopen it and provide us with updated details. 27 | 🤝 Thank you for your contributions. 28 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Binaries for programs and plugins 2 | *.exe 3 | *.exe~ 4 | *.dll 5 | *.so 6 | *.dylib 7 | 8 | # Test binary, build with `go test -c` 9 | *.test 10 | 11 | # Output of the go coverage tool, specifically when used with LiteIDE 12 | *.out 13 | 14 | # Packages ouput folder 15 | dist 16 | docs/godoc 17 | site 18 | 19 | # delve debugger file 20 | debug 21 | 22 | # VS Code settings 23 | .vscode/ 24 | 25 | # IntelliJ Idea settings 26 | .idea/ 27 | -------------------------------------------------------------------------------- /.golangci.yml: -------------------------------------------------------------------------------- 1 | version: "2" 2 | linters: 3 | enable: 4 | - misspell 5 | - revive 6 | disable: 7 | - gocyclo 8 | settings: 9 | gocyclo: 10 | min-complexity: 20 11 | exclusions: 12 | generated: lax 13 | presets: 14 | - comments 15 | - common-false-positives 16 | - legacy 17 | - std-error-handling 18 | rules: 19 | - path: (.+)\.go$ 20 | text: SA1019 # CPUTimesStat.Total is deprecated 21 | - path: (.+)\.go$ 22 | text: SA5008 # duplicate struct tag "choice" (staticcheck) 23 | - path: (.+)\.go$ 24 | text: QF1001 # could apply De Morgan's law 25 | paths: 26 | - third_party$ 27 | - builtin$ 28 | - examples$ 29 | formatters: 30 | exclusions: 31 | generated: lax 32 | paths: 33 | - third_party$ 34 | - builtin$ 35 | - examples$ 36 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | BSD 3-Clause License 2 | 3 | Copyright (c) 2022, CYBERTEC PostgreSQL International GmbH 4 | All rights reserved. 5 | 6 | Redistribution and use in source and binary forms, with or without 7 | modification, are permitted provided that the following conditions are met: 8 | 9 | * Redistributions of source code must retain the above copyright notice, this 10 | list of conditions and the following disclaimer. 11 | 12 | * Redistributions in binary form must reproduce the above copyright notice, 13 | this list of conditions and the following disclaimer in the documentation 14 | and/or other materials provided with the distribution. 15 | 16 | * Neither the name of the copyright holder nor the names of its 17 | contributors may be used to endorse or promote products derived from 18 | this software without specific prior written permission. 19 | 20 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" 21 | AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE 22 | IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 23 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE 24 | FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL 25 | DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR 26 | SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER 27 | CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, 28 | OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE 29 | OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 30 | -------------------------------------------------------------------------------- /api/doc.go: -------------------------------------------------------------------------------- 1 | // package api contains the API definitions for external use, such as the RPC sink. 2 | package api 3 | -------------------------------------------------------------------------------- /api/types.go: -------------------------------------------------------------------------------- 1 | package api 2 | 3 | import ( 4 | "github.com/cybertec-postgresql/pgwatch/v3/internal/metrics" 5 | "github.com/cybertec-postgresql/pgwatch/v3/internal/sinks" 6 | ) 7 | 8 | type ( 9 | // Metric represents a metric definition 10 | Metric = metrics.Metric 11 | // MeasurementEnvelope represents a collection of measurement messages wrapped up 12 | // with metadata such as metric name, source type, etc. 13 | MeasurementEnvelope = metrics.MeasurementEnvelope 14 | // RPCSyncRequest represents a request to sync metrics with the remote RPC sink 15 | RPCSyncRequest = sinks.SyncReq 16 | ) 17 | -------------------------------------------------------------------------------- /cmd/convert_metrics/doc.go: -------------------------------------------------------------------------------- 1 | // convert_metrics is a tool that converts v2 metric definitions to v3 format. 2 | package main 3 | -------------------------------------------------------------------------------- /cmd/pgwatch/version.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import "fmt" 4 | 5 | // version output variables 6 | var ( 7 | commit = "unknown" 8 | version = "unknown" 9 | date = "unknown" 10 | dbapi = "00179" 11 | ) 12 | 13 | func printVersion() { 14 | fmt.Printf(` 15 | Version info: 16 | Version: %s 17 | DB Schema: %s 18 | Git Commit: %s 19 | Built: %s 20 | 21 | `, version, dbapi, commit, date) 22 | } 23 | -------------------------------------------------------------------------------- /contrib/sample.systemd.service: -------------------------------------------------------------------------------- 1 | # This is an example of a systemD config file for pgwatch. 2 | # You can copy it to "/etc/systemd/system/pgwatch.service", adjust as necessary and then call 3 | # systemctl daemon-reload && systemctl start pgwatch && systemctl enable pgwatch 4 | # to start and also enable auto-start after reboot. 5 | 6 | [Unit] 7 | Description=Pgwatch Gathering Daemon 8 | After=network-online.target 9 | # When on the monitored node then it's a good idea to only launch after Postgres 10 | # After=postgresql 11 | 12 | [Service] 13 | User=postgres 14 | Type=simple 15 | ExecStart=/usr/bin/pgwatch --sources /etc/pgwatch/sample.sources.yaml --sink=prometheus://0.0.0.0:9187 16 | Restart=on-failure 17 | TimeoutStartSec=0 18 | 19 | [Install] 20 | WantedBy=multi-user.target 21 | -------------------------------------------------------------------------------- /docker/Dockerfile: -------------------------------------------------------------------------------- 1 | # ---------------------------------------------------------------- 2 | # 1. Build Web UI 3 | # ---------------------------------------------------------------- 4 | FROM node:22 AS uibuilder 5 | ADD internal/webui /webui 6 | RUN cd webui && yarn install --network-timeout 100000 && yarn build 7 | 8 | # ---------------------------------------------------------------- 9 | # 2. Build gatherer 10 | # ---------------------------------------------------------------- 11 | FROM golang:1.24 AS builder 12 | 13 | ARG VERSION 14 | ARG GIT_HASH 15 | ARG GIT_TIME 16 | 17 | COPY . /pgwatch 18 | COPY --from=uibuilder /webui/build /pgwatch/internal/webui/build 19 | RUN cd /pgwatch && CGO_ENABLED=0 go build -ldflags "-X 'main.commit=${GIT_HASH}' -X 'main.date=${GIT_TIME}' -X 'main.version=${VERSION}'" ./cmd/pgwatch 20 | 21 | # ---------------------------------------------------------------- 22 | # 3. Build the final image 23 | # ---------------------------------------------------------------- 24 | FROM alpine 25 | 26 | # Copy over the compiled gatherer 27 | COPY --from=builder /pgwatch/pgwatch /pgwatch/ 28 | COPY internal/metrics/metrics.yaml /pgwatch/metrics/metrics.yaml 29 | 30 | # Admin UI for configuring servers to be monitored 31 | EXPOSE 8080 32 | 33 | # Command to run the executable 34 | ENTRYPOINT ["/pgwatch/pgwatch"] 35 | -------------------------------------------------------------------------------- /docker/Dockerfile.postgres-plpython3u: -------------------------------------------------------------------------------- 1 | FROM postgres:17 2 | 3 | # Install the necessary packages for plpython3u 4 | RUN apt-get update && apt-get install -y \ 5 | postgresql-plpython3-17 postgresql-17-pg-qualstats python3-psutil \ 6 | pg-activity 7 | 8 | # Clean up 9 | RUN apt-get clean && rm -rf /var/lib/apt/lists/* -------------------------------------------------------------------------------- /docker/bootstrap/create_role_db.sql: -------------------------------------------------------------------------------- 1 | CREATE ROLE pgwatch WITH 2 | IN ROLE pg_monitor 3 | LOGIN PASSWORD 'pgwatchadmin'; -- change the pw for production 4 | 5 | CREATE DATABASE pgwatch OWNER pgwatch; 6 | 7 | CREATE DATABASE pgwatch_metrics OWNER pgwatch; -------------------------------------------------------------------------------- /docker/bootstrap/init_dbs.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | /pgwatch/pgwatch metric print-init full | psql -v ON_ERROR_STOP=1 --username "postgres" --dbname "pgwatch" 4 | -------------------------------------------------------------------------------- /docker/bootstrap/init_replication.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | # Only allow replication user to connect for replication purposes 3 | 4 | echo "host replication postgres 0.0.0.0/0 trust" >> "$PGDATA/pg_hba.conf" 5 | -------------------------------------------------------------------------------- /docker/bootstrap/init_supervisor.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | /pgwatch/bootstrap/init_persistent_config.sh 4 | 5 | supervisorctl start postgres 6 | sleep 10 7 | until pg_isready ; do sleep 10 ; done 8 | 9 | for prog in grafana pgwatch ; do 10 | echo "supervisorctl start $prog ..."'1' 11 | supervisorctl start $prog 12 | echo "sleep 5" 13 | sleep 5 14 | done 15 | 16 | /pgwatch/bootstrap/init_test_monitored_db.sh 17 | -------------------------------------------------------------------------------- /docker/bootstrap/init_test_monitored_db.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | if [ -n "$PW_TESTDB" ] ; then 4 | echo "adding test monitored database..." 5 | psql -v ON_ERROR_STOP=1 --username "postgres" --dbname "pgwatch" <<-EOSQL 6 | INSERT INTO pgwatch.source (name, preset_config, config, connstr) 7 | SELECT 'test', 'full', null, 'postgresql://pgwatch:pgwatchadmin@localhost:5432/pgwatch' 8 | WHERE NOT EXISTS (SELECT * FROM pgwatch.source WHERE name = 'test'); 9 | EOSQL 10 | fi -------------------------------------------------------------------------------- /docker/bootstrap/pgadmin_pass: -------------------------------------------------------------------------------- 1 | postgres:5432:postgres:pgwatch:pgwatchadmin -------------------------------------------------------------------------------- /docker/bootstrap/pgadmin_servers.json: -------------------------------------------------------------------------------- 1 | { 2 | "Servers": { 3 | "1": { 4 | "Name": "pgwatch", 5 | "Group": "Servers", 6 | "Port": 5432, 7 | "Username": "pgwatch", 8 | "Host": "postgres", 9 | "PassFile": "/pgadmin4/pass", 10 | "SSLMode": "disable", 11 | "MaintenanceDB": "postgres" 12 | } 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /docker/bootstrap/prometheus.yml: -------------------------------------------------------------------------------- 1 | global: 2 | scrape_interval: 15s 3 | scrape_timeout: 10s 4 | evaluation_interval: 15s 5 | alerting: 6 | alertmanagers: 7 | - static_configs: 8 | - targets: [] 9 | scheme: http 10 | timeout: 10s 11 | api_version: v2 12 | scrape_configs: 13 | - job_name: prometheus 14 | honor_timestamps: true 15 | scrape_interval: 15s 16 | scrape_timeout: 10s 17 | metrics_path: /metrics 18 | scheme: http 19 | static_configs: 20 | - targets: 21 | - localhost:9090 22 | - job_name: pgwatch 23 | honor_timestamps: true 24 | scrape_interval: 15s 25 | scrape_timeout: 10s 26 | metrics_path: /pgwatch 27 | scheme: http 28 | static_configs: 29 | - targets: 30 | - pgwatch:9187 31 | -------------------------------------------------------------------------------- /docker/build-docker-demo.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" 4 | cd "$(dirname "$SCRIPT_DIR")" 5 | docker build \ 6 | --build-arg GIT_TIME=`git show -s --format=%cI HEAD` \ 7 | --build-arg GIT_HASH=`git show -s --format=%H HEAD` \ 8 | --build-arg VERSION=`git rev-parse --abbrev-ref HEAD` \ 9 | -t cybertecpostgresql/pgwatch-demo:latest \ 10 | -f docker/demo/Dockerfile . 11 | -------------------------------------------------------------------------------- /docker/build-docker.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" 4 | cd "$(dirname "$SCRIPT_DIR")" 5 | docker build \ 6 | --build-arg GIT_TIME=`git show -s --format=%cI HEAD` \ 7 | --build-arg GIT_HASH=`git show -s --format=%H HEAD` \ 8 | --build-arg VERSION=`git rev-parse --abbrev-ref HEAD` \ 9 | -t cybertecpostgresql/pgwatch:latest \ 10 | -f docker/Dockerfile . 11 | -------------------------------------------------------------------------------- /docker/compose.add-test-db.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | cd "$(dirname "${BASH_SOURCE[0]}")" 4 | 5 | export MSYS_NO_PATHCONV=1 6 | 7 | # We want to pipe the output of the `pgwatch metric print-init` command to the `psql` command 8 | docker compose exec -T pgwatch /pgwatch/pgwatch metric print-init full | \ 9 | docker compose exec -T -i postgres psql -d pgwatch -v ON_ERROR_STOP=1 10 | 11 | docker compose exec -T postgres psql -d pgwatch -v ON_ERROR_STOP=1 -c \ 12 | "INSERT INTO pgwatch.source (name, preset_config, connstr) VALUES 13 | ('demo', 'full', 'postgresql://pgwatch:pgwatchadmin@postgres/pgwatch'), 14 | ('demo_standby', 'full', 'postgresql://pgwatch:pgwatchadmin@postgres-standby/pgwatch')" 15 | 16 | 17 | 18 | -------------------------------------------------------------------------------- /docker/compose.grafana.yml: -------------------------------------------------------------------------------- 1 | services: 2 | grafana: 3 | # to use latest Grafana v11, update the dashboards provisioning folder below 4 | image: grafana/grafana:10.4.15 5 | container_name: grafana 6 | user: "0:0" 7 | environment: 8 | GF_AUTH_ANONYMOUS_ENABLED: true 9 | GF_DASHBOARDS_DEFAULT_HOME_DASHBOARD_PATH: /var/lib/grafana/dashboards/1-global-db-overview.json 10 | GF_INSTALL_PLUGINS: marcusolsson-treemap-panel 11 | GF_AUTH_ANONYMOUS_ORG_ROLE: Admin 12 | ports: 13 | - "3000:3000" 14 | restart: unless-stopped 15 | volumes: 16 | - "../grafana/dashboards.yml:/etc/grafana/provisioning/dashboards/pgwatch_dashboards.yml" 17 | # Uncomment the datasource version you want to use 18 | - "../grafana/postgres_datasource.yml:/etc/grafana/provisioning/datasources/postgres_datasource.yml" 19 | # - "../grafana/prometheus_datasource.yml:/etc/grafana/provisioning/datasources/prometheus_datasource.yml" 20 | # Uncomment the dashboard version you want to use and comment out the other one. 21 | - "../grafana/postgres/v10:/var/lib/grafana/dashboards" 22 | # - "../grafana/postgres/v11:/var/lib/grafana/dashboards" 23 | # - "../grafana/prometheus/v10:/var/lib/grafana/dashboards" 24 | # - "../grafana/prometheus/v11:/var/lib/grafana/dashboards" 25 | depends_on: 26 | postgres: 27 | condition: service_healthy -------------------------------------------------------------------------------- /docker/compose.pgactivity.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | cd "$(dirname "${BASH_SOURCE[0]}")" 4 | 5 | docker compose exec postgres pg_activity -d pgwatch 6 | -------------------------------------------------------------------------------- /docker/compose.pgadmin.yml: -------------------------------------------------------------------------------- 1 | services: 2 | pgadmin: 3 | image: dpage/pgadmin4 4 | container_name: pgadmin 5 | environment: 6 | PGADMIN_DEFAULT_EMAIL: admin@local.com 7 | PGADMIN_DEFAULT_PASSWORD: admin 8 | volumes: 9 | - "./bootstrap/pgadmin_servers.json:/pgadmin4/servers.json" 10 | - "./bootstrap/pgadmin_pass:/pgadmin4/pass" 11 | ports: 12 | - "80:80" 13 | depends_on: 14 | postgres: 15 | condition: service_healthy 16 | profiles: 17 | - pgadmin -------------------------------------------------------------------------------- /docker/compose.pgbench.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | cd "$(dirname "${BASH_SOURCE[0]}")" 4 | 5 | docker compose exec -e PGDATABASE=pgwatch postgres sh -c \ 6 | "pgbench --initialize --scale=50 && 7 | pgbench --progress=5 --client=10 --jobs=2 --transactions=10000 && 8 | pgbench --initialize --init-steps=d" 9 | -------------------------------------------------------------------------------- /docker/compose.pgbouncer.yml: -------------------------------------------------------------------------------- 1 | services: 2 | pgbouncer: 3 | image: bitnami/pgbouncer:latest 4 | container_name: pgbouncer 5 | ports: 6 | - "6432:6432" 7 | environment: 8 | POSTGRESQL_HOST: postgres 9 | PGBOUNCER_AUTH_TYPE: trust 10 | depends_on: 11 | postgres: 12 | condition: service_healthy -------------------------------------------------------------------------------- /docker/compose.pgpool.yml: -------------------------------------------------------------------------------- 1 | services: 2 | pgpool: 3 | image: bitnami/pgpool:latest 4 | container_name: pgpool 5 | ports: 6 | - "7432:5432" 7 | environment: 8 | PGPOOL_BACKEND_NODES: 0:postgres:5432,1:postgres-standby:5432 9 | PGPOOL_SR_CHECK_USER: postgres 10 | PGPOOL_SR_CHECK_PASSWORD: standbypass 11 | PGPOOL_ENABLE_LDAP: no 12 | PGPOOL_POSTGRES_USERNAME: postgres 13 | PGPOOL_POSTGRES_PASSWORD: adminpassword 14 | PGPOOL_ADMIN_USERNAME: admin 15 | PGPOOL_ADMIN_PASSWORD: adminpassword 16 | depends_on: 17 | postgres: 18 | condition: service_healthy -------------------------------------------------------------------------------- /docker/compose.pgwatch.yml: -------------------------------------------------------------------------------- 1 | services: 2 | pgwatch: 3 | # uncomment build section below for dev experience 4 | build: 5 | context: .. 6 | dockerfile: ./docker/Dockerfile 7 | image: cybertecpostgresql/pgwatch:latest 8 | container_name: pgwatch 9 | environment: 10 | PW_SOURCES: postgresql://pgwatch@postgres:5432/pgwatch 11 | command: 12 | - "--sink=postgresql://pgwatch@postgres:5432/pgwatch_metrics" 13 | - "--sink=prometheus://pgwatch:9187/pgwatch" 14 | ports: 15 | - "8080:8080" 16 | - "9187:9187" 17 | depends_on: 18 | postgres: 19 | condition: service_healthy -------------------------------------------------------------------------------- /docker/compose.postgres.yml: -------------------------------------------------------------------------------- 1 | services: 2 | postgres: 3 | user: postgres 4 | # Custom Dockerfile.postgres within build section is used to enable plpython3u extension. 5 | # Comment out the build section to use the default PostgreSQL image. 6 | build: 7 | context: . 8 | dockerfile: Dockerfile.postgres-plpython3u 9 | # If you want pure PostgreSQL vanilla experience use: 10 | # image: &pgimage "postgres:latest" 11 | image: &pgimage postgres-plpython3u:latest 12 | container_name: postgres 13 | command: 14 | - "-cshared_preload_libraries=pg_stat_statements" 15 | - "-cpg_stat_statements.track=all" 16 | - "-ctrack_io_timing=on" 17 | - "-ctrack_functions=pl" 18 | ports: 19 | - "5432:5432" 20 | environment: 21 | POSTGRES_HOST_AUTH_METHOD: trust 22 | volumes: 23 | - "./bootstrap/init_replication.sh:/docker-entrypoint-initdb.d/init_replication.sh" 24 | - "./bootstrap/create_role_db.sql:/docker-entrypoint-initdb.d/create_role_db.sql" 25 | healthcheck: 26 | test: ["CMD-SHELL", "pg_isready"] 27 | interval: 10s 28 | timeout: 5s 29 | retries: 5 30 | 31 | postgres-standby: 32 | user: postgres 33 | image: *pgimage 34 | container_name: postgres-standby 35 | environment: 36 | POSTGRES_PASSWORD: standbypass 37 | POSTGRES_USER: postgres 38 | ports: 39 | - "5433:5432" 40 | depends_on: 41 | postgres: 42 | condition: service_healthy 43 | command: > 44 | bash -c " 45 | rm -rf /var/lib/postgresql/data/* && 46 | pg_basebackup -h postgres --pgdata=/var/lib/postgresql/data --wal-method=stream --progress --write-recovery-conf --create-slot --slot=standby_slot && 47 | chmod 0700 /var/lib/postgresql/data && 48 | postgres" 49 | links: 50 | - postgres -------------------------------------------------------------------------------- /docker/compose.prometheus.yml: -------------------------------------------------------------------------------- 1 | services: 2 | prometheus: 3 | image: prom/prometheus 4 | container_name: prometheus 5 | command: 6 | - '--config.file=/etc/prometheus/prometheus.yml' 7 | ports: 8 | - 9090:9090 9 | restart: unless-stopped 10 | volumes: 11 | - "./bootstrap/prometheus.yml:/etc/prometheus/prometheus.yml" 12 | depends_on: 13 | postgres: 14 | condition: service_healthy -------------------------------------------------------------------------------- /docker/compose.timescaledb.yml: -------------------------------------------------------------------------------- 1 | services: 2 | postgres: 3 | user: postgres 4 | image: &pgimage timescale/timescaledb:latest-pg17 5 | container_name: postgres 6 | command: 7 | - "-cshared_preload_libraries=pg_stat_statements,timescaledb" 8 | - "-cpg_stat_statements.track=all" 9 | - "-ctrack_io_timing=on" 10 | - "-ctrack_functions=pl" 11 | ports: 12 | - "5432:5432" 13 | environment: 14 | POSTGRES_HOST_AUTH_METHOD: trust 15 | volumes: 16 | - "./bootstrap/init_replication.sh:/docker-entrypoint-initdb.d/init_replication.sh" 17 | - "./bootstrap/create_role_db.sql:/docker-entrypoint-initdb.d/create_role_db.sql" 18 | healthcheck: 19 | test: ["CMD-SHELL", "pg_isready"] 20 | interval: 10s 21 | timeout: 5s 22 | retries: 5 23 | 24 | postgres-standby: 25 | user: postgres 26 | image: *pgimage 27 | container_name: postgres-standby 28 | environment: 29 | POSTGRES_PASSWORD: standbypass 30 | POSTGRES_USER: postgres 31 | ports: 32 | - "5433:5432" 33 | depends_on: 34 | postgres: 35 | condition: service_healthy 36 | command: > 37 | bash -c " 38 | rm -rf /var/lib/postgresql/data/* && 39 | pg_basebackup -h postgres --pgdata=/var/lib/postgresql/data --wal-method=stream --progress --write-recovery-conf --create-slot --slot=standby_slot && 40 | chmod 0700 /var/lib/postgresql/data && 41 | postgres" 42 | links: 43 | - postgres 44 | -------------------------------------------------------------------------------- /docker/demo/grafana.ini: -------------------------------------------------------------------------------- 1 | [server] 2 | protocol = http 3 | cert_file = /pgwatch/persistent-config/self-signed-ssl.pem 4 | cert_key = /pgwatch/persistent-config/self-signed-ssl.key 5 | 6 | [security] 7 | admin_user = admin 8 | admin_password = pgwatchadmin 9 | 10 | [auth.anonymous] 11 | enabled = true 12 | # Organization name that should be used for unauthenticated users 13 | org_name = Main Org. 14 | # Role for unauthenticated users, other valid values are `Editor` and `Admin` 15 | org_role = Editor 16 | 17 | [dashboards] 18 | default_home_dashboard_path = /var/lib/grafana/dashboards/1-global-db-overview.json 19 | 20 | [metrics] 21 | enabled = false 22 | 23 | [plugins] 24 | preinstall = marcusolsson-treemap-panel 25 | 26 | [feature_toggles] 27 | angularDeprecationUI = false -------------------------------------------------------------------------------- /docker/demo/postgresql.conf: -------------------------------------------------------------------------------- 1 | listen_addresses='*' 2 | shared_buffers='64MB' 3 | work_mem='16MB' 4 | checkpoint_timeout='1h' 5 | ssl=true 6 | ssl_key_file='/etc/ssl/private/ssl-cert-snakeoil.key' 7 | ssl_cert_file='/etc/ssl/certs/ssl-cert-snakeoil.pem' 8 | track_io_timing=on 9 | shared_preload_libraries = 'pg_stat_statements,timescaledb,pg_qualstats' 10 | track_functions='pl' 11 | wal_compression=zstd 12 | log_destination=csvlog 13 | logging_collector=on 14 | log_directory='/var/log/postgresql' 15 | log_filename='postgresql-%a.log' 16 | log_truncate_on_rotation=on 17 | autovacuum_freeze_max_age=2000000000 18 | wal_level=archive 19 | archive_mode=on 20 | archive_command='/bin/true' 21 | pg_qualstats.sample_rate = 0.1 22 | pg_qualstats.track_constants = off 23 | max_worker_processes=16 24 | max_locks_per_transaction=128 25 | -------------------------------------------------------------------------------- /docker/demo/supervisord.conf: -------------------------------------------------------------------------------- 1 | [supervisord] 2 | nodaemon=true 3 | user=root 4 | pidfile=/var/run/supervisord.pid 5 | logfile=/var/log/supervisor/supervisord.log 6 | childlogdir=/var/log/supervisor 7 | 8 | [unix_http_server] 9 | file=/var/run/supervisor.sock 10 | 11 | [rpcinterface:supervisor] 12 | supervisor.rpcinterface_factory = supervisor.rpcinterface:make_main_rpcinterface 13 | 14 | [supervisorctl] 15 | serverurl=unix:///var/run/supervisor.sock 16 | 17 | [program:init_supervisor] 18 | command=/pgwatch/bootstrap/init_supervisor.sh 19 | autorestart=false 20 | startsecs=0 21 | autostart=true 22 | redirect_stderr=true 23 | stdout_logfile=/dev/fd/1 24 | stdout_logfile_maxbytes=0 25 | 26 | [program:postgres] 27 | command=/usr/local/bin/docker-entrypoint.sh postgres -c config_file=/etc/postgresql/postgresql.conf 28 | startsecs=5 29 | priority=100 30 | stopsignal=INT 31 | autostart=false 32 | autorestart=false 33 | stdout_logfile=/dev/fd/1 34 | stdout_logfile_maxbytes=0 35 | redirect_stderr=true 36 | 37 | [program:pgwatch] 38 | command=/pgwatch/pgwatch --log-file=/var/log/pgwatch/pgwatch.log 39 | startsecs=5 40 | priority=300 41 | autostart=false 42 | autorestart=false 43 | redirect_stderr=true 44 | 45 | [program:grafana] 46 | command=/usr/sbin/grafana-server --homepath=/usr/share/grafana --pidfile=/var/run/grafana/grafana-server.pid --config=/etc/grafana/grafana.ini --packaging=deb cfg:default.paths.provisioning=/etc/grafana/provisioning cfg:default.paths.data=/var/lib/grafana cfg:default.paths.logs=/var/log/grafana cfg:default.paths.plugins=/var/lib/grafana/plugins 47 | user=grafana 48 | startsecs=5 49 | priority=500 50 | autostart=false 51 | autorestart=true 52 | redirect_stderr=true -------------------------------------------------------------------------------- /docker/docker-compose.timescaledb.yml: -------------------------------------------------------------------------------- 1 | include: 2 | - path: compose.timescaledb.yml 3 | - path: compose.pgpool.yml 4 | - path: compose.pgbouncer.yml 5 | - path: compose.grafana.yml 6 | - path: compose.pgwatch.yml 7 | - path: compose.pgadmin.yml 8 | - path: compose.prometheus.yml 9 | -------------------------------------------------------------------------------- /docker/docker-compose.yml: -------------------------------------------------------------------------------- 1 | include: 2 | - path: compose.postgres.yml 3 | - path: compose.pgpool.yml 4 | - path: compose.pgbouncer.yml 5 | - path: compose.grafana.yml 6 | - path: compose.pgwatch.yml 7 | - path: compose.pgadmin.yml 8 | - path: compose.prometheus.yml -------------------------------------------------------------------------------- /docker/test/launch_all_pg_versions/README_all_versions_testing.md: -------------------------------------------------------------------------------- 1 | # Docker launching scripts for metrics testing 2 | 3 | In this folder are scripts to launch Docker containers for all supported Postgres versions (9.0-12), optionally with replicas. 4 | By default standard Docker images are used with the following additions: 5 | 6 | * a volume named "pg$ver" is created 7 | * PL/Python is installed 8 | * "psutil" Python package is installed 9 | * "pg_stat_statements" extension is activated 10 | 11 | # PG version to container name and port mappings 12 | 13 | Postgres v11 container is launched under name "pg11" and exposed port will be 54311, i.e. following mapping is used: 14 | 15 | ``` 16 | for ver in {10..12} ; do 17 | echo "PG v${ver} => container: pg${ver}, port: 543${ver}" 18 | done 19 | 20 | 21 | PG v11 => container: pg11, port: 54311 22 | PG v12 => container: pg12, port: 54312 23 | ... 24 | PG v15 => container: pg15, port: 54315 25 | ``` 26 | 27 | Replica port = Master port + 1000 28 | 29 | # Speeding up testing 30 | 31 | If there's a need to constantly launch all images with replicas, it takes quite some time for "apt update/install" so it 32 | makes sense to do it once and then commit the changed containers into new images that can be then re-used, and adjust the 33 | POSTGRES_IMAGE_BASE variable in both launch scripts. 34 | 35 | ``` 36 | for x in {10..12} ; do 37 | ver="${x}" 38 | pgver="${x}" 39 | echo "docker commit pg${ver} postgres-pw3:${pgver}" 40 | docker commit pg${ver} postgres-pw3:${pgver} 41 | done 42 | ``` 43 | -------------------------------------------------------------------------------- /docker/test/launch_all_pg_versions/add_all_to_monitoring.sql: -------------------------------------------------------------------------------- 1 | /* primaries */ 2 | insert into pgwatch.monitored_db (md_unique_name, md_preset_config_name, md_config, md_hostname, md_port, md_dbname, md_user, md_password, md_is_superuser) 3 | select 'pg'||pgver, 'exhaustive', null, 'localhost', '543'||pgver, 'postgres', 'postgres', 'postgres', true 4 | from unnest(array[90,91,92,93,94,95,96,10,11,12]) as pgver 5 | where not exists ( 6 | select * from pgwatch.monitored_db where (md_unique_name, md_hostname, md_dbname) = ('pg'||pgver, 'localhost', 'postgres') 7 | ) 8 | ; 9 | 10 | /* replicas */ 11 | insert into pgwatch.monitored_db (md_unique_name, md_preset_config_name, md_config, md_hostname, md_port, md_dbname, md_user, md_password, md_is_superuser) 12 | select 'pg'||pgver||'_repl', 'exhaustive', null, 'localhost', ('543'||pgver)::int + 1000, 'postgres', 'postgres', 'postgres', true 13 | from unnest(array[90,91,92,93,94,95,96,10,11,12]) as pgver 14 | where not exists ( 15 | select * from pgwatch.monitored_db where (md_unique_name, md_hostname, md_dbname) = ('pg'||pgver||'_repl', 'localhost', 'postgres') 16 | ) 17 | ; 18 | -------------------------------------------------------------------------------- /docker/test/launch_all_pg_versions/get-all-pg-versions.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | for ver in {11..15} ; do 4 | docker pull postgres:$ver 5 | done 6 | -------------------------------------------------------------------------------- /docker/test/launch_all_pg_versions/pause-all-pg-versions.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | for ver in {11..15} ; do 4 | echo "pausing PG $ver ..." 5 | docker pause "pg${ver}" 6 | docker pause "pg${ver}-repl" 7 | done 8 | -------------------------------------------------------------------------------- /docker/test/launch_all_pg_versions/pgbench_on_all.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | DURATION=7200 4 | RATE=0.1 5 | SCALE=1 6 | DB=postgres 7 | 8 | for ver in {11..15} ; do 9 | 10 | echo "doing pgbench init for ${ver} ..." 11 | pgbench -h localhost -U postgres -p "543${ver}" -i $DB 12 | 13 | echo "launching pgbench for ${ver} ..." 14 | pgbench -h localhost -U postgres -p "543${ver}" -T $DURATION -R $RATE $DB & 15 | 16 | done 17 | 18 | echo "done. pgbench duration: $DURATION" -------------------------------------------------------------------------------- /docker/test/launch_all_pg_versions/purge-all-pg-versions.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | for ver in {11..15} ; do 4 | echo "stopping PG $ver ..." 5 | docker stop "pg${ver}" 6 | docker stop "pg${ver}-repl" 7 | 8 | echo "removing PG $ver ..." 9 | docker rm "pg${ver}" 10 | docker rm "pg${ver}-repl" 11 | 12 | echo "removing volumes for PG $ver ..." 13 | docker volume rm "pg${ver}" 14 | docker volume rm "pg${ver}-repl" 15 | done 16 | -------------------------------------------------------------------------------- /docker/test/launch_all_pg_versions/stop-all-pg-versions.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | for ver in {11..15} ; do 4 | echo "stopping PG $ver ..." 5 | docker stop "pg${ver}" 6 | docker stop "pg${ver}-repl" 7 | done 8 | -------------------------------------------------------------------------------- /docker/test/launch_all_pg_versions/unpause-all-pg-versions.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | for ver in {11..15} ; do 4 | echo "unpausing PG $ver ..." 5 | docker unpause "pg${ver}" 6 | docker unpause "pg${ver}-repl" 7 | done 8 | -------------------------------------------------------------------------------- /docs/CNAME: -------------------------------------------------------------------------------- 1 | pgwat.ch -------------------------------------------------------------------------------- /docs/_overrides/main.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %} 2 | 3 | {% block outdated %} 4 | You're not viewing the latest version. 5 | 6 | Click here to go to latest. 7 | 8 | {% endblock %} -------------------------------------------------------------------------------- /docs/concept/installation_options.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Installation options 3 | --- 4 | 5 | Besides freedom of choosing from a set of metric measurements storage options one can 6 | also choose how is the monitoring configuration 7 | (connect strings, metric sets and intervals) going to be stored. 8 | 9 | ## Configuration database based operation 10 | 11 | This is the original central pull mode depicted on the 12 | [architecture diagram](components.md#component-diagram). 13 | It requires a small schema to be rolled out on any Postgres 14 | database accessible to the metrics gathering daemon, which will hold the 15 | connect strings, metric definition SQLs and preset configurations and 16 | some other more minor attributes. For rollout details see the 17 | [custom installation](../tutorial/custom_installation.md) chapter. 18 | 19 | The default Docker demo image `cybertecpostgresql/pgwatch-demo` uses this approach. 20 | 21 | ## File based operation 22 | 23 | One can deploy the gatherer daemon(s) decentralized with 24 | *sources to be monitored* defined in simple YAML files. In that case there 25 | is no need for the central Postgres configuration database. See the 26 | [sample.sources.yaml](https://github.com/cybertec-postgresql/pgwatch/blob/master/contrib/sample.sources.yaml) 27 | config file for an example. 28 | 29 | !!! Note 30 | In this mode you also may want, but not forced, to point out the path to 31 | metric definition YAML file when starting the 32 | gatherer. Also note that the configuration system supports multiple 33 | YAML files in a folder so that you could easily programmatically manage 34 | things via *Ansible*, for example, and you can also use environment 35 | variables inside YAML files. 36 | -------------------------------------------------------------------------------- /docs/concept/kubernetes.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Kubernetes 3 | --- 4 | 5 | A basic Helm chart templates for installing pgwatch to a Kubernetes 6 | cluster are available as a standalone [repository](https://github.com/cybertec-postgresql/pgwatch-charts). 7 | 8 | !!! notice 9 | Charts are not considered as a part of pgwatch and 10 | are not maintained by pgwatch developers. 11 | 12 | The corresponding setup can be found in [repository](https://github.com/cybertec-postgresql/pgwatch-charts), 13 | whereas installation is done via the following commands: 14 | 15 | cd openshift_k8s 16 | helm install -f chart-values.yml pgwatch ./helm-chart 17 | 18 | Please have a look at `helm-chart/values.yaml` to get additional information of configurable options. 19 | -------------------------------------------------------------------------------- /docs/concept/web_ui.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: The Admin Web UI 3 | --- 4 | 5 | For easy configuration management (adding databases to monitoring, adding 6 | metrics) there is a Web application bundled. 7 | 8 | Besides managing the metrics gathering configurations, the two other 9 | useful features for the Web UI would be the possibility to look at the 10 | logs. 11 | 12 | Default port: **8080** 13 | 14 | Sample screenshot of the Web UI: 15 | 16 | [![A sample screenshot of the pgwatch admin Web UI](../gallery/webui_sources_grid.png)](../gallery/webui_sources_grid.png) 17 | 18 | ## Web UI security 19 | 20 | By default, the Web UI is not secured - anyone can view and modify the 21 | monitoring configuration. If some security is needed though it can be 22 | enabled: 23 | 24 | - HTTPS 25 | 26 | - Password protection is controlled by `--web-user`, `--web-password` command-line parameters or 27 | `PW_WEBUSER`, `PW_WEBPASSWORD` environmental variables. 28 | 29 | !!! Note 30 | It's better to use standard *LibPQ .pgpass files* so 31 | there's no requirement to store any passwords in pgwatch config 32 | database or YAML config file. 33 | 34 | For security sensitive environments make sure to always deploy password 35 | protection together with SSL, as it uses a standard cookie based 36 | techniques vulnerable to snooping / MITM attacks. 37 | -------------------------------------------------------------------------------- /docs/developer/LICENSE.md: -------------------------------------------------------------------------------- 1 | BSD 3-Clause License 2 | 3 | Copyright (c) 2022, CYBERTEC PostgreSQL International GmbH 4 | All rights reserved. 5 | 6 | Redistribution and use in source and binary forms, with or without 7 | modification, are permitted provided that the following conditions are met: 8 | 9 | * Redistributions of source code must retain the above copyright notice, this 10 | list of conditions and the following disclaimer. 11 | 12 | * Redistributions in binary form must reproduce the above copyright notice, 13 | this list of conditions and the following disclaimer in the documentation 14 | and/or other materials provided with the distribution. 15 | 16 | * Neither the name of the copyright holder nor the names of its 17 | contributors may be used to endorse or promote products derived from 18 | this software without specific prior written permission. 19 | 20 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" 21 | AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE 22 | IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 23 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE 24 | FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL 25 | DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR 26 | SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER 27 | CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, 28 | OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE 29 | OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 30 | -------------------------------------------------------------------------------- /docs/gallery/biggest_relations_treemap.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cybertec-postgresql/pgwatch/488977dba455f0d3e4a1611044aa2dc5d31f36ed/docs/gallery/biggest_relations_treemap.png -------------------------------------------------------------------------------- /docs/gallery/change_events.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cybertec-postgresql/pgwatch/488977dba455f0d3e4a1611044aa2dc5d31f36ed/docs/gallery/change_events.png -------------------------------------------------------------------------------- /docs/gallery/checkpointer_bgwriter.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cybertec-postgresql/pgwatch/488977dba455f0d3e4a1611044aa2dc5d31f36ed/docs/gallery/checkpointer_bgwriter.png -------------------------------------------------------------------------------- /docs/gallery/db_overview_time_lag_comparison.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cybertec-postgresql/pgwatch/488977dba455f0d3e4a1611044aa2dc5d31f36ed/docs/gallery/db_overview_time_lag_comparison.png -------------------------------------------------------------------------------- /docs/gallery/global_health.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cybertec-postgresql/pgwatch/488977dba455f0d3e4a1611044aa2dc5d31f36ed/docs/gallery/global_health.png -------------------------------------------------------------------------------- /docs/gallery/health_check.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cybertec-postgresql/pgwatch/488977dba455f0d3e4a1611044aa2dc5d31f36ed/docs/gallery/health_check.png -------------------------------------------------------------------------------- /docs/gallery/index_overview.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cybertec-postgresql/pgwatch/488977dba455f0d3e4a1611044aa2dc5d31f36ed/docs/gallery/index_overview.png -------------------------------------------------------------------------------- /docs/gallery/overview.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cybertec-postgresql/pgwatch/488977dba455f0d3e4a1611044aa2dc5d31f36ed/docs/gallery/overview.png -------------------------------------------------------------------------------- /docs/gallery/overview_developer.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cybertec-postgresql/pgwatch/488977dba455f0d3e4a1611044aa2dc5d31f36ed/docs/gallery/overview_developer.png -------------------------------------------------------------------------------- /docs/gallery/pgbouncer_stats.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cybertec-postgresql/pgwatch/488977dba455f0d3e4a1611044aa2dc5d31f36ed/docs/gallery/pgbouncer_stats.png -------------------------------------------------------------------------------- /docs/gallery/pgpool_status.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cybertec-postgresql/pgwatch/488977dba455f0d3e4a1611044aa2dc5d31f36ed/docs/gallery/pgpool_status.png -------------------------------------------------------------------------------- /docs/gallery/pgwatch_architecture.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cybertec-postgresql/pgwatch/488977dba455f0d3e4a1611044aa2dc5d31f36ed/docs/gallery/pgwatch_architecture.jpg -------------------------------------------------------------------------------- /docs/gallery/pgwatch_architecture_no_config.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cybertec-postgresql/pgwatch/488977dba455f0d3e4a1611044aa2dc5d31f36ed/docs/gallery/pgwatch_architecture_no_config.png -------------------------------------------------------------------------------- /docs/gallery/postres_versions_overview.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cybertec-postgresql/pgwatch/488977dba455f0d3e4a1611044aa2dc5d31f36ed/docs/gallery/postres_versions_overview.png -------------------------------------------------------------------------------- /docs/gallery/recommendations.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cybertec-postgresql/pgwatch/488977dba455f0d3e4a1611044aa2dc5d31f36ed/docs/gallery/recommendations.png -------------------------------------------------------------------------------- /docs/gallery/replication_lag.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cybertec-postgresql/pgwatch/488977dba455f0d3e4a1611044aa2dc5d31f36ed/docs/gallery/replication_lag.png -------------------------------------------------------------------------------- /docs/gallery/server_log_events.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cybertec-postgresql/pgwatch/488977dba455f0d3e4a1611044aa2dc5d31f36ed/docs/gallery/server_log_events.png -------------------------------------------------------------------------------- /docs/gallery/show_plans_realtime.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cybertec-postgresql/pgwatch/488977dba455f0d3e4a1611044aa2dc5d31f36ed/docs/gallery/show_plans_realtime.png -------------------------------------------------------------------------------- /docs/gallery/stat_activity_realtime.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cybertec-postgresql/pgwatch/488977dba455f0d3e4a1611044aa2dc5d31f36ed/docs/gallery/stat_activity_realtime.png -------------------------------------------------------------------------------- /docs/gallery/stat_statements_sql_search.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cybertec-postgresql/pgwatch/488977dba455f0d3e4a1611044aa2dc5d31f36ed/docs/gallery/stat_statements_sql_search.png -------------------------------------------------------------------------------- /docs/gallery/stat_statements_top.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cybertec-postgresql/pgwatch/488977dba455f0d3e4a1611044aa2dc5d31f36ed/docs/gallery/stat_statements_top.png -------------------------------------------------------------------------------- /docs/gallery/stat_statements_top_visual.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cybertec-postgresql/pgwatch/488977dba455f0d3e4a1611044aa2dc5d31f36ed/docs/gallery/stat_statements_top_visual.png -------------------------------------------------------------------------------- /docs/gallery/system_stats_psutil.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cybertec-postgresql/pgwatch/488977dba455f0d3e4a1611044aa2dc5d31f36ed/docs/gallery/system_stats_psutil.png -------------------------------------------------------------------------------- /docs/gallery/tables_top.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cybertec-postgresql/pgwatch/488977dba455f0d3e4a1611044aa2dc5d31f36ed/docs/gallery/tables_top.png -------------------------------------------------------------------------------- /docs/gallery/webui.md: -------------------------------------------------------------------------------- 1 | # Web User Interface 2 | 3 | The Web User Interface (WebUI) allows you to interact with the pgwatch and control monitored sources, metrics and presets 4 | definitions, and view and logs. 5 | 6 | Sources 7 | ![Sources](webui_sources_grid.png){data-gallery="webui"} 8 | 9 | Metrics 10 | ![Metrics](webui_metrics_grid.png){data-gallery="webui"} 11 | 12 | Presets 13 | ![Presets](webui_presets_grid.png){data-gallery="webui"} 14 | 15 | Logs 16 | ![Logs](webui_logs.png){data-gallery="webui"} -------------------------------------------------------------------------------- /docs/gallery/webui_logs.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cybertec-postgresql/pgwatch/488977dba455f0d3e4a1611044aa2dc5d31f36ed/docs/gallery/webui_logs.png -------------------------------------------------------------------------------- /docs/gallery/webui_metrics_grid.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cybertec-postgresql/pgwatch/488977dba455f0d3e4a1611044aa2dc5d31f36ed/docs/gallery/webui_metrics_grid.png -------------------------------------------------------------------------------- /docs/gallery/webui_presets_grid.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cybertec-postgresql/pgwatch/488977dba455f0d3e4a1611044aa2dc5d31f36ed/docs/gallery/webui_presets_grid.png -------------------------------------------------------------------------------- /docs/gallery/webui_sources_grid.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cybertec-postgresql/pgwatch/488977dba455f0d3e4a1611044aa2dc5d31f36ed/docs/gallery/webui_sources_grid.png -------------------------------------------------------------------------------- /docs/intro/features.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: List of main features 3 | --- 4 | 5 | - Non-invasive setup on PostgreSQL side - no extensions nor superuser 6 | rights are required for the base functionality so that even 7 | unprivileged users like developers can get a good overview of 8 | database activities without any hassle 9 | - Lots of preset metric configurations covering all performance 10 | critical PostgreSQL internal Statistics Collector data 11 | - Intuitive metrics presentation using a set of predefined dashboards 12 | for the very popular Grafana dashboarding engine with optional 13 | alerting support 14 | - Easy extensibility of metrics which are defined in pure SQL, thus 15 | they could also be from the business domain 16 | - Many metric data storage options - PostgreSQL, PostgreSQL with the 17 | compression enabled TimescaleDB extension, or Prometheus scraping 18 | - Multiple deployment options - PostgreSQL configuration DB, YAML or 19 | ENV configuration 20 | - Possible to monitoring all, single or a subset (list or regex) of 21 | databases of a PostgreSQL instance 22 | - Global or per DB configuration of metrics and metric fetching 23 | intervals 24 | - Kubernetes/OpenShift ready with sample templates and a Helm chart 25 | - PgBouncer, Pgpool2, AWS RDS and Patroni support with automatic 26 | member discovery 27 | - Internal REST API to monitor metrics gathering status remotely 28 | - Built-in security with SSL connections support for all components 29 | and passwords encryption for connect strings 30 | - Very low resource requirements for the collector even when 31 | monitoring hundreds of instances 32 | - Capabilities to go beyond PostgreSQL metrics gathering with built-in 33 | log parsing for error detection and OS level metrics collection via 34 | PL/Python "helper" stored procedures -------------------------------------------------------------------------------- /docs/intro/project_background.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Project background 3 | --- 4 | 5 | The pgwatch project got started back in 2016 by Kaarel Moppel and released in 2017 6 | initially for internal monitoring needs at Cybertec as all the Open 7 | Source PostgreSQL monitoring tools at the time had various limitations 8 | like being too slow and invasive to set up or providing a fixed set of 9 | visuals and metrics. 10 | 11 | For more background on the project motivations and design goals see the 12 | original series of blogposts announcing the project and the following 13 | feature updates released approximately twice per year. 14 | 15 | Cybertec also provides commercial 9-to-5 and 24/7 support for pgwatch. 16 | 17 | - [Project 18 | announcement](https://www.cybertec-postgresql.com/en/announcing-pgwatch2-a-simple-but-versatile-postgresql-monitoring-tool/) 19 | - [Implementation 20 | details](https://www.cybertec-postgresql.com/en/a-more-detailed-look-at-pgwatch2-postgresql-monitoring-tool/) 21 | - [Feature pack 22 | 1](https://www.cybertec-postgresql.com/en/new-features-for-cybertecs-pgwatch2-postgres-monitoring-tool/) 23 | - [Feature pack 24 | 2](https://www.cybertec-postgresql.com/en/updates-for-the-pgwatch2-postgres-monitoring-tool/) 25 | - [Feature pack 26 | 3](https://www.cybertec-postgresql.com/en/pgwatch2-feature-pack-3/) 27 | - [Feature pack 28 | 4](https://www.cybertec-postgresql.com/en/major-feature-update-for-the-pgwatch2-postgres-monitoring-tool/) 29 | - [Feature pack 30 | 5](https://www.cybertec-postgresql.com/en/version-1-6-of-pgwatch2-postgresql-monitoring-tool-released/) 31 | - [Feature pack 32 | 6](https://www.cybertec-postgresql.com/en/pgwatch2-v1-7-0-released/) 33 | - [Feature pack 34 | 7](https://www.cybertec-postgresql.com/en/pgwatch2-v1-8-0-released/) 35 | 36 | # Project feedback 37 | 38 | For feature requests or troubleshooting assistance please open an issue 39 | on project's [Github 40 | page](https://github.com/cybertec-postgresql/pgwatch). 41 | -------------------------------------------------------------------------------- /docs/reference/env_variables.md: -------------------------------------------------------------------------------- 1 | # Available env. variables by components 2 | 3 | Some variables influence multiple components. Command line parameters override env. variables (when doing custom deployments). 4 | 5 | ## Docker image specific 6 | 7 | - **PW_TESTDB** When set, the config DB itself will be added to monitoring as "test". Default: - 8 | 9 | ## Gatherer daemon 10 | 11 | See `pgwatch --help` output for details. 12 | 13 | ## Grafana 14 | 15 | - **PW_GRAFANANOANONYMOUS** Can be set to require login even for viewing dashboards. Default: - 16 | - **PW_GRAFANAUSER** Administrative user. Default: admin 17 | - **PW_GRAFANAPASSWORD** Administrative user password. Default: pgwatchadmin 18 | - **PW_GRAFANASSL** Use SSL. Default: - 19 | - **PW_GRAFANA_BASEURL** For linking to Grafana "Query details" dashboard from "Stat_stmt. overview". Default: 20 | -------------------------------------------------------------------------------- /grafana/dashboards.yml: -------------------------------------------------------------------------------- 1 | apiVersion: 1 2 | 3 | providers: 4 | - name: 'default' 5 | orgId: 1 6 | folder: '' 7 | type: 'file' 8 | disableDeletion: false 9 | updateIntervalSeconds: 10 #how often Grafana will scan for changed dashboards 10 | options: 11 | path: /var/lib/grafana/dashboards 12 | -------------------------------------------------------------------------------- /grafana/postgres_datasource.yml: -------------------------------------------------------------------------------- 1 | apiVersion: 1 2 | 3 | datasources: 4 | - name: pgwatch metrics 5 | uid: pgwatch-metrics 6 | type: postgres 7 | url: postgres:5432 8 | access: proxy 9 | password: pgwatchadmin 10 | user: pgwatch 11 | database: pgwatch_metrics 12 | basicAuth: false 13 | isDefault: true 14 | jsonData: 15 | sslmode: disable 16 | postgresVersion: 1500 17 | version: 1 18 | editable: true 19 | -------------------------------------------------------------------------------- /grafana/prometheus_datasource.yml: -------------------------------------------------------------------------------- 1 | apiVersion: 1 2 | 3 | datasources: 4 | - name: pg-metrics 5 | type: prometheus 6 | url: http://prometheus:9090 7 | access: proxy 8 | basicAuth: false 9 | isDefault: true 10 | version: 1 11 | editable: true -------------------------------------------------------------------------------- /internal/build.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # Enable trace mode 4 | set -x 5 | 6 | GIT_HASH=$(git show -s --format=%H HEAD) 7 | GIT_TIME=$(git show -s --format=%cI HEAD) 8 | VERSION=$(git rev-parse --abbrev-ref HEAD) 9 | go build -ldflags "-X 'main.commit=$GIT_HASH' -X 'main.date=$GIT_TIME' -X 'main.version=$VERSION'" 10 | -------------------------------------------------------------------------------- /internal/cmdopts/cmdoptions_test.go: -------------------------------------------------------------------------------- 1 | package cmdopts 2 | 3 | import ( 4 | "os" 5 | "testing" 6 | 7 | "github.com/cybertec-postgresql/pgwatch/v3/internal/log" 8 | flags "github.com/jessevdk/go-flags" 9 | "github.com/stretchr/testify/assert" 10 | ) 11 | 12 | // NewCmdOptions returns a new instance of CmdOptions with default values 13 | func NewCmdOptions(args ...string) *Options { 14 | cmdOpts := new(Options) 15 | _, _ = flags.NewParser(cmdOpts, flags.PrintErrors).ParseArgs(args) 16 | return cmdOpts 17 | } 18 | 19 | func TestParseFail(t *testing.T) { 20 | tests := [][]string{ 21 | {0: "go-test", "--unknown-option"}, 22 | {0: "go-test", "-c", "client01", "-f", "foo"}, 23 | } 24 | for _, d := range tests { 25 | os.Args = d 26 | _, err := New(nil) 27 | assert.Error(t, err) 28 | } 29 | } 30 | 31 | func TestParseSuccess(t *testing.T) { 32 | tests := [][]string{ 33 | {0: "go-test", "--help"}, 34 | } 35 | for _, d := range tests { 36 | os.Args = d 37 | c, err := New(nil) 38 | assert.True(t, c.Help) 39 | assert.Error(t, err) 40 | } 41 | } 42 | 43 | func TestLogLevel(t *testing.T) { 44 | c := &Options{Logging: log.CmdOpts{LogLevel: "debug"}} 45 | assert.True(t, c.Verbose()) 46 | c = &Options{Logging: log.CmdOpts{LogLevel: "info"}} 47 | assert.False(t, c.Verbose()) 48 | } 49 | 50 | func TestNewCmdOptions(t *testing.T) { 51 | c := NewCmdOptions("-c", "config_unit_test", "--password=somestrong") 52 | assert.NotNil(t, c) 53 | } 54 | 55 | func TestConfig(t *testing.T) { 56 | os.Args = []string{0: "config_test", "--sources=sample.config.yaml"} 57 | _, err := New(nil) 58 | assert.NoError(t, err) 59 | 60 | os.Args = []string{0: "config_test", "--unknown"} 61 | _, err = New(nil) 62 | assert.Error(t, err) 63 | 64 | os.Args = []string{0: "config_test"} // sources arg is missing, but set PW3_CONFIG 65 | t.Setenv("PW_SOURCES", "postgresql://foo:baz@bar/test") 66 | _, err = New(nil) 67 | assert.NoError(t, err) 68 | } 69 | -------------------------------------------------------------------------------- /internal/cmdopts/doc.go: -------------------------------------------------------------------------------- 1 | // Package cmdopts provides functionality to parse command line options and ENV variables. 2 | package cmdopts 3 | -------------------------------------------------------------------------------- /internal/db/conn.go: -------------------------------------------------------------------------------- 1 | package db 2 | 3 | import ( 4 | "context" 5 | "encoding/json" 6 | "reflect" 7 | 8 | pgx "github.com/jackc/pgx/v5" 9 | pgconn "github.com/jackc/pgx/v5/pgconn" 10 | pgxpool "github.com/jackc/pgx/v5/pgxpool" 11 | ) 12 | 13 | type Querier interface { 14 | Query(ctx context.Context, query string, args ...interface{}) (pgx.Rows, error) 15 | } 16 | 17 | // PgxIface is common interface for every pgx class 18 | type PgxIface interface { 19 | Begin(ctx context.Context) (pgx.Tx, error) 20 | Exec(context.Context, string, ...interface{}) (pgconn.CommandTag, error) 21 | QueryRow(context.Context, string, ...interface{}) pgx.Row 22 | Query(ctx context.Context, query string, args ...interface{}) (pgx.Rows, error) 23 | CopyFrom(ctx context.Context, tableName pgx.Identifier, columnNames []string, rowSrc pgx.CopyFromSource) (int64, error) 24 | } 25 | 26 | // PgxConnIface is interface representing pgx connection 27 | type PgxConnIface interface { 28 | PgxIface 29 | BeginTx(ctx context.Context, txOptions pgx.TxOptions) (pgx.Tx, error) 30 | Close(ctx context.Context) error 31 | Ping(ctx context.Context) error 32 | } 33 | 34 | // PgxPoolIface is interface representing pgx pool 35 | type PgxPoolIface interface { 36 | PgxIface 37 | Acquire(ctx context.Context) (*pgxpool.Conn, error) 38 | BeginTx(ctx context.Context, txOptions pgx.TxOptions) (pgx.Tx, error) 39 | Close() 40 | Config() *pgxpool.Config 41 | Ping(ctx context.Context) error 42 | Stat() *pgxpool.Stat 43 | } 44 | 45 | func MarshallParamToJSONB(v any) any { 46 | if v == nil { 47 | return nil 48 | } 49 | val := reflect.ValueOf(v) 50 | switch val.Kind() { 51 | case reflect.Map, reflect.Slice: 52 | if val.Len() == 0 { 53 | return nil 54 | } 55 | case reflect.Struct: 56 | if reflect.DeepEqual(v, reflect.Zero(val.Type()).Interface()) { 57 | return nil 58 | } 59 | } 60 | if b, err := json.Marshal(v); err == nil { 61 | return string(b) 62 | } 63 | return nil 64 | } 65 | -------------------------------------------------------------------------------- /internal/db/conn_test.go: -------------------------------------------------------------------------------- 1 | package db_test 2 | 3 | import ( 4 | "reflect" 5 | "testing" 6 | 7 | "github.com/cybertec-postgresql/pgwatch/v3/internal/db" 8 | ) 9 | 10 | func TestMarshallParam(t *testing.T) { 11 | tests := []struct { 12 | name string 13 | v any 14 | want any 15 | }{ 16 | { 17 | name: "nil", 18 | v: nil, 19 | want: nil, 20 | }, 21 | { 22 | name: "empty map", 23 | v: map[string]string{}, 24 | want: nil, 25 | }, 26 | { 27 | name: "empty slice", 28 | v: []string{}, 29 | want: nil, 30 | }, 31 | { 32 | name: "empty struct", 33 | v: struct{}{}, 34 | want: nil, 35 | }, 36 | { 37 | name: "non-empty map", 38 | v: map[string]string{"key": "value"}, 39 | want: `{"key":"value"}`, 40 | }, 41 | { 42 | name: "non-empty slice", 43 | v: []string{"value"}, 44 | want: `["value"]`, 45 | }, 46 | { 47 | name: "non-empty struct", 48 | v: struct{ Key string }{Key: "value"}, 49 | want: `{"Key":"value"}`, 50 | }, 51 | { 52 | name: "non-marshallable", 53 | v: make(chan struct{}), 54 | want: nil, 55 | }, 56 | } 57 | for _, tt := range tests { 58 | t.Run(tt.name, func(t *testing.T) { 59 | if got := db.MarshallParamToJSONB(tt.v); !reflect.DeepEqual(got, tt.want) { 60 | t.Errorf("MarshallParamToJSONB() = %v, want %v", got, tt.want) 61 | } 62 | }) 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /internal/db/doc.go: -------------------------------------------------------------------------------- 1 | // Package db provides common functionality to work with databases. 2 | package db 3 | -------------------------------------------------------------------------------- /internal/log/cmdopts.go: -------------------------------------------------------------------------------- 1 | package log 2 | 3 | // CmdOpts specifies the logging command-line options 4 | type CmdOpts struct { 5 | LogLevel string `short:"v" long:"log-level" mapstructure:"log-level" description:"Verbosity level for stdout and log file" choice:"debug" choice:"info" choice:"error" default:"info"` 6 | LogFile string `long:"log-file" mapstructure:"log-file" description:"File name to store logs"` 7 | LogFileFormat string `long:"log-file-format" mapstructure:"log-file-format" description:"Format of file logs" choice:"json" choice:"text" default:"json"` 8 | LogFileRotate bool `long:"log-file-rotate" mapstructure:"log-file-rotate" description:"Rotate log files"` 9 | LogFileSize int `long:"log-file-size" mapstructure:"log-file-size" description:"Maximum size in MB of the log file before it gets rotated" default:"100"` 10 | LogFileAge int `long:"log-file-age" mapstructure:"log-file-age" description:"Number of days to retain old log files, 0 means forever" default:"0"` 11 | LogFileNumber int `long:"log-file-number" mapstructure:"log-file-number" description:"Maximum number of old log files to retain, 0 to retain all" default:"0"` 12 | } 13 | -------------------------------------------------------------------------------- /internal/log/doc.go: -------------------------------------------------------------------------------- 1 | // Package log contains the logger interface and its implementation used in the project. 2 | package log 3 | -------------------------------------------------------------------------------- /internal/log/log_broker_hook_test.go: -------------------------------------------------------------------------------- 1 | package log 2 | 3 | import ( 4 | "context" 5 | "testing" 6 | 7 | "github.com/stretchr/testify/assert" 8 | ) 9 | 10 | func TestRemoveSubscriber(t *testing.T) { 11 | hook := NewBrokerHook(context.Background(), "info") 12 | msgChan1 := make(MessageChanType) 13 | msgChan2 := make(MessageChanType) 14 | hook.AddSubscriber(msgChan1) 15 | hook.AddSubscriber(msgChan2) 16 | assert.Equal(t, 2, len(hook.subscribers)) 17 | 18 | // Remove the first subscriber 19 | hook.RemoveSubscriber(msgChan1) 20 | assert.Equal(t, 1, len(hook.subscribers)) 21 | 22 | // Remove the last subscriber, this is where the "index out of range" error occurs 23 | hook.RemoveSubscriber(msgChan2) 24 | assert.Equal(t, 0, len(hook.subscribers)) 25 | } 26 | -------------------------------------------------------------------------------- /internal/log/log_file_test.go: -------------------------------------------------------------------------------- 1 | package log 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/sirupsen/logrus" 7 | "github.com/stretchr/testify/assert" 8 | "gopkg.in/natefinch/lumberjack.v2" 9 | ) 10 | 11 | func TestGetLogFileWriter(t *testing.T) { 12 | assert.IsType(t, getLogFileWriter(CmdOpts{LogFileRotate: true}), &lumberjack.Logger{}) 13 | assert.IsType(t, getLogFileWriter(CmdOpts{LogFileRotate: false}), "string") 14 | } 15 | 16 | func TestGetLogFileFormatter(t *testing.T) { 17 | assert.IsType(t, getLogFileFormatter(CmdOpts{LogFileFormat: "json"}), &logrus.JSONFormatter{}) 18 | assert.IsType(t, getLogFileFormatter(CmdOpts{LogFileFormat: "blah"}), &logrus.JSONFormatter{}) 19 | assert.IsType(t, getLogFileFormatter(CmdOpts{LogFileFormat: "text"}), &Formatter{}) 20 | } 21 | -------------------------------------------------------------------------------- /internal/log/log_test.go: -------------------------------------------------------------------------------- 1 | package log_test 2 | 3 | import ( 4 | "context" 5 | "os" 6 | "testing" 7 | 8 | "github.com/cybertec-postgresql/pgwatch/v3/internal/log" 9 | "github.com/jackc/pgx/v5/tracelog" 10 | "github.com/stretchr/testify/assert" 11 | ) 12 | 13 | func TestInit(t *testing.T) { 14 | assert.NotNil(t, log.Init(log.CmdOpts{LogLevel: "debug"})) 15 | l := log.Init(log.CmdOpts{LogLevel: "foobar"}) 16 | pgxl := log.NewPgxLogger(l) 17 | assert.NotNil(t, pgxl) 18 | ctx := log.WithLogger(context.Background(), l) 19 | assert.True(t, log.GetLogger(ctx) == l) 20 | assert.True(t, log.GetLogger(context.Background()) == log.FallbackLogger) 21 | } 22 | 23 | func TestFileLogger(t *testing.T) { 24 | l := log.Init(log.CmdOpts{LogLevel: "debug", LogFile: "test.log", LogFileFormat: "text"}) 25 | l.Info("test") 26 | assert.FileExists(t, "test.log", "Log file should be created") 27 | _ = os.Remove("test.log") 28 | } 29 | 30 | func TestPgxLog(_ *testing.T) { 31 | pgxl := log.NewPgxLogger(log.Init(log.CmdOpts{LogLevel: "trace"})) 32 | var level tracelog.LogLevel 33 | for level = tracelog.LogLevelNone; level <= tracelog.LogLevelTrace; level++ { 34 | pgxl.Log(context.Background(), level, "foo", map[string]interface{}{"func": "TestPgxLog"}) 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /internal/metrics/cmdopts.go: -------------------------------------------------------------------------------- 1 | package metrics 2 | 3 | import ( 4 | "time" 5 | ) 6 | 7 | // CmdOpts specifies metric command-line options 8 | type CmdOpts struct { 9 | Metrics string `short:"m" long:"metrics" mapstructure:"metrics" description:"Postgres URI or path to YAML file with metrics definitions" env:"PW_METRICS"` 10 | DirectOSStats bool `long:"direct-os-stats" mapstructure:"direct-os-stats" description:"Extract OS related psutil statistics not via PL/Python wrappers but directly on host" env:"PW_DIRECT_OS_STATS"` 11 | InstanceLevelCacheMaxSeconds int64 `long:"instance-level-cache-max-seconds" mapstructure:"instance-level-cache-max-seconds" description:"Max allowed staleness for instance level metric data shared between DBs of an instance. Set to 0 to disable" env:"PW_INSTANCE_LEVEL_CACHE_MAX_SECONDS" default:"30"` 12 | EmergencyPauseTriggerfile string `long:"emergency-pause-triggerfile" mapstructure:"emergency-pause-triggerfile" description:"When the file exists no metrics will be temporarily fetched" env:"PW_EMERGENCY_PAUSE_TRIGGERFILE" default:"/tmp/pgwatch-emergency-pause"` 13 | } 14 | 15 | func (c CmdOpts) CacheAge() time.Duration { 16 | if c.InstanceLevelCacheMaxSeconds < 0 { 17 | c.InstanceLevelCacheMaxSeconds = 0 18 | } 19 | return time.Duration(c.InstanceLevelCacheMaxSeconds) * time.Second 20 | } 21 | -------------------------------------------------------------------------------- /internal/metrics/cmdopts_test.go: -------------------------------------------------------------------------------- 1 | package metrics 2 | 3 | import ( 4 | "testing" 5 | "time" 6 | 7 | "github.com/stretchr/testify/assert" 8 | ) 9 | 10 | func TestCacheAge(t *testing.T) { 11 | tests := []struct { 12 | name string 13 | opts CmdOpts 14 | expected time.Duration 15 | }{ 16 | { 17 | name: "Cache enabled with positive value", 18 | opts: CmdOpts{InstanceLevelCacheMaxSeconds: 30}, 19 | expected: 30 * time.Second, 20 | }, 21 | { 22 | name: "Cache disabled with zero value", 23 | opts: CmdOpts{InstanceLevelCacheMaxSeconds: 0}, 24 | expected: 0, 25 | }, 26 | { 27 | name: "Cache disable with incorrect value", 28 | opts: CmdOpts{InstanceLevelCacheMaxSeconds: -30}, 29 | expected: 0, 30 | }, 31 | } 32 | 33 | for _, tt := range tests { 34 | t.Run(tt.name, func(t *testing.T) { 35 | assert.Equal(t, tt.expected, tt.opts.CacheAge()) 36 | }) 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /internal/metrics/default.go: -------------------------------------------------------------------------------- 1 | package metrics 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | ) 7 | 8 | func GetDefaultBuiltInMetrics() []string { 9 | return []string{"sproc_changes", "table_changes", "index_changes", "privilege_changes", "object_changes", "configuration_changes"} 10 | } 11 | 12 | // NewDefaultMetricReader creates a new default metric reader with an empty path. 13 | func NewDefaultMetricReader(context.Context) (ReaderWriter, error) { 14 | return &defaultMetricReader{}, nil 15 | } 16 | 17 | func GetDefaultMetrics() (metrics *Metrics) { 18 | defMetricReader := &fileMetricReader{} 19 | metrics, _ = defMetricReader.GetMetrics() 20 | return 21 | } 22 | 23 | type defaultMetricReader struct{} 24 | 25 | func (dmrw *defaultMetricReader) WriteMetrics(*Metrics) error { 26 | return errors.ErrUnsupported 27 | } 28 | 29 | func (dmrw *defaultMetricReader) DeleteMetric(string) error { 30 | return errors.ErrUnsupported 31 | } 32 | 33 | func (dmrw *defaultMetricReader) UpdateMetric(string, Metric) error { 34 | return errors.ErrUnsupported 35 | } 36 | 37 | func (dmrw *defaultMetricReader) DeletePreset(string) error { 38 | return errors.ErrUnsupported 39 | } 40 | 41 | func (dmrw *defaultMetricReader) UpdatePreset(string, Preset) error { 42 | return errors.ErrUnsupported 43 | } 44 | 45 | func (dmrw *defaultMetricReader) GetMetrics() (*Metrics, error) { 46 | return GetDefaultMetrics(), nil 47 | } 48 | -------------------------------------------------------------------------------- /internal/metrics/default_test.go: -------------------------------------------------------------------------------- 1 | package metrics_test 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "testing" 7 | 8 | "github.com/cybertec-postgresql/pgwatch/v3/internal/metrics" 9 | "github.com/stretchr/testify/assert" 10 | ) 11 | 12 | func TestGetDefaultBuiltInMetrics(t *testing.T) { 13 | expectedMetrics := []string{"sproc_changes", "table_changes", "index_changes", "privilege_changes", "object_changes", "configuration_changes"} 14 | metrics := metrics.GetDefaultBuiltInMetrics() 15 | assert.Equal(t, expectedMetrics, metrics, "The default built-in metrics should match the expected list") 16 | } 17 | 18 | func TestNewDefaultMetricReader(t *testing.T) { 19 | reader, err := metrics.NewDefaultMetricReader(context.Background()) 20 | assert.NoError(t, err, "Creating a new default metric reader should not produce an error") 21 | assert.NotNil(t, reader, "The metric reader should not be nil") 22 | } 23 | 24 | func TestDefaultMetricReaderUnsupportedOperations(t *testing.T) { 25 | reader, _ := metrics.NewDefaultMetricReader(context.Background()) 26 | err := reader.WriteMetrics(nil) 27 | assert.Equal(t, errors.ErrUnsupported, err, "WriteMetrics should return ErrUnsupported") 28 | 29 | err = reader.DeleteMetric("") 30 | assert.Equal(t, errors.ErrUnsupported, err, "DeleteMetric should return ErrUnsupported") 31 | 32 | err = reader.UpdateMetric("", metrics.Metric{}) 33 | assert.Equal(t, errors.ErrUnsupported, err, "UpdateMetric should return ErrUnsupported") 34 | 35 | err = reader.DeletePreset("") 36 | assert.Equal(t, errors.ErrUnsupported, err, "DeletePreset should return ErrUnsupported") 37 | 38 | err = reader.UpdatePreset("", metrics.Preset{}) 39 | assert.Equal(t, errors.ErrUnsupported, err, "UpdatePreset should return ErrUnsupported") 40 | 41 | metrics, err := reader.GetMetrics() 42 | assert.NotNil(t, metrics, "The metrics object should not be nil") 43 | assert.NoError(t, err, "GetMetrics should return default metrics") 44 | } 45 | -------------------------------------------------------------------------------- /internal/metrics/doc.go: -------------------------------------------------------------------------------- 1 | // Package metrics is responsible for reading and writing metric definitions. 2 | // 3 | // At the moment, metric definitions support two storages: 4 | // - PostgreSQL database 5 | // - YAML file 6 | // 7 | // # Content 8 | // 9 | // - `postgres*.go` files cover the functionality for the PostgreSQL database. 10 | // - `yaml*.go` files cover the functionality for the YAML file. 11 | // - `metrics.yaml` holds all default metrics and presets. 12 | // - `default.go` provides access to default metrics. 13 | package metrics 14 | -------------------------------------------------------------------------------- /internal/metrics/postgres_schema_test.go: -------------------------------------------------------------------------------- 1 | package metrics 2 | 3 | import ( 4 | "context" 5 | "testing" 6 | 7 | migrator "github.com/cybertec-postgresql/pgx-migrator" 8 | "github.com/pashagolub/pgxmock/v4" 9 | "github.com/stretchr/testify/assert" 10 | ) 11 | 12 | var ctx = context.Background() 13 | 14 | func TestMigrate(t *testing.T) { 15 | a := assert.New(t) 16 | conn, err := pgxmock.NewPool() 17 | a.NoError(err) 18 | 19 | conn.ExpectExec(`CREATE TABLE IF NOT EXISTS pgwatch\.migration`).WillReturnResult(pgxmock.NewResult("CREATE", 1)) 20 | conn.ExpectQuery(`SELECT count`).WillReturnRows(pgxmock.NewRows([]string{"count"}).AddRow(0)) 21 | conn.ExpectBegin() 22 | conn.ExpectExec(`INSERT INTO`).WillReturnResult(pgxmock.NewResult("INSERT", 1)) 23 | 24 | dmrw := &dbMetricReaderWriter{ctx, conn} 25 | err = dmrw.Migrate() 26 | a.NoError(err) 27 | } 28 | 29 | func TestNeedsMigration(t *testing.T) { 30 | a := assert.New(t) 31 | conn, err := pgxmock.NewPool() 32 | a.NoError(err) 33 | 34 | conn.ExpectQuery(`SELECT to_regclass`). 35 | WithArgs("pgwatch.migration"). 36 | WillReturnRows(pgxmock.NewRows([]string{"to_regclass"}).AddRow(true)) 37 | conn.ExpectQuery(`SELECT count`).WillReturnRows(pgxmock.NewRows([]string{"count"}).AddRow(0)) 38 | 39 | dmrw := &dbMetricReaderWriter{ctx, conn} 40 | needs, err := dmrw.NeedsMigration() 41 | a.NoError(err) 42 | a.True(needs) 43 | } 44 | 45 | func TestMigrateFail(t *testing.T) { 46 | oldInitMigrator := initMigrator 47 | t.Cleanup(func() { 48 | initMigrator = oldInitMigrator 49 | }) 50 | a := assert.New(t) 51 | dmrw := &dbMetricReaderWriter{} 52 | initMigrator = func(*dbMetricReaderWriter) (*migrator.Migrator, error) { 53 | return nil, assert.AnError 54 | } 55 | err := dmrw.Migrate() 56 | a.Error(err) 57 | } 58 | 59 | func TestNeedsMigrationFail(t *testing.T) { 60 | oldInitMigrator := initMigrator 61 | t.Cleanup(func() { 62 | initMigrator = oldInitMigrator 63 | }) 64 | a := assert.New(t) 65 | dmrw := &dbMetricReaderWriter{} 66 | initMigrator = func(*dbMetricReaderWriter) (*migrator.Migrator, error) { 67 | return nil, assert.AnError 68 | } 69 | _, err := dmrw.NeedsMigration() 70 | a.Error(err) 71 | } 72 | -------------------------------------------------------------------------------- /internal/metrics/types_test.go: -------------------------------------------------------------------------------- 1 | package metrics 2 | 3 | import ( 4 | "testing" 5 | "time" 6 | 7 | "github.com/stretchr/testify/assert" 8 | ) 9 | 10 | func TestGetSQL(t *testing.T) { 11 | m := Metric{} 12 | m.SQLs = SQLs{ 13 | 1: "one", 14 | 3: "three", 15 | 5: "five", 16 | 6: "six", 17 | } 18 | tests := map[int]string{ 19 | 2: "one", 20 | 3: "three", 21 | 4: "three", 22 | 5: "five", 23 | 6: "six", 24 | 10: "six", 25 | 0: "", 26 | } 27 | 28 | for i, tt := range tests { 29 | if got := m.GetSQL(i); got != tt { 30 | t.Errorf("VersionToInt() = %v, want %v", got, tt) 31 | } 32 | } 33 | } 34 | func TestPrimaryOnly(t *testing.T) { 35 | m := Metric{NodeStatus: "primary"} 36 | assert.True(t, m.PrimaryOnly()) 37 | assert.False(t, m.StandbyOnly()) 38 | m.NodeStatus = "standby" 39 | assert.False(t, m.PrimaryOnly()) 40 | assert.True(t, m.StandbyOnly()) 41 | } 42 | 43 | func TestMeasurement(t *testing.T) { 44 | m := NewMeasurement(1234567890) 45 | assert.Equal(t, int64(1234567890), m.GetEpoch(), "epoch should be equal") 46 | m[EpochColumnName] = "wrong type" 47 | assert.True(t, time.Now().UnixNano()-m.GetEpoch() < int64(time.Second), "epoch should be close to now") 48 | } 49 | 50 | func TestMeasurements(t *testing.T) { 51 | m := Measurements{} 52 | assert.False(t, m.IsEpochSet(), "epoch should not be set") 53 | assert.True(t, time.Now().UnixNano()-m.GetEpoch() < 100, "epoch should be close to now") 54 | m = append(m, NewMeasurement(1234567890)) 55 | assert.True(t, m.IsEpochSet(), "epoch should be set") 56 | assert.Equal(t, int64(1234567890), m.GetEpoch(), "epoch should be equal") 57 | m1 := m.DeepCopy() 58 | assert.Equal(t, m, m1, "deep copy should be equal") 59 | m1.Touch() 60 | assert.NotEqual(t, m, m1, "deep copy should be different") 61 | assert.True(t, time.Now().UnixNano()-m1.GetEpoch() < int64(time.Second), "epoch should be close to now") 62 | } 63 | -------------------------------------------------------------------------------- /internal/metrics/yaml.go: -------------------------------------------------------------------------------- 1 | package metrics 2 | 3 | import ( 4 | "context" 5 | _ "embed" 6 | "os" 7 | 8 | "gopkg.in/yaml.v3" 9 | ) 10 | 11 | func NewYAMLMetricReaderWriter(ctx context.Context, path string) (ReaderWriter, error) { 12 | if path == "" { 13 | return NewDefaultMetricReader(ctx) 14 | } 15 | return &fileMetricReader{ 16 | ctx: ctx, 17 | path: path, 18 | }, nil 19 | } 20 | 21 | type fileMetricReader struct { 22 | ctx context.Context 23 | path string 24 | } 25 | 26 | func (fmr *fileMetricReader) WriteMetrics(metricDefs *Metrics) error { 27 | yamlData, _ := yaml.Marshal(metricDefs) 28 | return os.WriteFile(fmr.path, yamlData, 0644) 29 | } 30 | 31 | //go:embed metrics.yaml 32 | var defaultMetricsYAML []byte 33 | 34 | func (fmr *fileMetricReader) GetMetrics() (metrics *Metrics, err error) { 35 | metrics = new(Metrics) 36 | var s []byte 37 | if fmr.path == "" { 38 | s = defaultMetricsYAML 39 | } else { 40 | if s, err = os.ReadFile(fmr.path); err != nil { 41 | return nil, err 42 | } 43 | } 44 | if err = yaml.Unmarshal(s, metrics); err != nil { 45 | return nil, err 46 | } 47 | return 48 | } 49 | 50 | func (fmr *fileMetricReader) DeleteMetric(metricName string) error { 51 | metrics, err := fmr.GetMetrics() 52 | if err != nil { 53 | return err 54 | } 55 | delete(metrics.MetricDefs, metricName) 56 | return fmr.WriteMetrics(metrics) 57 | } 58 | 59 | func (fmr *fileMetricReader) UpdateMetric(metricName string, metric Metric) error { 60 | metrics, err := fmr.GetMetrics() 61 | if err != nil { 62 | return err 63 | } 64 | metrics.MetricDefs[metricName] = metric 65 | return fmr.WriteMetrics(metrics) 66 | } 67 | 68 | func (fmr *fileMetricReader) DeletePreset(presetName string) error { 69 | metrics, err := fmr.GetMetrics() 70 | if err != nil { 71 | return err 72 | } 73 | delete(metrics.PresetDefs, presetName) 74 | return fmr.WriteMetrics(metrics) 75 | } 76 | 77 | func (fmr *fileMetricReader) UpdatePreset(presetName string, preset Preset) error { 78 | metrics, err := fmr.GetMetrics() 79 | if err != nil { 80 | return err 81 | } 82 | metrics.PresetDefs[presetName] = preset 83 | return fmr.WriteMetrics(metrics) 84 | } 85 | -------------------------------------------------------------------------------- /internal/reaper/cache.go: -------------------------------------------------------------------------------- 1 | package reaper 2 | 3 | import ( 4 | "sync" 5 | "time" 6 | 7 | "github.com/cybertec-postgresql/pgwatch/v3/internal/metrics" 8 | ) 9 | 10 | var lastSQLFetchError sync.Map 11 | 12 | type InstanceMetricCache struct { 13 | cache map[string](metrics.Measurements) // [dbUnique+metric]lastly_fetched_data 14 | sync.RWMutex 15 | } 16 | 17 | func NewInstanceMetricCache() *InstanceMetricCache { 18 | return &InstanceMetricCache{ 19 | cache: make(map[string](metrics.Measurements)), 20 | } 21 | } 22 | 23 | func (imc *InstanceMetricCache) Get(key string, age time.Duration) metrics.Measurements { 24 | if key == "" { 25 | return nil 26 | } 27 | imc.RLock() 28 | defer imc.RUnlock() 29 | instanceMetricEpochNs := (imc.cache[key]).GetEpoch() 30 | 31 | if time.Now().UnixNano()-instanceMetricEpochNs > age.Nanoseconds() { 32 | return nil 33 | } 34 | instanceMetricData, ok := imc.cache[key] 35 | if !ok { 36 | return nil 37 | } 38 | return instanceMetricData.DeepCopy() 39 | } 40 | 41 | func (imc *InstanceMetricCache) Put(key string, data metrics.Measurements) { 42 | if len(data) == 0 || key == "" { 43 | return 44 | } 45 | imc.Lock() 46 | defer imc.Unlock() 47 | m := data.DeepCopy() 48 | if !m.IsEpochSet() { 49 | m.Touch() 50 | } 51 | imc.cache[key] = m 52 | } 53 | -------------------------------------------------------------------------------- /internal/reaper/database_test.go: -------------------------------------------------------------------------------- 1 | package reaper 2 | 3 | import ( 4 | "context" 5 | "testing" 6 | 7 | "github.com/cybertec-postgresql/pgwatch/v3/internal/metrics" 8 | "github.com/cybertec-postgresql/pgwatch/v3/internal/sources" 9 | pgxmock "github.com/pashagolub/pgxmock/v4" 10 | "github.com/stretchr/testify/assert" 11 | ) 12 | 13 | func TestTryCreateMetricsFetchingHelpers(t *testing.T) { 14 | ctx := context.Background() 15 | mock, err := pgxmock.NewPool() 16 | assert.NoError(t, err) 17 | defer mock.Close() 18 | 19 | metricDefs.MetricDefs["metric1"] = metrics.Metric{ 20 | InitSQL: "CREATE FUNCTION metric1", 21 | } 22 | 23 | md := &sources.SourceConn{ 24 | Conn: mock, 25 | Source: sources.Source{ 26 | Name: "testdb", 27 | Metrics: map[string]float64{"metric1": 42, "nonexistent": 0}, 28 | MetricsStandby: map[string]float64{"metric1": 42}, 29 | }, 30 | } 31 | 32 | t.Run("success", func(t *testing.T) { 33 | mock.ExpectExec("CREATE FUNCTION metric1").WillReturnResult(pgxmock.NewResult("CREATE", 1)) 34 | 35 | err = TryCreateMetricsFetchingHelpers(ctx, md) 36 | assert.NoError(t, err) 37 | assert.NoError(t, mock.ExpectationsWereMet()) 38 | }) 39 | 40 | t.Run("error on exec", func(t *testing.T) { 41 | mock.ExpectExec("CREATE FUNCTION metric1").WillReturnError(assert.AnError) 42 | 43 | err = TryCreateMetricsFetchingHelpers(ctx, md) 44 | assert.Error(t, err) 45 | assert.NoError(t, mock.ExpectationsWereMet()) 46 | }) 47 | 48 | } 49 | -------------------------------------------------------------------------------- /internal/reaper/doc.go: -------------------------------------------------------------------------------- 1 | // Package reaper is responsible to query the metrics from monitored sources 2 | // and send measurements to sinks. 3 | package reaper 4 | -------------------------------------------------------------------------------- /internal/reaper/psutil_darwin.go: -------------------------------------------------------------------------------- 1 | package reaper 2 | 3 | import "errors" 4 | 5 | var ErrNotImplemented = errors.New("not implemented") 6 | 7 | func GetPathUnderlyingDeviceID(path string) (uint64, error) { 8 | return 0, ErrNotImplemented 9 | } 10 | -------------------------------------------------------------------------------- /internal/reaper/psutil_linux.go: -------------------------------------------------------------------------------- 1 | package reaper 2 | 3 | import ( 4 | "os" 5 | "syscall" 6 | ) 7 | 8 | func GetPathUnderlyingDeviceID(path string) (uint64, error) { 9 | fp, err := os.Open(path) 10 | if err != nil { 11 | return 0, err 12 | } 13 | fi, err := fp.Stat() 14 | if err != nil { 15 | return 0, err 16 | } 17 | stat := fi.Sys().(*syscall.Stat_t) 18 | return stat.Dev, nil 19 | } 20 | -------------------------------------------------------------------------------- /internal/reaper/psutil_windows.go: -------------------------------------------------------------------------------- 1 | package reaper 2 | 3 | import "errors" 4 | 5 | var ErrNotImplemented = errors.New("not implemented") 6 | 7 | func GetPathUnderlyingDeviceID(_ string) (uint64, error) { 8 | return 0, ErrNotImplemented 9 | } 10 | -------------------------------------------------------------------------------- /internal/sinks/cmdopts.go: -------------------------------------------------------------------------------- 1 | package sinks 2 | 3 | import "time" 4 | 5 | // CmdOpts specifies the storage configuration to store metrics measurements 6 | type CmdOpts struct { 7 | Sinks []string `long:"sink" mapstructure:"sink" description:"URI where metrics will be stored, can be used multiple times" env:"PW_SINK"` 8 | BatchingDelay time.Duration `long:"batching-delay" mapstructure:"batching-delay" description:"Max milliseconds to wait for a batched metrics flush" default:"250ms" env:"PW_BATCHING_DELAY"` 9 | Retention int `long:"retention" mapstructure:"retention" description:"If set, metrics older than that will be deleted" default:"14" env:"PW_RETENTION"` 10 | RealDbnameField string `long:"real-dbname-field" mapstructure:"real-dbname-field" description:"Tag key for real database name" env:"PW_REAL_DBNAME_FIELD" default:"real_dbname"` 11 | SystemIdentifierField string `long:"system-identifier-field" mapstructure:"system-identifier-field" description:"Tag key for system identifier value" env:"PW_SYSTEM_IDENTIFIER_FIELD" default:"sys_id"` 12 | } 13 | -------------------------------------------------------------------------------- /internal/sinks/doc.go: -------------------------------------------------------------------------------- 1 | // Package sinks rovides functionality to store monitored data in different ways. 2 | // 3 | // At the moment we provide sink connectors for 4 | // - PostgreSQL and flavours, 5 | // - Prometheus, 6 | // - plain JSON files, 7 | // - and RPC servers. 8 | // 9 | // To ensure the simultaneous storage of data in several storages, the `MultiWriter` class is implemented. 10 | package sinks 11 | -------------------------------------------------------------------------------- /internal/sinks/json.go: -------------------------------------------------------------------------------- 1 | package sinks 2 | 3 | import ( 4 | "context" 5 | "encoding/json" 6 | "time" 7 | 8 | "github.com/cybertec-postgresql/pgwatch/v3/internal/log" 9 | "github.com/cybertec-postgresql/pgwatch/v3/internal/metrics" 10 | "gopkg.in/natefinch/lumberjack.v2" 11 | ) 12 | 13 | // JSONWriter is a sink that writes metric measurements to a file in JSON format. 14 | // It supports compression and rotation of output files. The default rotation is based on the file size (100Mb). 15 | // JSONWriter is useful for debugging and testing purposes, as well as for integration with other systems, 16 | // such as log aggregators, analytics systems, and data processing pipelines, ML models, etc. 17 | type JSONWriter struct { 18 | ctx context.Context 19 | lw *lumberjack.Logger 20 | enc *json.Encoder 21 | } 22 | 23 | func NewJSONWriter(ctx context.Context, fname string) (*JSONWriter, error) { 24 | l := log.GetLogger(ctx).WithField("sink", "jsonfile").WithField("filename", fname) 25 | ctx = log.WithLogger(ctx, l) 26 | jw := &JSONWriter{ 27 | ctx: ctx, 28 | lw: &lumberjack.Logger{Filename: fname, Compress: true}, 29 | } 30 | jw.enc = json.NewEncoder(jw.lw) 31 | go jw.watchCtx() 32 | return jw, nil 33 | } 34 | 35 | func (jw *JSONWriter) Write(msg metrics.MeasurementEnvelope) error { 36 | if jw.ctx.Err() != nil { 37 | return jw.ctx.Err() 38 | } 39 | if len(msg.Data) == 0 { 40 | return nil 41 | } 42 | t1 := time.Now() 43 | written := 0 44 | 45 | dataRow := map[string]any{ 46 | "metric": msg.MetricName, 47 | "data": msg.Data, 48 | "dbname": msg.DBName, 49 | "custom_tags": msg.CustomTags, 50 | } 51 | if err := jw.enc.Encode(dataRow); err != nil { 52 | return err 53 | } 54 | written += len(msg.Data) 55 | 56 | diff := time.Since(t1) 57 | log.GetLogger(jw.ctx).WithField("rows", written).WithField("elapsed", diff).Info("measurements written") 58 | return nil 59 | } 60 | 61 | func (jw *JSONWriter) watchCtx() { 62 | <-jw.ctx.Done() 63 | jw.lw.Close() 64 | } 65 | 66 | func (jw *JSONWriter) SyncMetric(_, _, _ string) error { 67 | if jw.ctx.Err() != nil { 68 | return jw.ctx.Err() 69 | } 70 | // do nothing, we don't care 71 | return nil 72 | } 73 | -------------------------------------------------------------------------------- /internal/sinks/json_test.go: -------------------------------------------------------------------------------- 1 | package sinks 2 | 3 | import ( 4 | "context" 5 | "encoding/json" 6 | "os" 7 | "testing" 8 | 9 | "github.com/cybertec-postgresql/pgwatch/v3/internal/metrics" 10 | "github.com/stretchr/testify/assert" 11 | "github.com/stretchr/testify/require" 12 | ) 13 | 14 | func TestJSONWriter_Write(t *testing.T) { 15 | a := assert.New(t) 16 | r := require.New(t) 17 | // Define test data 18 | msg := metrics.MeasurementEnvelope{ 19 | MetricName: "test_metric", 20 | Data: metrics.Measurements{ 21 | {"number": 1, "string": "test_data"}, 22 | }, 23 | DBName: "test_db", 24 | CustomTags: map[string]string{"foo": "boo"}, 25 | } 26 | 27 | tempFile := t.TempDir() + "/test.json" 28 | ctx, cancel := context.WithCancel(context.Background()) 29 | jw, err := NewJSONWriter(ctx, tempFile) 30 | r.NoError(err) 31 | 32 | err = jw.Write(msg) 33 | a.NoError(err, "write successful") 34 | err = jw.Write(metrics.MeasurementEnvelope{}) 35 | r.NoError(err, "empty write successful") 36 | 37 | cancel() 38 | err = jw.Write(msg) 39 | a.Error(err, "context canceled") 40 | 41 | // Read the contents of the file 42 | var data map[string]any 43 | file, err := os.ReadFile(tempFile) 44 | r.NoError(err) 45 | err = json.Unmarshal(file, &data) 46 | r.NoError(err) 47 | a.Equal(msg.MetricName, data["metric"]) 48 | a.Equal(len(msg.Data), len(data["data"].([]any))) 49 | a.Equal(msg.DBName, data["dbname"]) 50 | a.Equal(len(msg.CustomTags), len(data["custom_tags"].(map[string]any))) 51 | } 52 | 53 | func TestJSONWriter_SyncMetric(t *testing.T) { 54 | // Create a temporary file for testing 55 | tempFile := t.TempDir() + "/test.json" 56 | 57 | ctx, cancel := context.WithCancel(context.Background()) 58 | jw, err := NewJSONWriter(ctx, tempFile) 59 | assert.NoError(t, err) 60 | 61 | // Call the function being tested 62 | err = jw.SyncMetric("", "", "") 63 | assert.NoError(t, err) 64 | 65 | cancel() 66 | err = jw.SyncMetric("", "", "") 67 | assert.Error(t, err, "context canceled") 68 | 69 | } 70 | -------------------------------------------------------------------------------- /internal/sinks/multiwriter_test.go: -------------------------------------------------------------------------------- 1 | package sinks 2 | 3 | import ( 4 | "context" 5 | "testing" 6 | 7 | "github.com/cybertec-postgresql/pgwatch/v3/internal/metrics" 8 | "github.com/stretchr/testify/assert" 9 | ) 10 | 11 | type MockWriter struct{} 12 | 13 | func (mw *MockWriter) SyncMetric(_, _, _ string) error { 14 | return nil 15 | } 16 | 17 | func (mw *MockWriter) Write(_ metrics.MeasurementEnvelope) error { 18 | return nil 19 | } 20 | 21 | func TestNewMultiWriter(t *testing.T) { 22 | input := []struct { 23 | opts *CmdOpts 24 | w bool // Writer returned 25 | err bool // error returned 26 | }{ 27 | {&CmdOpts{}, false, true}, 28 | {&CmdOpts{ 29 | Sinks: []string{"foo"}, 30 | }, false, true}, 31 | {&CmdOpts{ 32 | Sinks: []string{"jsonfile://test.json"}, 33 | }, true, false}, 34 | {&CmdOpts{ 35 | Sinks: []string{"jsonfile://test.json", "jsonfile://test1.json"}, 36 | }, true, false}, 37 | {&CmdOpts{ 38 | Sinks: []string{"prometheus://foo/"}, 39 | }, false, true}, 40 | {&CmdOpts{ 41 | Sinks: []string{"rpc://foo/"}, 42 | }, false, true}, 43 | {&CmdOpts{ 44 | Sinks: []string{"postgresql:///baz"}, 45 | }, false, true}, 46 | {&CmdOpts{ 47 | Sinks: []string{"foo:///"}, 48 | }, false, true}, 49 | } 50 | 51 | for _, i := range input { 52 | mw, err := NewSinkWriter(context.Background(), i.opts) 53 | if i.err { 54 | assert.Error(t, err) 55 | } else { 56 | assert.NoError(t, err) 57 | } 58 | if i.w { 59 | assert.NotNil(t, mw) 60 | } else { 61 | assert.Nil(t, mw) 62 | } 63 | } 64 | } 65 | 66 | func TestAddWriter(t *testing.T) { 67 | mw := &MultiWriter{} 68 | mockWriter := &MockWriter{} 69 | mw.AddWriter(mockWriter) 70 | assert.Equal(t, 1, len(mw.writers)) 71 | } 72 | 73 | func TestSyncMetrics(t *testing.T) { 74 | mw := &MultiWriter{} 75 | mockWriter := &MockWriter{} 76 | mw.AddWriter(mockWriter) 77 | err := mw.SyncMetric("db", "metric", "op") 78 | assert.NoError(t, err) 79 | } 80 | 81 | func TestWriteMeasurements(t *testing.T) { 82 | mw := &MultiWriter{} 83 | mockWriter := &MockWriter{} 84 | mw.AddWriter(mockWriter) 85 | err := mw.Write(metrics.MeasurementEnvelope{}) 86 | assert.NoError(t, err) 87 | } 88 | -------------------------------------------------------------------------------- /internal/sinks/rpc.go: -------------------------------------------------------------------------------- 1 | package sinks 2 | 3 | import ( 4 | "context" 5 | "net/rpc" 6 | 7 | "github.com/cybertec-postgresql/pgwatch/v3/internal/log" 8 | "github.com/cybertec-postgresql/pgwatch/v3/internal/metrics" 9 | ) 10 | 11 | // RPCWriter is a sink that sends metric measurements to a remote server using the RPC protocol. 12 | // Remote server should implement the Receiver interface. It's up to the implementer to define the 13 | // behavior of the server. It can be a simple logger, external storage, alerting system, 14 | // or an analytics system. 15 | type RPCWriter struct { 16 | ctx context.Context 17 | address string 18 | client *rpc.Client 19 | } 20 | 21 | func NewRPCWriter(ctx context.Context, address string) (*RPCWriter, error) { 22 | client, err := rpc.DialHTTP("tcp", address) 23 | if err != nil { 24 | return nil, err 25 | } 26 | l := log.GetLogger(ctx).WithField("sink", "rpc").WithField("address", address) 27 | ctx = log.WithLogger(ctx, l) 28 | rw := &RPCWriter{ 29 | ctx: ctx, 30 | address: address, 31 | client: client, 32 | } 33 | go rw.watchCtx() 34 | return rw, nil 35 | } 36 | 37 | // Sends Measurement Message to RPC Sink 38 | func (rw *RPCWriter) Write(msg metrics.MeasurementEnvelope) error { 39 | if rw.ctx.Err() != nil { 40 | return rw.ctx.Err() 41 | } 42 | var logMsg string 43 | if err := rw.client.Call("Receiver.UpdateMeasurements", &msg, &logMsg); err != nil { 44 | return err 45 | } 46 | if len(logMsg) > 0 { 47 | log.GetLogger(rw.ctx).Info(logMsg) 48 | } 49 | return nil 50 | } 51 | 52 | type SyncReq struct { 53 | DbName string 54 | MetricName string 55 | Operation string 56 | } 57 | 58 | func (rw *RPCWriter) SyncMetric(dbUnique string, metricName string, op string) error { 59 | var logMsg string 60 | if err := rw.client.Call("Receiver.SyncMetric", &SyncReq{ 61 | Operation: op, 62 | DbName: dbUnique, 63 | MetricName: metricName, 64 | }, &logMsg); err != nil { 65 | return err 66 | } 67 | if len(logMsg) > 0 { 68 | log.GetLogger(rw.ctx).Info(logMsg) 69 | } 70 | return nil 71 | } 72 | 73 | func (rw *RPCWriter) watchCtx() { 74 | <-rw.ctx.Done() 75 | rw.client.Close() 76 | } 77 | -------------------------------------------------------------------------------- /internal/sinks/sql/change_chunk_interval.sql: -------------------------------------------------------------------------------- 1 | -- DROP FUNCTION IF EXISTS admin.timescale_change_chunk_interval(interval); 2 | -- select * from admin.timescale_change_chunk_interval('1 day'); 3 | 4 | CREATE OR REPLACE FUNCTION admin.timescale_change_chunk_interval( 5 | new_interval interval 6 | ) 7 | RETURNS void AS 8 | /* 9 | changes all existing tables and writes the new default also into the admin.config table 10 | so that future new metric hypertables would also automatically use it 11 | */ 12 | $SQL$ 13 | DECLARE 14 | r record; 15 | BEGIN 16 | 17 | INSERT INTO admin.config 18 | SELECT 'timescale_chunk_interval', new_interval::text 19 | ON CONFLICT (key) DO UPDATE 20 | SET value = new_interval::text; 21 | 22 | FOR r IN (SELECT quote_ident(table_name) as metric 23 | FROM _timescaledb_catalog.hypertable 24 | WHERE schema_name = 'public') 25 | LOOP 26 | -- RAISE NOTICE 'setting % to %s ...', r.metric, new_interval; 27 | PERFORM set_chunk_time_interval(r.metric, new_interval); 28 | END LOOP; 29 | 30 | END; 31 | $SQL$ LANGUAGE plpgsql; 32 | 33 | -- GRANT EXECUTE ON FUNCTION admin.timescale_change_chunk_interval(interval) TO pgwatch; 34 | -------------------------------------------------------------------------------- /internal/sinks/sql/change_compression_interval.sql: -------------------------------------------------------------------------------- 1 | -- DROP FUNCTION IF EXISTS admin.timescale_change_compress_interval(interval); 2 | -- select * from admin.timescale_change_compress_interval('1 day'); 3 | 4 | CREATE OR REPLACE FUNCTION admin.timescale_change_compress_interval( 5 | new_interval interval 6 | ) 7 | RETURNS void AS 8 | /* 9 | changes all existing tables and writes the new default also into the admin.config table 10 | so that future new metric hypertables would also automatically use it 11 | */ 12 | $SQL$ 13 | DECLARE 14 | r record; 15 | l_timescale_version numeric; 16 | BEGIN 17 | 18 | INSERT INTO admin.config 19 | SELECT 'timescale_compress_interval', new_interval::text 20 | ON CONFLICT (key) DO UPDATE 21 | SET value = new_interval::text; 22 | 23 | FOR r IN (SELECT quote_ident(table_name) as metric 24 | FROM _timescaledb_catalog.hypertable 25 | WHERE schema_name = 'public') 26 | LOOP 27 | -- RAISE NOTICE 'setting % to %s ...', r.metric, new_interval; 28 | PERFORM set_chunk_time_interval(r.metric, new_interval); 29 | 30 | SELECT ((regexp_matches(extversion, '\d+\.\d+'))[1])::numeric INTO l_timescale_version FROM pg_extension WHERE extname = 'timescaledb'; 31 | IF l_timescale_version >= 2.0 THEN 32 | PERFORM remove_compression_policy(format('public.%I', r.metric), true); 33 | PERFORM add_compression_policy(format('public.%I', r.metric), new_interval); 34 | ELSE 35 | PERFORM remove_compress_chunks_policy(format('public.%I', r.metric)); 36 | PERFORM add_compress_chunks_policy(format('public.%I', r.metric), new_interval); 37 | END IF; 38 | END LOOP; 39 | 40 | END; 41 | $SQL$ LANGUAGE plpgsql; 42 | 43 | -- GRANT EXECUTE ON FUNCTION admin.timescale_change_compress_interval(interval) TO pgwatch; 44 | -------------------------------------------------------------------------------- /internal/sources/cmdopts.go: -------------------------------------------------------------------------------- 1 | package sources 2 | 3 | // SourceOpts specifies the sources related command-line options 4 | type CmdOpts struct { 5 | Sources string `short:"s" long:"sources" mapstructure:"config" description:"Postgres URI, file or folder of YAML files containing info on which DBs to monitor" env:"PW_SOURCES"` 6 | Refresh int `long:"refresh" mapstructure:"refresh" description:"How frequently to resync sources and metrics" env:"PW_REFRESH" default:"120"` 7 | Groups []string `short:"g" long:"group" mapstructure:"group" description:"Groups for filtering which databases to monitor. By default all are monitored" env:"PW_GROUP"` 8 | MinDbSizeMB int64 `long:"min-db-size-mb" mapstructure:"min-db-size-mb" description:"Smaller size DBs will be ignored and not monitored until they reach the threshold." env:"PW_MIN_DB_SIZE_MB" default:"0"` 9 | MaxParallelConnectionsPerDb int `long:"max-parallel-connections-per-db" mapstructure:"max-parallel-connections-per-db" description:"Max parallel metric fetches per DB. Note the multiplication effect on multi-DB instances" env:"PW_MAX_PARALLEL_CONNECTIONS_PER_DB" default:"4"` 10 | TryCreateListedExtsIfMissing string `long:"try-create-listed-exts-if-missing" mapstructure:"try-create-listed-exts-if-missing" description:"Try creating the listed extensions (comma sep.) on first connect for all monitored DBs when missing. Main usage - pg_stat_statements" env:"PW_TRY_CREATE_LISTED_EXTS_IF_MISSING" default:""` 11 | CreateHelpers bool `long:"create-helpers" mapstructure:"create-helpers" description:"Create helper database objects from metric definitions" env:"PW_CREATE_HELPERS"` 12 | } 13 | -------------------------------------------------------------------------------- /internal/sources/doc.go: -------------------------------------------------------------------------------- 1 | // Provides functionality to read monitored data from different sources. 2 | // 3 | // Sources defines how to get the information for the monitored databases. 4 | // At the moment, sources definitions support two storages: 5 | // * PostgreSQL database 6 | // * YAML file 7 | // 8 | // * `postgres.go` files cover the functionality for the PostgreSQL database. 9 | // * `yaml.go` files cover the functionality for the YAML file. 10 | // * `resolver.go` implements continuous discovery from patroni and postgres cluster. 11 | // * `types.go` defines the types and interfaces. 12 | // * `sample.sources.yaml` is a sample configuration file. 13 | package sources 14 | -------------------------------------------------------------------------------- /internal/sources/types_test.go: -------------------------------------------------------------------------------- 1 | package sources_test 2 | 3 | import ( 4 | "context" 5 | "testing" 6 | 7 | "github.com/stretchr/testify/assert" 8 | 9 | "github.com/cybertec-postgresql/pgwatch/v3/internal/sources" 10 | ) 11 | 12 | var ctx = context.Background() 13 | 14 | func TestKind_IsValid(t *testing.T) { 15 | tests := []struct { 16 | kind sources.Kind 17 | expected bool 18 | }{ 19 | {kind: sources.SourcePostgres, expected: true}, 20 | {kind: sources.SourcePostgresContinuous, expected: true}, 21 | {kind: sources.SourcePgBouncer, expected: true}, 22 | {kind: sources.SourcePgPool, expected: true}, 23 | {kind: sources.SourcePatroni, expected: true}, 24 | {kind: sources.SourcePatroniContinuous, expected: true}, 25 | {kind: sources.SourcePatroniNamespace, expected: true}, 26 | {kind: "invalid", expected: false}, 27 | } 28 | 29 | for _, tt := range tests { 30 | got := tt.kind.IsValid() 31 | assert.True(t, got == tt.expected, "IsValid(%v) = %v, want %v", tt.kind, got, tt.expected) 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /internal/webserver/cmdoptions_test.go: -------------------------------------------------------------------------------- 1 | package webserver 2 | 3 | import ( 4 | "os" 5 | "testing" 6 | 7 | flags "github.com/jessevdk/go-flags" 8 | "github.com/stretchr/testify/assert" 9 | ) 10 | 11 | func TestWebDisableOpt(t *testing.T) { 12 | a := assert.New(t) 13 | testCases := []struct { 14 | args []string 15 | expected string 16 | expectError bool 17 | }{ 18 | {[]string{0: "config_test"}, "", false}, 19 | {[]string{0: "config_test", "--web-disable"}, WebDisableAll, false}, 20 | {[]string{0: "config_test", "--web-disable=all"}, WebDisableAll, false}, 21 | {[]string{0: "config_test", "--web-disable=ui"}, WebDisableUI, false}, 22 | {[]string{0: "config_test", "--web-disable=foo"}, "", true}, 23 | } 24 | 25 | for _, tc := range testCases { 26 | opts := new(CmdOpts) 27 | os.Args = tc.args 28 | _, err := flags.NewParser(opts, flags.HelpFlag).Parse() 29 | 30 | if tc.expectError { 31 | a.Error(err) 32 | a.Empty(opts.WebDisable) 33 | } else { 34 | a.NoError(err) 35 | a.Equal(tc.expected, opts.WebDisable) 36 | } 37 | } 38 | 39 | } 40 | -------------------------------------------------------------------------------- /internal/webserver/cmdopts.go: -------------------------------------------------------------------------------- 1 | package webserver 2 | 3 | const ( 4 | WebDisableAll string = "all" 5 | WebDisableUI string = "ui" 6 | ) 7 | 8 | // CmdOpts specifies the internal web UI server command-line options 9 | type CmdOpts struct { 10 | WebDisable string `long:"web-disable" mapstructure:"web-disable" description:"Disable REST API and/or web UI" env:"PW_WEBDISABLE" optional:"true" optional-value:"all" choice:"all" choice:"ui"` 11 | WebAddr string `long:"web-addr" mapstructure:"web-addr" description:"TCP address in the form 'host:port' to listen on" default:":8080" env:"PW_WEBADDR"` 12 | WebUser string `long:"web-user" mapstructure:"web-user" description:"Admin login" env:"PW_WEBUSER"` 13 | WebPassword string `long:"web-password" mapstructure:"web-password" description:"Admin password" env:"PW_WEBPASSWORD"` 14 | } 15 | -------------------------------------------------------------------------------- /internal/webserver/doc.go: -------------------------------------------------------------------------------- 1 | // Package webserver serves the REST API and the web user interface. 2 | package webserver 3 | -------------------------------------------------------------------------------- /internal/webserver/source.go: -------------------------------------------------------------------------------- 1 | package webserver 2 | 3 | import ( 4 | "encoding/json" 5 | "io" 6 | "net/http" 7 | 8 | "github.com/cybertec-postgresql/pgwatch/v3/internal/sources" 9 | ) 10 | 11 | func (server *WebUIServer) handleSources(w http.ResponseWriter, r *http.Request) { 12 | var ( 13 | err error 14 | params []byte 15 | res string 16 | ) 17 | 18 | defer func() { 19 | if err != nil { 20 | http.Error(w, err.Error(), http.StatusInternalServerError) 21 | } 22 | }() 23 | 24 | switch r.Method { 25 | case http.MethodGet: 26 | // return monitored databases 27 | if res, err = server.GetSources(); err != nil { 28 | return 29 | } 30 | _, err = w.Write([]byte(res)) 31 | 32 | case http.MethodPost: 33 | // add new monitored database 34 | if params, err = io.ReadAll(r.Body); err != nil { 35 | return 36 | } 37 | err = server.UpdateSource(params) 38 | 39 | case http.MethodDelete: 40 | // delete monitored database 41 | err = server.DeleteSource(r.URL.Query().Get("name")) 42 | 43 | case http.MethodOptions: 44 | w.Header().Set("Allow", "GET, POST, DELETE, OPTIONS") 45 | w.WriteHeader(http.StatusNoContent) 46 | 47 | default: 48 | w.Header().Set("Allow", "GET, POST, DELETE, OPTIONS") 49 | http.Error(w, "method not allowed", http.StatusMethodNotAllowed) 50 | } 51 | } 52 | 53 | // GetSources returns the list of sources fo find databases for monitoring 54 | func (server *WebUIServer) GetSources() (res string, err error) { 55 | var dbs sources.Sources 56 | if dbs, err = server.sourcesReaderWriter.GetSources(); err != nil { 57 | return 58 | } 59 | b, _ := json.Marshal(dbs) 60 | res = string(b) 61 | return 62 | } 63 | 64 | // DeleteSource removes the source from the list of configured sources 65 | func (server *WebUIServer) DeleteSource(database string) error { 66 | return server.sourcesReaderWriter.DeleteSource(database) 67 | } 68 | 69 | // UpdateSource updates the configured source information 70 | func (server *WebUIServer) UpdateSource(params []byte) error { 71 | var md sources.Source 72 | err := json.Unmarshal(params, &md) 73 | if err != nil { 74 | return err 75 | } 76 | return server.sourcesReaderWriter.UpdateSource(md) 77 | } 78 | -------------------------------------------------------------------------------- /internal/webserver/wslog_test.go: -------------------------------------------------------------------------------- 1 | package webserver 2 | 3 | import ( 4 | "net/http" 5 | "net/http/httptest" 6 | "net/url" 7 | "strings" 8 | "testing" 9 | "time" 10 | 11 | "github.com/cybertec-postgresql/pgwatch/v3/internal/log" 12 | "github.com/gorilla/websocket" 13 | "github.com/stretchr/testify/assert" 14 | "github.com/stretchr/testify/require" 15 | ) 16 | 17 | func TestServeWsLog_UpgradeError(t *testing.T) { 18 | ts := &WebUIServer{Logger: log.FallbackLogger} 19 | r := httptest.NewRequest(http.MethodGet, "/wslog", nil) 20 | w := httptest.NewRecorder() 21 | // No websocket headers, should fail to upgrade 22 | ts.serveWsLog(w, r) 23 | resp := w.Result() 24 | assert.Equal(t, http.StatusBadRequest, resp.StatusCode) 25 | } 26 | 27 | func TestServeWsLog_Success(t *testing.T) { 28 | ts := &WebUIServer{Logger: log.Init(log.CmdOpts{LogLevel: "debug"})} 29 | h := http.HandlerFunc(ts.serveWsLog) 30 | tsServer := httptest.NewServer(h) 31 | defer tsServer.Close() 32 | 33 | u := "ws" + strings.TrimPrefix(tsServer.URL, "http") 34 | uParsed, _ := url.Parse(u) 35 | uParsed.Path = "/wslog" 36 | 37 | ws, resp, err := websocket.DefaultDialer.Dial(uParsed.String(), nil) 38 | require.NotNil(t, ws) 39 | require.NoError(t, err) 40 | assert.Equal(t, http.StatusSwitchingProtocols, resp.StatusCode) 41 | 42 | // send ping message to keep connection alive 43 | assert.NoError(t, ws.WriteMessage(websocket.PingMessage, nil)) 44 | 45 | // send some log message 46 | time.Sleep(100 * time.Millisecond) 47 | ts.Info("Test message") 48 | // check output though the websocket 49 | assert.NoError(t, ws.SetReadDeadline(time.Now().Add(2*time.Second))) 50 | msgType, msg, err := ws.ReadMessage() 51 | assert.NoError(t, err) 52 | assert.Equal(t, websocket.TextMessage, msgType) 53 | assert.NotEmpty(t, msg) 54 | assert.Contains(t, string(msg), "Test message") 55 | time.Sleep(4 * time.Second) 56 | // check if the connection is closed 57 | assert.NoError(t, ws.Close()) 58 | ts.Info("Test message after websocket close") 59 | assert.Error(t, ws.SetReadDeadline(time.Now().Add(2*time.Second))) 60 | _, _, err = ws.ReadMessage() 61 | assert.Error(t, err, "should error because connection is closed") 62 | } 63 | -------------------------------------------------------------------------------- /internal/webui/.env: -------------------------------------------------------------------------------- 1 | PORT=4000 2 | PROXY_TARGET=http://localhost 3 | SKIP_PREFLIGHT_CHECK=true 4 | NODE_OPTIONS="--max-old-space-size=8192" 5 | -------------------------------------------------------------------------------- /internal/webui/.eslintignore: -------------------------------------------------------------------------------- 1 | internal/serviceWorker.js 2 | internal/setupTests.ts 3 | internal/**/__tests__/** 4 | internal/**/test/** 5 | .eslintrc.js -------------------------------------------------------------------------------- /internal/webui/.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.js 7 | 8 | # testing 9 | /coverage 10 | 11 | # production 12 | /build 13 | 14 | # misc 15 | .DS_Store 16 | .env.local 17 | .env.development.local 18 | .env.test.local 19 | .env.production.local 20 | .eslintcache 21 | 22 | npm-debug.log* 23 | yarn-debug.log* 24 | yarn-error.log* 25 | -------------------------------------------------------------------------------- /internal/webui/.prettierignore: -------------------------------------------------------------------------------- 1 | build 2 | serviceWorker.js 3 | -------------------------------------------------------------------------------- /internal/webui/.prettierrc.json: -------------------------------------------------------------------------------- 1 | {} 2 | -------------------------------------------------------------------------------- /internal/webui/README.md: -------------------------------------------------------------------------------- 1 | # pgwatch web-gui 2 | 3 | ## Quick Start 4 | 5 | To run the app, you need to have yarn installed on your device. [Install yarn](https://classic.yarnpkg.com/lang/en/docs/install/). 6 | 7 | After yarn installed, in project directory, you should run: 8 | 9 | ``` 10 | # Installs all dependencies for a project 11 | yarn install 12 | ``` 13 | 14 | After all dependencies installed, you can run: 15 | 16 | ``` 17 | # Runs the app in the development mode 18 | yarn start 19 | ``` 20 | 21 | Open [http://localhost:4000](http://localhost:4000) to view it in the browser. 22 | 23 | The page will reload if you make edits. 24 | You will also see any lint errors in the console. 25 | -------------------------------------------------------------------------------- /internal/webui/doc.go: -------------------------------------------------------------------------------- 1 | // Package webui provides a web-based user interface for the service. 2 | package webui 3 | -------------------------------------------------------------------------------- /internal/webui/embed.go: -------------------------------------------------------------------------------- 1 | package webui 2 | 3 | import ( 4 | "embed" 5 | "io/fs" 6 | ) 7 | 8 | //go:embed build 9 | var efs embed.FS 10 | 11 | var WebUIFs fs.FS 12 | 13 | func init() { 14 | WebUIFs, _ = fs.Sub(efs, "build") 15 | } 16 | -------------------------------------------------------------------------------- /internal/webui/embed_test.go: -------------------------------------------------------------------------------- 1 | package webui 2 | 3 | import ( 4 | "io/fs" 5 | "testing" 6 | ) 7 | 8 | func TestWebUIInit(t *testing.T) { 9 | if WebUIFs == nil { 10 | t.Error("WebUIFs is nil") 11 | } 12 | if _, err := fs.Stat(WebUIFs, "index.html"); err != nil { 13 | t.Errorf("WebUIFs does not contain index.html: %v", err) 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /internal/webui/public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cybertec-postgresql/pgwatch/488977dba455f0d3e4a1611044aa2dc5d31f36ed/internal/webui/public/favicon.ico -------------------------------------------------------------------------------- /internal/webui/public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 12 | 13 | 17 | 18 | 27 | pgwatch dashboard 28 | 29 | 30 | 31 |
32 | 42 | 43 | 44 | -------------------------------------------------------------------------------- /internal/webui/public/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cybertec-postgresql/pgwatch/488977dba455f0d3e4a1611044aa2dc5d31f36ed/internal/webui/public/logo.png -------------------------------------------------------------------------------- /internal/webui/public/logo192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cybertec-postgresql/pgwatch/488977dba455f0d3e4a1611044aa2dc5d31f36ed/internal/webui/public/logo192.png -------------------------------------------------------------------------------- /internal/webui/public/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "short_name": "React App", 3 | "name": "Create React App Sample", 4 | "icons": [ 5 | { 6 | "src": "favicon.ico", 7 | "sizes": "64x64 32x32 24x24 16x16", 8 | "type": "image/x-icon" 9 | }, 10 | { 11 | "src": "logo192.png", 12 | "type": "image/png", 13 | "sizes": "192x192" 14 | } 15 | ], 16 | "start_url": ".", 17 | "display": "standalone", 18 | "theme_color": "#000000", 19 | "background_color": "#ffffff" 20 | } -------------------------------------------------------------------------------- /internal/webui/public/robots.txt: -------------------------------------------------------------------------------- 1 | # https://www.robotstxt.org/robotstxt.html 2 | User-agent: * 3 | Disallow: 4 | -------------------------------------------------------------------------------- /internal/webui/src/App.test.tsx: -------------------------------------------------------------------------------- 1 | import { render, screen } from "@testing-library/react"; 2 | import App from "./App"; 3 | 4 | test("renders learn react link", () => { 5 | render(); 6 | const linkElement = screen.getByText(/learn react/i); 7 | expect(linkElement).toBeInTheDocument(); 8 | }); 9 | -------------------------------------------------------------------------------- /internal/webui/src/App.tsx: -------------------------------------------------------------------------------- 1 | import { useMemo } from "react"; 2 | 3 | import { Box, Toolbar } from "@mui/material"; 4 | import CssBaseline from "@mui/material/CssBaseline"; 5 | import { ThemeProvider, createTheme } from "@mui/material/styles"; 6 | 7 | import { QueryClientProvider } from "QueryClient"; 8 | 9 | import { Alert } from "components/Alert/Alert"; 10 | import { Route, Routes } from "react-router-dom"; 11 | import { PrivateRoute } from "layout/PrivateRoute"; 12 | import { privateRoutes, publicRoutes } from "layout/Routes"; 13 | 14 | import { AppBar } from "./layout/AppBar"; 15 | 16 | const mdTheme = createTheme(); 17 | 18 | export default function App() { 19 | 20 | const publicRoutesItems = useMemo( 21 | () => 22 | publicRoutes.map((route) => ( 23 | } /> 24 | )), 25 | [] 26 | ); 27 | 28 | const privateRoutesItems = useMemo( 29 | () => 30 | privateRoutes.map((route) => ( 31 | } /> 32 | )), 33 | [] 34 | ); 35 | 36 | return ( 37 | 38 | 39 | 40 | 41 | 42 | 53 | 54 | 55 | {publicRoutesItems} 56 | {privateRoutesItems} 57 | 58 | 59 | 60 | 61 | 62 | 63 | ); 64 | } 65 | -------------------------------------------------------------------------------- /internal/webui/src/QueryClient.tsx: -------------------------------------------------------------------------------- 1 | import { QueryClientProvider as ClientProvider, MutationCache, QueryCache, QueryClient } from "@tanstack/react-query"; 2 | import { isUnauthorized } from "api"; 3 | import axios from "axios"; 4 | import { useNavigate } from "react-router-dom"; 5 | import { logout } from "queries/Auth"; 6 | import { useAlert } from "utils/AlertContext"; 7 | 8 | type Props = { 9 | children: JSX.Element 10 | }; 11 | 12 | export const QueryClientProvider = ({ children }: Props) => { 13 | const { callAlert } = useAlert(); 14 | const navigate = useNavigate(); 15 | 16 | const queryClient = new QueryClient({ 17 | queryCache: new QueryCache({ 18 | onError: (error) => { 19 | if (axios.isAxiosError(error)) { 20 | if (isUnauthorized(error)) { 21 | callAlert("error", `${error.response?.data}`); 22 | logout(navigate); 23 | } 24 | } 25 | } 26 | }), 27 | mutationCache: new MutationCache({ 28 | onSuccess: (_data, _variables, _context, mutation) => { 29 | callAlert("success", "Success"); 30 | if (mutation.options.mutationKey) { 31 | queryClient.invalidateQueries(mutation.options.mutationKey); 32 | } 33 | }, 34 | onError: (error) => { 35 | if (axios.isAxiosError(error)) { 36 | callAlert("error", `${error.response?.data}`); 37 | if (isUnauthorized(error)) { 38 | logout(navigate); 39 | } 40 | } 41 | } 42 | }) 43 | }); 44 | 45 | return ( 46 | 47 | {children} 48 | 49 | ); 50 | }; 51 | -------------------------------------------------------------------------------- /internal/webui/src/api.ts: -------------------------------------------------------------------------------- 1 | import axios, { AxiosError } from "axios"; 2 | import { getToken } from "services/Token"; 3 | 4 | export const apiClient = () => { 5 | const apiEndpoint = window.location.origin; 6 | const instance = axios.create({ 7 | baseURL: apiEndpoint, 8 | }); 9 | 10 | instance.interceptors.request.use(req => { 11 | req.headers.set("Token", getToken()); 12 | return req; 13 | }); 14 | 15 | return instance; 16 | }; 17 | 18 | export const isUnauthorized = (error: AxiosError) => { 19 | if (error.response?.status === axios.HttpStatusCode.Unauthorized) { 20 | return true; 21 | } 22 | return false; 23 | }; 24 | -------------------------------------------------------------------------------- /internal/webui/src/components/Alert/Alert.styles.ts: -------------------------------------------------------------------------------- 1 | import { makeStyles } from "tss-react/mui"; 2 | 3 | export const useAlertStyles = makeStyles()( 4 | () => ({ 5 | root: { 6 | minWidth: 400, 7 | maxWidth: 400, 8 | whiteSpace: "nowrap", 9 | alignItems: "center", 10 | }, 11 | }), 12 | ); 13 | -------------------------------------------------------------------------------- /internal/webui/src/components/Alert/Alert.tsx: -------------------------------------------------------------------------------- 1 | import { Alert as MuiAlert, Snackbar, Tooltip, Typography } from "@mui/material"; 2 | import { useAlert } from "utils/AlertContext"; 3 | import { useAlertStyles } from "./Alert.styles"; 4 | 5 | export const Alert = () => { 6 | const { open, severity, message, closeAlert } = useAlert(); 7 | 8 | const { classes } = useAlertStyles(); 9 | 10 | return ( 11 | 12 | 17 | 18 | 19 | {message} 20 | 21 | 22 | 23 | 24 | ); 25 | }; 26 | -------------------------------------------------------------------------------- /internal/webui/src/components/Autocomplete/Autocomplete.tsx: -------------------------------------------------------------------------------- 1 | import { AutocompleteRenderInputParams, Autocomplete as MuiAutocomplete, TextField } from "@mui/material"; 2 | import { ControllerRenderProps } from "react-hook-form"; 3 | 4 | type Props = { 5 | id?: string; 6 | label: string; 7 | options: { label: string }[]; 8 | loading?: boolean; 9 | error?: boolean; 10 | } & ControllerRenderProps; 11 | 12 | export const Autocomplete = (props: Props) => { 13 | const { id, label, options, loading, error, ...field } = props; 14 | 15 | const customInput = (params: AutocompleteRenderInputParams) => ( 16 | 21 | ); 22 | 23 | return ( 24 | field.onChange(value ? value.label : "")} 30 | loading={loading} 31 | componentsProps={{ 32 | popper: { 33 | modifiers: [ 34 | { 35 | name: 'flip', 36 | enabled: false 37 | }, 38 | { 39 | name: 'preventOverflow', 40 | enabled: false 41 | } 42 | ] 43 | } 44 | }} 45 | /> 46 | ); 47 | }; 48 | -------------------------------------------------------------------------------- /internal/webui/src/components/Error/Error.styles.ts: -------------------------------------------------------------------------------- 1 | import { makeStyles } from "tss-react/mui"; 2 | 3 | export const useErrorStyles = makeStyles()( 4 | () => ({ 5 | root: { 6 | width: "100%", 7 | height: "100%", 8 | display: "flex", 9 | flexDirection: "column", 10 | justifyContent: "center", 11 | alignItems: "center", 12 | gap: 3, 13 | }, 14 | }) 15 | ); 16 | -------------------------------------------------------------------------------- /internal/webui/src/components/Error/Error.tsx: -------------------------------------------------------------------------------- 1 | import { Typography } from "@mui/material"; 2 | import { useErrorStyles } from "./Error.styles"; 3 | 4 | type Props = { 5 | message: string 6 | } 7 | 8 | export const Error = ({ message }: Props) => { 9 | const { classes } = useErrorStyles(); 10 | 11 | return ( 12 |
13 | Oops! 14 | Something went wrong... 15 | {message} 16 |
17 | ); 18 | }; 19 | -------------------------------------------------------------------------------- /internal/webui/src/components/GridActions/GridActions.styles.ts: -------------------------------------------------------------------------------- 1 | import { makeStyles } from 'tss-react/mui'; 2 | 3 | export const useGridActionsStyles = makeStyles()( 4 | () => ({ 5 | root: { 6 | display: "flex", 7 | width: "100%", 8 | justifyContent: "space-between", 9 | }, 10 | }), 11 | ); 12 | -------------------------------------------------------------------------------- /internal/webui/src/components/GridActions/GridActions.tsx: -------------------------------------------------------------------------------- 1 | import DeleteIcon from "@mui/icons-material/Delete"; 2 | import EditIcon from "@mui/icons-material/Edit"; 3 | import { IconButton } from "@mui/material"; 4 | import { useGridActionsStyles } from "./GridActions.styles"; 5 | 6 | type Props = { 7 | handleEditClick: () => void; 8 | handleDeleteClick: () => void; 9 | children?: React.ReactNode; 10 | }; 11 | 12 | export const GridActions = (props: Props) => { 13 | const { children, handleDeleteClick, handleEditClick } = props; 14 | const { classes } = useGridActionsStyles(); 15 | 16 | return ( 17 |
18 | 19 | 20 | 21 | 22 | 23 | 24 | {children} 25 |
26 | ); 27 | }; 28 | -------------------------------------------------------------------------------- /internal/webui/src/components/GridToolbar/GridToolbar.tsx: -------------------------------------------------------------------------------- 1 | import AddIcon from "@mui/icons-material/Add"; 2 | import { Button } from '@mui/material'; 3 | import { GridToolbarColumnsButton, GridToolbarContainer, GridToolbarFilterButton } from "@mui/x-data-grid"; 4 | 5 | type Props = { 6 | onNewClick: () => void; 7 | children?: React.ReactNode; 8 | }; 9 | 10 | export const GridToolbar = (props: Props) => { 11 | const { onNewClick, children } = props; 12 | 13 | return ( 14 | 15 | 16 | 17 | 18 | {children} 19 | 20 | ); 21 | }; 22 | -------------------------------------------------------------------------------- /internal/webui/src/components/Loading/Loading.styles.ts: -------------------------------------------------------------------------------- 1 | import { makeStyles } from "tss-react/mui"; 2 | 3 | export const useLoadingStyles = makeStyles()( 4 | () => ({ 5 | root: { 6 | width: "100%", 7 | height: "100%", 8 | display: "flex", 9 | justifyContent: "center", 10 | alignItems: "center", 11 | }, 12 | }), 13 | ); 14 | -------------------------------------------------------------------------------- /internal/webui/src/components/Loading/Loading.tsx: -------------------------------------------------------------------------------- 1 | import { CircularProgress } from "@mui/material"; 2 | import { useLoadingStyles } from "./Loading.styles"; 3 | 4 | export const Loading = () => { 5 | const { classes } = useLoadingStyles(); 6 | 7 | return ( 8 |
9 | 10 |
11 | ); 12 | }; 13 | -------------------------------------------------------------------------------- /internal/webui/src/components/MetricPopUp/MetricPopUp.consts.tsx: -------------------------------------------------------------------------------- 1 | import { GridColDef } from "@mui/x-data-grid"; 2 | 3 | export const useMetricPopUpColumns = (): GridColDef[] => ([ 4 | { 5 | field: "name", 6 | headerName: "Name", 7 | flex: 1, 8 | }, 9 | { 10 | field: "interval", 11 | headerName: "Update interval", 12 | align: "center", 13 | headerAlign: "center", 14 | flex: 1, 15 | }, 16 | ]); 17 | -------------------------------------------------------------------------------- /internal/webui/src/components/MetricPopUp/MetricPopUp.tsx: -------------------------------------------------------------------------------- 1 | import { useMemo, useState } from "react"; 2 | import TableViewIcon from "@mui/icons-material/TableView"; 3 | import { Dialog, DialogContent, IconButton } from "@mui/material"; 4 | import { DataGrid } from "@mui/x-data-grid"; 5 | import { useMetricPopUpColumns } from "./MetricPopUp.consts"; 6 | 7 | type MetricRows = { 8 | name: string; 9 | interval: number; 10 | }; 11 | 12 | type Props = { 13 | Metrics?: Record | null; 14 | }; 15 | 16 | export const MetricPopUp = ({ Metrics }: Props) => { 17 | const [open, setOpen] = useState(false); 18 | 19 | const rows: MetricRows[] = useMemo(() => { 20 | if (Metrics) { 21 | return Object.keys(Metrics).map((key) => ({ 22 | name: key, 23 | interval: Metrics[key], 24 | })); 25 | } 26 | return []; 27 | }, [Metrics]); 28 | 29 | const columns = useMetricPopUpColumns(); 30 | 31 | const handleOpen = () => setOpen(true); 32 | 33 | const handleClose = () => setOpen(false); 34 | 35 | return rows.length !== 0 ? ( 36 | <> 37 | 38 | 39 | 40 | 45 | 46 | row.name} 48 | columns={columns} 49 | rows={rows} 50 | rowsPerPageOptions={[]} 51 | autoHeight 52 | disableColumnMenu 53 | /> 54 | 55 | 56 | 57 | ) : null; 58 | }; 59 | -------------------------------------------------------------------------------- /internal/webui/src/components/PasswordInput/PasswordInput.tsx: -------------------------------------------------------------------------------- 1 | import { ComponentProps, forwardRef, useState } from "react"; 2 | import VisibilityIcon from "@mui/icons-material/Visibility"; 3 | import VisibilityOffIcon from "@mui/icons-material/VisibilityOff"; 4 | import { IconButton, OutlinedInput } from "@mui/material"; 5 | 6 | export const PasswordInput = forwardRef>((props, ref) => { 7 | const [isVisible, setisVisible] = useState(false); 8 | 9 | const handleVisibilityChange = () => { 10 | setisVisible((prev) => !prev); 11 | }; 12 | 13 | return ( 14 | 20 | {isVisible ? : } 21 | 22 | } 23 | /> 24 | ); 25 | }); 26 | 27 | -------------------------------------------------------------------------------- /internal/webui/src/components/WarningDialog/WarningDialog.tsx: -------------------------------------------------------------------------------- 1 | import { Button, Dialog, DialogActions, DialogContent, DialogContentText, DialogTitle } from "@mui/material"; 2 | 3 | type Props = { 4 | open: boolean; 5 | message: string; 6 | onClose: () => void; 7 | onSubmit: () => void; 8 | }; 9 | 10 | export const WarningDialog = (props: Props) => { 11 | const {onSubmit, onClose, open, message} = props; 12 | 13 | return ( 14 | 15 | Warning 16 | 17 | 18 | {message} 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | ); 27 | }; 28 | -------------------------------------------------------------------------------- /internal/webui/src/consts/queryKeys.ts: -------------------------------------------------------------------------------- 1 | export enum QueryKeys { 2 | Metric = "Metric", 3 | Preset = "Preset", 4 | Source = "Source", 5 | }; 6 | -------------------------------------------------------------------------------- /internal/webui/src/containers/MetricFormDialog/MetricFormDialog.consts.ts: -------------------------------------------------------------------------------- 1 | import { MetricGridRow } from "pages/MetricsPage/components/MetricsGrid/MetricsGrid.types"; 2 | import { MetricRequestBody } from "types/Metric/MetricRequestBody"; 3 | import yaml from "yaml"; 4 | import { MetricFormValues } from "./components/MetricForm/MetricForm.types"; 5 | 6 | export const convertGauges = (data: string[] | null) => data && data.toString().replace(/,/g, "\n"); 7 | 8 | export const getMetricInitialValues = (data?: MetricGridRow): MetricFormValues => { 9 | return { 10 | Name: data?.Key ?? "", 11 | StorageName: data?.Metric.StorageName ?? "", 12 | NodeStatus: data?.Metric.NodeStatus ?? "", 13 | Description: data?.Metric.Description ?? "", 14 | Gauges: convertGauges(data?.Metric.Gauges ?? [""]), 15 | InitSQL: data?.Metric.InitSQL ?? "", 16 | IsInstanceLevel: data?.Metric.IsInstanceLevel ?? false, 17 | SQLs: yaml.stringify(data?.Metric.SQLs) ?? "", 18 | }; 19 | }; 20 | 21 | export const createMetricRequest = (values: MetricFormValues): MetricRequestBody => { 22 | const sqls: Record = {}; 23 | yaml.parse(values.SQLs, (key, value) => { 24 | if (key) { 25 | const version = Number(key); 26 | if (Number.isNaN(version)) { 27 | throw new Error("Version is not a valid number"); 28 | } 29 | sqls[Number(key)] = String(value); 30 | } 31 | }); 32 | 33 | return { 34 | Name: values.Name, 35 | Data: { 36 | StorageName: values.StorageName, 37 | NodeStatus: values.NodeStatus, 38 | Description: values.Description, 39 | Gauges: values.Gauges?.split("\n"), 40 | InitSQL: values.InitSQL, 41 | IsInstanceLevel: values.IsInstanceLevel, 42 | SQLs: sqls, 43 | }, 44 | }; 45 | }; 46 | -------------------------------------------------------------------------------- /internal/webui/src/containers/MetricFormDialog/components/MetricForm/MetricForm.consts.ts: -------------------------------------------------------------------------------- 1 | import * as Yup from "yup"; 2 | 3 | export enum MetricFormSteps { 4 | General = "General", 5 | Settings = "Settings", 6 | SQLs = "SQLs", 7 | }; 8 | 9 | export const metricFormValuesValidationSchema = Yup.object({ 10 | Name: Yup.string().trim().required("Name is required"), 11 | StorageName: Yup.string().optional().nullable(), 12 | NodeStatus: Yup.string().optional().nullable(), 13 | Description: Yup.string().optional().nullable(), 14 | Gauges: Yup.string().optional().nullable(), 15 | InitSQL: Yup.string().optional().nullable(), 16 | IsInstanceLevel: Yup.bool().required(), 17 | SQLs: Yup.string().trim().required("SQLs is required"), 18 | }); 19 | -------------------------------------------------------------------------------- /internal/webui/src/containers/MetricFormDialog/components/MetricForm/MetricForm.tsx: -------------------------------------------------------------------------------- 1 | import { useState } from "react"; 2 | import { useFormStyles } from "styles/form"; 3 | import { MetricFormSteps } from "./MetricForm.consts"; 4 | import { MetricFormStep } from "./MetricForm.types"; 5 | import { MetricFormStepGeneral } from "./components/MetricFormStepGeneral"; 6 | import { MetricFormStepSQL } from "./components/MetricFormStepSQL"; 7 | import { MetricFormStepSettings } from "./components/MetricFormStepSettings"; 8 | import { StepButtons } from "./components/StepButtons/StepButtons"; 9 | 10 | export const MetricForm = () => { 11 | const [currentStep, setCurrentStep] = useState(MetricFormSteps.General); 12 | const { classes } = useFormStyles(); 13 | 14 | return ( 15 |
16 | 17 | {currentStep === MetricFormSteps.General && ( 18 | 19 | )} 20 | {currentStep === MetricFormSteps.Settings && ( 21 | 22 | )} 23 | {currentStep === MetricFormSteps.SQLs && ( 24 | 25 | )} 26 |
27 | ); 28 | }; 29 | -------------------------------------------------------------------------------- /internal/webui/src/containers/MetricFormDialog/components/MetricForm/MetricForm.types.ts: -------------------------------------------------------------------------------- 1 | import { MetricFormSteps } from "./MetricForm.consts"; 2 | 3 | export type MetricFormStep = keyof typeof MetricFormSteps; 4 | 5 | type MetricFormGeneral = { 6 | Name: string; 7 | StorageName?: string | null; 8 | NodeStatus?: string | null; 9 | Description?: string | null; 10 | }; 11 | 12 | type MetricFormSettings = { 13 | Gauges?: string | null; 14 | InitSQL?: string | null; 15 | IsInstanceLevel: boolean; 16 | }; 17 | 18 | type MetricFormSQL = { 19 | SQLs: string; 20 | }; 21 | 22 | export type MetricFormValues = MetricFormGeneral & MetricFormSettings & MetricFormSQL; 23 | -------------------------------------------------------------------------------- /internal/webui/src/containers/MetricFormDialog/components/MetricForm/components/MetricFormStepSQL.tsx: -------------------------------------------------------------------------------- 1 | import { FormControl, FormHelperText, InputLabel, OutlinedInput } from "@mui/material"; 2 | import { useFormContext } from "react-hook-form"; 3 | import { useFormStyles } from "styles/form"; 4 | import { MetricFormValues } from "../MetricForm.types"; 5 | 6 | export const MetricFormStepSQL = () => { 7 | const { register, formState: { errors } } = useFormContext(); 8 | const { classes, cx } = useFormStyles(); 9 | 10 | const hasError = (field: keyof MetricFormValues) => !!errors[field]; 11 | 12 | const getError = (field: keyof MetricFormValues) => { 13 | const error = errors[field]; 14 | if (error) { 15 | return error.message; 16 | } 17 | return undefined; 18 | }; 19 | 20 | return ( 21 |
22 | 27 | SQLs 28 | 42 | {getError("SQLs")} 43 | 44 |
45 | ); 46 | }; 47 | -------------------------------------------------------------------------------- /internal/webui/src/containers/MetricFormDialog/components/MetricForm/components/MetricFormStepSettings.tsx: -------------------------------------------------------------------------------- 1 | import { Checkbox, FormControl, FormControlLabel, FormHelperText, InputLabel, OutlinedInput } from "@mui/material"; 2 | import { useController, useFormContext } from "react-hook-form"; 3 | import { useFormStyles } from "styles/form"; 4 | import { MetricFormValues } from "../MetricForm.types"; 5 | 6 | export const MetricFormStepSettings = () => { 7 | const { register, control } = useFormContext(); 8 | const { classes, cx } = useFormStyles(); 9 | 10 | const { field } = useController({ name: "IsInstanceLevel", control }); 11 | 12 | return ( 13 |
14 | 18 | Gauges 19 | 27 | Write every gauge with a new line 28 | 29 | 33 | Init SQL 34 | 41 | 42 | 52 | } 53 | /> 54 |
55 | ); 56 | }; 57 | -------------------------------------------------------------------------------- /internal/webui/src/containers/MetricFormDialog/components/MetricForm/components/StepButtons/StepButtons.consts.ts: -------------------------------------------------------------------------------- 1 | import { MetricFormSteps } from "../../MetricForm.consts"; 2 | 3 | export const formErrors = { 4 | [MetricFormSteps.General]: ["Name"], 5 | [MetricFormSteps.Settings]: [""], 6 | [MetricFormSteps.SQLs]: ["SQLs"], 7 | }; 8 | -------------------------------------------------------------------------------- /internal/webui/src/containers/MetricFormDialog/components/MetricForm/components/StepButtons/StepButtons.tsx: -------------------------------------------------------------------------------- 1 | import { ToggleButton, ToggleButtonGroup } from "@mui/material"; 2 | import { useFormContext } from "react-hook-form"; 3 | import { MetricFormSteps } from "../../MetricForm.consts"; 4 | import { MetricFormStep, MetricFormValues } from "../../MetricForm.types"; 5 | import { formErrors } from "./StepButtons.consts"; 6 | 7 | type Props = { 8 | currentStep: MetricFormStep; 9 | setCurrentStep: React.Dispatch>, 10 | }; 11 | 12 | export const StepButtons = (props: Props) => { 13 | const { currentStep, setCurrentStep } = props; 14 | 15 | const { formState: { errors } } = useFormContext(); 16 | 17 | const handleStepChange = (_e: any, value?: MetricFormStep) => { 18 | value && setCurrentStep(value); 19 | }; 20 | 21 | const isStepError = (step: MetricFormStep) => { 22 | const fields = formErrors[step]; 23 | return Object.keys(errors).some(error => fields.includes(error)); 24 | }; 25 | 26 | const buttons = Object.entries(MetricFormSteps).map(([value, label]) => ( 27 | 36 | {label} 37 | 38 | )); 39 | 40 | return ( 41 | 49 | {buttons} 50 | 51 | ); 52 | }; 53 | -------------------------------------------------------------------------------- /internal/webui/src/containers/PresetFormDialog/PresetFormDialog.consts.ts: -------------------------------------------------------------------------------- 1 | import { PresetGridRow } from "pages/PresetsPage/components/PresetsGrid/PresetsGrid.types"; 2 | import { PresetRequestBody } from "types/Preset/PresetRequestBody"; 3 | import { PresetFormValues } from "./components/PresetForm/PresetForm.types"; 4 | 5 | export const getPresetInitialValues = (data?: PresetGridRow): PresetFormValues => { 6 | const metrics = data ? 7 | Object.keys(data.Preset.Metrics).map((key) => ({ 8 | Name: key, 9 | Interval: data.Preset.Metrics[key], 10 | })) : [{ Name: "", Interval: 10 }]; 11 | 12 | return { 13 | Name: data?.Key ?? "", 14 | Description: data?.Preset.Description ?? "", 15 | Metrics: metrics, 16 | }; 17 | }; 18 | 19 | export const createPresetRequest = (values: PresetFormValues): PresetRequestBody => { 20 | const metrics: Record = {}; 21 | values.Metrics.map(({ Name, Interval }) => metrics[Name] = Interval); 22 | return { 23 | Name: values.Name, 24 | Data: { 25 | Description: values.Description, 26 | Metrics: metrics, 27 | }, 28 | }; 29 | }; 30 | -------------------------------------------------------------------------------- /internal/webui/src/containers/PresetFormDialog/components/PresetForm/PresetForm.consts.ts: -------------------------------------------------------------------------------- 1 | import * as Yup from "yup"; 2 | 3 | export enum PresetFormSteps { 4 | General = "General", 5 | Metrics = "Metrics", 6 | }; 7 | 8 | const metricValidationSchema = Yup.object({ 9 | Name: Yup.string().required("Choose metric or delete it"), 10 | Interval: Yup.number().typeError("Please provide a number") 11 | .required("Interval is required") 12 | .min(10, "Min. interval is 10") 13 | .max(604800, "Max. interval is 604800"), 14 | }); 15 | 16 | export const presetFormValuesValidationSchema = Yup.object({ 17 | Name: Yup.string().required("Name is required"), 18 | Description: Yup.string().optional(), 19 | Metrics: Yup.array().of(metricValidationSchema).test((arr, context) => { 20 | if (!arr) { 21 | return; 22 | } 23 | const list = arr.map(val => val.Name); 24 | const set = [...new Set(arr.map(val => val.Name))]; 25 | if (list.length === set.length) { 26 | return true; 27 | } 28 | const idx = list.findIndex((v, i) => v !== set[i]); 29 | return context.createError({ path: `${context.path}[${idx}].Name`, message: "Name should be unique", type: "unique" }); 30 | }).required("Metrics are required"), 31 | }); 32 | -------------------------------------------------------------------------------- /internal/webui/src/containers/PresetFormDialog/components/PresetForm/PresetForm.tsx: -------------------------------------------------------------------------------- 1 | import { useState } from "react"; 2 | import { useFormStyles } from "styles/form"; 3 | import { PresetFormSteps } from "./PresetForm.consts"; 4 | import { PresetFormStep } from "./PresetForm.types"; 5 | import { PresetFormStepGeneral } from "./components/PresetFormStepGeneral"; 6 | import { PresetFormStepMetrics } from "./components/PresetFormStepMetrics"; 7 | import { StepButtons } from "./components/StepButtons/StepButtons"; 8 | 9 | export const PresetForm = () => { 10 | const [currentStep, setCurrentStep] = useState(PresetFormSteps.General); 11 | const { classes } = useFormStyles(); 12 | 13 | return ( 14 |
15 | 16 | {currentStep === PresetFormSteps.General && ( 17 | 18 | )} 19 | {currentStep === PresetFormSteps.Metrics && ( 20 | 21 | )} 22 |
23 | ); 24 | }; 25 | -------------------------------------------------------------------------------- /internal/webui/src/containers/PresetFormDialog/components/PresetForm/PresetForm.types.ts: -------------------------------------------------------------------------------- 1 | import { PresetFormSteps } from "./PresetForm.consts"; 2 | 3 | export type PresetFormStep = keyof typeof PresetFormSteps; 4 | 5 | type PresetFormGeneral = { 6 | Name: string; 7 | Description?: string; 8 | }; 9 | 10 | type PresetFormMetrics = { 11 | Metrics: { 12 | Name: string; 13 | Interval: number; 14 | }[]; 15 | }; 16 | 17 | export type PresetFormValues = PresetFormGeneral & PresetFormMetrics; 18 | -------------------------------------------------------------------------------- /internal/webui/src/containers/PresetFormDialog/components/PresetForm/components/PresetFormStepGeneral.tsx: -------------------------------------------------------------------------------- 1 | import { FormControl, FormHelperText, InputLabel, OutlinedInput } from "@mui/material"; 2 | import { useFormContext } from "react-hook-form"; 3 | import { useFormStyles } from "styles/form"; 4 | import { PresetFormValues } from "../PresetForm.types"; 5 | 6 | export const PresetFormStepGeneral = () => { 7 | const { register, formState: { errors } } = useFormContext(); 8 | const { classes, cx } = useFormStyles(); 9 | 10 | const hasError = (field: keyof PresetFormValues) => !!errors[field]; 11 | 12 | const getError = (field: keyof PresetFormValues) => { 13 | const error = errors[field]; 14 | return error && error.message; 15 | }; 16 | 17 | return ( 18 |
19 | 24 | Name 25 | 31 | {getError("Name")} 32 | 33 | 37 | Description 38 | 45 | 46 |
47 | ); 48 | }; 49 | -------------------------------------------------------------------------------- /internal/webui/src/containers/PresetFormDialog/components/PresetForm/components/StepButtons/StepButtons.consts.ts: -------------------------------------------------------------------------------- 1 | import { PresetFormSteps } from "../../PresetForm.consts"; 2 | 3 | export const formErrors = { 4 | [PresetFormSteps.General]: ["Name"], 5 | [PresetFormSteps.Metrics]: ["Metrics", "Name", "Interval"], 6 | }; 7 | -------------------------------------------------------------------------------- /internal/webui/src/containers/PresetFormDialog/components/PresetForm/components/StepButtons/StepButtons.tsx: -------------------------------------------------------------------------------- 1 | import { ToggleButton, ToggleButtonGroup } from "@mui/material"; 2 | import { useFormContext } from "react-hook-form"; 3 | import { PresetFormSteps } from "../../PresetForm.consts"; 4 | import { PresetFormStep, PresetFormValues } from "../../PresetForm.types"; 5 | import { formErrors } from "./StepButtons.consts"; 6 | 7 | type Props = { 8 | currentStep: PresetFormStep; 9 | setCurrentStep: React.Dispatch>; 10 | }; 11 | 12 | export const StepButtons = (props: Props) => { 13 | const { currentStep, setCurrentStep } = props; 14 | 15 | const { formState: { errors } } = useFormContext(); 16 | 17 | const handleStepChange = (_e: any, value?: PresetFormStep) => { 18 | value && setCurrentStep(value); 19 | }; 20 | 21 | const isStepError = (step: PresetFormStep) => { 22 | const fields = formErrors[step]; 23 | return Object.keys(errors).some(error => fields.includes(error)); 24 | }; 25 | 26 | const buttons = Object.entries(PresetFormSteps).map(([value, label]) => ( 27 | 36 | {label} 37 | 38 | )); 39 | 40 | return( 41 | 49 | {buttons} 50 | 51 | ); 52 | }; 53 | -------------------------------------------------------------------------------- /internal/webui/src/containers/SourceFormDialog/SourceFormDialog.consts.ts: -------------------------------------------------------------------------------- 1 | import { Source } from "types/Source/Source"; 2 | import { toArrayFromRecord } from "utils/toArrayFromRecord"; 3 | import { toRecordFromArray } from "utils/toRecordFromArray"; 4 | import { SourceFormValues } from "./components/SourceForm/SourceForm.types"; 5 | 6 | export const getSourceInitialValues = (data?: Source): SourceFormValues => ({ 7 | Name: data?.Name ?? "", 8 | Group: data?.Group ?? "default", 9 | ConnStr: data?.ConnStr ?? "", 10 | Kind: data?.Kind ?? "postgres", 11 | IsEnabled: data?.IsEnabled ?? true, 12 | Metrics: toArrayFromRecord(data?.Metrics), 13 | MetricsStandby: toArrayFromRecord(data?.MetricsStandby), 14 | PresetMetrics: data?.PresetMetrics ?? "basic", 15 | PresetMetricsStandby: data?.PresetMetricsStandby ?? "", 16 | OnlyIfMaster: data?.OnlyIfMaster ?? false, 17 | CustomTags: toArrayFromRecord(data?.CustomTags), 18 | IncludePattern: data?.IncludePattern ?? "", 19 | ExcludePattern: data?.ExcludePattern ?? "", 20 | }); 21 | 22 | export const createSourceRequest = (values: SourceFormValues): Source => ({ 23 | Name: values.Name, 24 | Group: values.Group, 25 | ConnStr: values.ConnStr, 26 | Kind: values.Kind, 27 | IsEnabled: values.IsEnabled, 28 | Metrics: toRecordFromArray(values.Metrics), 29 | MetricsStandby: toRecordFromArray(values.MetricsStandby), 30 | PresetMetrics: values.PresetMetrics ?? "", 31 | PresetMetricsStandby: values.PresetMetricsStandby ?? "", 32 | OnlyIfMaster: values.OnlyIfMaster, 33 | CustomTags: toRecordFromArray(values.CustomTags), 34 | IncludePattern: values.IncludePattern ?? "", 35 | ExcludePattern: values.ExcludePattern ?? "", 36 | HostConfig: {}, 37 | }); 38 | -------------------------------------------------------------------------------- /internal/webui/src/containers/SourceFormDialog/components/SourceForm/SourceForm.tsx: -------------------------------------------------------------------------------- 1 | import { useState } from "react"; 2 | import { useFormStyles } from "styles/form"; 3 | import { SourceFormSteps } from "./SourceForm.consts"; 4 | import { SourceFormStep } from "./SourceForm.types"; 5 | import { SourceFormStepGeneral } from "./components/SourceFormStepGeneral"; 6 | import { SourceFormStepMetrics } from "./components/SourceFormStepMetrics"; 7 | import { SourceFormStepTags } from "./components/SourceFormStepTags"; 8 | import { StepButtons } from "./components/StepButtons/StepButtons"; 9 | 10 | export const SourceForm = () => { 11 | const [currentStep, setCurrentStep] = useState(SourceFormSteps.General); 12 | const { classes } = useFormStyles(); 13 | 14 | return ( 15 |
16 | 17 | {currentStep === SourceFormSteps.General && } 18 | {currentStep === SourceFormSteps.Metrics && } 19 | {currentStep === SourceFormSteps.Tags && } 20 |
21 | ); 22 | }; 23 | -------------------------------------------------------------------------------- /internal/webui/src/containers/SourceFormDialog/components/SourceForm/SourceForm.types.ts: -------------------------------------------------------------------------------- 1 | import { SourceFormSteps } from "./SourceForm.consts"; 2 | 3 | export type SourceFormStep = keyof typeof SourceFormSteps; 4 | 5 | type SourceFormGeneral = { 6 | Name: string; 7 | Group: string; 8 | ConnStr: string; 9 | Kind: string; 10 | IncludePattern?: string | null; 11 | ExcludePattern?: string | null; 12 | IsEnabled: boolean; 13 | }; 14 | 15 | type SourceFormMetrics = { 16 | Metrics?: { 17 | Name: string; 18 | Value: number; 19 | }[] | null; 20 | MetricsStandby?: { 21 | Name: string; 22 | Value: number; 23 | }[] | null; 24 | PresetMetrics?: string | null; 25 | PresetMetricsStandby?: string | null; 26 | OnlyIfMaster: boolean; 27 | }; 28 | 29 | type SourceFormTags = { 30 | CustomTags?: { 31 | Name: string; 32 | Value: string; 33 | }[] | null, 34 | }; 35 | 36 | export type SourceFormValues = 37 | SourceFormGeneral & 38 | SourceFormMetrics & 39 | SourceFormTags; 40 | -------------------------------------------------------------------------------- /internal/webui/src/containers/SourceFormDialog/components/SourceForm/components/StepButtons/StepButtons.consts.ts: -------------------------------------------------------------------------------- 1 | import { SourceFormSteps } from "../../SourceForm.consts"; 2 | 3 | export const formErrors = { 4 | [SourceFormSteps.General]: ["Name", "Group", "ConnStr", "Kind"], 5 | [SourceFormSteps.Metrics]: ["Metrics", "MetricsStandby", "PresetMetrics"], 6 | [SourceFormSteps.Tags]: ["CustomTags"], 7 | }; 8 | -------------------------------------------------------------------------------- /internal/webui/src/containers/SourceFormDialog/components/SourceForm/components/StepButtons/StepButtons.tsx: -------------------------------------------------------------------------------- 1 | import { ToggleButton, ToggleButtonGroup } from "@mui/material"; 2 | import { useFormContext } from "react-hook-form"; 3 | import { SourceFormSteps } from "../../SourceForm.consts"; 4 | import { SourceFormStep, SourceFormValues } from "../../SourceForm.types"; 5 | import { formErrors } from "./StepButtons.consts"; 6 | 7 | type Props = { 8 | currentStep: SourceFormStep; 9 | setCurrentStep: React.Dispatch>; 10 | }; 11 | 12 | export const StepButtons = (props: Props) => { 13 | const { currentStep, setCurrentStep } = props; 14 | 15 | const { formState: { errors } } = useFormContext(); 16 | 17 | const handleStepChange = (_e: any, value?: SourceFormStep) => { 18 | value && setCurrentStep(value); 19 | }; 20 | 21 | const isStepError = (step: SourceFormStep) => { 22 | const fields = formErrors[step]; 23 | return Object.keys(errors).some(error => fields.includes(error)); 24 | }; 25 | 26 | const buttons = Object.entries(SourceFormSteps).map(([value, label]) => ( 27 | 36 | {label} 37 | 38 | )); 39 | 40 | return ( 41 | 49 | {buttons} 50 | 51 | ); 52 | }; 53 | -------------------------------------------------------------------------------- /internal/webui/src/containers/SourceFormDialog/components/SourceForm/components/TestConnection/TestConnection.tsx: -------------------------------------------------------------------------------- 1 | import CableIcon from '@mui/icons-material/Cable'; 2 | import { IconButton, Tooltip } from "@mui/material"; 3 | import { useTestConnection } from "queries/Source"; 4 | 5 | type Props = { 6 | ConnStr: string; 7 | }; 8 | 9 | export const TestConnection = ({ ConnStr }: Props) => { 10 | const testConnection = useTestConnection(); 11 | 12 | const handleTestConnection = () => { 13 | testConnection.mutate(ConnStr); 14 | }; 15 | 16 | return ( 17 | 18 | 19 | 20 | 21 | 22 | ); 23 | }; 24 | -------------------------------------------------------------------------------- /internal/webui/src/contexts/MetricForm/MetricForm.consts.ts: -------------------------------------------------------------------------------- 1 | import { MetricFormContextType } from "./MetricForm.types"; 2 | 3 | export const MetricFormContextDefaults: MetricFormContextType = { 4 | data: undefined, 5 | setData: () => undefined, 6 | open: false, 7 | handleOpen: () => undefined, 8 | handleClose: () => undefined, 9 | }; 10 | -------------------------------------------------------------------------------- /internal/webui/src/contexts/MetricForm/MetricForm.context.ts: -------------------------------------------------------------------------------- 1 | import { createContext, useContext } from "react"; 2 | import { MetricFormContextDefaults } from "./MetricForm.consts"; 3 | import { MetricFormContextType } from "./MetricForm.types"; 4 | 5 | export const MetricFormContext = createContext(MetricFormContextDefaults); 6 | 7 | export const useMetricFormContext = () => useContext(MetricFormContext); 8 | -------------------------------------------------------------------------------- /internal/webui/src/contexts/MetricForm/MetricForm.provider.tsx: -------------------------------------------------------------------------------- 1 | import { useState } from "react"; 2 | import { MetricGridRow } from "pages/MetricsPage/components/MetricsGrid/MetricsGrid.types"; 3 | import { MetricFormContext } from "./MetricForm.context"; 4 | import { MetricFormContextType } from "./MetricForm.types"; 5 | 6 | type Props = { 7 | children?: React.ReactNode; 8 | }; 9 | 10 | export const MetricFormProvider = ({ children }: Props) => { 11 | const [data, setData] = useState(undefined); 12 | const [open, setOpen] = useState(false); 13 | 14 | const handleOpen = () => setOpen(true); 15 | 16 | const handleClose = () => setOpen(false); 17 | 18 | const getContextValue = (): MetricFormContextType => ({ 19 | data, 20 | setData, 21 | open, 22 | handleOpen, 23 | handleClose, 24 | }); 25 | 26 | return ( 27 | 28 | {children} 29 | 30 | ); 31 | }; 32 | -------------------------------------------------------------------------------- /internal/webui/src/contexts/MetricForm/MetricForm.types.ts: -------------------------------------------------------------------------------- 1 | import { MetricGridRow } from "pages/MetricsPage/components/MetricsGrid/MetricsGrid.types"; 2 | 3 | export type MetricFormContextType = { 4 | data: MetricGridRow | undefined; 5 | setData: React.Dispatch>; 6 | open: boolean; 7 | handleOpen: () => void; 8 | handleClose: () => void; 9 | }; 10 | -------------------------------------------------------------------------------- /internal/webui/src/contexts/PresetForm/PresetForm.consts.ts: -------------------------------------------------------------------------------- 1 | import { PresetFormContextType } from "./PresetForm.types"; 2 | 3 | export const PresetFormContextDefaults: PresetFormContextType = { 4 | data: undefined, 5 | setData: () => undefined, 6 | open: false, 7 | handleOpen: () => undefined, 8 | handleClose: () => undefined, 9 | }; 10 | -------------------------------------------------------------------------------- /internal/webui/src/contexts/PresetForm/PresetForm.context.ts: -------------------------------------------------------------------------------- 1 | import { createContext, useContext } from "react"; 2 | import { PresetFormContextDefaults } from "./PresetForm.consts"; 3 | import { PresetFormContextType } from "./PresetForm.types"; 4 | 5 | export const PresetFormContext = createContext(PresetFormContextDefaults); 6 | 7 | export const usePresetFormContext = () => useContext(PresetFormContext); 8 | -------------------------------------------------------------------------------- /internal/webui/src/contexts/PresetForm/PresetForm.provider.tsx: -------------------------------------------------------------------------------- 1 | import { useState } from "react"; 2 | import { PresetGridRow } from "pages/PresetsPage/components/PresetsGrid/PresetsGrid.types"; 3 | import { PresetFormContext } from "./PresetForm.context"; 4 | import { PresetFormContextType } from "./PresetForm.types"; 5 | 6 | type Props = { 7 | children?: React.ReactNode; 8 | }; 9 | 10 | export const PresetFormProvider = ({ children }: Props) => { 11 | const [data, setData] = useState(undefined); 12 | const [open, setOpen] = useState(false); 13 | 14 | const handleOpen = () => setOpen(true); 15 | 16 | const handleClose = () => setOpen(false); 17 | 18 | const getContextValue = (): PresetFormContextType => ({ 19 | data, 20 | setData, 21 | open, 22 | handleOpen, 23 | handleClose, 24 | }); 25 | 26 | return ( 27 | 28 | {children} 29 | 30 | ); 31 | }; 32 | -------------------------------------------------------------------------------- /internal/webui/src/contexts/PresetForm/PresetForm.types.ts: -------------------------------------------------------------------------------- 1 | import { PresetGridRow } from "pages/PresetsPage/components/PresetsGrid/PresetsGrid.types"; 2 | 3 | export type PresetFormContextType = { 4 | data: PresetGridRow | undefined; 5 | setData: React.Dispatch>; 6 | open: boolean; 7 | handleOpen: () => void; 8 | handleClose: () => void; 9 | }; 10 | -------------------------------------------------------------------------------- /internal/webui/src/contexts/SourceForm/SourceForm.consts.ts: -------------------------------------------------------------------------------- 1 | import { SourceFormActions, SourceFormContextType } from "./SourceForm.types"; 2 | 3 | export const SourceFormContextDefaults: SourceFormContextType = { 4 | data: undefined, 5 | setData: () => undefined, 6 | action: SourceFormActions.Create, 7 | setAction: () => undefined, 8 | open: false, 9 | handleOpen: () => undefined, 10 | handleClose: () => undefined, 11 | }; 12 | -------------------------------------------------------------------------------- /internal/webui/src/contexts/SourceForm/SourceForm.context.ts: -------------------------------------------------------------------------------- 1 | import { createContext, useContext } from "react"; 2 | import { SourceFormContextDefaults } from "./SourceForm.consts"; 3 | import { SourceFormContextType } from "./SourceForm.types"; 4 | 5 | export const SourceFormContext = createContext(SourceFormContextDefaults); 6 | 7 | export const useSourceFormContext = () => useContext(SourceFormContext); 8 | -------------------------------------------------------------------------------- /internal/webui/src/contexts/SourceForm/SourceForm.provider.tsx: -------------------------------------------------------------------------------- 1 | import { useState } from "react"; 2 | import { Source } from "types/Source/Source"; 3 | import { SourceFormContext } from "./SourceForm.context"; 4 | import { SourceFormActions, SourceFormContextType } from "./SourceForm.types"; 5 | 6 | type Props = { 7 | children?: React.ReactNode; 8 | }; 9 | 10 | export const SourceFormProvider = ({ children }: Props) => { 11 | const [data, setData] = useState(undefined); 12 | const [action, setAction] = useState(SourceFormActions.Create); 13 | const [open, setOpen] = useState(false); 14 | 15 | const handleOpen = () => { setOpen(true); }; 16 | 17 | const handleClose = () => setOpen(false); 18 | 19 | const getContextValue = (): SourceFormContextType => ({ 20 | data, 21 | setData, 22 | action, 23 | setAction, 24 | open, 25 | handleOpen, 26 | handleClose, 27 | }); 28 | 29 | return ( 30 | 31 | {children} 32 | 33 | ); 34 | }; 35 | -------------------------------------------------------------------------------- /internal/webui/src/contexts/SourceForm/SourceForm.types.ts: -------------------------------------------------------------------------------- 1 | import { Source } from "types/Source/Source"; 2 | 3 | export enum SourceFormActions { 4 | Create = "Create", 5 | Edit = "Edit", 6 | Copy = "Copy", 7 | }; 8 | 9 | export type SourceFormContextType = { 10 | data: Source | undefined; 11 | setData: React.Dispatch>; 12 | action: SourceFormActions; 13 | setAction: React.Dispatch>; 14 | open: boolean; 15 | handleOpen: () => void; 16 | handleClose: () => void; 17 | }; 18 | -------------------------------------------------------------------------------- /internal/webui/src/hooks/useGridColumnVisibility.ts: -------------------------------------------------------------------------------- 1 | import { useCallback, useState } from 'react'; 2 | import { GridColDef, GridColumnVisibilityModel } from '@mui/x-data-grid'; 3 | 4 | export const useGridColumnVisibility = ( 5 | storageKey: string, 6 | columns: GridColDef[] 7 | ) => { 8 | const [columnVisibility, setColumnVisibility] = useState(() => { 9 | const defaultVisibility = columns?.reduce((acc, col) => ({ 10 | ...acc, 11 | [col.field]: col.hide !== true 12 | }), {}); 13 | 14 | const saved = localStorage.getItem(storageKey); 15 | const savedVisibility = saved ? JSON.parse(saved) : {}; 16 | 17 | return { 18 | ...defaultVisibility, 19 | ...savedVisibility 20 | }; 21 | }); 22 | 23 | const handleColumnVisibilityChange = useCallback((newModel: GridColumnVisibilityModel) => { 24 | setColumnVisibility(newModel); 25 | localStorage.setItem(storageKey, JSON.stringify(newModel)); 26 | }, [storageKey]); 27 | 28 | return { 29 | columnVisibility, 30 | onColumnVisibilityChange: handleColumnVisibilityChange 31 | }; 32 | }; 33 | -------------------------------------------------------------------------------- /internal/webui/src/index.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import ReactDOM from "react-dom/client"; 3 | import { BrowserRouter } from "react-router-dom"; 4 | import { AlertProvider } from "utils/AlertContext"; 5 | import App from "./App"; 6 | import reportWebVitals from "./reportWebVitals"; 7 | 8 | const root = ReactDOM.createRoot( 9 | document.getElementById("root") as HTMLElement 10 | ); 11 | root.render( 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | ); 20 | 21 | // If you want to start measuring performance in your app, pass a function 22 | // to log results (for example: reportWebVitals(console.log)) 23 | // or send to an analytics endpoint. Learn more: https://bit.ly/CRA-vitals 24 | reportWebVitals(); 25 | -------------------------------------------------------------------------------- /internal/webui/src/layout/AppBar.tsx: -------------------------------------------------------------------------------- 1 | import LogoutIcon from '@mui/icons-material/Logout'; 2 | import { Box, Button, AppBar as MuiAppBar, Toolbar, Typography } from "@mui/material"; 3 | import { NavLink, useNavigate } from "react-router-dom"; 4 | import { logout } from 'queries/Auth'; 5 | import { getToken } from "services/Token"; 6 | import { privateRoutes } from "./Routes"; 7 | 8 | export const AppBar = () => { 9 | const navigate = useNavigate(); 10 | const token = getToken(); 11 | 12 | const menuLinks = privateRoutes.map((item) => ( 13 | 17 | {({ isActive }) => ( 18 | 19 | )} 20 | 21 | )); 22 | 23 | const handleLogout = () => { 24 | logout(navigate); 25 | }; 26 | 27 | return ( 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | PGWATCH 39 | 40 | 41 | { 42 | token && 43 | 44 | {menuLinks} 45 | 48 | 49 | } 50 | 51 | 52 | 53 | ); 54 | }; 55 | -------------------------------------------------------------------------------- /internal/webui/src/layout/PrivateRoute.tsx: -------------------------------------------------------------------------------- 1 | import { Navigate } from "react-router-dom"; 2 | import { getToken } from "services/Token"; 3 | 4 | type Props = { 5 | children: JSX.Element 6 | }; 7 | 8 | export const PrivateRoute = ({ children }: Props) => { 9 | const token = getToken(); 10 | 11 | if (!token) { 12 | return ; 13 | } 14 | 15 | return children; 16 | }; 17 | -------------------------------------------------------------------------------- /internal/webui/src/layout/Routes.ts: -------------------------------------------------------------------------------- 1 | import { LoginPage } from "pages/LoginPage/LoginPage"; 2 | import { LogsPage } from "pages/LogsPage/LogsPage"; 3 | import { MetricsPage } from "pages/MetricsPage/MetricsPage"; 4 | import { PresetsPage } from "pages/PresetsPage/PresetsPage"; 5 | import { SourcesPage } from "pages/SourcesPage/SourcesPage"; 6 | 7 | export const publicRoutes = [ 8 | { 9 | title: "Login", 10 | link: "/", 11 | element: LoginPage 12 | } 13 | ]; 14 | 15 | export const privateRoutes = [ 16 | { 17 | title: "Sources", 18 | link: "/sources", 19 | element: SourcesPage, 20 | }, 21 | { 22 | title: "Metrics", 23 | link: "/metrics", 24 | element: MetricsPage, 25 | }, 26 | { 27 | title: "Presets", 28 | link: "/presets", 29 | element: PresetsPage, 30 | }, 31 | { 32 | title: "Logs", 33 | link: "/logs", 34 | element: LogsPage, 35 | }, 36 | ]; 37 | -------------------------------------------------------------------------------- /internal/webui/src/layout/const.ts: -------------------------------------------------------------------------------- 1 | export const DRAWER_WIDTH = 240; 2 | -------------------------------------------------------------------------------- /internal/webui/src/pages/LoginPage/LoginPage.styles.ts: -------------------------------------------------------------------------------- 1 | import { makeStyles } from "tss-react/mui"; 2 | 3 | export const useLoginPageStyles = makeStyles()( 4 | () => ({ 5 | root: { 6 | display: "flex", 7 | justifyContent: "center", 8 | alignItems: "center", 9 | flexDirection: "column", 10 | gap: "5px", 11 | }, 12 | }), 13 | ); 14 | -------------------------------------------------------------------------------- /internal/webui/src/pages/LoginPage/LoginPage.tsx: -------------------------------------------------------------------------------- 1 | import { usePageStyles } from "styles/page"; 2 | import { useLoginPageStyles } from "./LoginPage.styles"; 3 | import { LoginForm } from "./components/LoginForm/LoginForm"; 4 | 5 | export const LoginPage = () => { 6 | const { classes: pageClasses } = usePageStyles(); 7 | const { classes: signLoginClasses, cx } = useLoginPageStyles(); 8 | 9 | return ( 10 |
11 | 12 |
13 | ); 14 | }; 15 | -------------------------------------------------------------------------------- /internal/webui/src/pages/LoginPage/components/LoginForm/LoginForm.consts.ts: -------------------------------------------------------------------------------- 1 | import * as Yup from "yup"; 2 | 3 | export const loginFormValuesValidationSchema = Yup.object({ 4 | user: Yup.string().required("User is required"), 5 | password: Yup.string().required("Password is required"), 6 | }); 7 | -------------------------------------------------------------------------------- /internal/webui/src/pages/LoginPage/components/LoginForm/LoginForm.styles.ts: -------------------------------------------------------------------------------- 1 | import { makeStyles } from "tss-react/mui"; 2 | 3 | export const useLoginFormStyles = makeStyles()( 4 | () => ({ 5 | form: { 6 | width: "270px", 7 | display: "flex", 8 | flexDirection: "column", 9 | gap: "24px", 10 | }, 11 | }) 12 | ); 13 | -------------------------------------------------------------------------------- /internal/webui/src/pages/LoginPage/components/LoginForm/LoginForm.types.ts: -------------------------------------------------------------------------------- 1 | export type LoginFormValues = { 2 | user: string; 3 | password: string; 4 | }; 5 | -------------------------------------------------------------------------------- /internal/webui/src/pages/LogsPage/LogsPage.tsx: -------------------------------------------------------------------------------- 1 | import { usePageStyles } from "styles/page"; 2 | import { Logs } from "./components/Logs"; 3 | 4 | export const LogsPage = () => { 5 | const { classes } = usePageStyles(); 6 | 7 | return ( 8 |
9 | 10 |
11 | ); 12 | }; 13 | -------------------------------------------------------------------------------- /internal/webui/src/pages/LogsPage/components/Logs.styles.ts: -------------------------------------------------------------------------------- 1 | import { makeStyles } from "tss-react/mui"; 2 | 3 | export const useLogsStyles = makeStyles()( 4 | () => ({ 5 | root: { 6 | backgroundColor: "black", 7 | flexGrow: 1, 8 | position: "relative", 9 | width: "100%", 10 | height: "100%", 11 | }, 12 | grid: { 13 | color: "white", 14 | position: "absolute", 15 | inset: "1em", 16 | overflowY: "auto", 17 | overflowX: "hidden", 18 | wordWrap: "break-word", 19 | whiteSpace: "pre-wrap", 20 | fontSize: "14px", 21 | margin: 0 22 | }, 23 | log: { 24 | margin: "0 0 0.5em", 25 | }, 26 | }), 27 | ); 28 | -------------------------------------------------------------------------------- /internal/webui/src/pages/LogsPage/components/Logs.tsx: -------------------------------------------------------------------------------- 1 | import { useEffect, useState } from "react"; 2 | import { Loading } from "components/Loading/Loading"; 3 | import { ReadyState } from "react-use-websocket"; 4 | import { usePageStyles } from "styles/page"; 5 | import { useLogs } from "queries/Log"; 6 | import { useAlert } from "utils/AlertContext"; 7 | import { useLogsStyles } from "./Logs.styles"; 8 | import { LogOutput } from "./components/LogOutput"; 9 | 10 | export const Logs = () => { 11 | const { classes: pageClasses } = usePageStyles(); 12 | const { classes: logsClasses } = useLogsStyles(); 13 | 14 | const [logsHistory, setLogsHistory] = useState[]>([]); 15 | const { lastMessage, readyState } = useLogs(); 16 | const { callAlert } = useAlert(); 17 | 18 | useEffect(() => { 19 | if (lastMessage !== null) { 20 | setLogsHistory((prev) => prev.concat(lastMessage)); 21 | } 22 | }, [lastMessage]); 23 | 24 | useEffect(() => { 25 | switch (readyState) { 26 | case ReadyState.OPEN: 27 | callAlert("success", "Connection established"); 28 | break; 29 | case ReadyState.CLOSED: 30 | callAlert("error", "Connection closed"); 31 | break; 32 | case ReadyState.UNINSTANTIATED: 33 | callAlert("error", "Connection uninstantiated"); 34 | break; 35 | } 36 | }, [readyState]); // eslint-disable-line 37 | 38 | return ( 39 |
40 |
41 | {readyState === ReadyState.CONNECTING && } 42 |
43 |           {[...logsHistory].reverse().map((log, index) => (
44 |             
45 |           ))}
46 |         
47 |
48 |
49 | ); 50 | }; 51 | -------------------------------------------------------------------------------- /internal/webui/src/pages/LogsPage/components/components/LogOutput.consts.ts: -------------------------------------------------------------------------------- 1 | 2 | export const logEventColor: Record = { 3 | "[INFO]": "green", 4 | "[WARNING]": "orange", 5 | "[DEBUG]": "purple", 6 | "[ERROR]": "red", 7 | }; 8 | -------------------------------------------------------------------------------- /internal/webui/src/pages/LogsPage/components/components/LogOutput.tsx: -------------------------------------------------------------------------------- 1 | import { useMemo } from "react"; 2 | import { useLogsStyles } from "../Logs.styles"; 3 | import { logEventColor } from "./LogOutput.consts"; 4 | 5 | type Props = { 6 | log: MessageEvent; 7 | index: number; 8 | }; 9 | 10 | export const LogOutput = ({ log, index }: Props) => { 11 | const { classes } = useLogsStyles(); 12 | 13 | const splittedLog = useMemo( 14 | () => String(log.data).split(" "), 15 | [log.data], 16 | ); 17 | 18 | return ( 19 |

20 | { 21 | splittedLog.map((val, idx) => { 22 | if (logEventColor[val]) { 23 | return ( {val}); 24 | } else { 25 | if (idx === 0) { 26 | return val; 27 | } 28 | return " " + val; 29 | } 30 | }) 31 | } 32 |

33 | ); 34 | }; 35 | -------------------------------------------------------------------------------- /internal/webui/src/pages/MetricsPage/MetricsPage.tsx: -------------------------------------------------------------------------------- 1 | import { usePageStyles } from "styles/page"; 2 | import { MetricsGrid } from "./components/MetricsGrid/MetricsGrid"; 3 | 4 | export const MetricsPage = () => { 5 | 6 | const { classes } = usePageStyles(); 7 | 8 | return ( 9 |
10 | 11 |
12 | ); 13 | }; 14 | -------------------------------------------------------------------------------- /internal/webui/src/pages/MetricsPage/components/MetricsGrid/MetricsGrid.types.ts: -------------------------------------------------------------------------------- 1 | import { Metric } from "types/Metric/Metric"; 2 | 3 | export type MetricGridRow = { 4 | Key: string; 5 | Metric: Metric; 6 | }; 7 | -------------------------------------------------------------------------------- /internal/webui/src/pages/MetricsPage/components/MetricsGrid/components/MetricsGridActions/MetricsGridActions.tsx: -------------------------------------------------------------------------------- 1 | import { useEffect, useState } from "react"; 2 | import { GridActions } from "components/GridActions/GridActions"; 3 | import { WarningDialog } from "components/WarningDialog/WarningDialog"; 4 | import { useMetricFormContext } from "contexts/MetricForm/MetricForm.context"; 5 | import { useDeleteMetric } from "queries/Metric"; 6 | import { MetricGridRow } from "../../MetricsGrid.types"; 7 | 8 | type Props = { 9 | metric: MetricGridRow; 10 | }; 11 | 12 | export const MetricsGridActions = ({ metric }: Props) => { 13 | const { setData, handleOpen } = useMetricFormContext(); 14 | const [dialogOpen, setDialogOpen] = useState(false); 15 | const { mutate, isSuccess } = useDeleteMetric(); 16 | 17 | useEffect(() => { 18 | isSuccess && handleDialogClose(); 19 | }, [isSuccess]); 20 | 21 | const handleDialogClose = () => setDialogOpen(false); 22 | 23 | const handleEditClick = () => { 24 | setData(metric); 25 | handleOpen(); 26 | }; 27 | 28 | const handleDeleteClick = () => setDialogOpen(true); 29 | 30 | const handleSubmit = () => mutate(metric.Key); 31 | 32 | const message = `Are you sure want to delete metric "${metric.Key}"`; 33 | 34 | return ( 35 | <> 36 | 37 | 38 | 39 | ); 40 | }; 41 | -------------------------------------------------------------------------------- /internal/webui/src/pages/MetricsPage/components/MetricsGrid/components/MetricsGridToolbar/MetricsGridToolbar.tsx: -------------------------------------------------------------------------------- 1 | import { GridToolbar } from "components/GridToolbar/GridToolbar"; 2 | import { useMetricFormContext } from "contexts/MetricForm/MetricForm.context"; 3 | 4 | export const MetricsGridToolbar = () => { 5 | const { handleOpen, setData } = useMetricFormContext(); 6 | 7 | const onNewClick = () => { 8 | setData(undefined); 9 | handleOpen(); 10 | }; 11 | 12 | return ( 13 | 14 | ); 15 | }; 16 | -------------------------------------------------------------------------------- /internal/webui/src/pages/MetricsPage/components/MetricsGrid/components/SqlPopUp/SqlPopUp.consts.tsx: -------------------------------------------------------------------------------- 1 | import { GridColDef } from "@mui/x-data-grid"; 2 | 3 | export const useSqlPopUpColumns = (): GridColDef[] => [ 4 | { 5 | field: "version", 6 | headerName: "Version", 7 | }, 8 | { 9 | field: "sql", 10 | headerName: "SQL", 11 | align: "center", 12 | headerAlign: "center", 13 | width: 600, 14 | }, 15 | ]; 16 | -------------------------------------------------------------------------------- /internal/webui/src/pages/MetricsPage/components/MetricsGrid/components/SqlPopUp/SqlPopUp.tsx: -------------------------------------------------------------------------------- 1 | import { useMemo, useState } from "react"; 2 | import TableViewIcon from '@mui/icons-material/TableView'; 3 | import { Dialog, DialogContent, DialogTitle, IconButton, Tooltip } from "@mui/material"; 4 | import { DataGrid } from "@mui/x-data-grid"; 5 | import { useSqlPopUpColumns } from "./SqlPopUp.consts"; 6 | 7 | type SQLRows = { 8 | version: number; 9 | sql: string; 10 | }; 11 | 12 | type Props = { 13 | SQLs: Record; 14 | }; 15 | 16 | export const SqlPopUp = ({ SQLs }: Props) => { 17 | const [open, setOpen] = useState(false); 18 | 19 | const rows: SQLRows[] = useMemo(() => { 20 | return Object.keys(SQLs).map((key) => ({ 21 | version: Number(key), 22 | sql: SQLs[Number(key)], 23 | })); 24 | }, [SQLs]); 25 | 26 | const columns = useSqlPopUpColumns(); 27 | 28 | const handleOpen = () => setOpen(true); 29 | 30 | const handleClose = () => setOpen(false); 31 | 32 | return rows.length !== 0 ? ( 33 | <> 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 46 | SQLs 47 | 48 | row.version} 50 | columns={columns} 51 | rows={rows} 52 | rowsPerPageOptions={[]} 53 | getRowHeight={() => "auto"} 54 | autoHeight 55 | disableColumnMenu 56 | /> 57 | 58 | 59 | 60 | ) : null; 61 | }; 62 | -------------------------------------------------------------------------------- /internal/webui/src/pages/PresetsPage/PresetsPage.tsx: -------------------------------------------------------------------------------- 1 | import { usePageStyles } from "styles/page"; 2 | import { PresetsGrid } from "./components/PresetsGrid/PresetsGrid"; 3 | 4 | export const PresetsPage = () => { 5 | const { classes } = usePageStyles(); 6 | 7 | return ( 8 |
9 | 10 |
11 | ); 12 | }; 13 | -------------------------------------------------------------------------------- /internal/webui/src/pages/PresetsPage/components/PresetsGrid/PresetsGrid.consts.tsx: -------------------------------------------------------------------------------- 1 | import { GridColDef } from "@mui/x-data-grid"; 2 | import { MetricPopUp } from "../../../../components/MetricPopUp/MetricPopUp"; 3 | import { PresetGridRow } from "./PresetsGrid.types"; 4 | import { PresetsGridActions } from "./components/PresetsGridActions/PresetsGridActions"; 5 | 6 | export const usePresetsGridColumns = (): GridColDef[] => ([ 7 | { 8 | field: "Key", 9 | headerName: "Name", 10 | width: 200, 11 | align: "left", 12 | headerAlign: "left", 13 | }, 14 | { 15 | field: "Description", 16 | headerName: "Description", 17 | flex: 1, 18 | align: "left", 19 | headerAlign: "center", 20 | valueGetter: ({ row }) => row.Preset.Description, 21 | }, 22 | { 23 | field: "Metrics", 24 | headerName: "Metrics", 25 | width: 120, 26 | align: "center", 27 | headerAlign: "center", 28 | renderCell: ({ row }) => 29 | }, 30 | { 31 | field: "Actions", 32 | headerName: "Actions", 33 | headerAlign: "center", 34 | renderCell: ({ row }) => 35 | }, 36 | ]); 37 | -------------------------------------------------------------------------------- /internal/webui/src/pages/PresetsPage/components/PresetsGrid/PresetsGrid.types.ts: -------------------------------------------------------------------------------- 1 | import { Preset } from "types/Preset/Preset"; 2 | 3 | export type PresetGridRow = { 4 | Key: string; 5 | Preset: Preset; 6 | }; 7 | -------------------------------------------------------------------------------- /internal/webui/src/pages/PresetsPage/components/PresetsGrid/components/PresetsGridActions/PresetsGridActions.tsx: -------------------------------------------------------------------------------- 1 | import { useEffect, useMemo, useState } from "react"; 2 | import { GridActions } from "components/GridActions/GridActions"; 3 | import { WarningDialog } from "components/WarningDialog/WarningDialog"; 4 | import { usePresetFormContext } from "contexts/PresetForm/PresetForm.context"; 5 | import { useDeletePreset } from "queries/Preset"; 6 | import { PresetGridRow } from "../../PresetsGrid.types"; 7 | 8 | type Props = { 9 | preset: PresetGridRow; 10 | }; 11 | 12 | export const PresetsGridActions = ({ preset }: Props) => { 13 | const [dialogOpen, setDialogOpen] = useState(false); 14 | const { setData, handleOpen } = usePresetFormContext(); 15 | const { mutate, isSuccess } = useDeletePreset(); 16 | 17 | const handleDialogClose = () => setDialogOpen(false); 18 | 19 | const handleEditClick = () => { 20 | setData(preset); 21 | handleOpen(); 22 | }; 23 | 24 | const handleDeleteClick = () => setDialogOpen(true); 25 | 26 | const handleSubmit = () => mutate(preset.Key); 27 | 28 | const message = useMemo( 29 | () => `Are you sure want to delete preset "${preset.Key}"`, 30 | [preset], 31 | ); 32 | 33 | useEffect(() => { 34 | isSuccess && handleDialogClose(); 35 | }, [isSuccess]); 36 | 37 | return ( 38 | <> 39 | 40 | 41 | 42 | ); 43 | }; 44 | -------------------------------------------------------------------------------- /internal/webui/src/pages/PresetsPage/components/PresetsGrid/components/PresetsGridToolbar/PresetsGridToolbar.tsx: -------------------------------------------------------------------------------- 1 | import { GridToolbar } from "components/GridToolbar/GridToolbar"; 2 | import { usePresetFormContext } from "contexts/PresetForm/PresetForm.context"; 3 | 4 | export const PresetsGridToolbar = () => { 5 | const { handleOpen, setData } = usePresetFormContext(); 6 | 7 | const onNewClick = () => { 8 | setData(undefined); 9 | handleOpen(); 10 | }; 11 | 12 | return ( 13 | 14 | ); 15 | }; 16 | -------------------------------------------------------------------------------- /internal/webui/src/pages/SourcesPage/SourcesPage.tsx: -------------------------------------------------------------------------------- 1 | import { usePageStyles } from "styles/page"; 2 | import { SourcesGrid } from "./components/SourcesGrid/SourcesGrid"; 3 | 4 | export const SourcesPage = () => { 5 | const { classes } = usePageStyles(); 6 | 7 | return( 8 |
9 | 10 |
11 | ); 12 | }; 13 | -------------------------------------------------------------------------------- /internal/webui/src/pages/SourcesPage/components/SourcesGrid/SourcesGrid.tsx: -------------------------------------------------------------------------------- 1 | import { DataGrid } from "@mui/x-data-grid"; 2 | import { Error } from "components/Error/Error"; 3 | import { Loading } from "components/Loading/Loading"; 4 | import { SourceFormDialog } from "containers/SourceFormDialog/SourceFormDialog"; 5 | import { SourceFormProvider } from "contexts/SourceForm/SourceForm.provider"; 6 | import { useGridColumnVisibility } from 'hooks/useGridColumnVisibility'; 7 | import { usePageStyles } from "styles/page"; 8 | import { useSources } from "queries/Source"; 9 | import { useSourcesGridColumns } from "./SourcesGrid.consts"; 10 | import { SourcesGridToolbar } from "./components/SourcesGridToolbar"; 11 | 12 | export const SourcesGrid = () => { 13 | const { classes } = usePageStyles(); 14 | 15 | const { data, isLoading, isError, error } = useSources(); 16 | 17 | const columns = useSourcesGridColumns(); 18 | const { columnVisibility, onColumnVisibilityChange } = useGridColumnVisibility('SOURCES_GRID', columns); 19 | 20 | if (isLoading) { 21 | return ( 22 | 23 | ); 24 | } 25 | 26 | if (isError) { 27 | const err = error as Error; 28 | return ( 29 | 30 | ); 31 | } 32 | 33 | return ( 34 |
35 | 36 | row.Name} 38 | columns={columns} 39 | rows={data ?? []} 40 | rowsPerPageOptions={[]} 41 | components={{ Toolbar: () => }} 42 | disableColumnMenu 43 | columnVisibilityModel={columnVisibility} 44 | onColumnVisibilityModelChange={onColumnVisibilityChange} 45 | /> 46 | 47 | 48 |
49 | ); 50 | }; 51 | -------------------------------------------------------------------------------- /internal/webui/src/pages/SourcesPage/components/SourcesGrid/components/CustomTagsPopUp/CustomTagsPopUp.consts.ts: -------------------------------------------------------------------------------- 1 | import { GridColDef } from "@mui/x-data-grid"; 2 | 3 | export const useCustomTagsPopUpColumns = (): GridColDef[] => ([ 4 | { 5 | field: "name", 6 | headerName: "Name", 7 | flex: 1, 8 | }, 9 | { 10 | field: "value", 11 | headerName: "Value", 12 | align: "center", 13 | headerAlign: "center", 14 | flex: 1, 15 | }, 16 | ]); 17 | -------------------------------------------------------------------------------- /internal/webui/src/pages/SourcesPage/components/SourcesGrid/components/CustomTagsPopUp/CustomTagsPopUp.tsx: -------------------------------------------------------------------------------- 1 | import { useMemo, useState } from "react"; 2 | import TableViewIcon from "@mui/icons-material/TableView"; 3 | import { Dialog, DialogContent, IconButton } from "@mui/material"; 4 | import { DataGrid } from "@mui/x-data-grid"; 5 | import { useCustomTagsPopUpColumns } from "./CustomTagsPopUp.consts"; 6 | 7 | 8 | type CustomTagsRows = { 9 | name: string; 10 | value: string; 11 | }; 12 | 13 | type Props = { 14 | CustomTags?: Record | null; 15 | }; 16 | 17 | export const CustomTagsPopUp = ({ CustomTags }: Props) => { 18 | const [open, setOpen] = useState(false); 19 | 20 | const rows: CustomTagsRows[] = useMemo(() => { 21 | if (CustomTags) { 22 | return Object.keys(CustomTags).map((key) => ({ 23 | name: key, 24 | value: CustomTags[key], 25 | })); 26 | } 27 | return []; 28 | }, [CustomTags]); 29 | 30 | const columns = useCustomTagsPopUpColumns(); 31 | 32 | const handleOpen = () => setOpen(true); 33 | 34 | const handleClose = () => setOpen(false); 35 | 36 | return rows.length !== 0 ? ( 37 | <> 38 | 39 | 40 | 41 | 46 | 47 | row.name} 49 | columns={columns} 50 | rows={rows} 51 | rowsPerPageOptions={[]} 52 | autoHeight 53 | disableColumnMenu 54 | /> 55 | 56 | 57 | 58 | ) : null; 59 | }; 60 | -------------------------------------------------------------------------------- /internal/webui/src/pages/SourcesPage/components/SourcesGrid/components/EnabledSourceSwitch.tsx: -------------------------------------------------------------------------------- 1 | import { useEffect, useState } from "react"; 2 | import { Switch } from "@mui/material"; 3 | import { Source } from "types/Source/Source"; 4 | import { useEditSourceEnable } from "queries/Source"; 5 | 6 | 7 | type EnabledSourceSwitchProps = { 8 | source: Source; 9 | }; 10 | 11 | export const EnabledSourceSwitch = ({ source }: EnabledSourceSwitchProps) => { 12 | const [checked, setChecked] = useState(source.IsEnabled); 13 | 14 | const editEnabled = useEditSourceEnable(); 15 | const { status } = editEnabled; 16 | 17 | const handleChange = (_e: any, value: boolean) => { 18 | setChecked(value); 19 | editEnabled.mutate({ 20 | ...source, 21 | IsEnabled: value, 22 | }); 23 | }; 24 | 25 | useEffect(() => { 26 | if (status === "error") { 27 | setChecked((prev) => !prev); 28 | } 29 | }, [status]); 30 | 31 | return ( 32 | 36 | ); 37 | }; 38 | -------------------------------------------------------------------------------- /internal/webui/src/pages/SourcesPage/components/SourcesGrid/components/HostConfigPopUp/HostConfigPopUp.consts.ts: -------------------------------------------------------------------------------- 1 | import { HostConfigFormValues } from "./HostConfigPopUp.types"; 2 | 3 | export const getHostConfigInitialValues = (data?: object): HostConfigFormValues => ({ 4 | HostConfig: JSON.stringify(data ?? {}, undefined, 2), 5 | }); 6 | -------------------------------------------------------------------------------- /internal/webui/src/pages/SourcesPage/components/SourcesGrid/components/HostConfigPopUp/HostConfigPopUp.types.ts: -------------------------------------------------------------------------------- 1 | export type HostConfigFormValues = { 2 | HostConfig: string; 3 | }; 4 | -------------------------------------------------------------------------------- /internal/webui/src/pages/SourcesPage/components/SourcesGrid/components/MaskConnectionString.tsx: -------------------------------------------------------------------------------- 1 | import { useState } from "react"; 2 | import Visibility from "@mui/icons-material/Visibility"; 3 | import VisibilityOff from "@mui/icons-material/VisibilityOff"; 4 | import { IconButton } from "@mui/material"; 5 | import { Source } from "types/Source/Source"; 6 | 7 | type MaskedTextProps = { 8 | source: Source; 9 | }; 10 | 11 | const mask = (connStr: string) => { 12 | if (connStr.includes("://")) { 13 | return connStr.replace( 14 | /(postgresql:\/\/[^:]+:)([^@]+)(@.*)/, 15 | (_, start, pass, end) => `${start}${"•".repeat(8)}${end}` 16 | ); 17 | } else { 18 | return connStr.replace( 19 | /(Password=)[^;]+(;|$)/i, (_, start, end) => `${start}${"•".repeat(8)}${end}` 20 | ); 21 | } 22 | }; 23 | 24 | export const MaskConnectionString = ({ source }: MaskedTextProps) => { 25 | const [unmask, setUnmask] = useState(false); 26 | 27 | const handleUnmask = () => setUnmask(!unmask); 28 | 29 | return ( 30 | <> 31 | {unmask ? source.ConnStr : mask(source.ConnStr)} 32 | 33 | { 34 | unmask ? : 35 | } 36 | 37 | 38 | ); 39 | }; 40 | -------------------------------------------------------------------------------- /internal/webui/src/pages/SourcesPage/components/SourcesGrid/components/SourcesGridActions.tsx: -------------------------------------------------------------------------------- 1 | import { useEffect, useMemo, useState } from "react"; 2 | import ContentCopyIcon from "@mui/icons-material/ContentCopy"; 3 | import { IconButton } from "@mui/material"; 4 | import { GridActions } from "components/GridActions/GridActions"; 5 | import { WarningDialog } from "components/WarningDialog/WarningDialog"; 6 | import { useSourceFormContext } from "contexts/SourceForm/SourceForm.context"; 7 | import { SourceFormActions } from "contexts/SourceForm/SourceForm.types"; 8 | import { Source } from "types/Source/Source"; 9 | import { useDeleteSource } from "queries/Source"; 10 | 11 | type Props = { 12 | source: Source; 13 | }; 14 | 15 | export const SourcesGridActions = ({ source }: Props) => { 16 | const [dialogOpen, setDialogOpen] = useState(false); 17 | const { setData, setAction, handleOpen } = useSourceFormContext(); 18 | const { mutate, isSuccess } = useDeleteSource(); 19 | 20 | const handleDialogClose = () => setDialogOpen(false); 21 | 22 | const handleEditClick = () => { 23 | setData(source); 24 | setAction(SourceFormActions.Edit); 25 | handleOpen(); 26 | }; 27 | 28 | const handleCopyClick = () => { 29 | setData(source); 30 | setAction(SourceFormActions.Copy); 31 | handleOpen(); 32 | }; 33 | 34 | const handleDeleteClick = () => setDialogOpen(true); 35 | 36 | const handleSubmit = () => mutate(source.Name); 37 | 38 | const message = useMemo( 39 | () => `Are you sure want to delete source "${source.Name}"`, 40 | [source], 41 | ); 42 | 43 | useEffect(() => { 44 | isSuccess && handleDialogClose(); 45 | }, [isSuccess]); 46 | 47 | return ( 48 | <> 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | ); 57 | }; 58 | -------------------------------------------------------------------------------- /internal/webui/src/pages/SourcesPage/components/SourcesGrid/components/SourcesGridToolbar.tsx: -------------------------------------------------------------------------------- 1 | import { GridToolbar } from "components/GridToolbar/GridToolbar"; 2 | import { useSourceFormContext } from "contexts/SourceForm/SourceForm.context"; 3 | import { SourceFormActions } from "contexts/SourceForm/SourceForm.types"; 4 | 5 | export const SourcesGridToolbar = () => { 6 | const { handleOpen, setData, setAction } = useSourceFormContext(); 7 | 8 | const onNewClick = () => { 9 | setData(undefined); 10 | setAction(SourceFormActions.Create); 11 | handleOpen(); 12 | }; 13 | 14 | return ( 15 | 16 | ); 17 | }; 18 | -------------------------------------------------------------------------------- /internal/webui/src/queries/Auth/index.ts: -------------------------------------------------------------------------------- 1 | import { useMutation } from "@tanstack/react-query"; 2 | import { LoginFormValues } from "pages/LoginPage/components/LoginForm/LoginForm.types"; 3 | import { NavigateFunction } from "react-router-dom"; 4 | import AuthService from "services/Auth"; 5 | import { removeToken, setToken } from "services/Token"; 6 | 7 | const services = AuthService.getInstance(); 8 | 9 | export const useLogin = (navigate: NavigateFunction) => useMutation({ 10 | mutationFn: async (data: LoginFormValues) => await services.login(data), 11 | onSuccess: (data) => { 12 | setToken(data); 13 | navigate("/sources", { replace: true }); 14 | } 15 | }); 16 | 17 | export const logout = (navigate: NavigateFunction) => { 18 | removeToken(); 19 | navigate("/", { replace: true }); 20 | }; 21 | -------------------------------------------------------------------------------- /internal/webui/src/queries/Log/index.ts: -------------------------------------------------------------------------------- 1 | import { useWebSocket } from "react-use-websocket/dist/lib/use-websocket"; 2 | import { getToken } from "services/Token"; 3 | 4 | export const useLogs = () => { 5 | const socketProtocol = window.location.protocol === "https:" ? "wss://" : "ws://"; 6 | const token = getToken() || ""; 7 | 8 | return useWebSocket( 9 | `${socketProtocol}${window.location.host}/log`, 10 | { 11 | queryParams: { 12 | "Token": token, 13 | }, 14 | }, 15 | ); 16 | }; 17 | -------------------------------------------------------------------------------- /internal/webui/src/queries/Metric/index.ts: -------------------------------------------------------------------------------- 1 | import { useMutation, useQuery } from "@tanstack/react-query"; 2 | import { QueryKeys } from "consts/queryKeys"; 3 | import { Metrics } from "types/Metric/Metric"; 4 | import { MetricRequestBody } from "types/Metric/MetricRequestBody"; 5 | import MetricService from "services/Metric"; 6 | 7 | const services = MetricService.getInstance(); 8 | 9 | export const useMetrics = () => useQuery({ 10 | queryKey: [QueryKeys.Metric], 11 | queryFn: async () => await services.getMetrics() 12 | }); 13 | 14 | export const useDeleteMetric = () => useMutation({ 15 | mutationKey: [QueryKeys.Metric], 16 | mutationFn: async (data: string) => await services.deleteMetric(data) 17 | }); 18 | 19 | export const useEditMetric = () => useMutation({ 20 | mutationKey: [QueryKeys.Metric], 21 | mutationFn: async (data: MetricRequestBody) => await services.editMetric(data), 22 | }); 23 | 24 | export const useAddMetric = () => useMutation({ 25 | mutationKey: [QueryKeys.Metric], 26 | mutationFn: async (data: MetricRequestBody) => await services.addMetric(data), 27 | }); 28 | -------------------------------------------------------------------------------- /internal/webui/src/queries/Preset/index.ts: -------------------------------------------------------------------------------- 1 | import { useMutation, useQuery } from "@tanstack/react-query"; 2 | import { QueryKeys } from "consts/queryKeys"; 3 | import { Presets } from "types/Preset/Preset"; 4 | import { PresetRequestBody } from "types/Preset/PresetRequestBody"; 5 | import PresetService from "services/Preset"; 6 | 7 | const services = PresetService.getInstance(); 8 | 9 | export const usePresets = () => useQuery({ 10 | queryKey: [QueryKeys.Preset], 11 | queryFn: async () => await services.getPresets() 12 | }); 13 | 14 | export const useDeletePreset = () => useMutation({ 15 | mutationKey: [QueryKeys.Preset], 16 | mutationFn: async (name: string) => await services.deletePreset(name) 17 | }); 18 | 19 | export const useEditPreset = () => useMutation({ 20 | mutationKey: [QueryKeys.Preset], 21 | mutationFn: async (data: PresetRequestBody) => await services.editPreset(data), 22 | }); 23 | 24 | export const useAddPreset = () => useMutation({ 25 | mutationKey: [QueryKeys.Preset], 26 | mutationFn: async (data: PresetRequestBody) => await services.addPreset(data), 27 | }); 28 | -------------------------------------------------------------------------------- /internal/webui/src/queries/Source/index.ts: -------------------------------------------------------------------------------- 1 | import { useMutation, useQuery } from "@tanstack/react-query"; 2 | import { QueryKeys } from "consts/queryKeys"; 3 | import { Source } from "types/Source/Source"; 4 | import { SourceRequestBody } from "types/Source/SourceRequestBody"; 5 | import SourceService from "services/Source"; 6 | 7 | const services = SourceService.getInstance(); 8 | 9 | export const useSources = () => useQuery({ 10 | queryKey: [QueryKeys.Source], 11 | queryFn: async () => await services.getSources() 12 | }); 13 | 14 | export const useDeleteSource = () => useMutation({ 15 | mutationKey: [QueryKeys.Source], 16 | mutationFn: async (uniqueName: string) => await services.deleteSource(uniqueName) 17 | }); 18 | 19 | export const useEditSourceEnable = () => useMutation({ 20 | mutationKey: [QueryKeys.Source], 21 | mutationFn: async (data: Source) => await services.editSourceEnable(data) 22 | }); 23 | 24 | export const useEditSourceHostConfig = () => useMutation({ 25 | mutationKey: [QueryKeys.Source], 26 | mutationFn: async (data: Source) => await services.editSourceHostConfig(data), 27 | }); 28 | 29 | export const useEditSource = () => useMutation({ 30 | mutationKey: [QueryKeys.Source], 31 | mutationFn: async (data: SourceRequestBody) => await services.editSource(data), 32 | }); 33 | 34 | export const useAddSource = () => useMutation({ 35 | mutationKey: [QueryKeys.Source], 36 | mutationFn: async (data: Source) => await services.addSource(data), 37 | }); 38 | 39 | export const useTestConnection = () => useMutation({ 40 | mutationFn: async (data: string) => await services.testSourceConnection(data) 41 | }); 42 | -------------------------------------------------------------------------------- /internal/webui/src/react-app-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | -------------------------------------------------------------------------------- /internal/webui/src/reportWebVitals.ts: -------------------------------------------------------------------------------- 1 | import { ReportHandler } from "web-vitals"; 2 | 3 | const reportWebVitals = (onPerfEntry?: ReportHandler) => { 4 | if (onPerfEntry && onPerfEntry instanceof Function) { 5 | import("web-vitals").then(({ getCLS, getFID, getFCP, getLCP, getTTFB }) => { 6 | getCLS(onPerfEntry); 7 | getFID(onPerfEntry); 8 | getFCP(onPerfEntry); 9 | getLCP(onPerfEntry); 10 | getTTFB(onPerfEntry); 11 | }); 12 | } 13 | }; 14 | 15 | export default reportWebVitals; 16 | -------------------------------------------------------------------------------- /internal/webui/src/services/Auth/index.ts: -------------------------------------------------------------------------------- 1 | import { apiClient } from "api"; 2 | import { AxiosInstance } from "axios"; 3 | import { LoginFormValues } from "pages/LoginPage/components/LoginForm/LoginForm.types"; 4 | 5 | export default class AuthService { 6 | private api: AxiosInstance; 7 | private static _instance: AuthService; 8 | 9 | constructor() { 10 | this.api = apiClient(); 11 | } 12 | 13 | public static getInstance(): AuthService { 14 | if (!AuthService._instance) { 15 | AuthService._instance = new AuthService(); 16 | } 17 | 18 | return AuthService._instance; 19 | }; 20 | 21 | public async login(data: LoginFormValues) { 22 | return await this.api.post("/login", data). 23 | then(response => response.data); 24 | }; 25 | } 26 | -------------------------------------------------------------------------------- /internal/webui/src/services/Metric/index.ts: -------------------------------------------------------------------------------- 1 | import { apiClient } from "api"; 2 | import { AxiosInstance } from "axios"; 3 | import { Metrics } from "types/Metric/Metric"; 4 | import { MetricRequestBody } from "types/Metric/MetricRequestBody"; 5 | 6 | 7 | export default class MetricService { 8 | private api: AxiosInstance; 9 | private static _instance: MetricService; 10 | 11 | constructor() { 12 | this.api = apiClient(); 13 | } 14 | 15 | public static getInstance(): MetricService { 16 | if (!MetricService._instance) { 17 | MetricService._instance = new MetricService(); 18 | } 19 | 20 | return MetricService._instance; 21 | }; 22 | 23 | public async getMetrics(): Promise { 24 | return await this.api.get("/metric"). 25 | then(response => response.data); 26 | }; 27 | 28 | public async deleteMetric(data: string) { 29 | return await this.api.delete("/metric", { params: { "key": data } }). 30 | then(response => response.data); 31 | }; 32 | 33 | public async addMetric(data: MetricRequestBody) { 34 | return await this.api.post("/metric", data.Data, { params: { "name": data.Name } }). 35 | then(response => response); 36 | }; 37 | 38 | public async editMetric(data: MetricRequestBody) { 39 | return await this.api.post("/metric", data.Data, { params: { "name": data.Name } }). 40 | then(response => response); 41 | }; 42 | } 43 | -------------------------------------------------------------------------------- /internal/webui/src/services/Preset/index.ts: -------------------------------------------------------------------------------- 1 | import { apiClient } from "api"; 2 | import { AxiosInstance } from "axios"; 3 | import { PresetRequestBody } from "types/Preset/PresetRequestBody"; 4 | 5 | 6 | export default class PresetService { 7 | private api: AxiosInstance; 8 | private static _instance: PresetService; 9 | 10 | constructor() { 11 | this.api = apiClient(); 12 | } 13 | 14 | public static getInstance(): PresetService { 15 | if (!PresetService._instance) { 16 | PresetService._instance = new PresetService(); 17 | } 18 | 19 | return PresetService._instance; 20 | }; 21 | 22 | public async getPresets() { 23 | return await this.api.get("/preset"). 24 | then(response => response.data); 25 | }; 26 | 27 | public async deletePreset(name: string) { 28 | return await this.api.delete("/preset", { params: { name } }). 29 | then(response => response.data); 30 | }; 31 | 32 | public async addPreset(data: PresetRequestBody) { 33 | return await this.api.post("/preset", data.Data, { params: { "name": data.Name } }). 34 | then(response => response); 35 | }; 36 | 37 | public async editPreset(data: PresetRequestBody) { 38 | return await this.api.post("/preset", data.Data, { params: { "name": data.Name } }). 39 | then(response => response); 40 | }; 41 | }; 42 | -------------------------------------------------------------------------------- /internal/webui/src/services/Source/index.ts: -------------------------------------------------------------------------------- 1 | import { apiClient } from "api"; 2 | import { AxiosInstance } from "axios"; 3 | import { Source } from "types/Source/Source"; 4 | import { SourceRequestBody } from "types/Source/SourceRequestBody"; 5 | 6 | export default class SourceService { 7 | private api: AxiosInstance; 8 | private static _instance: SourceService; 9 | 10 | constructor() { 11 | this.api = apiClient(); 12 | } 13 | 14 | public static getInstance(): SourceService { 15 | if (!SourceService._instance) { 16 | SourceService._instance = new SourceService(); 17 | } 18 | 19 | return SourceService._instance; 20 | }; 21 | 22 | public async getSources() { 23 | return await this.api.get("/source"). 24 | then(response => response.data); 25 | }; 26 | 27 | public async deleteSource(uniqueName: string) { 28 | return await this.api.delete("/source", { params: { "name": uniqueName } }). 29 | then(response => response.data); 30 | }; 31 | 32 | public async addSource(data: Source) { 33 | return await this.api.post("/source", data). 34 | then(response => response); 35 | }; 36 | 37 | public async editSource(data: SourceRequestBody) { 38 | return await this.api.post("/source", data.data, { params: { "name": data.Name } }). 39 | then(response => response); 40 | }; 41 | 42 | public async editSourceEnable(data: Source) { 43 | return await this.api.post("/source", data, { params: { "name": data.Name } }). 44 | then(response => response); 45 | }; 46 | 47 | public async editSourceHostConfig(data: Source) { 48 | return await this.api.post("/source", data, { params: { "name": data.Name } }); 49 | }; 50 | 51 | public async testSourceConnection(data: string) { 52 | return await this.api.post("/test-connect", data); 53 | }; 54 | } 55 | -------------------------------------------------------------------------------- /internal/webui/src/services/Token/index.ts: -------------------------------------------------------------------------------- 1 | 2 | export const getToken = (): string | null => { 3 | return sessionStorage.getItem("token"); 4 | }; 5 | 6 | export const setToken = (token: string) => { 7 | sessionStorage.setItem("token", token); 8 | }; 9 | 10 | export const removeToken = () => { 11 | sessionStorage.removeItem("token"); 12 | }; 13 | -------------------------------------------------------------------------------- /internal/webui/src/setupTests.ts: -------------------------------------------------------------------------------- 1 | // jest-dom adds custom jest matchers for asserting on DOM nodes. 2 | // allows you to do things like: 3 | // expect(element).toHaveTextContent(/react/i) 4 | // learn more: https://github.com/testing-library/jest-dom 5 | import "@testing-library/jest-dom"; 6 | -------------------------------------------------------------------------------- /internal/webui/src/styles/form.ts: -------------------------------------------------------------------------------- 1 | import { makeStyles } from "tss-react/mui"; 2 | 3 | export const useFormStyles = makeStyles()( 4 | () => ({ 5 | formDialog: { 6 | "& .MuiPaper-root": { 7 | maxWidth: "750px", 8 | }, 9 | }, 10 | formContent: { 11 | maxWidth: "550px", 12 | width: "550px", 13 | }, 14 | form: { 15 | paddingTop: "15px", 16 | width: "100%", 17 | display: "flex", 18 | flexFlow: "column", 19 | gap: "10px", 20 | }, 21 | row: { 22 | display: "flex", 23 | width: "100%", 24 | alignItems: "flex-start", 25 | justifyContent: "space-between", 26 | }, 27 | formControlInput: { 28 | display: "flex", 29 | "&$formControlBlock": { 30 | display: "block", 31 | }, 32 | "& .MuiFormHelperText-root": { 33 | margin: "0px", 34 | paddingLeft: "5px", 35 | }, 36 | }, 37 | formControlCheckbox: { 38 | "&.MuiFormControlLabel-root": { 39 | flexDirection: "unset", 40 | marginLeft: "0px", 41 | width: "fit-content", 42 | }, 43 | }, 44 | addButton: { 45 | justifyContent: "end", 46 | }, 47 | formButtons: { 48 | "&.MuiDialogActions-root": { 49 | padding: "0px 8px 8px", 50 | }, 51 | }, 52 | widthDefault: { 53 | maxWidth: "270px", 54 | width: "100%", 55 | }, 56 | widthFull: { 57 | maxWidth: "100%", 58 | width: "100%", 59 | }, 60 | hidden: { 61 | display: "none", 62 | } 63 | }), 64 | ); 65 | -------------------------------------------------------------------------------- /internal/webui/src/styles/page.ts: -------------------------------------------------------------------------------- 1 | import { makeStyles } from "tss-react/mui"; 2 | 3 | export const usePageStyles = makeStyles()( 4 | () => ({ 5 | root: { 6 | flex: "1 1 auto", 7 | }, 8 | page: { 9 | display: "flex", 10 | flexDirection: "column", 11 | gap: 1, 12 | height: "100%", 13 | }, 14 | }), 15 | ); 16 | -------------------------------------------------------------------------------- /internal/webui/src/types/Metric/Metric.ts: -------------------------------------------------------------------------------- 1 | export type Metric = { 2 | SQLs: Record; 3 | InitSQL: string; 4 | NodeStatus: string; 5 | Gauges: string[]; 6 | IsInstanceLevel: boolean; 7 | StorageName: string; 8 | Description: string; 9 | }; 10 | 11 | export type Metrics = Record; 12 | -------------------------------------------------------------------------------- /internal/webui/src/types/Metric/MetricRequestBody.ts: -------------------------------------------------------------------------------- 1 | 2 | export type MetricRequestBody = { 3 | Name: string; 4 | Data: { 5 | StorageName?: string | null; 6 | NodeStatus?: string | null; 7 | Description?: string | null; 8 | Gauges?: string[] | null; 9 | InitSQL?: string | null; 10 | IsInstanceLevel: boolean; 11 | SQLs: Record; 12 | }; 13 | }; 14 | -------------------------------------------------------------------------------- /internal/webui/src/types/Preset/Preset.ts: -------------------------------------------------------------------------------- 1 | export type Preset = { 2 | Description: string; 3 | Metrics: Record; 4 | }; 5 | 6 | export type Presets = Record; 7 | -------------------------------------------------------------------------------- /internal/webui/src/types/Preset/PresetRequestBody.ts: -------------------------------------------------------------------------------- 1 | export type PresetRequestBody = { 2 | Name: string; 3 | Data: { 4 | Description?: string; 5 | Metrics: Record; 6 | }, 7 | }; 8 | -------------------------------------------------------------------------------- /internal/webui/src/types/Source/Source.ts: -------------------------------------------------------------------------------- 1 | export type Source = { 2 | Name: string; 3 | Group: string; 4 | ConnStr: string; 5 | Metrics: Record | null; 6 | MetricsStandby: Record | null; 7 | Kind: string; 8 | IncludePattern: string; 9 | ExcludePattern: string; 10 | PresetMetrics: string; 11 | PresetMetricsStandby: string; 12 | IsEnabled: boolean; 13 | CustomTags: Record | null; 14 | HostConfig: object; 15 | OnlyIfMaster: boolean; 16 | }; 17 | -------------------------------------------------------------------------------- /internal/webui/src/types/Source/SourceRequestBody.ts: -------------------------------------------------------------------------------- 1 | import { Source } from "./Source"; 2 | 3 | export type SourceRequestBody = { 4 | Name: string; 5 | data: Source; 6 | }; 7 | -------------------------------------------------------------------------------- /internal/webui/src/utils/AlertContext.tsx: -------------------------------------------------------------------------------- 1 | import { createContext, useContext, useState } from "react"; 2 | import { AlertColor, SnackbarCloseReason } from "@mui/material"; 3 | 4 | export type AlertContextType = { 5 | open: boolean; 6 | severity: AlertColor; 7 | message: string; 8 | callAlert: (alertSeverity: AlertColor, alertMessage: string) => void; 9 | closeAlert: (event: Event | React.SyntheticEvent, reason: SnackbarCloseReason) => void; 10 | }; 11 | 12 | type AlertProviderProps = { 13 | children: JSX.Element; 14 | }; 15 | 16 | export const AlertContext = createContext(null); 17 | 18 | export const AlertProvider = ({ children }: AlertProviderProps) => { 19 | const [open, setOpen] = useState(false); 20 | const [severity, setSeverity] = useState("success"); 21 | const [message, setMessage] = useState(""); 22 | 23 | const callAlert = (alertSeverity: AlertColor, alertMessage: string) => { 24 | setSeverity(alertSeverity); 25 | setMessage(alertMessage); 26 | setOpen(true); 27 | }; 28 | 29 | const closeAlert = (_event: Event | React.SyntheticEvent, reason: SnackbarCloseReason) => { 30 | if (reason && (reason === "clickaway" || reason === "escapeKeyDown")) { 31 | return; 32 | } 33 | setOpen(false); 34 | }; 35 | 36 | return ( 37 | 38 | {children} 39 | 40 | ); 41 | }; 42 | 43 | export const useAlert = () => useContext(AlertContext) as AlertContextType; 44 | -------------------------------------------------------------------------------- /internal/webui/src/utils/toArrayFromRecord.ts: -------------------------------------------------------------------------------- 1 | export type ArrayFromRecord = { 2 | Name: string; 3 | Value: T 4 | }; 5 | 6 | export const toArrayFromRecord = (record?: Record | null): ArrayFromRecord[] => { 7 | if (record) { 8 | return Object.keys(record).map((key) => ({ 9 | Name: key, 10 | Value: record[key], 11 | })); 12 | } 13 | return []; 14 | }; 15 | -------------------------------------------------------------------------------- /internal/webui/src/utils/toRecordFromArray.ts: -------------------------------------------------------------------------------- 1 | import { ArrayFromRecord } from "./toArrayFromRecord"; 2 | 3 | export const toRecordFromArray = (arr?: ArrayFromRecord[] | null) => { 4 | if (arr) { 5 | const record: Record = {}; 6 | arr.map((val) => record[val.Name] = val.Value); 7 | return record; 8 | } 9 | return {}; 10 | }; 11 | -------------------------------------------------------------------------------- /internal/webui/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "baseUrl": "src", 4 | "rootDir": "src", 5 | "outDir": "build/dist", 6 | "target": "es2019", 7 | "lib": ["es2019", "dom", "dom.iterable", "esnext"], 8 | "allowJs": true, 9 | "module": "esnext", 10 | "moduleResolution": "node", 11 | "forceConsistentCasingInFileNames": true, 12 | "noImplicitReturns": true, 13 | "noImplicitThis": true, 14 | "noImplicitAny": true, 15 | "strictNullChecks": true, 16 | //"suppressImplicitAnyIndexErrors": true, 17 | "ignoreDeprecations": "5.0", 18 | "allowSyntheticDefaultImports": true, 19 | "esModuleInterop": true, 20 | "skipLibCheck": true, 21 | "strict": true, 22 | "resolveJsonModule": true, 23 | "isolatedModules": true, 24 | "noEmit": true, 25 | "downlevelIteration": true, 26 | "noFallthroughCasesInSwitch": true, 27 | "jsx": "react-jsx" 28 | }, 29 | "exclude": [ 30 | "node_modules", 31 | "build", 32 | "webpack", 33 | "jest", 34 | "public" 35 | ], 36 | "include": ["src"] 37 | } 38 | --------------------------------------------------------------------------------