diff --git a/README.md b/README.md index c2cee90..cffa2ab 100644 --- a/README.md +++ b/README.md @@ -32,12 +32,13 @@ QueryHawk monitors and visualizes key SQL metrics to help users improve database ## Introduction -QueryHawk delivers comprehensive SQL database monitoring and visualization, empowering developers and database administrators to optimize performance and quickly identify bottlenecks. Built on industry-standard tools including Prometheus, Grafana, and PostgreSQL Exporter, all containerized with Docker for seamless deployment. +QueryHawk delivers comprehensive SQL database monitoring and visualization, empowering developers and database administrators to optimize performance and quickly identify bottlenecks. Built on industry-standard tools including Grafana, Grafana Alloy, Jaeger, Loki, and Mimir, all containerized with Docker for seamless deployment. - ✅ Real-time SQL query analysis with millisecond-precision execution metrics - ✅ Complete visibility into query execution plans with detailed buffer and cache statistics - ✅ Interactive dashboards for visualizing database health and performance trends - ✅ Query comparison tool to benchmark and optimize SQL performance +- ✅ Redis performance testing to benchmark PostgreSQL execution time against Redis cache retrieval - ✅ Track query execution paths across entire application with distributed tracing With QueryHawk's intuitive interface, teams can proactively manage database performance, reduce troubleshooting time, and make data-driven optimization decisions. The containerized architecture ensures easy deployment across development, staging, and production environments. @@ -48,12 +49,13 @@ Gain insights into your SQL databases and enhance how your team approaches datab ## 🔍 Deep SQL Query Analysis -- Execution Plan Visibility: Analyze "EXPLAIN ANALYZE" results with detailed metrics on planning time, execution time, and resource usage. -- Cache Performance Metrics: Monitor cache hit ratios and buffer statistics to identify memory optimization opportunities. -- Query Comparison: Evaluate startup and total costs for queries to understand their impact on database resources. -- Secure Connection Testing: Connect to any PostgreSQL database with SSL support and connection validation. -- Query Performance Profiling: Test queries before deployment with comprehensive performance metrics. -- Historical Comparison: Store and compare query performance over time to track optimization progress. +- Execution Plan Analysis: Run "EXPLAIN ANALYZE" to capture detailed planning and execution metrics in real time. +- Query Performance Profiling: Measure execution time, rows processed, loops, and buffer usage before deploying queries. +- Side-by-Side Query Comparison: Compare unoptimized vs optimized queries with clear performance breakdowns. +- Redis Benchmarking: Benchmark PostgreSQL queries against Redis cache retrieval to quantify caching improvements. +- Cache Insights: Analyze cache hit ratios and shared buffer usage to identify memory optimization opportunities. +- Historical Tracking: Save and revisit past queries to monitor performance improvements over time. +- Secure Database Connections: Connect to any PostgreSQL database with SSL support and connection validation. ## 📊 Real-time Performance Monitoring @@ -62,6 +64,7 @@ Once connected, QueryHawk will display multiple metrics, including: - Transaction rate - Cache hit ratio - Active connections +- Top 10 Slowest Queries - Tuple operations - Lock metrics - I/O statistics @@ -88,7 +91,7 @@ QueryHawk includes distributed tracing capabilities: - Docker-based Deployment: Quickly deploy the entire monitoring stack with Docker Compose. - Secure Authentication: GitHub OAuth integration for secure user management. -- Dynamic Exporters: Automatically create and manage PostgreSQL exporters. +- Dynamic Exporters: Automatically create and manage Grafana Alloy targets for PostgreSQL monitoring without restarting services. ## Initial Set-up and Installation @@ -121,6 +124,13 @@ GITHUB_CLIENT_ID=your_github_client_id GITHUB_CLIENT_SECRET=your_github_client_secret JWT_SECRET=your_jwt_secret SUPABASE_URI=your_supabase_uri + + +# Supabase PostgreSQL connection details (found in Supabase dashboard under Project Settings > Database) +POSTGRES_HOST=aws-0-us-east-2.pooler.supabase.com # Your Supabase pooler host +POSTGRES_USER=postgres.your_project_ref # Format: postgres. +POSTGRES_DB=postgres # Default Supabase database name +POSTGRES_PASSWORD=your_database_password # Your Supabase database password ``` 4. Start the services @@ -222,13 +232,17 @@ docker system prune -a ![Postman](https://img.shields.io/badge/Postman-ff6c37?style=for-the-badge&logo=postman&logoColor=white) ![Docker](https://img.shields.io/badge/Docker-2496ED?style=for-the-badge&logo=docker&logoColor=white) ![NPM](https://img.shields.io/badge/NPM-%23CB3837.svg?style=for-the-badge&logo=npm&logoColor=white) -![Prometheus](https://img.shields.io/badge/Prometheus-E6522C?style=for-the-badge&logo=prometheus&logoColor=white) +![Redis](https://img.shields.io/badge/Redis-DC382D?style=for-the-badge&logo=redis&logoColor=white) +![Jaeger](https://img.shields.io/badge/Jaeger-66CFE3?style=for-the-badge&logo=jaeger&logoColor=white) +![Loki](https://img.shields.io/badge/Loki-F46800?style=for-the-badge&logo=grafana&logoColor=white) ![OpenTelemetry](https://img.shields.io/badge/OpenTelemetry-F57600?style=for-the-badge&logo=OpenTelemetry&logoColor=white) ![Grafana](https://img.shields.io/badge/Grafana-F46800?style=for-the-badge&logo=grafana&logoColor=white) +![Grafana Alloy](https://img.shields.io/badge/Grafana_Alloy-F46800?style=for-the-badge&logo=grafana&logoColor=white) +![Mimir](https://img.shields.io/badge/Mimir-F46800?style=for-the-badge&logo=grafana&logoColor=white) +![Recharts](https://img.shields.io/badge/Recharts-22B5BF?style=for-the-badge&logo=recharts&logoColor=white) ![JWT](https://img.shields.io/badge/JWT-FFAA33?style=for-the-badge&logo=jsonwebtokens&logoColor=white) ![GitHub OAuth](https://img.shields.io/badge/GitHub_OAuth-181717?style=for-the-badge&logo=github&logoColor=white) ![.env](https://img.shields.io/badge/.env-ECD53F?style=for-the-badge&logoColor=white) -![Dockerode](https://img.shields.io/badge/Dockerode-blue?style=for-the-badge&logo=dockerode&logoColor=white) ![TS-Node](https://img.shields.io/badge/TSNode-blue?style=for-the-badge&logo=ts-node&logoColor=white) ![Nodemon](https://img.shields.io/badge/Nodemon-76D04B?style=for-the-badge&logo=nodemon&logoColor=white) ![ESLint](https://img.shields.io/badge/ESLint-4B32C3?style=for-the-badge&logo=eslint&logoColor=white) @@ -243,13 +257,17 @@ docker system prune -a Login +
---
+
+ ![Dashboard](/src/components/assets/QH_Dashboard.png) +
@@ -257,7 +275,11 @@ docker system prune -a
-![Query](/src/components/assets/QH_Query.png) +
+ +![Metrics](/src/components/assets/QH_Metrics.png) + +
@@ -265,7 +287,23 @@ docker system prune -a
-![Metrics](/src/components/assets/QH_Metrics.png) +
+ +![Query Comparison](./src/components/assets/QH_QueryComparison.png) + +
+ +
+ +--- + +
+ +
+ +![Redis vs PostgreSQL](./src/components/assets/QH_RedisVsPostgreSQL.png) + +
diff --git a/docker-compose.yml b/docker-compose.yml index ec63c53..f494f48 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -61,12 +61,14 @@ services: # This section defines all the services (containers) we want to run - CORS_ORIGIN=http://localhost:5173 - JWT_SECRET=${JWT_SECRET} - SUPABASE_URI=${SUPABASE_URI} + - REDIS_URL=redis://redis:6379 - NODE_ENV=development - OTEL_EXPORTER_OTLP_ENDPOINT=http://grafana-alloy:14318/v1/traces # OTLP HTTP endpoint for Grafana Alloy - OTEL_SERVICE_NAME=queryhawk-backend - OTEL_SERVICE_VERSION=1.0.0 depends_on: - jaeger + - redis networks: - queryhawk_monitoring_network deploy: @@ -74,6 +76,13 @@ services: # This section defines all the services (containers) we want to run limits: memory: 768M + redis: + image: redis:7.2-alpine + ports: + - '6379:6379' + networks: + - queryhawk_monitoring_network + jaeger: # This is the name we're giving to our service image: jaegertracing/all-in-one:latest #ports section maps ports from container to computer diff --git a/package-lock.json b/package-lock.json index e173a9c..14ca48e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -27,6 +27,7 @@ "dockerode": "^4.0.4", "dotenv": "^16.4.7", "express": "^4.21.2", + "ioredis": "^5.10.1", "jsonwebtoken": "^9.0.2", "lucide-react": "^0.474.0", "pg": "^8.13.3", @@ -106,7 +107,6 @@ "integrity": "sha512-yDBHV9kQNcr2/sUr9jghVyz9C3Y5G2zUM2H2lo+9mKv4sFgbA8s8Z9t8D1jiTkGoO/NoIfKMyKWr4s6CN23ZwQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@ampproject/remapping": "^2.2.0", "@babel/code-frame": "^7.27.1", @@ -465,6 +465,34 @@ "node": ">=v18" } }, + "node_modules/@commitlint/load/node_modules/cosmiconfig": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-9.0.1.tgz", + "integrity": "sha512-hr4ihw+DBqcvrsEDioRO31Z17x71pUYoNe/4h6Z0wB72p7MU7/9gH8Q3s12NFhHPfYBBOV3qyfUxmr/Yn3shnQ==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "env-paths": "^2.2.1", + "import-fresh": "^3.3.0", + "js-yaml": "^4.1.0", + "parse-json": "^5.2.0" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/d-fischer" + }, + "peerDependencies": { + "typescript": ">=4.9.5" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, "node_modules/@commitlint/load/node_modules/cosmiconfig-typescript-loader": { "version": "6.2.0", "resolved": "https://registry.npmjs.org/cosmiconfig-typescript-loader/-/cosmiconfig-typescript-loader-6.2.0.tgz", @@ -611,7 +639,6 @@ "resolved": "https://registry.npmjs.org/@emotion/react/-/react-11.14.0.tgz", "integrity": "sha512-O000MLDBDdk/EohJPFUqvnp4qnHeYkVP5B0xEG0D/L7cOKP9kefu2DXn8dj74cQfsEzUqh+sr1RzFqiL1o+PpA==", "license": "MIT", - "peer": true, "dependencies": { "@babel/runtime": "^7.18.3", "@emotion/babel-plugin": "^11.13.5", @@ -655,7 +682,6 @@ "resolved": "https://registry.npmjs.org/@emotion/styled/-/styled-11.14.1.tgz", "integrity": "sha512-qEEJt42DuToa3gurlH4Qqc1kVpNq8wO8cJtDzU46TjlzWjDlsVyevtYCRijVq3SrHsROS+gVQ8Fnea108GnKzw==", "license": "MIT", - "peer": true, "dependencies": { "@babel/runtime": "^7.18.3", "@emotion/babel-plugin": "^11.13.5", @@ -1394,6 +1420,12 @@ "url": "https://github.com/sponsors/nzakas" } }, + "node_modules/@ioredis/commands": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/@ioredis/commands/-/commands-1.5.1.tgz", + "integrity": "sha512-JH8ZL/ywcJyR9MmJ5BNqZllXNZQqQbnVZOqpPQqE1vHiFgAw4NHbvE0FOduNU8IX9babitBT46571OnPTT0Zcw==", + "license": "MIT" + }, "node_modules/@jridgewell/gen-mapping": { "version": "0.3.13", "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", @@ -1480,7 +1512,6 @@ "resolved": "https://registry.npmjs.org/@mui/material/-/material-5.18.0.tgz", "integrity": "sha512-bbH/HaJZpFtXGvWg3TsBWG4eyt3gah3E7nCNU8GLyRjVoWcA91Vm/T+sjHfUcwgJSw9iLtucfHBoq+qW/T30aA==", "license": "MIT", - "peer": true, "dependencies": { "@babel/runtime": "^7.23.9", "@mui/core-downloads-tracker": "^5.18.0", @@ -1708,7 +1739,6 @@ "resolved": "https://registry.npmjs.org/@opentelemetry/api/-/api-1.9.0.tgz", "integrity": "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg==", "license": "Apache-2.0", - "peer": true, "engines": { "node": ">=8.0.0" } @@ -4090,7 +4120,6 @@ "resolved": "https://registry.npmjs.org/@types/node/-/node-22.18.0.tgz", "integrity": "sha512-m5ObIqwsUp6BZzyiy4RdZpzWGub9bqLJMvZDD0QMXhxjqMHMENlj+SqF5QxoUwaQNFe+8kz8XM8ZQhqkQPTgMQ==", "license": "MIT", - "peer": true, "dependencies": { "undici-types": "~6.21.0" } @@ -4157,7 +4186,6 @@ "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.24.tgz", "integrity": "sha512-0dLEBsA1kI3OezMBF8nSsb7Nk19ZnsyE1LLhB8r27KbgU5H4pvuqZLdtE+aUkJVoXgTVuA+iLIwmZ0TuK4tx6A==", "license": "MIT", - "peer": true, "dependencies": { "@types/prop-types": "*", "csstype": "^3.0.2" @@ -4169,7 +4197,6 @@ "integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==", "devOptional": true, "license": "MIT", - "peer": true, "peerDependencies": { "@types/react": "^18.0.0" } @@ -4267,7 +4294,6 @@ "integrity": "sha512-gTtSdWX9xiMPA/7MV9STjJOOYtWwIJIYxkQxnSV1U3xcE+mnJSH3f6zI0RYP+ew66WSlZ5ed+h0VCxsvdC1jJg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.41.0", "@typescript-eslint/types": "8.41.0", @@ -4605,7 +4631,6 @@ "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "license": "MIT", - "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -4966,7 +4991,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "caniuse-lite": "^1.0.30001737", "electron-to-chromium": "^1.5.211", @@ -5288,6 +5312,15 @@ "node": ">=6" } }, + "node_modules/cluster-key-slot": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/cluster-key-slot/-/cluster-key-slot-1.1.2.tgz", + "integrity": "sha512-RMr0FhtfXemyinomL4hrWcYJxmX6deFdCxpJzhDttxgO1+bcCnkk+9drydLVDmAMG7NE6aN/fl4F7ucU/90gAA==", + "license": "Apache-2.0", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/color-convert": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", @@ -5817,6 +5850,15 @@ "node": ">=0.4.0" } }, + "node_modules/denque": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/denque/-/denque-2.1.0.tgz", + "integrity": "sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw==", + "license": "Apache-2.0", + "engines": { + "node": ">=0.10" + } + }, "node_modules/depd": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", @@ -6135,7 +6177,6 @@ "integrity": "sha512-RNCHRX5EwdrESy3Jc9o8ie8Bog+PeYvvSR8sDGoZxNFTvZ4dlxUB3WzQ3bQMztFrSRODGrLLj8g6OFuGY/aiQg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.2.0", "@eslint-community/regexpp": "^4.12.1", @@ -7319,6 +7360,30 @@ "node": ">=12" } }, + "node_modules/ioredis": { + "version": "5.10.1", + "resolved": "https://registry.npmjs.org/ioredis/-/ioredis-5.10.1.tgz", + "integrity": "sha512-HuEDBTI70aYdx1v6U97SbNx9F1+svQKBDo30o0b9fw055LMepzpOOd0Ccg9Q6tbqmBSJaMuY0fB7yw9/vjBYCA==", + "license": "MIT", + "dependencies": { + "@ioredis/commands": "1.5.1", + "cluster-key-slot": "^1.1.0", + "debug": "^4.3.4", + "denque": "^2.1.0", + "lodash.defaults": "^4.2.0", + "lodash.isarguments": "^3.1.0", + "redis-errors": "^1.2.0", + "redis-parser": "^3.0.0", + "standard-as-callback": "^2.1.0" + }, + "engines": { + "node": ">=12.22.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/ioredis" + } + }, "node_modules/ipaddr.js": { "version": "1.9.1", "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", @@ -7682,12 +7747,24 @@ "integrity": "sha512-TwuEnCnxbc3rAvhf/LbG7tJUDzhqXyFnv3dtzLOPgCG/hODL7WFnsbwktkD7yUV0RrreP/l1PALq/YSg6VvjlA==", "license": "MIT" }, + "node_modules/lodash.defaults": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/lodash.defaults/-/lodash.defaults-4.2.0.tgz", + "integrity": "sha512-qjxPLHd3r5DnsdGacqOMU6pb/avJzdh9tFX2ymgoZE27BmjXrNy/y4LoaiTeAb+O3gL8AfpJGtqfX/ae2leYYQ==", + "license": "MIT" + }, "node_modules/lodash.includes": { "version": "4.3.0", "resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz", "integrity": "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==", "license": "MIT" }, + "node_modules/lodash.isarguments": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/lodash.isarguments/-/lodash.isarguments-3.1.0.tgz", + "integrity": "sha512-chi4NHZlZqZD18a0imDHnZPrDeBbTtVN7GXMwuGdRH9qotxAjYs3aVLKc7zNOG9eddR5Ksd8rvFEBc9SsggPpg==", + "license": "MIT" + }, "node_modules/lodash.isboolean": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz", @@ -8401,7 +8478,6 @@ "resolved": "https://registry.npmjs.org/pg/-/pg-8.16.3.tgz", "integrity": "sha512-enxc1h0jA/aq5oSDMvqyW3q89ra6XIIDZgCX9vkMrnz5DFTw/Ny3Li2lFQ+pt3L6MCgm/5o2o8HW9hiJji+xvw==", "license": "MIT", - "peer": true, "dependencies": { "pg-connection-string": "^2.9.1", "pg-pool": "^3.10.1", @@ -8742,7 +8818,6 @@ "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", "license": "MIT", - "peer": true, "dependencies": { "loose-envify": "^1.1.0" }, @@ -8755,7 +8830,6 @@ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", "license": "MIT", - "peer": true, "dependencies": { "loose-envify": "^1.1.0", "scheduler": "^0.23.2" @@ -8992,6 +9066,27 @@ "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==", "license": "MIT" }, + "node_modules/redis-errors": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/redis-errors/-/redis-errors-1.2.0.tgz", + "integrity": "sha512-1qny3OExCf0UvUV/5wpYKf2YwPcOqXzkwKKSmKHiE6ZMQs5heeE/c8eXK+PNllPvmjgAbfnsbpkGZWy8cBpn9w==", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/redis-parser": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/redis-parser/-/redis-parser-3.0.0.tgz", + "integrity": "sha512-DJnGAeenTdpMEH6uAJRK/uiyEIH9WVsUmoLwzudwGJUwZPp80PDBWPHXSAGNPwNvIXAbe7MSUB1zQFugFml66A==", + "license": "MIT", + "dependencies": { + "redis-errors": "^1.0.0" + }, + "engines": { + "node": ">=4" + } + }, "node_modules/require-directory": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", @@ -9507,6 +9602,12 @@ "dev": true, "license": "MIT" }, + "node_modules/standard-as-callback": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/standard-as-callback/-/standard-as-callback-2.1.0.tgz", + "integrity": "sha512-qoRRSyROncaz1z0mvYqIE4lCd9p2R90i6GxW3uZv5ucSu8tU7B5HXUP1gG8pVZsYNVaXjk8ClXHPttLyxAL48A==", + "license": "MIT" + }, "node_modules/statuses": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.1.tgz", @@ -9719,7 +9820,6 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -9886,7 +9986,6 @@ "integrity": "sha512-+wKjMNU9w/EaQayHXb7WA7ZaHY6hN8WgfvHNQ3t1PnU91/7O8TcTnIhCDYTZwnt8JsO9IBqZ30Ln1r7pPF52Aw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "esbuild": "~0.25.0", "get-tsconfig": "^4.7.5" @@ -9952,7 +10051,6 @@ "integrity": "sha512-hjcS1mhfuyi4WW8IWtjP7brDrG2cuDZukyrYrSauoXGNgx0S7zceP07adYkJycEr56BOUTNPzbInooiN3fn1qw==", "dev": true, "license": "Apache-2.0", - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -10173,7 +10271,6 @@ "integrity": "sha512-cZn6NDFE7wdTpINgs++ZJ4N49W2vRp8LCKrn3Ob1kYNtOo21vfDoaV5GzBfLU4MovSAB8uNRm4jgzVQZ+mBzPQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.4.4", @@ -10780,7 +10877,6 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -11317,7 +11413,6 @@ "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "esbuild": "^0.21.3", "postcss": "^8.4.43", @@ -11495,7 +11590,6 @@ "integrity": "sha512-mplynKqc1C2hTVYxd0PU2xQAc22TI1vShAYGksCCfxbn/dFwnHTNi1bvYsBTkhdUNtGIf5xNOg938rrSSYvS9A==", "dev": true, "license": "ISC", - "peer": true, "bin": { "yaml": "bin.mjs" }, diff --git a/package.json b/package.json index 0e43a3f..eab2efe 100644 --- a/package.json +++ b/package.json @@ -34,6 +34,7 @@ "dockerode": "^4.0.4", "dotenv": "^16.4.7", "express": "^4.21.2", + "ioredis": "^5.10.1", "jsonwebtoken": "^9.0.2", "lucide-react": "^0.474.0", "pg": "^8.13.3", diff --git a/server/controllers/monitoringController.ts b/server/controllers/monitoringController.ts index 153b9ae..0f7c057 100644 --- a/server/controllers/monitoringController.ts +++ b/server/controllers/monitoringController.ts @@ -383,6 +383,7 @@ const collectUserDatabaseMetrics = async ( AND query NOT ILIKE '%pg_timezone_names%' AND query NOT LIKE '--%' AND query NOT LIKE 'do $$%' + AND query NOT ILIKE 'EXPLAIN%' ORDER BY mean_exec_time DESC LIMIT 10 `, diff --git a/server/controllers/redisController.ts b/server/controllers/redisController.ts new file mode 100644 index 0000000..b9f16c0 --- /dev/null +++ b/server/controllers/redisController.ts @@ -0,0 +1,72 @@ +import { NextFunction, Request, RequestHandler, Response } from 'express'; +import { Client } from 'pg'; +import Redis from 'ioredis'; + +type RedisController = { + runRedisTest: RequestHandler; +}; + +const redisController: RedisController = { + runRedisTest: async (req: Request, res: Response, next: NextFunction) => { + const { uri_string, queryId } = req.body; + const queryText = res.locals.originalQuery; + + // users database + const client = new Client({ + connectionString: uri_string, + ssl: { + rejectUnauthorized: false, // Required for Supabase connections + }, + }); + + const redisClient = new Redis(`redis://redis:6379`); + + try { + await client.connect(); + const result = await client.query(queryText); + + await client.end(); + + // create key for cache + const key = `query:${queryId}`; + // use stringify since redis doesnt take objects + await redisClient.set(key, JSON.stringify(result.rows)); + + // measure retrieval time using performance.now() + const start = performance.now(); + + // retrieve value + const value = await redisClient.get(key); + + const end = performance.now(); + + const duration = end - start; + + if (value == null) { + return next({ + log: `redisControler: runRedisTest: Value is null`, + status: 500, + message: { + err: `Value from redisClient.get() is null`, + }, + }); + } + res.json({ + pgExecutionTime: res.locals.queryMetrics, + redisRetrievalTime: duration, + }); + } catch (err) { + return next({ + log: `redisControler: runRedisTest: Unexpected error ${err instanceof Error ? err.message : 'Unknown error'} `, + status: 500, + message: { + err: `Error has occured while executing stored query.`, + }, + }); + } finally { + redisClient.disconnect(); + } + }, +}; + +export default redisController; diff --git a/server/controllers/userDatabaseController.ts b/server/controllers/userDatabaseController.ts index aa6fc22..3223ac7 100644 --- a/server/controllers/userDatabaseController.ts +++ b/server/controllers/userDatabaseController.ts @@ -36,6 +36,7 @@ type UserDatabaseController = { analyzeQuery: RequestHandler; compareQueries: RequestHandler; getQueryHistory: RequestHandler; + fetchOriginalQuery: RequestHandler; }; // Consistent EXPLAIN wrapper for query analysis @@ -169,7 +170,7 @@ const userDatabaseController: UserDatabaseController = { ], ); - res.status(200).json(queryMetrics); + res.status(200).json({ ...queryMetrics, id: queryId }); } catch (err) { console.error('Error saving metrics', err); return next({ @@ -336,6 +337,48 @@ const userDatabaseController: UserDatabaseController = { ); } }, + // Fetch one specific query by queryID + fetchOriginalQuery: async ( + req: Request, + res: Response, + next: NextFunction, + ) => { + try { + const { queryId } = req.body; + if (!queryId) { + sendBadRequest(res, 'Query id is missing.'); + return; + } + const queryResult = await appDbPool.query( + ` + SELECT + q.query_text AS "queryText", + m.execution_time AS "executionTime" + FROM queries q + JOIN metrics m ON q.id = m.query_id + WHERE q.id = $1 + + `, + [queryId], + ); + + if (!queryResult.rows[0]) { + sendBadRequest(res, `Query not found`); + return; + } + res.locals.queryMetrics = queryResult.rows[0].executionTime; + res.locals.originalQuery = queryResult.rows[0].queryText; + return next(); + } catch (err) { + return next({ + log: `userDatabaseController: fetchOriginalQuery: Unexpected error ${err instanceof Error ? err.message : 'Unknown error'} `, + status: 500, + message: { + err: `Error has occured while fetching specific query results.`, + }, + }); + } + }, }; export default userDatabaseController; diff --git a/server/routes/apiRoutes.ts b/server/routes/apiRoutes.ts index 9762cb7..1288373 100644 --- a/server/routes/apiRoutes.ts +++ b/server/routes/apiRoutes.ts @@ -12,6 +12,7 @@ import { listActiveTargets, getTargetStatus, } from '../utils/alloyPostgresExporter'; +import redisController from '../controllers/redisController'; const router = express.Router(); @@ -73,6 +74,14 @@ router.get( userDatabaseController.getSavedQueries, ); +// Run query with redis +router.post( + '/run-query/redis', + authenticateUser, + userDatabaseController.fetchOriginalQuery, + redisController.runRedisTest, +); + // Add query analysis endpoints router.post( '/query/analyze', diff --git a/src/App.tsx b/src/App.tsx index facf5d3..86e0ee5 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -22,7 +22,7 @@ function App() { } /> diff --git a/src/components/QueryMonitor/index.tsx b/src/components/QueryMonitor/index.tsx index 483a388..56e9ca9 100644 --- a/src/components/QueryMonitor/index.tsx +++ b/src/components/QueryMonitor/index.tsx @@ -203,13 +203,13 @@ const QueryMonitor: React.FC = () => { {/* Test Query Button */} diff --git a/src/components/QueryPerformance/MetricsTable.tsx b/src/components/QueryPerformance/MetricsTable.tsx index 414f02f..e81dc77 100644 --- a/src/components/QueryPerformance/MetricsTable.tsx +++ b/src/components/QueryPerformance/MetricsTable.tsx @@ -11,6 +11,7 @@ import { // Define the interface for query metrics export interface QueryMetrics { + id: number; // Required in order to run redis on fresh new query test executionTime: number; planningTime: number; rowsReturned: number; @@ -50,7 +51,7 @@ const MetricsTable: React.FC = ({ metrics }) => { Execution Time - {Math.floor(metrics.executionTime).toLocaleString()} ms + {metrics.executionTime.toFixed(2)} ms @@ -82,7 +83,9 @@ const MetricsTable: React.FC = ({ metrics }) => { Cache Hit Ratio - {metrics.cacheHitRatio}% + + {metrics.cacheHitRatio.toFixed(2)}% + diff --git a/src/components/QueryPerformance/QueryComparisonForm.tsx b/src/components/QueryPerformance/QueryComparisonForm.tsx deleted file mode 100644 index 4d6c1e7..0000000 --- a/src/components/QueryPerformance/QueryComparisonForm.tsx +++ /dev/null @@ -1,100 +0,0 @@ -import React from 'react'; -import { - Dialog, - DialogTitle, - DialogContent, - DialogContentText, - DialogActions, - Button, - FormControl, - InputLabel, - Select, - MenuItem, -} from '@mui/material'; -import { SavedQuery } from './QueryHistory'; - -interface ComparisonDialogProps { - open: boolean; - onClose: () => void; - savedQueries: SavedQuery[]; - selectedQueries: { - first: number | null; - second: number | null; - }; - onSelectQuery: (key: 'first' | 'second', value: number) => void; - onCompare: () => void; -} - -const ComparisonDialog: React.FC = ({ - open, - onClose, - savedQueries, - selectedQueries, - onSelectQuery, - onCompare, -}) => { - return ( - - Compare Queries - - - Select two queries to compare their performance metrics side by side. - - - - First Query - - - - - Second Query - - - - - - - - - ); -}; - -export default ComparisonDialog; diff --git a/src/components/QueryPerformance/QueryComparisonPage.tsx b/src/components/QueryPerformance/QueryComparisonPage.tsx index 256ad96..2a2a122 100644 --- a/src/components/QueryPerformance/QueryComparisonPage.tsx +++ b/src/components/QueryPerformance/QueryComparisonPage.tsx @@ -1,4 +1,4 @@ -import React, { useState, useEffect } from 'react'; +import React from 'react'; import { Box, Typography, @@ -8,59 +8,58 @@ import { CardContent, Paper, } from '@mui/material'; -import { SavedQuery } from './QueryHistory'; +import { + BarChart, + Bar, + XAxis, + YAxis, + CartesianGrid, + ResponsiveContainer, + Tooltip, + Legend, +} from 'recharts'; +import { SavedQuery } from './QueryHistoryDialog'; import MetricsTable from './MetricsTable'; -import CompareArrowsIcon from '@mui/icons-material/CompareArrows'; -import LeanQueryAnalyzer from './LeanQueryAnalyzer'; -interface QueryComparisonViewProps { +interface QueryComparisonPageProps { firstQuery: SavedQuery | null; secondQuery: SavedQuery | null; onExitCompare: () => void; } +const COLORS = { + before: '#EF4444', + after: '#10B981', +}; -const QueryComparisonView: React.FC = ({ +const QueryComparisonPage: React.FC = ({ firstQuery, secondQuery, onExitCompare, }) => { - const [comparisonResults, setComparisonResults] = useState(null); - const [loading, setLoading] = useState(false); - if (!firstQuery || !secondQuery) return null; - // Function to compare queries using the enhanced API - const handleCompareQueries = async () => { - setLoading(true); - try { - const token = localStorage.getItem('authToken'); - const response = await fetch('http://localhost:4002/api/query/compare', { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - Authorization: `Bearer ${token}`, - }, - body: JSON.stringify({ - query1: firstQuery.queryText, - query2: secondQuery.queryText, - }), - }); - - if (response.ok) { - const results = await response.json(); - setComparisonResults(results); - } - } catch (error) { - console.error('Comparison failed:', error); - } finally { - setLoading(false); - } - }; - - // Auto-compare when component mounts - useEffect(() => { - handleCompareQueries(); - }, [firstQuery, secondQuery]); + const comparisonChartData = [ + { + metric: 'Exec Time (ms)', + Before: firstQuery.metrics.executionTime, + After: secondQuery.metrics.executionTime, + }, + { + metric: 'Total Cost', + Before: firstQuery.metrics.totalCost, + After: secondQuery.metrics.totalCost, + }, + { + metric: 'Buffer Reads', + Before: firstQuery.metrics.sharedReadBlocks ?? 0, + After: secondQuery.metrics.sharedReadBlocks ?? 0, + }, + { + metric: 'Cache Hit (%)', + Before: firstQuery.metrics.cacheHitRatio ?? 0, + After: secondQuery.metrics.cacheHitRatio ?? 0, + }, + ]; return ( @@ -75,14 +74,7 @@ const QueryComparisonView: React.FC = ({ Query Comparison - + - - + {/* First Query */} - - + + {firstQuery.queryName} @@ -111,13 +104,21 @@ const QueryComparisonView: React.FC = ({ {/* Second Query */} - - + + {secondQuery.queryName} - + {secondQuery.queryText} @@ -125,7 +126,6 @@ const QueryComparisonView: React.FC = ({ - {/* Performance Difference Summary */} @@ -139,18 +139,35 @@ const QueryComparisonView: React.FC = ({ Execution Time - + + secondQuery.metrics.executionTime + ? '#10b981' + : '#EF4444', + }} + > {Math.abs( - ((firstQuery.metrics.executionTime - - secondQuery.metrics.executionTime) / - firstQuery.metrics.executionTime) * - 100 + ((parseFloat(firstQuery.metrics.executionTime.toFixed(2)) - + parseFloat( + secondQuery.metrics.executionTime.toFixed(2), + )) / + parseFloat(firstQuery.metrics.executionTime.toFixed(2))) * + 100, ).toFixed(2)} % {firstQuery.metrics.executionTime > secondQuery.metrics.executionTime - ? ' faster' - : ' slower'} + ? ` faster` + : ` slower`} + + + {firstQuery.metrics.executionTime > + secondQuery.metrics.executionTime + ? `${secondQuery.queryName} is recommended` + : `${firstQuery.queryName} is recommended`} @@ -160,12 +177,21 @@ const QueryComparisonView: React.FC = ({ Planning Time - + + secondQuery.metrics.planningTime + ? '#10b981' + : '#EF4444', + }} + > {Math.abs( - ((firstQuery.metrics.planningTime - - secondQuery.metrics.planningTime) / - firstQuery.metrics.planningTime) * - 100 + ((parseFloat(firstQuery.metrics.planningTime.toFixed(2)) - + parseFloat(secondQuery.metrics.planningTime.toFixed(2))) / + parseFloat(firstQuery.metrics.planningTime.toFixed(2))) * + 100, ).toFixed(2)} % {firstQuery.metrics.planningTime > @@ -173,6 +199,12 @@ const QueryComparisonView: React.FC = ({ ? ' faster' : ' slower'} + + {firstQuery.metrics.planningTime > + secondQuery.metrics.planningTime + ? `${secondQuery.queryName} is recommended` + : `${firstQuery.queryName} is recommended`} + @@ -181,33 +213,88 @@ const QueryComparisonView: React.FC = ({ Total Cost - + + secondQuery.metrics.totalCost + ? '#10B981' + : '#EF4444', + }} + > {Math.abs( - ((firstQuery.metrics.totalCost - - secondQuery.metrics.totalCost) / - firstQuery.metrics.totalCost) * - 100 + ((parseFloat(firstQuery.metrics.totalCost.toFixed(2)) - + parseFloat(secondQuery.metrics.totalCost.toFixed(2))) / + parseFloat(firstQuery.metrics.totalCost.toFixed(2))) * + 100, ).toFixed(2)} % {firstQuery.metrics.totalCost > secondQuery.metrics.totalCost ? ' lower' : ' higher'} + + {firstQuery.metrics.totalCost > secondQuery.metrics.totalCost + ? `${secondQuery.queryName} is recommended` + : `${firstQuery.queryName} is recommended`} + - - {/* Enhanced Lean Query Analyzer Comparison */} - {comparisonResults && ( - - )} + {/* Bar chart */} + + + + Performance Chart + + + + + + + + + + + + + + ); }; -export default QueryComparisonView; +export default QueryComparisonPage; diff --git a/src/components/QueryPerformance/QueryHistory.tsx b/src/components/QueryPerformance/QueryHistoryDialog.tsx similarity index 71% rename from src/components/QueryPerformance/QueryHistory.tsx rename to src/components/QueryPerformance/QueryHistoryDialog.tsx index 0f806c6..9843433 100644 --- a/src/components/QueryPerformance/QueryHistory.tsx +++ b/src/components/QueryPerformance/QueryHistoryDialog.tsx @@ -15,6 +15,8 @@ import { TableRow, TableCell, Paper, + Checkbox, + Tooltip, } from '@mui/material'; import CloseIcon from '@mui/icons-material/Close'; import CompareArrowsIcon from '@mui/icons-material/CompareArrows'; @@ -33,9 +35,15 @@ interface QueryHistoryDialogProps { open: boolean; onClose: () => void; savedQueries: SavedQuery[]; - onLoadQuery: (queryText: string, metrics: QueryMetrics) => void; - onOpenCompare: () => void; - onCompareWithCurrent?: (historicalQuery: SavedQuery) => void; + onLoadQuery: ( + queryId: number, + queryName: string, + queryText: string, + metrics: QueryMetrics, + ) => void; + selectedQueryIds: number[]; + onToggleSelect: (id: number) => void; + onCompare: () => void; } const QueryHistoryDialog: React.FC = ({ @@ -43,8 +51,9 @@ const QueryHistoryDialog: React.FC = ({ onClose, savedQueries, onLoadQuery, - onOpenCompare, - onCompareWithCurrent, + selectedQueryIds, + onToggleSelect, + onCompare, }) => { return ( = ({ + Name Query Execution Time @@ -91,6 +101,12 @@ const QueryHistoryDialog: React.FC = ({ {savedQueries.map((item) => ( + + onToggleSelect(item.id)} + /> + {item.queryName} {item.queryText.length > 30 @@ -113,24 +129,18 @@ const QueryHistoryDialog: React.FC = ({ size='small' sx={{ textTransform: 'none', - mr: 1, }} onClick={() => - onLoadQuery(item.queryText, item.metrics) + onLoadQuery( + item.id, + item.queryName, + item.queryText, + item.metrics, + ) } > Load - ))} @@ -140,13 +150,30 @@ const QueryHistoryDialog: React.FC = ({ )} - + + + + diff --git a/src/components/QueryPerformance/RedisTestDialog.tsx b/src/components/QueryPerformance/RedisTestDialog.tsx new file mode 100644 index 0000000..f4bf406 --- /dev/null +++ b/src/components/QueryPerformance/RedisTestDialog.tsx @@ -0,0 +1,99 @@ +import React from 'react'; +import { + Dialog, + DialogTitle, + DialogContent, + CircularProgress, + Box, + Typography, + IconButton, +} from '@mui/material'; +import { BarChart, XAxis, YAxis, Bar, Cell } from 'recharts'; +import CloseIcon from '@mui/icons-material/Close'; + +export interface RedisTestResult { + pgExecutionTime: number; + redisRetrievalTime: number; +} + +interface RedisTestDialogProps { + open: boolean; + onClose: () => void; + redisMetrics: RedisTestResult | null; + redisLoading: boolean; +} + +function RedisChart({ redisMetrics }: { redisMetrics: RedisTestResult }) { + const { pgExecutionTime, redisRetrievalTime } = redisMetrics; + const data = [ + { name: 'PostgreSQL', value: pgExecutionTime }, + { name: 'Redis', value: redisRetrievalTime }, + ]; + + return ( + + + + Performance Metrics + + + PostgreSQL Execution Time: {pgExecutionTime.toFixed(2)} ms + + + Redis Retrieval Time: {redisRetrievalTime.toFixed(2)} ms + + + + + + `${value} ms`} + width={65} + stroke='#ffffff' + /> + + + + + + + ); +} + +const RedisTestDialog: React.FC = ({ + open, + onClose, + redisMetrics, + redisLoading, +}) => { + return ( + + + Redis vs. PostgreSQL Performance + + + + + + {redisLoading ? ( + + + + ) : redisMetrics ? ( + + + + ) : null} + + + ); +}; + +export default RedisTestDialog; diff --git a/src/components/QueryPerformance/TestQueryForm.tsx b/src/components/QueryPerformance/TestQueryForm.tsx index 5b5fd17..6438a46 100644 --- a/src/components/QueryPerformance/TestQueryForm.tsx +++ b/src/components/QueryPerformance/TestQueryForm.tsx @@ -7,6 +7,7 @@ import { CircularProgress, TextField, Typography, + Tooltip, } from '@mui/material'; // Define the button styles @@ -16,6 +17,7 @@ const buttonStyles = { px: 4, borderRadius: 1.5, whiteSpace: 'nowrap', + width: '100%', }; // Define the input styles @@ -61,6 +63,7 @@ const TestQueryForm: React.FC = ({ fullWidth value={uri_string} onChange={(e) => onUriChange(e.target.value)} + // helperText={!uri_string ? 'Required for Redis Comparison' : ''} sx={inputStyles} /> = ({ value={query} onChange={(e) => onQueryChange(e.target.value)} /> - + + + + diff --git a/src/components/QueryPerformance/TestQueryPage.tsx b/src/components/QueryPerformance/TestQueryPage.tsx index 4ce1238..562ee42 100644 --- a/src/components/QueryPerformance/TestQueryPage.tsx +++ b/src/components/QueryPerformance/TestQueryPage.tsx @@ -10,17 +10,17 @@ import { Alert, Button, CssBaseline, + Tooltip, } from '@mui/material'; import { useNavigate } from 'react-router-dom'; // Import custom components import Header from './Header'; // Nav bar on component on top import MetricsTable, { QueryMetrics } from './MetricsTable'; // component that has the query mertics -import QueryHistory, { SavedQuery } from './QueryHistory'; // component that you can view your past queries. -import QueryComparisonForm from './QueryComparisonForm'; +import QueryHistoryDialog, { SavedQuery } from './QueryHistoryDialog'; // component that you can view your past queries. import QueryComparisonPage from './QueryComparisonPage'; import TestQueryForm from './TestQueryForm'; -import LeanQueryAnalyzer from './LeanQueryAnalyzer'; +import RedisTestDialog, { RedisTestResult } from './RedisTestDialog'; // Import the same dark theme configuration as before const darkTheme = createTheme({ @@ -42,6 +42,7 @@ const darkTheme = createTheme({ const TestQueryPage: React.FC = () => { const navigate = useNavigate(); const [uri_string, setUri_string] = useState(''); + const [queryId, setQueryId] = useState(null); const [query, setQuery] = useState(''); const [queryName, setQueryName] = useState(''); const [loading, setLoading] = useState(false); @@ -49,25 +50,21 @@ const TestQueryPage: React.FC = () => { const [queryMetrics, setQueryMetrics] = useState(null); const [isAuthenticated, setIsAuthenticated] = useState(false); - // State for Lean Query Analyzer - const [analysis, setAnalysis] = useState(null); - const [insights, setInsights] = useState(null); - // State for saved queries and comparison const [savedQueries, setSavedQueries] = useState([]); const [showQueryHistory, setShowQueryHistory] = useState(false); - const [showComparisonDialog, setShowComparisonDialog] = useState(false); - const [selectedQueries, setSelectedQueries] = useState<{ - first: number | null; - second: number | null; - }>({ - first: null, - second: null, - }); - const [compareMode, setCompareMode] = useState(false); + const [firstQuery, setFirstQuery] = useState(null); const [secondQuery, setSecondQuery] = useState(null); + const [selectedQueryIds, setSelectedQueryIds] = useState([]); + const [compareMode, setCompareMode] = useState(false); + // Redis state + const [redisDialogOpen, setRedisDialogOpen] = useState(false); + const [redisLoading, setRedisLoading] = useState(false); + const [redisMetrics, setRedisMetrics] = useState( + null, + ); // Create authentication check const checkAuthentication = () => { const token = localStorage.getItem('authToken'); @@ -81,13 +78,17 @@ const TestQueryPage: React.FC = () => { } }; - // Check if user is authenticated on component mount + // Run once on mount - check auth and load saved queries if authenticated useEffect(() => { - checkAuthentication(); - if (isAuthenticated) { + const isAuthed = checkAuthentication(); + if (isAuthed) { fetchSavedQueries(); } - }, [isAuthenticated]); + // Run once on mount only - adding fetchSavedQueries to deps would cause an infinite loop + // since it's recreated on every render. checkAuthentication return value is used + // instead of isAuthenticated state to avoid stale state timing issue. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); // Fetch saved queries from the backend const fetchSavedQueries = async () => { @@ -164,9 +165,7 @@ const TestQueryPage: React.FC = () => { const data: QueryMetrics = await response.json(); setQueryMetrics(data); - - // Analyze the query for enhanced insights - await handleAnalyzeQuery(query); + setQueryId(data.id); // adding this for the redis button to work on fresh new query, // Refresh the saved queries list after successful fetch await fetchSavedQueries(); @@ -178,91 +177,92 @@ const TestQueryPage: React.FC = () => { } }; - // Function to handle selecting queries for comparison const handleCompare = () => { - if (selectedQueries.first !== null && selectedQueries.second !== null) { - const first = - savedQueries.find((q) => q.id === selectedQueries.first) || null; - const second = - savedQueries.find((q) => q.id === selectedQueries.second) || null; - - setFirstQuery(first); - setSecondQuery(second); - setCompareMode(true); - setShowComparisonDialog(false); - } + const first = + savedQueries.find((q) => q.id === selectedQueryIds[0]) ?? null; + const second = + savedQueries.find((q) => q.id === selectedQueryIds[1]) ?? null; + setFirstQuery(first); + setSecondQuery(second); + setCompareMode(true); }; // Function to handle loading a query from history // Takes in the string and metrics thats that have a set type for each metric. - const handleLoadQuery = (queryText: string, metrics: QueryMetrics) => { + const handleLoadQuery = ( + id: number, + name: string, + queryText: string, + metrics: QueryMetrics, + ) => { + setQueryId(id); + setUri_string(''); + setQueryName(name); setQuery(queryText); setQueryMetrics(metrics); setShowQueryHistory(false); + setCompareMode(false); }; - // Function to handle query selection for comparison - const handleSelectQuery = (key: 'first' | 'second', value: number) => { - setSelectedQueries({ - ...selectedQueries, - [key]: value, - }); + // Redirect to login if user is not authenticated + const handleLogin = () => { + navigate('/auth'); }; - // Function to analyze query for enhanced insights with tracing - const handleAnalyzeQuery = async (sqlQuery: string) => { - // Create a trace ID for this query analysis - const traceId = Math.random().toString(36).substring(2, 15); - + // Function to handle redis test + const handleRedisTest = async () => { + // Things it needs + setRedisLoading(true); + setRedisDialogOpen(true); + setRedisMetrics(null); + setError(null); + try { - console.log(`Starting query analysis trace: ${traceId}`); - - const token = localStorage.getItem('authToken'); - const response = await fetch('http://localhost:4002/api/query/analyze', { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - Authorization: `Bearer ${token}`, - // Add trace context headers - 'X-Trace-Id': traceId, - 'X-Query-Source': 'test-query-page', + // check if user authenticated + if (!checkAuthentication()) { + throw Error('Authentication required. Please login to continue'); + } + const token = localStorage.getItem(`authToken`); + + // need to fetch our route in our backend that has runRedisTest + // it needs the queryId + const response = await fetch( + 'http://localhost:4002/api/run-query/redis', + { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${token}`, + }, + body: JSON.stringify({ + uri_string, + queryId, + }), }, - body: JSON.stringify({ - sqlQuery, - traceContext: { - traceId, - source: 'frontend.test-query-page', - timestamp: new Date().toISOString(), - } - }), - }); + ); - if (response.ok) { - const result = await response.json(); - setAnalysis(result.analysis); - setInsights(result.insights); - - // Log successful analysis with trace context - console.log(`Query analysis completed successfully`, { - traceId, - executionTime: result.analysis?.executionTime, - cacheHitRatio: result.analysis?.cacheHitRatio, - recommendations: result.insights?.recommendations?.length || 0, - }); - } else { - console.error(`Query analysis failed with status: ${response.status}`, { - traceId, - status: response.status, - }); + if (response.status === 401) { + localStorage.removeItem('authToken'); + localStorage.removeItem('user'); + setIsAuthenticated(false); + throw Error('Authentication required. Please login to continue.'); } - } catch (error) { - console.error('Analysis failed:', error, { traceId }); - } - }; - // Redirect to login if user is not authenticated - const handleLogin = () => { - navigate('/auth'); + if (!response.ok) { + throw Error('Failed to fetch metrics with Redis.'); + } + + const data: RedisTestResult = await response.json(); + setRedisMetrics(data); + } catch (err) { + setRedisDialogOpen(false); + setError( + 'Error fetching metrics with Redis. Make sure to input correct URI', + ); + console.error(err); + } finally { + setRedisLoading(false); + } }; return ( @@ -274,7 +274,6 @@ const TestQueryPage: React.FC = () => { isAuthenticated={isAuthenticated} onHistoryClick={() => { setShowQueryHistory(true); - setCompareMode(false); }} /> @@ -302,10 +301,9 @@ const TestQueryPage: React.FC = () => { firstQuery={firstQuery} secondQuery={secondQuery} onExitCompare={() => { + setSelectedQueryIds([]); // When we exit out setCompareMode becomes false and the page goes back to normal view. setCompareMode(false); - setFirstQuery(null); // - setSecondQuery(null); }} /> ) : ( @@ -335,64 +333,67 @@ const TestQueryPage: React.FC = () => { Query Metrics + + + + + + + )} - - {/* Lean Query Analyzer */} - {analysis && insights && ( - - )} )} {/* Modals */} - setShowQueryHistory(false)} savedQueries={savedQueries} onLoadQuery={handleLoadQuery} - onOpenCompare={() => { - setShowQueryHistory(false); - setShowComparisonDialog(true); - }} - onCompareWithCurrent={(historicalQuery) => { - setShowQueryHistory(false); - setFirstQuery(historicalQuery); - setSecondQuery({ - id: 0, - queryName: 'Current Query', - queryText: query, - metrics: queryMetrics || { - executionTime: 0, - planningTime: 0, - rowsReturned: 0, - actualLoops: 0, - sharedHitBlocks: 0, - sharedReadBlocks: 0, - workMem: 0, - cacheHitRatio: 0, - startupCost: 0, - totalCost: 0, - }, - createdAt: new Date().toISOString(), - }); - setCompareMode(true); + selectedQueryIds={selectedQueryIds} + onToggleSelect={(id: number) => { + setSelectedQueryIds((prev) => + prev.includes(id) ? prev.filter((x) => x !== id) : [...prev, id], + ); }} - /> - setShowComparisonDialog(false)} - savedQueries={savedQueries} - selectedQueries={selectedQueries} - onSelectQuery={handleSelectQuery} onCompare={handleCompare} /> + setRedisDialogOpen(false)} + redisMetrics={redisMetrics} + redisLoading={redisLoading} + /> ); }; diff --git a/src/components/assets/QH_Dashboard.png b/src/components/assets/QH_Dashboard.png index 34387c4..c0e9710 100644 Binary files a/src/components/assets/QH_Dashboard.png and b/src/components/assets/QH_Dashboard.png differ diff --git a/src/components/assets/QH_Metrics.png b/src/components/assets/QH_Metrics.png index 12a901f..cce5f44 100644 Binary files a/src/components/assets/QH_Metrics.png and b/src/components/assets/QH_Metrics.png differ diff --git a/src/components/assets/QH_Query.png b/src/components/assets/QH_Query.png deleted file mode 100644 index 779bf93..0000000 Binary files a/src/components/assets/QH_Query.png and /dev/null differ diff --git a/src/components/assets/QH_QueryComparison.png b/src/components/assets/QH_QueryComparison.png new file mode 100644 index 0000000..fa3bc85 Binary files /dev/null and b/src/components/assets/QH_QueryComparison.png differ diff --git a/src/components/assets/QH_RedisVsPostgreSQL.png b/src/components/assets/QH_RedisVsPostgreSQL.png new file mode 100644 index 0000000..4b15aaa Binary files /dev/null and b/src/components/assets/QH_RedisVsPostgreSQL.png differ