From 60a6a5b38c43b4efd08b466fa9474eaa9483a27e Mon Sep 17 00:00:00 2001 From: Jugal Kishore Date: Wed, 19 Aug 2026 17:25:12 +0000 Subject: [PATCH 1/5] feat(onboarding): add Grok Build, GitHub Copilot, Serilog, GCP Integration datasources (#12595) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #### Description - Adds Grok Build, GitHub Copilot, Serilog, and GCP Integration to the onboarding data source picker. - Adds a runtime step under AWS Lambda โ†’ Traces, so the new Go SDK guide is reachable alongside the auto-instrumentation layers. - New `github-copilot.svg`; the other three reuse existing logos (`grok`, `dotnet`, `gcp`). #### Issues closed by this PR Closes SigNoz/signoz.io#3999 Closes SigNoz/signoz.io#3982 Closes SigNoz/signoz.io#3972 Closes SigNoz/signoz.io#3947 Closes SigNoz/signoz.io#3806 --- frontend/src/assets/Logos/azure.svg | 3 + frontend/src/assets/Logos/cloudnative-pg.svg | 1 + frontend/src/assets/Logos/dify.svg | 5 + frontend/src/assets/Logos/from-log-file.svg | 2 +- frontend/src/assets/Logos/github-copilot.svg | 3 + frontend/src/assets/Logos/keda.svg | 1 + frontend/src/assets/Logos/kong.svg | 24 + frontend/src/assets/Logos/neon.svg | 3 + .../onboarding-config-with-links.ts | 585 ++++++++++++++++-- 9 files changed, 582 insertions(+), 45 deletions(-) create mode 100644 frontend/src/assets/Logos/azure.svg create mode 100644 frontend/src/assets/Logos/cloudnative-pg.svg create mode 100644 frontend/src/assets/Logos/dify.svg create mode 100644 frontend/src/assets/Logos/github-copilot.svg create mode 100644 frontend/src/assets/Logos/keda.svg create mode 100644 frontend/src/assets/Logos/kong.svg create mode 100644 frontend/src/assets/Logos/neon.svg diff --git a/frontend/src/assets/Logos/azure.svg b/frontend/src/assets/Logos/azure.svg new file mode 100644 index 00000000000..fa4fb79256c --- /dev/null +++ b/frontend/src/assets/Logos/azure.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/src/assets/Logos/cloudnative-pg.svg b/frontend/src/assets/Logos/cloudnative-pg.svg new file mode 100644 index 00000000000..856041e8e77 --- /dev/null +++ b/frontend/src/assets/Logos/cloudnative-pg.svg @@ -0,0 +1 @@ +CloudNativePG Icon - Color diff --git a/frontend/src/assets/Logos/dify.svg b/frontend/src/assets/Logos/dify.svg new file mode 100644 index 00000000000..73ccf88eccc --- /dev/null +++ b/frontend/src/assets/Logos/dify.svg @@ -0,0 +1,5 @@ + + Dify + + + diff --git a/frontend/src/assets/Logos/from-log-file.svg b/frontend/src/assets/Logos/from-log-file.svg index e1aa82a96dd..4464747be13 100644 --- a/frontend/src/assets/Logos/from-log-file.svg +++ b/frontend/src/assets/Logos/from-log-file.svg @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/frontend/src/assets/Logos/github-copilot.svg b/frontend/src/assets/Logos/github-copilot.svg new file mode 100644 index 00000000000..4671aed9b0f --- /dev/null +++ b/frontend/src/assets/Logos/github-copilot.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/src/assets/Logos/keda.svg b/frontend/src/assets/Logos/keda.svg new file mode 100644 index 00000000000..a36aa275462 --- /dev/null +++ b/frontend/src/assets/Logos/keda.svg @@ -0,0 +1 @@ +keda-icon diff --git a/frontend/src/assets/Logos/kong.svg b/frontend/src/assets/Logos/kong.svg new file mode 100644 index 00000000000..d808c03e79e --- /dev/null +++ b/frontend/src/assets/Logos/kong.svg @@ -0,0 +1,24 @@ + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/frontend/src/assets/Logos/neon.svg b/frontend/src/assets/Logos/neon.svg new file mode 100644 index 00000000000..10fbd24b161 --- /dev/null +++ b/frontend/src/assets/Logos/neon.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/src/container/OnboardingV2Container/onboarding-configs/onboarding-config-with-links.ts b/frontend/src/container/OnboardingV2Container/onboarding-configs/onboarding-config-with-links.ts index e820c53d102..2e8d4dccd68 100644 --- a/frontend/src/container/OnboardingV2Container/onboarding-configs/onboarding-config-with-links.ts +++ b/frontend/src/container/OnboardingV2Container/onboarding-configs/onboarding-config-with-links.ts @@ -21,6 +21,7 @@ import azureMysqlUrl from '@/assets/Logos/azure-mysql.svg'; import azureOpenaiUrl from '@/assets/Logos/azure-openai.svg'; import azureSqlDatabaseMetricsUrl from '@/assets/Logos/azure-sql-database-metrics.svg'; import azureVmUrl from '@/assets/Logos/azure-vm.svg'; +import azureUrl from '@/assets/Logos/azure.svg'; import basetenUrl from '@/assets/Logos/baseten.svg'; import cassandraUrl from '@/assets/Logos/cassandra.svg'; import celeryUrl from '@/assets/Logos/celery.svg'; @@ -28,6 +29,7 @@ import certManagerUrl from '@/assets/Logos/cert-manager.svg'; import claudeCodeUrl from '@/assets/Logos/claude-code.svg'; import clickhouseUrl from '@/assets/Logos/clickhouse.svg'; import cloudflareUrl from '@/assets/Logos/cloudflare.svg'; +import cloudnativePgUrl from '@/assets/Logos/cloudnative-pg.svg'; import cloudwatchLogsUrl from '@/assets/Logos/cloudwatch-logs.svg'; import cohereUrl from '@/assets/Logos/cohere.svg'; import confluentKafkaUrl from '@/assets/Logos/confluent-kafka.svg'; @@ -39,6 +41,7 @@ import datadogUrl from '@/assets/Logos/datadog.svg'; import dbosUrl from '@/assets/Logos/dbos.svg'; import deepseekUrl from '@/assets/Logos/deepseek.svg'; import denoUrl from '@/assets/Logos/deno.svg'; +import difyUrl from '@/assets/Logos/dify.svg'; import dockerUrl from '@/assets/Logos/docker.svg'; import documentLoadUrl from '@/assets/Logos/document-load.svg'; import dotnetUrl from '@/assets/Logos/dotnet.svg'; @@ -70,6 +73,8 @@ import gcpCloudStorageUrl from '@/assets/Logos/gcp-cloud-storage.svg'; import gcpComputeEngineUrl from '@/assets/Logos/gcp-compute-engine.svg'; import gcpGkeUrl from '@/assets/Logos/gcp-gke.svg'; import gcpVpcUrl from '@/assets/Logos/gcp-vpc.svg'; +import gcpUrl from '@/assets/Logos/gcp.svg'; +import githubCopilotUrl from '@/assets/Logos/github-copilot.svg'; import githubUrl from '@/assets/Logos/github.svg'; import goUrl from '@/assets/Logos/go.svg'; import googleAdkUrl from '@/assets/Logos/google-adk.svg'; @@ -96,6 +101,8 @@ import javascriptUrl from '@/assets/Logos/javascript.svg'; import jbossUrl from '@/assets/Logos/jboss.svg'; import jenkinsUrl from '@/assets/Logos/jenkins.svg'; import kafkaUrl from '@/assets/Logos/kafka.svg'; +import kedaUrl from '@/assets/Logos/keda.svg'; +import kongUrl from '@/assets/Logos/kong.svg'; import kubernetesUrl from '@/assets/Logos/kubernetes.svg'; import lambdaUrl from '@/assets/Logos/lambda.svg'; import langchainUrl from '@/assets/Logos/langchain.svg'; @@ -114,6 +121,7 @@ import microsoftSqlServerUrl from '@/assets/Logos/microsoft-sql-server.svg'; import mistralUrl from '@/assets/Logos/mistral.svg'; import mongoUrl from '@/assets/Logos/mongo.svg'; import n8nUrl from '@/assets/Logos/n8n.svg'; +import neonUrl from '@/assets/Logos/neon.svg'; import newrelicUrl from '@/assets/Logos/newrelic.svg'; import nextjsUrl from '@/assets/Logos/nextjs.svg'; import nginxUrl from '@/assets/Logos/nginx.svg'; @@ -2859,6 +2867,25 @@ const onboardingConfigWithLinks = [ label: 'Traces', imgUrl: lambdaUrl, link: '/docs/aws-monitoring/lambda/lambda-traces/', + question: { + desc: 'Which runtime does your Lambda function use?', + helpText: + 'Python, Node.js, Java, and Ruby use the OpenTelemetry auto-instrumentation layer. Go has no layer, so you add the SDK to your code.', + options: [ + { + key: 'aws-lambda-traces-auto', + label: 'Python, Node.js, Java, Ruby', + imgUrl: lambdaUrl, + link: '/docs/aws-monitoring/lambda/lambda-traces/', + }, + { + key: 'aws-lambda-traces-golang', + label: 'Go', + imgUrl: goUrl, + link: '/docs/aws-monitoring/lambda/lambda-traces-golang/', + }, + ], + }, }, { key: 'aws-lambda-metrics', @@ -5510,8 +5537,10 @@ const onboardingConfigWithLinks = [ module: 'metrics', relatedSearchKeywords: [ 'integrations', + 'logs', 'metrics', 'supabase', + 'supabase logs', 'supabase metrics', 'supabase monitoring', 'supabase observability', @@ -5545,43 +5574,23 @@ const onboardingConfigWithLinks = [ label: 'Traefik', imgUrl: opentelemetryUrl, tags: ['infrastructure monitoring'], - module: 'infrastructure', + module: 'apm', relatedSearchKeywords: [ 'infrastructure', 'traefik', - 'traefik access logs', - 'traefik logs', 'traefik metrics', 'traefik monitoring', 'traefik observability', 'traefik tracing', ], link: '/docs/tutorial/traefik-observability/', - question: { - desc: 'Which Traefik signals do you want to send to SigNoz?', - type: 'select', - options: [ - { - key: 'traefik-metrics-traces', - label: 'Metrics & Traces', - imgUrl: opentelemetryUrl, - link: '/docs/tutorial/traefik-observability/', - }, - { - key: 'traefik-logs', - label: 'Access Logs', - imgUrl: opentelemetryUrl, - link: '/docs/integrations/opentelemetry-traefik/', - }, - ], - }, }, { dataSource: 'mongodb-atlas', label: 'MongoDB (Atlas)', imgUrl: mongoUrl, tags: ['database'], - module: 'database', + module: 'metrics', relatedSearchKeywords: [ 'atlas', 'atlas metrics', @@ -5600,36 +5609,15 @@ const onboardingConfigWithLinks = [ label: 'MySQL', imgUrl: opentelemetryUrl, tags: ['database'], - module: 'database', + module: 'metrics', relatedSearchKeywords: [ 'database', 'mysql', - 'mysql error log', - 'mysql logs', 'mysql metrics', 'mysql monitoring', 'mysql observability', - 'mysql slow query log', ], link: '/docs/metrics-management/mysql-metrics/', - question: { - desc: 'Which MySQL signals do you want to send to SigNoz?', - type: 'select', - options: [ - { - key: 'mysql-metrics', - label: 'Metrics', - imgUrl: opentelemetryUrl, - link: '/docs/metrics-management/mysql-metrics/', - }, - { - key: 'mysql-logs', - label: 'Logs', - imgUrl: opentelemetryUrl, - link: '/docs/integrations/opentelemetry-mysql/', - }, - ], - }, }, { dataSource: 'jmx', @@ -6992,5 +6980,514 @@ const onboardingConfigWithLinks = [ id: 'dspy', link: '/docs/dspy-observability/', }, + { + dataSource: 'grok-build', + label: 'Grok Build', + imgUrl: grokUrl, + tags: ['LLM Monitoring'], + module: 'metrics', + relatedSearchKeywords: [ + 'coding agent', + 'grok build', + 'grok build events', + 'grok build logs', + 'grok build metrics', + 'grok build monitoring', + 'grok build observability', + 'llm', + 'llm monitoring', + 'metrics', + 'monitoring', + 'observability', + 'otel grok build integration', + 'terminal coding agent', + 'token usage', + 'xai', + ], + id: 'grok-build', + link: '/docs/grok-build-observability/', + }, + { + dataSource: 'neon', + label: 'Neon', + imgUrl: neonUrl, + tags: ['database'], + module: 'metrics', + relatedSearchKeywords: [ + 'database', + 'neon', + 'neon database', + 'neon db', + 'neon logs', + 'neon metrics', + 'neon monitoring', + 'neon observability', + 'neondb', + 'opentelemetry neon', + 'postgres', + 'postgresql', + 'serverless postgres', + ], + id: 'neon', + link: '/docs/integrations/opentelemetry-neondb/', + }, + { + dataSource: 'dify', + label: 'Dify', + imgUrl: difyUrl, + tags: ['LLM Monitoring'], + module: 'apm', + relatedSearchKeywords: [ + 'ai agent', + 'dify', + 'dify monitoring', + 'dify observability', + 'dify traces', + 'llm', + 'llm monitoring', + 'metrics', + 'no code ai', + 'observability', + 'opentelemetry dify', + 'traces', + ], + id: 'dify', + link: '/docs/dify-observability/', + }, + { + dataSource: 'firecrawl', + label: 'Firecrawl', + imgUrl: llmMonitoringUrl, + tags: ['LLM Monitoring'], + module: 'apm', + relatedSearchKeywords: [ + 'crawl', + 'firecrawl', + 'firecrawl metrics', + 'firecrawl monitoring', + 'firecrawl observability', + 'firecrawl traces', + 'llm', + 'llm monitoring', + 'opentelemetry firecrawl', + 'scrape', + 'traces', + 'web scraping', + ], + id: 'firecrawl', + link: '/docs/firecrawl-monitoring/', + }, + { + dataSource: 'keda', + label: 'KEDA', + imgUrl: kedaUrl, + tags: ['infrastructure monitoring', 'metrics'], + module: 'metrics', + relatedSearchKeywords: [ + 'autoscaling', + 'event driven autoscaling', + 'keda', + 'keda metrics', + 'keda monitoring', + 'kubernetes', + 'kubernetes autoscaling', + 'metrics', + 'opentelemetry keda', + 'scaled jobs', + 'scaled objects', + ], + id: 'keda', + link: '/docs/metrics-management/keda-metrics/', + }, + { + dataSource: 'opentelemetry-collector-metrics', + label: 'OpenTelemetry Collector Metrics', + imgUrl: opentelemetryUrl, + tags: ['infrastructure monitoring', 'metrics'], + module: 'metrics', + relatedSearchKeywords: [ + 'collector health', + 'collector internal metrics', + 'collector metrics', + 'metrics', + 'opentelemetry', + 'opentelemetry collector', + 'otel collector', + 'otelcol metrics', + 'pipeline health', + ], + id: 'opentelemetry-collector-metrics', + link: '/docs/metrics-management/opentelemetry-collector-metrics/', + }, + { + dataSource: 'cloudnative-pg', + label: 'CloudNativePG', + imgUrl: cloudnativePgUrl, + tags: ['database'], + module: 'metrics', + relatedSearchKeywords: [ + 'cloud native postgres', + 'cloudnativepg', + 'cnpg', + 'cnpg metrics', + 'database', + 'kubernetes postgres', + 'metrics', + 'opentelemetry cloudnativepg', + 'postgres', + 'postgresql', + ], + id: 'cloudnative-pg', + link: '/docs/metrics-management/opentelemetry-cloudnative-pg/', + }, + { + dataSource: 'kong-gateway', + label: 'Kong Gateway', + imgUrl: kongUrl, + tags: ['infrastructure monitoring'], + module: 'apm', + relatedSearchKeywords: [ + 'api gateway', + 'kong', + 'kong gateway', + 'kong logs', + 'kong metrics', + 'kong monitoring', + 'kong observability', + 'kong traces', + 'opentelemetry kong', + 'proxy', + 'traces', + ], + id: 'kong-gateway', + link: '/docs/integrations/kong-gateway/', + }, + { + dataSource: 'github-copilot', + label: 'GitHub Copilot', + imgUrl: githubCopilotUrl, + tags: ['LLM Monitoring'], + module: 'apm', + relatedSearchKeywords: [ + 'coding agent', + 'copilot', + 'copilot chat', + 'github copilot', + 'github copilot metrics', + 'github copilot monitoring', + 'github copilot observability', + 'github copilot traces', + 'llm', + 'llm monitoring', + 'monitoring', + 'observability', + 'otel github copilot integration', + 'traces', + 'tracing', + 'vs code', + ], + id: 'github-copilot', + link: '/docs/github-copilot-monitoring/', + }, + { + dataSource: 'serilog', + label: 'Serilog', + imgUrl: dotnetUrl, + tags: ['logs'], + module: 'logs', + relatedSearchKeywords: [ + '.net', + 'asp.net', + 'c#', + 'csharp', + 'dotnet', + 'dotnet logs', + 'logging', + 'logs', + 'monitoring', + 'observability', + 'otel serilog integration', + 'serilog', + 'serilog logs', + 'serilog sink', + 'structured logging', + ], + id: 'serilog', + link: '/docs/logs-management/send-logs/serilog-to-signoz/', + }, + { + dataSource: 'gcp-integration', + label: 'GCP Integration', + imgUrl: gcpUrl, + tags: ['GCP'], + module: 'metrics', + relatedSearchKeywords: [ + 'cloud monitoring metrics', + 'connect gcp account', + 'gcp', + 'gcp integration', + 'gcp manual setup', + 'gcp metrics', + 'gcp monitoring', + 'gcp observability', + 'gcp service account', + 'google cloud', + 'google cloud integration', + 'metrics', + 'monitoring', + 'observability', + 'opentelemetry collector gcp', + ], + id: 'gcp-integration', + link: '/docs/integrations/gcp/gcp-integration/', + }, + { + dataSource: 'azure-cosmos-db', + label: 'Azure Cosmos DB', + imgUrl: azureUrl, + tags: ['Azure'], + module: 'dashboards', + relatedSearchKeywords: [ + 'azure', + 'azure cosmos db', + 'azure integration', + 'cosmos db', + 'cosmosdb', + 'database', + 'metrics', + 'monitoring', + 'nosql', + 'observability', + 'one click azure', + 'request units', + ], + id: 'azure-cosmos-db', + link: '/integrations/azure?service=cosmosdb', + internalRedirect: true, + }, + { + dataSource: 'azure-mongodb', + label: 'Azure MongoDB vCore', + imgUrl: mongoUrl, + tags: ['Azure'], + module: 'dashboards', + relatedSearchKeywords: [ + 'azure', + 'azure integration', + 'azure mongodb', + 'azure mongodb vcore', + 'database', + 'metrics', + 'mongodb', + 'mongodb vcore', + 'monitoring', + 'nosql', + 'observability', + 'one click azure', + ], + id: 'azure-mongodb', + link: '/integrations/azure?service=mongodb', + internalRedirect: true, + }, + { + dataSource: 'azure-postgresql-flexible-server', + label: 'Azure PostgreSQL Flexible Server', + imgUrl: postgresqlUrl, + tags: ['Azure'], + module: 'dashboards', + relatedSearchKeywords: [ + 'azure', + 'azure integration', + 'azure postgresql', + 'azure postgresql flexible server', + 'database', + 'metrics', + 'monitoring', + 'observability', + 'one click azure', + 'postgres', + 'postgresql', + 'postgresql flexible server', + ], + id: 'azure-postgresql-flexible-server', + link: '/integrations/azure?service=postgresqlflexibleserver', + internalRedirect: true, + }, + { + dataSource: 'azure-cache-redis', + label: 'Azure Cache for Redis', + imgUrl: redisUrl, + tags: ['Azure'], + module: 'dashboards', + relatedSearchKeywords: [ + 'azure', + 'azure cache for redis', + 'azure integration', + 'azure redis', + 'cache', + 'database', + 'metrics', + 'monitoring', + 'observability', + 'one click azure', + 'redis', + ], + id: 'azure-cache-redis', + link: '/integrations/azure?service=redis', + internalRedirect: true, + }, + { + dataSource: 'azure-sql-managed-instance', + label: 'Azure SQL Managed Instance', + imgUrl: azureSqlDatabaseMetricsUrl, + tags: ['Azure'], + module: 'dashboards', + relatedSearchKeywords: [ + 'azure', + 'azure integration', + 'azure sql', + 'azure sql managed instance', + 'database', + 'managed instance', + 'metrics', + 'monitoring', + 'observability', + 'one click azure', + 'sql server', + ], + id: 'azure-sql-managed-instance', + link: '/integrations/azure?service=sqldatabasemi', + internalRedirect: true, + }, + { + dataSource: 'azure-cassandra-db', + label: 'Azure Managed Instance for Apache Cassandra', + imgUrl: cassandraUrl, + tags: ['Azure'], + module: 'dashboards', + relatedSearchKeywords: [ + 'apache cassandra', + 'azure', + 'azure cassandra', + 'azure integration', + 'azure managed instance for apache cassandra', + 'cassandra', + 'database', + 'metrics', + 'monitoring', + 'observability', + 'one click azure', + ], + id: 'azure-cassandra-db', + link: '/integrations/azure?service=cassandradb', + internalRedirect: true, + }, + { + dataSource: 'gcp-cloud-sql-postgresql', + label: 'GCP Cloud SQL for PostgreSQL', + imgUrl: gcpCloudSqlUrl, + tags: ['GCP'], + module: 'dashboards', + relatedSearchKeywords: [ + 'cloud sql', + 'cloud sql for postgresql', + 'database', + 'gcp', + 'gcp integration', + 'google cloud', + 'metrics', + 'monitoring', + 'observability', + 'postgres', + 'postgresql', + ], + id: 'gcp-cloud-sql-postgresql', + link: '/integrations/gcp?service=cloudsql_postgres', + internalRedirect: true, + }, + { + dataSource: 'gcp-memorystore-redis', + label: 'GCP Memorystore for Redis', + imgUrl: redisUrl, + tags: ['GCP'], + module: 'dashboards', + relatedSearchKeywords: [ + 'cache', + 'database', + 'gcp', + 'gcp integration', + 'google cloud', + 'memorystore', + 'memorystore for redis', + 'metrics', + 'monitoring', + 'observability', + 'redis', + ], + id: 'gcp-memorystore-redis', + link: '/integrations/gcp?service=memorystore_redis', + internalRedirect: true, + }, + { + dataSource: 'supabase-logs', + label: 'Supabase Logs', + imgUrl: supabaseUrl, + tags: ['logs'], + module: 'logs', + relatedSearchKeywords: [ + 'database', + 'logging', + 'logs', + 'postgres', + 'postgresql', + 'send supabase logs', + 'supabase', + 'supabase log drains', + 'supabase logs', + 'supabase observability', + ], + id: 'supabase-logs', + link: '/docs/logs-management/send-logs/supabase-logs/', + }, + { + dataSource: 'traefik-logs', + label: 'Traefik Access Logs', + imgUrl: opentelemetryUrl, + tags: ['logs'], + module: 'logs', + relatedSearchKeywords: [ + 'access logs', + 'logging', + 'logs', + 'opentelemetry traefik', + 'proxy', + 'reverse proxy', + 'traefik', + 'traefik access logs', + 'traefik logs', + ], + id: 'traefik-logs', + link: '/docs/integrations/opentelemetry-traefik/', + }, + { + dataSource: 'mysql-logs', + label: 'MySQL Logs', + imgUrl: opentelemetryUrl, + tags: ['logs'], + module: 'logs', + relatedSearchKeywords: [ + 'database', + 'logging', + 'logs', + 'mysql', + 'mysql error log', + 'mysql general query log', + 'mysql logs', + 'mysql slow query log', + 'opentelemetry mysql', + ], + id: 'mysql-logs', + link: '/docs/integrations/opentelemetry-mysql/', + }, ]; export default onboardingConfigWithLinks; From 5b94d79e4614b9c43b5f08e2824d067826df140f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Vinicius=20Louren=C3=A7o?= <12551007+H4ad@users.noreply.github.com> Date: Wed, 19 Aug 2026 17:56:21 +0000 Subject: [PATCH 2/5] fix(infrastructure-monitoring): page reset on switch category & page outside total (#12453) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Pull Request --- ### ๐Ÿ“„ Summary > Why does this change exist? > What problem does it solve, and why is this the right approach? This PR fixes the following issues: - page not resetting to 1 when switch - bug was only detected/present when coming from deep link - page not resetting to 1 when page produces a offset higher than total - you had to switch to hosts to be able to see data again #### Screenshots / Screen Recordings (if applicable) > Include screenshots or screen recordings that clearly show the behavior before the change and the result after the change. This helps reviewers quickly understand the impact and verify the update. Before: Issue with page not reseting to 1 when changing category (after refresh): https://github.com/user-attachments/assets/00872b38-1263-43c1-8322-64d31ee1ee6a Issue with page outside the offset: https://github.com/user-attachments/assets/5194fb2e-5af3-491b-baf7-b4aa3a330c83 --- After: Issue with page not reseting to 1 when changing category (after refresh): https://github.com/user-attachments/assets/545e5914-c26f-4189-b15a-dc399bdee28b Issue with page outside the offset: https://github.com/user-attachments/assets/1b93d162-22a3-41c8-802e-aa2aa6012db9 #### Issues closed by this PR > Reference issues using `Closes #issue-number` to enable automatic closure on merge. Closes https://github.com/SigNoz/pulse-pod/issues/208 --- ### โœ… Change Type _Select all that apply_ - [ ] โœจ Feature - [x] ๐Ÿ› Bug fix - [ ] โ™ป๏ธ Refactor - [ ] ๐Ÿ› ๏ธ Infra / Tooling - [ ] ๐Ÿงช Test-only --- ### ๐Ÿ› Bug Context > Required if this PR fixes a bug Both issues are caused after the refactor to the new table component and after joining the categories into single component (without unmount/mount when switching categories). #### Root Cause > What caused the issue? > Regression, faulty assumption, edge case, refactor, etc. Lack of reset the page to 1, and no proper way to detect and reset page to 1 when outside the boundaries. #### Fix Strategy > How does this PR address the root cause? Reset to page 1 after switch category and also include hook on tanstack to ensure we reset page to last when outside the params. --- ### ๐Ÿงช Testing Strategy > How was this change validated? - Tests added/updated: Yes - Manual verification: Yes - Edge cases covered: - --- ### โš ๏ธ Risk & Impact Assessment > What could break? How do we recover? - Blast radius: Infrastructure Monitoring - Potential regressions: - - Rollback plan: Open a new PR to fix the issue --- ### ๐Ÿ“ Changelog > Fill only if this affects users, APIs, UI, or documented behavior > Use **N/A** for internal or non-user-facing changes | Field | Value | |------|-------| | Deployment Type | Cloud / OSS / Enterprise | | Change Type | Bug Fix | | Description | We fixed two issues around pagination inside Infrastructure Monitoring causing the page not resetting to 1 after switch category or when offset is higher than total amount of items. | --- ### ๐Ÿ“‹ Checklist - [x] Tests added or explicitly not required - [x] Manually tested - [ ] Breaking changes documented - [ ] Backward compatibility considered --- .../__tests__/useRecoverFromEmptyPage.test.ts | 280 ++++++++++++++++++ .../components/TanStackTableView/index.tsx | 46 +++ .../useRecoverFromEmptyPage.ts | 107 +++++++ .../TanStackTableView/useTableParams.ts | 19 +- .../InfraMonitoringK8sV2/Base/K8sBaseList.tsx | 12 + .../Base/__tests__/K8sBaseList.test.tsx | 174 +++++++++++ .../InfraMonitoringK8s.tsx | 3 + .../__tests__/InfraMonitoringK8s.test.tsx | 128 ++++++++ .../container/InfraMonitoringK8sV2/hooks.ts | 3 + 9 files changed, 770 insertions(+), 2 deletions(-) create mode 100644 frontend/src/components/TanStackTableView/__tests__/useRecoverFromEmptyPage.test.ts create mode 100644 frontend/src/components/TanStackTableView/useRecoverFromEmptyPage.ts create mode 100644 frontend/src/container/InfraMonitoringK8sV2/__tests__/InfraMonitoringK8s.test.tsx diff --git a/frontend/src/components/TanStackTableView/__tests__/useRecoverFromEmptyPage.test.ts b/frontend/src/components/TanStackTableView/__tests__/useRecoverFromEmptyPage.test.ts new file mode 100644 index 00000000000..9ecd4a89730 --- /dev/null +++ b/frontend/src/components/TanStackTableView/__tests__/useRecoverFromEmptyPage.test.ts @@ -0,0 +1,280 @@ +import { renderHook } from '@testing-library/react'; + +import { + useRecoverFromEmptyPage, + UseRecoverFromEmptyPageParams, +} from '../useRecoverFromEmptyPage'; + +const REPLACE = { history: 'replace' }; + +function renderRecovery( + overrides: Partial = {}, +): { setPage: jest.Mock; rerender: (next?: unknown) => void } { + const setPage = jest.fn(); + const props: UseRecoverFromEmptyPageParams = { + page: 1, + pageSize: 10, + rowCount: 10, + total: 100, + isFetching: false, + setPage, + ...overrides, + }; + + const { rerender } = renderHook( + (next: UseRecoverFromEmptyPageParams) => useRecoverFromEmptyPage(next), + { initialProps: props }, + ); + + return { + setPage, + rerender: (next?: unknown): void => + rerender({ ...props, ...(next as Partial) }), + }; +} + +describe('useRecoverFromEmptyPage', () => { + it('leaves the page alone while it still holds rows', () => { + const { setPage } = renderRecovery({ page: 3, rowCount: 10 }); + + expect(setPage).not.toHaveBeenCalled(); + }); + + it('leaves the page alone on page 1 with no rows at all', () => { + const { setPage } = renderRecovery({ page: 1, rowCount: 0, total: 0 }); + + expect(setPage).not.toHaveBeenCalled(); + }); + + it('jumps to the last page that holds data when the page is out of range', () => { + const { setPage } = renderRecovery({ + page: 7, + pageSize: 10, + rowCount: 0, + total: 25, + }); + + expect(setPage).toHaveBeenCalledWith(3, REPLACE); + }); + + it('replaces the history entry so the back button does not return to the empty page', () => { + const { setPage } = renderRecovery({ page: 4, rowCount: 0, total: 10 }); + + expect(setPage).toHaveBeenCalledWith(1, REPLACE); + }); + + it('falls back to page 1 when the total is unknown', () => { + const { setPage } = renderRecovery({ page: 5, rowCount: 0, total: 0 }); + + expect(setPage).toHaveBeenCalledWith(1, REPLACE); + }); + + it('steps back one page when the total claims the page should have data', () => { + // total says 100 rows exist, yet page 5 came back empty โ€” step back rather + // than stall on a page the query cannot actually serve. + const { setPage } = renderRecovery({ + page: 5, + pageSize: 10, + rowCount: 0, + total: 100, + }); + + expect(setPage).toHaveBeenCalledWith(4, REPLACE); + }); + + it('clamps a page below the first one', () => { + const { setPage } = renderRecovery({ page: 0, rowCount: 10 }); + + expect(setPage).toHaveBeenCalledWith(1, REPLACE); + }); + + it('falls back to page 1 when pageSize is zero', () => { + const { setPage } = renderRecovery({ + page: 5, + pageSize: 0, + rowCount: 0, + total: 100, + }); + + expect(setPage).toHaveBeenCalledWith(1, REPLACE); + }); + + it('waits for the request to settle before moving the user', () => { + const { setPage, rerender } = renderRecovery({ + page: 3, + rowCount: 0, + total: 10, + isFetching: true, + }); + + expect(setPage).not.toHaveBeenCalled(); + + rerender({ page: 3, rowCount: 0, total: 10, isFetching: false }); + + expect(setPage).toHaveBeenCalledWith(1, REPLACE); + }); + + it('clamps a page below the first one even when the query failed', () => { + // A negative offset is what made the request fail (400 "offset cannot be + // negative"), so retrying the same page loops forever โ€” clamp regardless. + const { setPage } = renderRecovery({ + page: 0, + rowCount: 0, + total: 0, + isDisabled: true, + }); + + expect(setPage).toHaveBeenCalledWith(1, REPLACE); + }); + + it('clamps a page below the first one while the query is still in flight', () => { + const { setPage } = renderRecovery({ + page: -2, + rowCount: 0, + isFetching: true, + }); + + expect(setPage).toHaveBeenCalledWith(1, REPLACE); + }); + + it('keeps the page when the query failed so a retry lands where the user was', () => { + const { setPage } = renderRecovery({ + page: 3, + rowCount: 0, + total: 0, + isDisabled: true, + }); + + expect(setPage).not.toHaveBeenCalled(); + }); + + it('clamps a page below the first one exactly once while the request settles', () => { + // The clamp runs ahead of both gates, so a request settling underneath an + // uncorrected page must not re-issue the same history rewrite. + const { setPage, rerender } = renderRecovery({ + page: 0, + rowCount: 0, + total: 0, + isFetching: true, + }); + + expect(setPage).toHaveBeenCalledTimes(1); + + rerender({ page: 0, rowCount: 0, total: 0, isFetching: false }); + + expect(setPage).toHaveBeenCalledTimes(1); + }); + + it('stops correcting once the corrected page comes back with rows', () => { + const { setPage, rerender } = renderRecovery({ + page: 7, + pageSize: 10, + rowCount: 0, + total: 25, + }); + + expect(setPage).toHaveBeenCalledWith(3, REPLACE); + + // The correction lands: the query refetches, then resolves with the rows page 3 holds. + rerender({ page: 3, pageSize: 10, rowCount: 0, total: 25, isFetching: true }); + rerender({ + page: 3, + pageSize: 10, + rowCount: 5, + total: 25, + isFetching: false, + }); + + expect(setPage).toHaveBeenCalledTimes(1); + }); + + it('does not correct again while the same page is still being observed', () => { + const { setPage, rerender } = renderRecovery({ + page: 5, + pageSize: 10, + rowCount: 0, + total: 100, + }); + + expect(setPage).toHaveBeenCalledTimes(1); + + // A refetch cycle that leaves the page untouched โ€” the correction is already in flight. + rerender({ + page: 5, + pageSize: 10, + rowCount: 0, + total: 100, + isFetching: true, + }); + rerender({ + page: 5, + pageSize: 10, + rowCount: 0, + total: 100, + isFetching: false, + }); + + expect(setPage).toHaveBeenCalledTimes(1); + }); + + it('gives up on step-backs and jumps to page 1 when the total keeps lying', () => { + // `total` claims 400 rows exist, but every page comes back empty. Walking back one + // page at a time would cost a request per hop, so bail out to page 1 instead. + const { setPage, rerender } = renderRecovery({ + page: 40, + pageSize: 10, + rowCount: 0, + total: 400, + }); + + expect(setPage).toHaveBeenNthCalledWith(1, 39, REPLACE); + + rerender({ page: 39, pageSize: 10, rowCount: 0, total: 400 }); + + expect(setPage).toHaveBeenNthCalledWith(2, 38, REPLACE); + + rerender({ page: 38, pageSize: 10, rowCount: 0, total: 400 }); + + expect(setPage).toHaveBeenNthCalledWith(3, 1, REPLACE); + expect(setPage).toHaveBeenCalledTimes(3); + }); + + it('corrects again when the user returns to a page that is still empty', () => { + const { setPage, rerender } = renderRecovery({ + page: 3, + pageSize: 10, + rowCount: 0, + total: 10, + }); + + expect(setPage).toHaveBeenNthCalledWith(1, 1, REPLACE); + + rerender({ page: 1, pageSize: 10, rowCount: 10, total: 10 }); + rerender({ page: 3, pageSize: 10, rowCount: 0, total: 10 }); + + expect(setPage).toHaveBeenNthCalledWith(2, 1, REPLACE); + }); + + it('does not re-run the correction when setPage is a fresh function each render', () => { + // The hook reads setPage through a ref, so an inline arrow must not turn the + // ungated `page < 1` clamp into a per-render history rewrite. + const setPage = jest.fn(); + const { rerender } = renderHook( + () => + useRecoverFromEmptyPage({ + page: 0, + pageSize: 10, + rowCount: 0, + total: 0, + isFetching: false, + setPage: (nextPage, options): void => setPage(nextPage, options), + }), + { initialProps: undefined }, + ); + + rerender(undefined); + rerender(undefined); + + expect(setPage).toHaveBeenCalledTimes(1); + }); +}); diff --git a/frontend/src/components/TanStackTableView/index.tsx b/frontend/src/components/TanStackTableView/index.tsx index 2f69ef2be87..995f77a6adf 100644 --- a/frontend/src/components/TanStackTableView/index.tsx +++ b/frontend/src/components/TanStackTableView/index.tsx @@ -9,6 +9,7 @@ export * from './useCalculatedPageSize'; export * from './useColumnState'; export * from './useColumnStore'; export * from './usePreferredPageSize.store'; +export * from './useRecoverFromEmptyPage'; export * from './useTableParams'; /** @@ -285,6 +286,51 @@ export * from './useTableParams'; * * **Pagination shows "Auto" option** when `calculatedPageSize` is passed, allowing users * to reset to auto-calculated size. + * + * **`setPage` accepts history options**: `setPage(page, { history: 'replace' })` rewrites the + * current history entry instead of pushing a new one. Use `replace` for corrections the user + * did not ask for โ€” otherwise the back button walks straight back into the state that was just + * corrected. Only applies when the page is synced to the URL; local (non-URL) pages ignore it. + * + * @example useRecoverFromEmptyPage โ€” send the user back to a page that has data + * + * When rows disappear underneath the current page (filters narrowed, time range moved, items + * deleted), the user is stranded on an empty page they cannot leave by scrolling. This hook + * watches the fetched result and corrects the page with `history: 'replace'`, so the back + * button does not return to the empty page. + * + * Correction rules: + * - `page < 1` โ†’ jump to page 1, even while fetching or disabled. Such a page usually maps to a + * negative offset the API rejects (400 `offset cannot be negative`), so the response can never + * confirm the page is empty โ€” deferring to it would strand the user on a permanent error. + * - Page is empty and not page 1 โ†’ go to `min(ceil(total / pageSize), page - 1)`. When `total` + * is trustworthy that lands on the last page holding data; when `total` is unknown or zero it + * lands on page 1; and when `total` claims this page should have had rows it steps back a + * single page. Repeated step-backs give up and jump to page 1 after the second one, so a + * badly inflated `total` cannot walk the user down one request at a time. + * - Page has rows, or the user is already on page 1 โ†’ do nothing (an empty page 1 means there + * is genuinely nothing to show). + * + * Pass `isFetching` so the hook waits for the request to settle, and `isDisabled` so a failed + * request is not mistaken for an empty page. Neither gate suppresses the `page < 1` clamp. + * + * ```tsx + * import { useRecoverFromEmptyPage, useTableParams } from 'components/TanStackTableView'; + * + * const { page, limit, setPage } = useTableParams(QUERY_PARAMS, { page: 1, limit: 20 }); + * const { data, isLoading, isFetching, isError } = useListQuery({ page, limit }); + * + * useRecoverFromEmptyPage({ + * page, + * pageSize: limit, + * rowCount: data?.rows.length ?? 0, + * total: data?.total ?? 0, + * isFetching: isLoading || isFetching, + * // Skip correction on errors โ€” no rows there means "request failed", not "page is empty". + * isDisabled: isError, + * setPage, + * }); + * ``` */ const TanStackTable = Object.assign(TanStackTableBase, { Text: TanStackTableText, diff --git a/frontend/src/components/TanStackTableView/useRecoverFromEmptyPage.ts b/frontend/src/components/TanStackTableView/useRecoverFromEmptyPage.ts new file mode 100644 index 00000000000..78d0c7db8b7 --- /dev/null +++ b/frontend/src/components/TanStackTableView/useRecoverFromEmptyPage.ts @@ -0,0 +1,107 @@ +import { useEffect, useRef } from 'react'; + +import { SetPageOptions } from './useTableParams'; + +const FIRST_PAGE = 1; +const REPLACE_HISTORY: SetPageOptions = { history: 'replace' }; + +/** + * How many single-page step-backs to attempt before giving up and going to page 1. + * + * A step-back only happens when `total` claims the current page should hold data but the + * response came back empty. Each hop costs a request, so an inflated `total` on a high page + * number would otherwise walk the user down one page at a time behind a spinner. + */ +const MAX_STEP_BACKS = 2; + +type Correction = { + from: number; + to: number; +}; + +export type UseRecoverFromEmptyPageParams = { + page: number; + pageSize: number; + rowCount: number; + total: number; + isFetching: boolean; + isDisabled?: boolean; + setPage: (page: number, options?: SetPageOptions) => void; +}; + +export function useRecoverFromEmptyPage({ + page, + pageSize, + rowCount, + total, + isFetching, + isDisabled = false, + setPage, +}: UseRecoverFromEmptyPageParams): void { + const setPageRef = useRef(setPage); + const lastCorrectionRef = useRef(null); + const stepBacksRef = useRef(0); + + useEffect(() => { + setPageRef.current = setPage; + }); + + useEffect(() => { + if (lastCorrectionRef.current && lastCorrectionRef.current.from !== page) { + lastCorrectionRef.current = null; + } + + const correctTo = (nextPage: number): boolean => { + if (lastCorrectionRef.current?.to === nextPage) { + return false; + } + + lastCorrectionRef.current = { from: page, to: nextPage }; + setPageRef.current(nextPage, REPLACE_HISTORY); + return true; + }; + + // A page below the first one is invalid on its own terms โ€” it usually maps to a + // negative offset the API rejects outright, so waiting for a response that will + // never arrive (or trusting a failed one) would strand the user for good. + if (page < FIRST_PAGE) { + stepBacksRef.current = 0; + void correctTo(FIRST_PAGE); + return; + } + + if (isFetching || isDisabled) { + return; + } + + // The page has data, or there is genuinely nothing to show anywhere. + if (rowCount > 0 || page === FIRST_PAGE) { + stepBacksRef.current = 0; + return; + } + + const currentPage = Math.floor(page); + const lastPageWithData = + pageSize > 0 && total > 0 ? Math.ceil(total / pageSize) : FIRST_PAGE; + const nextPage = Math.max( + FIRST_PAGE, + Math.min(lastPageWithData, currentPage - 1), + ); + + // `total` disagrees with the response: it says this page should have rows, so the + // only safe move is one page back. Cap how often that repeats โ€” every hop is a + // request, and a badly inflated `total` would otherwise crawl down from page 40. + const isStepBack = nextPage === currentPage - 1; + + if (isStepBack && stepBacksRef.current >= MAX_STEP_BACKS) { + if (correctTo(FIRST_PAGE)) { + stepBacksRef.current = 0; + } + return; + } + + if (correctTo(nextPage) && isStepBack) { + stepBacksRef.current += 1; + } + }, [isFetching, isDisabled, page, pageSize, rowCount, total]); +} diff --git a/frontend/src/components/TanStackTableView/useTableParams.ts b/frontend/src/components/TanStackTableView/useTableParams.ts index 9b93e74b7ca..b3fe4223f78 100644 --- a/frontend/src/components/TanStackTableView/useTableParams.ts +++ b/frontend/src/components/TanStackTableView/useTableParams.ts @@ -29,12 +29,16 @@ type Defaults = { cleanupOnUnmount?: boolean; }; +export type SetPageOptions = { + history?: 'push' | 'replace'; +}; + export type TableParamsResult = { page: number; limit: number; orderBy: SortState | null; expanded: ExpandedState; - setPage: (p: number) => void; + setPage: (p: number, options?: SetPageOptions) => void; setLimit: (l: number) => void; setOrderBy: (s: SortState | null) => void; setExpanded: (updaterOrValue: Updater) => void; @@ -249,6 +253,17 @@ export function useTableParams( [], ); + const setUrlPageWithOptions = useCallback( + (page: number, options?: SetPageOptions): void => { + void setUrlPage(page, options); + }, + [setUrlPage], + ); + + const setLocalPageValue = useCallback((page: number): void => { + setLocalPage(page); + }, []); + const orderByUrlMemoKey = `${urlOrderBy?.columnName}${urlOrderBy?.order}`; const prevOrderByRef = useRef(null); @@ -303,7 +318,7 @@ export function useTableParams( limit: useUrlForLimit ? urlLimit : localLimit, orderBy: (useUrlForOrderBy ? urlOrderBy : localOrderBy) as SortState | null, expanded: useUrlForExpanded ? urlExpanded : localExpanded, - setPage: useUrlForPage ? setUrlPage : setLocalPage, + setPage: useUrlForPage ? setUrlPageWithOptions : setLocalPageValue, setLimit: useUrlForLimit ? setUrlLimit : setLocalLimitWithPersist, setOrderBy: useUrlForOrderBy ? setUrlOrderBy : setLocalOrderBy, setExpanded: useUrlForExpanded ? setUrlExpanded : handleSetLocalExpanded, diff --git a/frontend/src/container/InfraMonitoringK8sV2/Base/K8sBaseList.tsx b/frontend/src/container/InfraMonitoringK8sV2/Base/K8sBaseList.tsx index 0d58ed5c361..82e65cdc19d 100644 --- a/frontend/src/container/InfraMonitoringK8sV2/Base/K8sBaseList.tsx +++ b/frontend/src/container/InfraMonitoringK8sV2/Base/K8sBaseList.tsx @@ -7,6 +7,7 @@ import TanStackTable, { TableColumnDef, useCalculatedPageSize, useHiddenColumnIds, + useRecoverFromEmptyPage, useTableParams, } from 'components/TanStackTableView'; import { InfraMonitoringEvents } from 'constants/events'; @@ -136,6 +137,7 @@ export function K8sBaseList< page: currentPage, limit: currentPageSize, setLimit, + setPage, } = useTableParams( { page: INFRA_MONITORING_K8S_PARAMS_KEYS.PAGE, @@ -243,6 +245,16 @@ export function K8sBaseList< const totalCount = data?.total || 0; const hasFilters = !!expression?.trim(); + useRecoverFromEmptyPage({ + page: currentPage, + pageSize: currentPageSize, + rowCount: pageData.length, + total: totalCount, + isFetching: isLoading || isFetching, + isDisabled: isError || Boolean(data?.error), + setPage, + }); + const getGroupKeyFn = useCallback( (item: T) => getGroupedByMeta(item, groupBy), [groupBy], diff --git a/frontend/src/container/InfraMonitoringK8sV2/Base/__tests__/K8sBaseList.test.tsx b/frontend/src/container/InfraMonitoringK8sV2/Base/__tests__/K8sBaseList.test.tsx index c7314ec03e3..fa91a2bf2f6 100644 --- a/frontend/src/container/InfraMonitoringK8sV2/Base/__tests__/K8sBaseList.test.tsx +++ b/frontend/src/container/InfraMonitoringK8sV2/Base/__tests__/K8sBaseList.test.tsx @@ -591,12 +591,14 @@ describe('K8sBaseList', () => { }); describe('with empty data', () => { + const onUrlUpdateMock = jest.fn(); const fetchListDataMock = jest.fn< ReturnType['fetchListData']>>, Parameters['fetchListData']>> >(); beforeEach(() => { + onUrlUpdateMock.mockClear(); fetchListDataMock.mockClear(); fetchListDataMock.mockResolvedValue({ data: [], @@ -605,6 +607,7 @@ describe('K8sBaseList', () => { }); renderComponent({ + onUrlUpdate: onUrlUpdateMock, entity: InfraMonitoringEntity.PODS, eventCategory: InfraMonitoringEvents.Pod, fetchListData: fetchListDataMock, @@ -625,6 +628,177 @@ describe('K8sBaseList', () => { expect(fetchListDataMock).toHaveBeenCalled(); }); }); + + it('should not rewrite the page when already on the first page', async () => { + await waitFor(() => { + expect(fetchListDataMock).toHaveBeenCalled(); + }); + + const pageUpdates = onUrlUpdateMock.mock.calls + .map((call) => call[0].searchParams.get('page')) + .filter(Boolean); + + expect(pageUpdates).toHaveLength(0); + }); + }); + + describe('with a page beyond the end of the list', () => { + const onUrlUpdateMock = jest.fn(); + const fetchListDataMock = jest.fn< + ReturnType['fetchListData']>>, + Parameters['fetchListData']>> + >(); + + // 25 rows exist, so pages 1-3 serve data and page 7 of 10 comes back empty. + const rows: TestItem[] = Array.from({ length: 25 }, (_, index) => ({ + id: `pod-${index + 1}`, + })); + + beforeEach(() => { + onUrlUpdateMock.mockClear(); + fetchListDataMock.mockClear(); + // Offset-aware on purpose: a mock that answers empty for every offset would let + // the assertions pass against a page the recovery has already moved on from. + fetchListDataMock.mockImplementation(async ({ offset = 0, limit = 10 }) => ({ + data: rows.slice(offset, offset + limit), + total: rows.length, + error: null, + })); + + renderComponent({ + onUrlUpdate: onUrlUpdateMock, + entity: InfraMonitoringEntity.PODS, + eventCategory: InfraMonitoringEvents.Pod, + fetchListData: fetchListDataMock, + queryParams: { page: '7', pageSize: '10' }, + tableColumns: createTestColumns(), + getRowKey: (row): string => row.id, + getItemKey: (row): string => row.id, + }); + }); + + it('should send the user back to the last page holding data', async () => { + // The rows of page 3 on screen are what proves the recovery settled there, + // rather than passing through on its way somewhere else. + await expect(screen.findByText('pod-21')).resolves.toBeInTheDocument(); + + const pageUpdates = onUrlUpdateMock.mock.calls + .map((call) => call[0].searchParams.get('page')) + .filter(Boolean); + + expect(pageUpdates).toStrictEqual(['3']); + }); + + it('should correct the page in a single hop', async () => { + await expect(screen.findByText('pod-21')).resolves.toBeInTheDocument(); + + // Only the original out-of-range page and the corrected one are requested. + expect( + fetchListDataMock.mock.calls.map((call) => call[0].offset), + ).toStrictEqual([60, 20]); + }); + + it('should replace the history entry instead of pushing the correction', async () => { + await expect(screen.findByText('pod-21')).resolves.toBeInTheDocument(); + + const pageCorrection = onUrlUpdateMock.mock.calls.find( + (call) => call[0].searchParams.get('page') === '3', + ); + + expect(pageCorrection?.[0].options.history).toBe('replace'); + }); + }); + + describe('with a page below the first one', () => { + const onUrlUpdateMock = jest.fn(); + const fetchListDataMock = jest.fn< + ReturnType['fetchListData']>>, + Parameters['fetchListData']>> + >(); + + beforeEach(() => { + onUrlUpdateMock.mockClear(); + fetchListDataMock.mockClear(); + // page=0 turns into offset=-10, which the API rejects outright โ€” the list + // can only recover by clamping the page, never by reading the response. + fetchListDataMock.mockImplementation(async ({ offset = 0 }) => { + if (offset < 0) { + throw new APIError({ + httpStatusCode: 400, + error: { + code: 'invalid_input', + message: 'offset cannot be negative', + url: '', + errors: [], + }, + }); + } + + return { data: [{ id: 'pod-1' }], total: 1, error: null }; + }); + + renderComponent({ + onUrlUpdate: onUrlUpdateMock, + entity: InfraMonitoringEntity.PODS, + eventCategory: InfraMonitoringEvents.Pod, + fetchListData: fetchListDataMock, + queryParams: { page: '0', pageSize: '10' }, + tableColumns: createTestColumns(), + getRowKey: (row): string => row.id, + getItemKey: (row): string => row.id, + }); + }); + + it('should reject the request that carried the negative offset', async () => { + await waitFor(() => { + expect( + fetchListDataMock.mock.calls.some((call) => call[0].offset === -10), + ).toBe(true); + }); + + await expect( + fetchListDataMock.mock.results[0].value as Promise, + ).rejects.toThrow('offset cannot be negative'); + }); + + it('should clamp the page to the first one even though the request failed', async () => { + await waitFor(() => { + expect(onUrlUpdateMock).toHaveBeenCalled(); + }); + + // Page 1 is the default, so the correction drops the param rather than + // writing `page=1`. + const pageCorrection = onUrlUpdateMock.mock.calls.find( + (call) => call[0].searchParams.get('page') === null, + ); + + expect(pageCorrection).toBeDefined(); + expect(pageCorrection?.[0].queryString).toBe('?pageSize=10'); + }); + + it('should replace the history entry instead of pushing the correction', async () => { + await waitFor(() => { + expect(onUrlUpdateMock).toHaveBeenCalled(); + }); + + const pageCorrection = onUrlUpdateMock.mock.calls.find( + (call) => call[0].searchParams.get('page') === null, + ); + + expect(pageCorrection?.[0].options.history).toBe('replace'); + }); + + it('should refetch with a non-negative offset after clamping', async () => { + await waitFor(() => { + expect( + fetchListDataMock.mock.calls.some((call) => call[0].offset === 0), + ).toBe(true); + }); + + await waitFor(() => { + expect(screen.getByText('pod-1')).toBeInTheDocument(); + }); + }); }); describe('with error response', () => { diff --git a/frontend/src/container/InfraMonitoringK8sV2/InfraMonitoringK8s.tsx b/frontend/src/container/InfraMonitoringK8sV2/InfraMonitoringK8s.tsx index 53353ce583c..94e3b9fada1 100644 --- a/frontend/src/container/InfraMonitoringK8sV2/InfraMonitoringK8s.tsx +++ b/frontend/src/container/InfraMonitoringK8sV2/InfraMonitoringK8s.tsx @@ -47,6 +47,7 @@ import { useInfraMonitoringCategory, useInfraMonitoringGroupBy, useInfraMonitoringOrderBy, + useInfraMonitoringPageListing, useInfraMonitoringSelectedItemParams, } from './hooks'; @@ -67,6 +68,7 @@ export default function InfraMonitoringK8s(): JSX.Element { const [, setGroupBy] = useInfraMonitoringGroupBy(); const [, setOrderBy] = useInfraMonitoringOrderBy(); const [, setSelectedItemParams] = useInfraMonitoringSelectedItemParams(); + const [, setCurrentPage] = useInfraMonitoringPageListing(); const compositeQuery = useGetCompositeQueryParam(); const { currentQuery, redirectWithQueryBuilderData } = useQueryBuilder(); @@ -218,6 +220,7 @@ export default function InfraMonitoringK8s(): JSX.Element { void setSelectedCategory(key as string); void setOrderBy(null); void setGroupBy(null); + void setCurrentPage(null); setSelectedItemParams(null); redirectWithQueryBuilderData({ ...currentQuery, diff --git a/frontend/src/container/InfraMonitoringK8sV2/__tests__/InfraMonitoringK8s.test.tsx b/frontend/src/container/InfraMonitoringK8sV2/__tests__/InfraMonitoringK8s.test.tsx new file mode 100644 index 00000000000..55d183768b8 --- /dev/null +++ b/frontend/src/container/InfraMonitoringK8sV2/__tests__/InfraMonitoringK8s.test.tsx @@ -0,0 +1,128 @@ +import React from 'react'; +import { QueryClient, QueryClientProvider } from 'react-query'; +// eslint-disable-next-line no-restricted-imports +import { Provider } from 'react-redux'; +import { MemoryRouter } from 'react-router-dom'; +import { MemoryRouter as MemoryRouterV5 } from 'react-router-dom-v5-compat'; +import { TooltipProvider } from '@signozhq/ui/tooltip'; +import { fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { NuqsTestingAdapter, UrlUpdateEvent } from 'nuqs/adapters/testing'; +import { AppProvider } from 'providers/App/App'; +import { QueryBuilderProvider } from 'providers/QueryBuilder'; +import TimezoneProvider from 'providers/Timezone'; +import store from 'store'; + +import { K8sCategories } from '../constants'; +import InfraMonitoringK8s from '../InfraMonitoringK8s'; + +// Quick filters fire their own field APIs and are irrelevant to pagination. +jest.mock('components/QuickFilters/QuickFilters', () => ({ + __esModule: true, + default: (): JSX.Element =>
, +})); + +// The list owns its own page recovery; stubbing it keeps the page param under the +// sole control of the category handler being tested here. +jest.mock('../Base/K8sDynamicList', () => ({ + __esModule: true, + K8sDynamicList: (): JSX.Element =>
, + default: (): JSX.Element =>
, +})); + +// Analytics only; jsdom lacks the Performance navigation entries it reads. +jest.mock('lib/navigation', () => ({ + getNavigationReferrer: (): string => 'direct', +})); + +function renderPage( + queryParams: Record, + onUrlUpdate: jest.Mock, +): void { + const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }); + + render( + + + + + + + + + + + + + + + + + + + , + ); +} + +describe('InfraMonitoringK8s', () => { + describe('when the category changes from a page other than the first', () => { + const onUrlUpdateMock = jest.fn(); + + beforeEach(async () => { + onUrlUpdateMock.mockClear(); + + renderPage( + { category: K8sCategories.PODS, page: '3', pageSize: '10' }, + onUrlUpdateMock, + ); + + await screen.findByTestId(`category-${K8sCategories.NODES}`); + }); + + it('should drop the page so the new category starts at the first one', async () => { + fireEvent.click(screen.getByTestId(`category-${K8sCategories.NODES}`)); + + // Page 3 of pods says nothing about nodes โ€” keeping it asks the new entity + // for an offset it may not have. The param is cleared rather than set to 1, + // since an absent page already means the first one. + await waitFor(() => { + const categorySwitch = onUrlUpdateMock.mock.calls.find( + (call) => call[0].searchParams.get('category') === K8sCategories.NODES, + ); + + expect(categorySwitch).toBeDefined(); + expect(categorySwitch?.[0].searchParams.get('page')).toBeNull(); + }); + }); + + it('should keep the page size, which is not category specific', async () => { + fireEvent.click(screen.getByTestId(`category-${K8sCategories.NODES}`)); + + await waitFor(() => { + const categorySwitch = onUrlUpdateMock.mock.calls.find( + (call) => call[0].searchParams.get('category') === K8sCategories.NODES, + ); + + expect(categorySwitch?.[0].searchParams.get('pageSize')).toBe('10'); + }); + }); + + it('should leave the page alone when the same category is clicked again', async () => { + fireEvent.click(screen.getByTestId(`category-${K8sCategories.PODS}`)); + + await waitFor(() => { + expect(screen.getByTestId('k8s-dynamic-list')).toBeInTheDocument(); + }); + + const droppedPage = onUrlUpdateMock.mock.calls.some( + (call) => !call[0].searchParams.has('page'), + ); + + expect(droppedPage).toBe(false); + }); + }); +}); diff --git a/frontend/src/container/InfraMonitoringK8sV2/hooks.ts b/frontend/src/container/InfraMonitoringK8sV2/hooks.ts index d4e08041377..33e4bbd0ba1 100644 --- a/frontend/src/container/InfraMonitoringK8sV2/hooks.ts +++ b/frontend/src/container/InfraMonitoringK8sV2/hooks.ts @@ -34,6 +34,9 @@ export const useInfraMonitoringPageListing = (): UseQueryStateReturn< > => useQueryState( INFRA_MONITORING_K8S_PARAMS_KEYS.PAGE, + // do not use .withDefault here, this can cause bugs when + // two hooks of nuqs define default twice, this is also + // defined at useTableParams parseAsInteger.withOptions(defaultNuqsOptions), ); From ecf510ed6702ae1d9511a997d4e368bb71c61d74 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Vinicius=20Louren=C3=A7o?= <12551007+H4ad@users.noreply.github.com> Date: Wed, 19 Aug 2026 18:00:15 +0000 Subject: [PATCH 3/5] refactor(query-builder): drop ClientSideQBSearch & QueryBuilderSearch (#12427) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Pull Request --- ### ๐Ÿ“„ Summary > Why does this change exist? > What problem does it solve, and why is this the right approach? These are pending code that was supposed to be deleted after Infrastructure Monitoring & Alert History adopt the QBv5. #### Issues closed by this PR > Reference issues using `Closes #issue-number` to enable automatic closure on merge. Closes https://github.com/SigNoz/engineering-pod/issues/5117 Closes https://github.com/SigNoz/engineering-pod/issues/5116 --- ### โœ… Change Type _Select all that apply_ - [ ] โœจ Feature - [ ] ๐Ÿ› Bug fix - [x] โ™ป๏ธ Refactor - [ ] ๐Ÿ› ๏ธ Infra / Tooling - [ ] ๐Ÿงช Test-only --- ### ๐Ÿงช Testing Strategy > How was this change validated? - Tests added/updated: Yes - Manual verification: - - Edge cases covered: - --- ### โš ๏ธ Risk & Impact Assessment > What could break? How do we recover? - Blast radius: Query Builder - Potential regressions: Deleting more code than needed - Rollback plan: Revert the deletion. --- ### ๐Ÿ“ Changelog > Fill only if this affects users, APIs, UI, or documented behavior > Use **N/A** for internal or non-user-facing changes | Field | Value | |------|-------| | Deployment Type | Cloud / OSS / Enterprise | | Change Type | Maintenance | | Description | N/A | --- ### ๐Ÿ“‹ Checklist - [x] Tests added or explicitly not required - [x] Manually tested - [ ] Breaking changes documented - [ ] Backward compatibility considered --- .../ClientSideQBSearch.styles.scss | 5 - .../ClientSideQBSearch/ClientSideQBSearch.tsx | 661 ------------------ .../src/components/QueryBuilderV2/utils.ts | 2 +- .../Checkbox/checkboxFilterQuery.ts | 2 +- .../__tests__/useStableTotalCount.test.ts | 91 +++ .../TanStackTableView/useStableTotalCount.ts | 35 + .../src/container/ExplorerOrderBy/index.tsx | 2 +- .../logAttributeActions.utils.ts | 2 +- .../Inspect/__tests__/QueryBuilder.test.tsx | 2 +- .../QueryBuilder/QueryBuilder.interfaces.ts | 6 +- .../filters/AggregateEveryFilter/index.tsx | 45 -- .../AggregatorFilter/AggregatorFilter.tsx | 4 +- .../filters/Formula/Limit/Limit.tsx | 2 +- .../filters/Formula/OrderBy/OrderByFilter.tsx | 2 +- .../OrderBy/useOrderByFormulaFilter.tsx | 2 +- .../filters/GroupByFilter/GroupByFilter.tsx | 4 +- .../filters/LimitFilter/LimitFilter.tsx | 31 - .../MetricNameSelector.test.tsx | 2 +- .../MetricNameSelector/MetricNameSelector.tsx | 4 +- .../OperatorsSelect/OperatorsSelect.tsx | 2 +- .../OptionRenderer.styles.scss | 0 .../OptionRenderer.tsx | 0 .../filters/OptionRenderer/style.ts | 22 + .../filters/OrderByFilter/OrderByFilter.tsx | 2 +- .../filters/OrderByFilter/useOrderByFilter.ts | 2 +- .../OptionRendererForLogs.tsx | 70 -- .../QueryBuilderSearch.styles.scss | 298 -------- .../filters/QueryBuilderSearch/index.tsx | 558 --------------- .../ExampleQueriesRendererForLogs.tsx | 2 - .../QueryBuilderSearchDropdown.tsx | 4 +- .../QueryBuilderSearchV2.tsx | 10 +- .../QueryBuilderSearchV2/Suggestions.tsx | 2 +- .../config.ts | 0 .../constant.ts | 0 .../style.ts | 26 - .../utils.ts | 15 +- .../container/QueryBuilder/filters/utils.ts | 20 +- .../Drilldown/metricsCorrelationUtils.tsx | 2 +- frontend/src/hooks/logs/useActiveLog.ts | 2 +- .../src/hooks/queryBuilder/useAutoComplete.ts | 185 ----- .../queryBuilder/useFetchKeysAndValues.ts | 308 -------- .../src/hooks/queryBuilder/useOperators.ts | 31 - frontend/src/hooks/queryBuilder/useOptions.ts | 220 ------ .../useSetCurrentKeyAndOperator.ts | 32 - frontend/src/hooks/queryBuilder/useTag.ts | 104 +-- frontend/src/hooks/trace/useTraceActions.ts | 2 +- .../SpanDetailsPanel/SpanLogs/SpanLogs.tsx | 2 +- .../SpanLogs/useSpanContextLogs.ts | 2 +- .../hooks/useSpanAttributeActions.ts | 2 +- 49 files changed, 189 insertions(+), 2640 deletions(-) delete mode 100644 frontend/src/components/ClientSideQBSearch/ClientSideQBSearch.styles.scss delete mode 100644 frontend/src/components/ClientSideQBSearch/ClientSideQBSearch.tsx create mode 100644 frontend/src/components/TanStackTableView/__tests__/useStableTotalCount.test.ts create mode 100644 frontend/src/components/TanStackTableView/useStableTotalCount.ts delete mode 100644 frontend/src/container/QueryBuilder/filters/AggregateEveryFilter/index.tsx delete mode 100644 frontend/src/container/QueryBuilder/filters/LimitFilter/LimitFilter.tsx rename frontend/src/container/QueryBuilder/filters/{QueryBuilderSearch => OptionRenderer}/OptionRenderer.styles.scss (100%) rename frontend/src/container/QueryBuilder/filters/{QueryBuilderSearch => OptionRenderer}/OptionRenderer.tsx (100%) create mode 100644 frontend/src/container/QueryBuilder/filters/OptionRenderer/style.ts delete mode 100644 frontend/src/container/QueryBuilder/filters/QueryBuilderSearch/OptionRendererForLogs.tsx delete mode 100644 frontend/src/container/QueryBuilder/filters/QueryBuilderSearch/QueryBuilderSearch.styles.scss delete mode 100644 frontend/src/container/QueryBuilder/filters/QueryBuilderSearch/index.tsx rename frontend/src/container/QueryBuilder/filters/{QueryBuilderSearch => QueryBuilderSearchV2}/ExampleQueriesRendererForLogs.tsx (92%) rename frontend/src/container/QueryBuilder/filters/{QueryBuilderSearch => QueryBuilderSearchV2}/config.ts (100%) rename frontend/src/container/QueryBuilder/filters/{QueryBuilderSearch => QueryBuilderSearchV2}/constant.ts (100%) rename frontend/src/container/QueryBuilder/filters/{QueryBuilderSearch => QueryBuilderSearchV2}/style.ts (53%) rename frontend/src/container/QueryBuilder/filters/{QueryBuilderSearch => QueryBuilderSearchV2}/utils.ts (92%) delete mode 100644 frontend/src/hooks/queryBuilder/useAutoComplete.ts delete mode 100644 frontend/src/hooks/queryBuilder/useFetchKeysAndValues.ts delete mode 100644 frontend/src/hooks/queryBuilder/useOperators.ts delete mode 100644 frontend/src/hooks/queryBuilder/useOptions.ts delete mode 100644 frontend/src/hooks/queryBuilder/useSetCurrentKeyAndOperator.ts diff --git a/frontend/src/components/ClientSideQBSearch/ClientSideQBSearch.styles.scss b/frontend/src/components/ClientSideQBSearch/ClientSideQBSearch.styles.scss deleted file mode 100644 index bb1fa9678ce..00000000000 --- a/frontend/src/components/ClientSideQBSearch/ClientSideQBSearch.styles.scss +++ /dev/null @@ -1,5 +0,0 @@ -.client-side-qb-search { - .ant-select-selection-search { - width: max-content !important; - } -} diff --git a/frontend/src/components/ClientSideQBSearch/ClientSideQBSearch.tsx b/frontend/src/components/ClientSideQBSearch/ClientSideQBSearch.tsx deleted file mode 100644 index 435188243be..00000000000 --- a/frontend/src/components/ClientSideQBSearch/ClientSideQBSearch.tsx +++ /dev/null @@ -1,661 +0,0 @@ -/* eslint-disable sonarjs/cognitive-complexity */ - -import { - KeyboardEvent, - useCallback, - useEffect, - useMemo, - useRef, - useState, -} from 'react'; -import { Color } from '@signozhq/design-tokens'; -import { Select, Tooltip } from 'antd'; -import { - OPERATORS, - QUERY_BUILDER_OPERATORS_BY_TYPES, - QUERY_BUILDER_SEARCH_VALUES, -} from 'constants/queryBuilder'; -import { CustomTagProps } from 'container/QueryBuilder/filters/QueryBuilderSearch'; -import { selectStyle } from 'container/QueryBuilder/filters/QueryBuilderSearch/config'; -import { PLACEHOLDER } from 'container/QueryBuilder/filters/QueryBuilderSearch/constant'; -import { TypographyText } from 'container/QueryBuilder/filters/QueryBuilderSearch/style'; -import { - checkCommaInValue, - getOperatorFromValue, - getOperatorValue, - getTagToken, - isInNInOperator, -} from 'container/QueryBuilder/filters/QueryBuilderSearch/utils'; -import { - DropdownState, - ITag, - Option, -} from 'container/QueryBuilder/filters/QueryBuilderSearchV2/QueryBuilderSearchV2'; -import Suggestions from 'container/QueryBuilder/filters/QueryBuilderSearchV2/Suggestions'; -import { WhereClauseConfig } from 'hooks/queryBuilder/useAutoComplete'; -import { validationMapper } from 'hooks/queryBuilder/useIsValidTag'; -import { operatorTypeMapper } from 'hooks/queryBuilder/useOperatorType'; -import { useIsDarkMode } from 'hooks/useDarkMode'; -import { isArray, isEmpty, isEqual, isObject } from 'lodash-es'; -import { ChevronDown, ChevronUp } from '@signozhq/icons'; -import type { BaseSelectRef } from 'rc-select'; -import { - BaseAutocompleteData, - DataTypes, -} from 'types/api/queryBuilder/queryAutocompleteResponse'; -import { - IBuilderQuery, - TagFilter, -} from 'types/api/queryBuilder/queryBuilderData'; -import { popupContainer } from 'utils/selectPopupContainer'; -import { v4 as uuid } from 'uuid'; - -import './ClientSideQBSearch.styles.scss'; -import { Badge } from '@signozhq/ui/badge'; - -export interface AttributeKey { - key: string; -} - -export interface AttributeValuesMap { - [key: string]: AttributeValue; -} - -interface ClientSideQBSearchProps { - filters: TagFilter; - onChange: (value: TagFilter) => void; - whereClauseConfig?: WhereClauseConfig; - placeholder?: string; - className?: string; - suffixIcon?: React.ReactNode; - attributeValuesMap?: AttributeValuesMap; - attributeKeys: AttributeKey[]; -} - -interface AttributeValue { - stringAttributeValues: string[] | []; - numberAttributeValues: number[] | []; - boolAttributeValues: boolean[] | []; -} - -function ClientSideQBSearch( - props: ClientSideQBSearchProps, -): React.ReactElement { - const { - onChange, - placeholder, - className, - suffixIcon, - whereClauseConfig, - attributeValuesMap, - attributeKeys, - filters, - } = props; - - const isDarkMode = useIsDarkMode(); - - const selectRef = useRef(null); - - const [isOpen, setIsOpen] = useState(false); - - // create the tags from the initial query here, this should only be computed on the first load as post that tags and query will be always in sync. - const [tags, setTags] = useState(filters.items as ITag[]); - - // this will maintain the current state of in process filter item - const [currentFilterItem, setCurrentFilterItem] = useState(); - - const [currentState, setCurrentState] = useState( - DropdownState.ATTRIBUTE_KEY, - ); - - // to maintain the current running state until the tokenization happens for the tag - const [searchValue, setSearchValue] = useState(''); - - const [dropdownOptions, setDropdownOptions] = useState([]); - - const attributeValues = useMemo(() => { - if (currentFilterItem?.key?.key) { - return attributeValuesMap?.[currentFilterItem.key.key]; - } - return { - stringAttributeValues: [], - numberAttributeValues: [], - boolAttributeValues: [], - }; - }, [attributeValuesMap, currentFilterItem?.key?.key]); - - const handleDropdownSelect = useCallback( - (value: string) => { - let parsedValue: BaseAutocompleteData | string; - - try { - parsedValue = JSON.parse(value); - } catch { - parsedValue = value; - } - if (currentState === DropdownState.ATTRIBUTE_KEY) { - setCurrentFilterItem((prev) => ({ - ...prev, - key: parsedValue as BaseAutocompleteData, - op: '', - value: '', - })); - setCurrentState(DropdownState.OPERATOR); - setSearchValue((parsedValue as BaseAutocompleteData)?.key); - } else if (currentState === DropdownState.OPERATOR) { - if (value === OPERATORS.EXISTS || value === OPERATORS.NOT_EXISTS) { - setTags((prev) => [ - ...prev, - { - key: currentFilterItem?.key, - op: value, - value: '', - } as ITag, - ]); - setCurrentFilterItem(undefined); - setSearchValue(''); - setCurrentState(DropdownState.ATTRIBUTE_KEY); - } else { - setCurrentFilterItem((prev) => ({ - key: prev?.key as BaseAutocompleteData, - op: value as string, - value: '', - })); - setCurrentState(DropdownState.ATTRIBUTE_VALUE); - setSearchValue(`${currentFilterItem?.key?.key} ${value}`); - } - } else if (currentState === DropdownState.ATTRIBUTE_VALUE) { - const operatorType = - operatorTypeMapper[currentFilterItem?.op || ''] || 'NOT_VALID'; - const isMulti = operatorType === QUERY_BUILDER_SEARCH_VALUES.MULTIPLY; - - if (isMulti) { - const { tagKey, tagOperator, tagValue } = getTagToken(searchValue); - // this condition takes care of adding the IN/NIN multi values when pressed enter on an already existing value. - // not the best interaction but in sync with what we have today! - if (tagValue.includes(String(value))) { - setSearchValue(''); - setCurrentState(DropdownState.ATTRIBUTE_KEY); - setCurrentFilterItem(undefined); - setTags((prev) => [ - ...prev, - { - key: currentFilterItem?.key, - op: currentFilterItem?.op, - value: tagValue, - } as ITag, - ]); - return; - } - // this is for adding subsequent comma seperated values - const newSearch = [...tagValue]; - newSearch[newSearch.length === 0 ? 0 : newSearch.length - 1] = value; - const newSearchValue = newSearch.join(','); - setSearchValue(`${tagKey} ${tagOperator} ${newSearchValue},`); - } else { - setSearchValue(''); - setCurrentState(DropdownState.ATTRIBUTE_KEY); - setCurrentFilterItem(undefined); - setTags((prev) => [ - ...prev, - { - key: currentFilterItem?.key, - op: currentFilterItem?.op, - value, - } as ITag, - ]); - } - } - }, - [currentFilterItem?.key, currentFilterItem?.op, currentState, searchValue], - ); - - const handleSearch = useCallback((value: string) => { - setSearchValue(value); - }, []); - - const onInputKeyDownHandler = useCallback( - (event: KeyboardEvent): void => { - if (event.key === 'Backspace' && !searchValue) { - event.stopPropagation(); - setTags((prev) => prev.slice(0, -1)); - } - }, - [searchValue], - ); - - const handleOnBlur = useCallback((): void => { - if (searchValue) { - const operatorType = - operatorTypeMapper[currentFilterItem?.op || ''] || 'NOT_VALID'; - // if key is added and operator is not present then convert to body CONTAINS key - if ( - currentFilterItem?.key && - isEmpty(currentFilterItem?.op) && - whereClauseConfig?.customKey === 'body' && - whereClauseConfig?.customOp === OPERATORS.CONTAINS - ) { - setTags((prev) => [ - ...prev, - { - key: { - key: 'body', - dataType: DataTypes.String, - type: '', - id: 'body--string----true', - }, - op: OPERATORS.CONTAINS, - value: currentFilterItem?.key?.key, - }, - ]); - setCurrentFilterItem(undefined); - setSearchValue(''); - setCurrentState(DropdownState.ATTRIBUTE_KEY); - } else if ( - currentFilterItem?.op === OPERATORS.EXISTS || - currentFilterItem?.op === OPERATORS.NOT_EXISTS - ) { - // is exists and not exists operator is present then convert directly to tag! no need of value here - setTags((prev) => [ - ...prev, - { - key: currentFilterItem?.key, - op: currentFilterItem?.op, - value: '', - }, - ]); - setCurrentFilterItem(undefined); - setSearchValue(''); - setCurrentState(DropdownState.ATTRIBUTE_KEY); - } else if ( - // if the current state is in sync with the kind of operator used then convert into a tag - validationMapper[operatorType]?.( - isArray(currentFilterItem?.value) - ? currentFilterItem?.value.length || 0 - : 1, - ) - ) { - setTags((prev) => [ - ...prev, - { - key: currentFilterItem?.key as BaseAutocompleteData, - op: currentFilterItem?.op as string, - value: currentFilterItem?.value || '', - }, - ]); - setCurrentFilterItem(undefined); - setSearchValue(''); - setCurrentState(DropdownState.ATTRIBUTE_KEY); - } - } - }, [ - currentFilterItem?.key, - currentFilterItem?.op, - currentFilterItem?.value, - searchValue, - whereClauseConfig?.customKey, - whereClauseConfig?.customOp, - ]); - - // this useEffect takes care of tokenisation based on the search state - useEffect(() => { - // if there is no search value reset to the default state - if (!searchValue) { - setCurrentFilterItem(undefined); - setCurrentState(DropdownState.ATTRIBUTE_KEY); - } - - // split the current search value based on delimiters - const { tagKey, tagOperator, tagValue } = getTagToken(searchValue); - - if ( - // Case 1 - if key is defined but the search text doesn't match with the set key, - // can happen when user selects from dropdown and then deletes a few characters - currentFilterItem?.key && - currentFilterItem?.key?.key !== tagKey.split(' ')[0] - ) { - setCurrentFilterItem(undefined); - setCurrentState(DropdownState.ATTRIBUTE_KEY); - } else if (tagOperator && isEmpty(currentFilterItem?.op)) { - // Case 2 -> key is set and now typing for the operator - if ( - tagOperator === OPERATORS.EXISTS || - tagOperator === OPERATORS.NOT_EXISTS - ) { - setTags((prev) => [ - ...prev, - { - key: currentFilterItem?.key, - op: tagOperator, - value: '', - } as ITag, - ]); - setCurrentFilterItem(undefined); - setSearchValue(''); - setCurrentState(DropdownState.ATTRIBUTE_KEY); - } else { - setCurrentFilterItem((prev) => ({ - key: prev?.key as BaseAutocompleteData, - op: tagOperator, - value: '', - })); - - setCurrentState(DropdownState.ATTRIBUTE_VALUE); - } - } else if ( - // Case 3 -> selected operator from dropdown and then erased a part of it - !isEmpty(currentFilterItem?.op) && - tagOperator !== currentFilterItem?.op - ) { - setCurrentFilterItem((prev) => ({ - key: prev?.key as BaseAutocompleteData, - op: '', - value: '', - })); - setCurrentState(DropdownState.OPERATOR); - } else if (currentState === DropdownState.ATTRIBUTE_VALUE) { - // Case 4 -> the final value state where we set the current filter values and the tokenisation happens on either - // dropdown click or blur event - const currentValue = { - key: currentFilterItem?.key as BaseAutocompleteData, - op: currentFilterItem?.op as string, - value: tagValue, - }; - if (!isEqual(currentValue, currentFilterItem)) { - setCurrentFilterItem((prev) => ({ - key: prev?.key as BaseAutocompleteData, - op: prev?.op as string, - value: tagValue, - })); - } - } - }, [ - currentFilterItem, - currentFilterItem?.key, - currentFilterItem?.op, - searchValue, - currentState, - ]); - - // the useEffect takes care of setting the dropdown values correctly on change of the current state - useEffect(() => { - if (currentState === DropdownState.ATTRIBUTE_KEY) { - const filteredAttributeKeys = attributeKeys.filter((key) => - key.key.startsWith(searchValue), - ); - setDropdownOptions( - filteredAttributeKeys?.map( - (key) => - ({ - label: key.key, - value: key, - }) as Option, - ) || [], - ); - } - if (currentState === DropdownState.OPERATOR) { - const keyOperator = searchValue.split(' '); - const partialOperator = keyOperator?.[1]; - const strippedKey = keyOperator?.[0]; - - let operatorOptions; - if (currentFilterItem?.key?.dataType) { - operatorOptions = QUERY_BUILDER_OPERATORS_BY_TYPES[ - currentFilterItem.key - .dataType as keyof typeof QUERY_BUILDER_OPERATORS_BY_TYPES - ].map((operator) => ({ - label: operator, - value: operator, - })); - - if (partialOperator) { - operatorOptions = operatorOptions.filter((op) => - op.label.startsWith(partialOperator.toLocaleUpperCase()), - ); - } - setDropdownOptions(operatorOptions); - } else if (strippedKey.endsWith('[*]') && strippedKey.startsWith('body.')) { - operatorOptions = [OPERATORS.HAS, OPERATORS.NHAS].map((operator) => ({ - label: operator, - value: operator, - })); - setDropdownOptions(operatorOptions); - } else { - operatorOptions = QUERY_BUILDER_OPERATORS_BY_TYPES.universal.map( - (operator) => ({ - label: operator, - value: operator, - }), - ); - - if (partialOperator) { - operatorOptions = operatorOptions.filter((op) => - op.label.startsWith(partialOperator.toLocaleUpperCase()), - ); - } - setDropdownOptions(operatorOptions); - } - } - - if (currentState === DropdownState.ATTRIBUTE_VALUE) { - const values: Array = []; - const { tagValue } = getTagToken(searchValue); - if (isArray(tagValue)) { - if (!isEmpty(tagValue[tagValue.length - 1])) { - values.push(tagValue[tagValue.length - 1]); - } - } else if (!isEmpty(tagValue)) { - values.push(tagValue); - } - - const currentAttributeValues = - attributeValues?.stringAttributeValues || - attributeValues?.numberAttributeValues || - attributeValues?.boolAttributeValues || - []; - - values.push(...currentAttributeValues); - - if (attributeValuesMap) { - setDropdownOptions( - values.map( - (val) => - ({ - label: checkCommaInValue(String(val)), - value: val, - }) as Option, - ), - ); - } else { - // If attributeValuesMap is not provided, don't set dropdown options - setDropdownOptions([]); - } - } - }, [ - attributeValues, - currentFilterItem?.key?.dataType, - currentState, - attributeKeys, - searchValue, - attributeValuesMap, - ]); - - useEffect(() => { - const filterTags: IBuilderQuery['filters'] = { - op: 'AND', - items: [], - }; - tags.forEach((tag) => { - const computedTagValue = - tag.value && - Array.isArray(tag.value) && - tag.value[tag.value.length - 1] === '' - ? tag.value?.slice(0, -1) - : (tag.value ?? ''); - filterTags.items.push({ - id: tag.id || uuid().slice(0, 8), - key: tag.key, - op: getOperatorValue(tag.op), - value: computedTagValue, - }); - }); - - if (!isEqual(filters, filterTags)) { - onChange(filterTags); - setTags( - filterTags.items.map((tag) => ({ - ...tag, - op: getOperatorFromValue(tag.op), - })) as ITag[], - ); - } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [tags]); - - const queryTags = useMemo( - () => tags.map((tag) => `${tag.key.key} ${tag.op} ${tag.value}`), - [tags], - ); - - const onTagRender = ({ - value, - closable, - onClose, - }: CustomTagProps): React.ReactElement => { - const { tagOperator } = getTagToken(value); - const isInNin = isInNInOperator(tagOperator); - const chipValue = isInNin - ? value?.trim()?.replace(/,\s*$/, '') - : value?.trim(); - - const indexInQueryTags = queryTags.findIndex((qTag) => isEqual(qTag, value)); - const tagDetails = tags[indexInQueryTags]; - - const onCloseHandler = (): void => { - onClose(); - setSearchValue(''); - setTags((prev) => prev.filter((t) => !isEqual(t, tagDetails))); - }; - - const tagEditHandler = (value: string): void => { - setCurrentFilterItem(tagDetails); - setSearchValue(value); - setCurrentState(DropdownState.ATTRIBUTE_VALUE); - setTags((prev) => prev.filter((t) => !isEqual(t, tagDetails))); - }; - - const isDisabled = !!searchValue; - - return ( - - { - e.preventDefault(); - onCloseHandler(); - }} - > - - { - if (!isDisabled) { - tagEditHandler(value); - } - }} - > - {chipValue} - - - - - ); - }; - - const suffixIconContent = useMemo(() => { - if (suffixIcon) { - return suffixIcon; - } - return isOpen ? ( - - ) : ( - - ); - }, [isDarkMode, isOpen, suffixIcon]); - - return ( -
- -
- ); -} - -ClientSideQBSearch.defaultProps = { - placeholder: PLACEHOLDER, - className: '', - suffixIcon: null, - whereClauseConfig: {}, - attributeValuesMap: {}, -}; - -export default ClientSideQBSearch; diff --git a/frontend/src/components/QueryBuilderV2/utils.ts b/frontend/src/components/QueryBuilderV2/utils.ts index 4d2a01cde9f..a5fee05f9c1 100644 --- a/frontend/src/components/QueryBuilderV2/utils.ts +++ b/frontend/src/components/QueryBuilderV2/utils.ts @@ -8,7 +8,7 @@ import { OPERATORS, QUERY_BUILDER_FUNCTIONS, } from 'constants/antlrQueryConstants'; -import { getOperatorValue } from 'container/QueryBuilder/filters/QueryBuilderSearch/utils'; +import { getOperatorValue } from 'container/QueryBuilder/filters/QueryBuilderSearchV2/utils'; import FilterQueryLexer from 'parser/FilterQueryLexer'; import FilterQueryParser, { AndExpressionContext, diff --git a/frontend/src/components/QuickFilters/FilterRenderers/Checkbox/checkboxFilterQuery.ts b/frontend/src/components/QuickFilters/FilterRenderers/Checkbox/checkboxFilterQuery.ts index 9ff528c58dd..1e855557fb5 100644 --- a/frontend/src/components/QuickFilters/FilterRenderers/Checkbox/checkboxFilterQuery.ts +++ b/frontend/src/components/QuickFilters/FilterRenderers/Checkbox/checkboxFilterQuery.ts @@ -5,7 +5,7 @@ import { QuickFiltersSource, } from 'components/QuickFilters/types'; import { OPERATORS } from 'constants/antlrQueryConstants'; -import { getOperatorValue } from 'container/QueryBuilder/filters/QueryBuilderSearch/utils'; +import { getOperatorValue } from 'container/QueryBuilder/filters/QueryBuilderSearchV2/utils'; import { cloneDeep, isArray } from 'lodash-es'; import { Query, TagFilterItem } from 'types/api/queryBuilder/queryBuilderData'; import { v4 as uuid } from 'uuid'; diff --git a/frontend/src/components/TanStackTableView/__tests__/useStableTotalCount.test.ts b/frontend/src/components/TanStackTableView/__tests__/useStableTotalCount.test.ts new file mode 100644 index 00000000000..f0ae2efd843 --- /dev/null +++ b/frontend/src/components/TanStackTableView/__tests__/useStableTotalCount.test.ts @@ -0,0 +1,91 @@ +import { renderHook } from '@testing-library/react'; + +import { + useStableTotalCount, + UseStableTotalCountParams, +} from '../useStableTotalCount'; + +function renderStableTotalCount(initial: UseStableTotalCountParams): { + result: { current: number }; + rerender: (next: UseStableTotalCountParams) => void; +} { + const { result, rerender } = renderHook( + (params: UseStableTotalCountParams) => useStableTotalCount(params), + { initialProps: initial }, + ); + + return { result, rerender }; +} + +describe('useStableTotalCount', () => { + it('reports the total once the load settles', () => { + const { result } = renderStableTotalCount({ + total: 100, + isLoading: false, + resetKey: 'pods', + }); + + expect(result.current).toBe(100); + }); + + it('keeps the last count while the same list refetches', () => { + const { result, rerender } = renderStableTotalCount({ + total: 100, + isLoading: false, + resetKey: 'pods', + }); + + rerender({ total: 0, isLoading: true, resetKey: 'pods' }); + + expect(result.current).toBe(100); + }); + + it('forgets the count when the list changes', () => { + const { result, rerender } = renderStableTotalCount({ + total: 100, + isLoading: false, + resetKey: 'pods', + }); + + rerender({ total: 0, isLoading: true, resetKey: 'nodes' }); + + expect(result.current).toBe(0); + }); + + it('reports the new list total once it arrives', () => { + const { result, rerender } = renderStableTotalCount({ + total: 100, + isLoading: false, + resetKey: 'pods', + }); + + rerender({ total: 0, isLoading: true, resetKey: 'nodes' }); + rerender({ total: 25, isLoading: false, resetKey: 'nodes' }); + + expect(result.current).toBe(25); + }); + + it('reports zero for a list that is genuinely empty', () => { + const { result } = renderStableTotalCount({ + total: 0, + isLoading: false, + resetKey: 'pods', + }); + + expect(result.current).toBe(0); + }); + + it('holds nothing back when no reset key is given', () => { + const { result, rerender } = renderStableTotalCount({ + total: 100, + isLoading: false, + resetKey: undefined, + }); + + // Without a key there is no context to compare, so the cache behaves as it + // always did: it survives the refetch. + rerender({ total: 0, isLoading: true, resetKey: undefined }); + + expect(result.current).toBe(100); + }); +}); diff --git a/frontend/src/components/TanStackTableView/useStableTotalCount.ts b/frontend/src/components/TanStackTableView/useStableTotalCount.ts new file mode 100644 index 00000000000..560975fee4c --- /dev/null +++ b/frontend/src/components/TanStackTableView/useStableTotalCount.ts @@ -0,0 +1,35 @@ +import { useRef } from 'react'; + +export type UseStableTotalCountParams = { + total: number | undefined; + isLoading: boolean; + /** + * Identifies the list being counted. When it changes, the cached count is + * dropped so the previous list's page count cannot outlive it. + */ + resetKey: string | undefined; +}; + +/** + * Holds on to the last non-zero total so the pagination does not flash while the + * same list refetches, and forgets it as soon as `resetKey` moves to another list. + */ +export function useStableTotalCount({ + total, + isLoading, + resetKey, +}: UseStableTotalCountParams): number { + const prevTotalRef = useRef(total || 0); + const prevResetKeyRef = useRef(resetKey); + + if (prevResetKeyRef.current !== resetKey) { + prevResetKeyRef.current = resetKey; + prevTotalRef.current = 0; + } + + if (total && total > 0) { + prevTotalRef.current = total; + } + + return isLoading ? prevTotalRef.current : total || 0; +} diff --git a/frontend/src/container/ExplorerOrderBy/index.tsx b/frontend/src/container/ExplorerOrderBy/index.tsx index ebd77d49028..b0360813b03 100644 --- a/frontend/src/container/ExplorerOrderBy/index.tsx +++ b/frontend/src/container/ExplorerOrderBy/index.tsx @@ -2,7 +2,7 @@ import { memo, useMemo } from 'react'; import { Select, Spin } from 'antd'; import { OrderByFilterProps } from 'container/QueryBuilder/filters/OrderByFilter/OrderByFilter.interfaces'; import { useOrderByFilter } from 'container/QueryBuilder/filters/OrderByFilter/useOrderByFilter'; -import { selectStyle } from 'container/QueryBuilder/filters/QueryBuilderSearch/config'; +import { selectStyle } from 'container/QueryBuilder/filters/QueryBuilderSearchV2/config'; import { useGetAggregateKeys } from 'hooks/queryBuilder/useGetAggregateKeys'; import { DataTypes } from 'types/api/queryBuilder/queryAutocompleteResponse'; import { StringOperators } from 'types/common/queryBuilder'; diff --git a/frontend/src/container/LogDetailedView/logAttributeActions.utils.ts b/frontend/src/container/LogDetailedView/logAttributeActions.utils.ts index 36a3d05b93a..17556e16bf4 100644 --- a/frontend/src/container/LogDetailedView/logAttributeActions.utils.ts +++ b/frontend/src/container/LogDetailedView/logAttributeActions.utils.ts @@ -10,7 +10,7 @@ import { RESTRICTED_SELECTED_FIELDS, } from 'container/LogsFilters/config'; import { MetricsType } from 'container/MetricsApplication/constant'; -import { getOperatorValue } from 'container/QueryBuilder/filters/QueryBuilderSearch/utils'; +import { getOperatorValue } from 'container/QueryBuilder/filters/QueryBuilderSearchV2/utils'; import { chooseAutocompleteFromCustomValue } from 'lib/newQueryBuilder/chooseAutocompleteFromCustomValue'; import { BaseAutocompleteData, diff --git a/frontend/src/container/MetricsExplorer/Inspect/__tests__/QueryBuilder.test.tsx b/frontend/src/container/MetricsExplorer/Inspect/__tests__/QueryBuilder.test.tsx index 14511cfa1e5..12bfa555bde 100644 --- a/frontend/src/container/MetricsExplorer/Inspect/__tests__/QueryBuilder.test.tsx +++ b/frontend/src/container/MetricsExplorer/Inspect/__tests__/QueryBuilder.test.tsx @@ -42,7 +42,7 @@ jest.mock('hooks/useDebounce', () => ({ })); jest.mock( - 'container/QueryBuilder/filters/QueryBuilderSearch/OptionRenderer', + 'container/QueryBuilder/filters/OptionRenderer/OptionRenderer', () => ({ __esModule: true, default: ({ value }: { value: string }): JSX.Element => {value}, diff --git a/frontend/src/container/QueryBuilder/QueryBuilder.interfaces.ts b/frontend/src/container/QueryBuilder/QueryBuilder.interfaces.ts index 8acfb391f3e..fc486cbfd2b 100644 --- a/frontend/src/container/QueryBuilder/QueryBuilder.interfaces.ts +++ b/frontend/src/container/QueryBuilder/QueryBuilder.interfaces.ts @@ -1,11 +1,15 @@ import { ReactNode } from 'react'; import { PANEL_TYPES } from 'constants/queryBuilder'; -import { WhereClauseConfig } from 'hooks/queryBuilder/useAutoComplete'; import { IBuilderQuery } from 'types/api/queryBuilder/queryBuilderData'; import { DataSource } from 'types/common/queryBuilder'; import { OrderByFilterProps } from './filters/OrderByFilter/OrderByFilter.interfaces'; +export type WhereClauseConfig = { + customKey: string; + customOp: string; +}; + type FilterConfigs = { [Key in keyof Omit]: { isHidden: boolean; diff --git a/frontend/src/container/QueryBuilder/filters/AggregateEveryFilter/index.tsx b/frontend/src/container/QueryBuilder/filters/AggregateEveryFilter/index.tsx deleted file mode 100644 index f905e0422bf..00000000000 --- a/frontend/src/container/QueryBuilder/filters/AggregateEveryFilter/index.tsx +++ /dev/null @@ -1,45 +0,0 @@ -import { useMemo } from 'react'; -import { InputNumber, InputNumberProps } from 'antd'; -import { IBuilderQuery } from 'types/api/queryBuilder/queryBuilderData'; -import { DataSource } from 'types/common/queryBuilder'; - -import { selectStyle } from '../QueryBuilderSearch/config'; - -function AggregateEveryFilter({ - onChange, - query, - disabled, -}: AggregateEveryFilterProps): JSX.Element { - const isMetricsDataSource = useMemo( - () => query.dataSource === DataSource.METRICS, - [query.dataSource], - ); - - const onChangeHandler: InputNumberProps['onChange'] = (event) => { - if (event && event >= 0) { - onChange(event); - } - }; - - const isDisabled = - (isMetricsDataSource && !query.aggregateAttribute?.key) || disabled; - - return ( - - ); -} - -interface AggregateEveryFilterProps { - onChange: (values: number) => void; - query: IBuilderQuery; - disabled: boolean; -} - -export default AggregateEveryFilter; diff --git a/frontend/src/container/QueryBuilder/filters/AggregatorFilter/AggregatorFilter.tsx b/frontend/src/container/QueryBuilder/filters/AggregatorFilter/AggregatorFilter.tsx index 83923a556a7..89c5a7bc9ad 100644 --- a/frontend/src/container/QueryBuilder/filters/AggregatorFilter/AggregatorFilter.tsx +++ b/frontend/src/container/QueryBuilder/filters/AggregatorFilter/AggregatorFilter.tsx @@ -24,8 +24,8 @@ import { DataSource } from 'types/common/queryBuilder'; import { ExtendedSelectOption } from 'types/common/select'; import { useSelectPopupContainer } from 'utils/selectPopupContainer'; -import { selectStyle } from '../QueryBuilderSearch/config'; -import OptionRenderer from '../QueryBuilderSearch/OptionRenderer'; +import { selectStyle } from '../QueryBuilderSearchV2/config'; +import OptionRenderer from '../OptionRenderer/OptionRenderer'; // ** Types import { AgregatorFilterProps } from './AggregatorFilter.intefaces'; diff --git a/frontend/src/container/QueryBuilder/filters/Formula/Limit/Limit.tsx b/frontend/src/container/QueryBuilder/filters/Formula/Limit/Limit.tsx index b279ff587a8..33dc8f6387a 100644 --- a/frontend/src/container/QueryBuilder/filters/Formula/Limit/Limit.tsx +++ b/frontend/src/container/QueryBuilder/filters/Formula/Limit/Limit.tsx @@ -1,6 +1,6 @@ import { InputNumber } from 'antd'; -import { selectStyle } from '../../QueryBuilderSearch/config'; +import { selectStyle } from '../../QueryBuilderSearchV2/config'; import { handleKeyDownLimitFilter } from '../../utils'; import { LimitFilterProps } from './types'; diff --git a/frontend/src/container/QueryBuilder/filters/Formula/OrderBy/OrderByFilter.tsx b/frontend/src/container/QueryBuilder/filters/Formula/OrderBy/OrderByFilter.tsx index b127520b834..ea843e3dd53 100644 --- a/frontend/src/container/QueryBuilder/filters/Formula/OrderBy/OrderByFilter.tsx +++ b/frontend/src/container/QueryBuilder/filters/Formula/OrderBy/OrderByFilter.tsx @@ -4,7 +4,7 @@ import { useGetAggregateKeys } from 'hooks/queryBuilder/useGetAggregateKeys'; import { MetricAggregateOperator } from 'types/common/queryBuilder'; import { useSelectPopupContainer } from 'utils/selectPopupContainer'; -import { selectStyle } from '../../QueryBuilderSearch/config'; +import { selectStyle } from '../../QueryBuilderSearchV2/config'; import { OrderByProps } from './types'; import { useOrderByFormulaFilter } from './useOrderByFormulaFilter'; diff --git a/frontend/src/container/QueryBuilder/filters/Formula/OrderBy/useOrderByFormulaFilter.tsx b/frontend/src/container/QueryBuilder/filters/Formula/OrderBy/useOrderByFormulaFilter.tsx index 0f71e6560c0..1c8d1e23ea2 100644 --- a/frontend/src/container/QueryBuilder/filters/Formula/OrderBy/useOrderByFormulaFilter.tsx +++ b/frontend/src/container/QueryBuilder/filters/Formula/OrderBy/useOrderByFormulaFilter.tsx @@ -16,7 +16,7 @@ import { mapLabelValuePairs, orderByValueDelimiter, } from '../../OrderByFilter/utils'; -import { getRemoveOrderFromValue } from '../../QueryBuilderSearch/utils'; +import { getRemoveOrderFromValue } from '../../QueryBuilderSearchV2/utils'; import { getUniqueOrderByValues, getValidOrderByResult } from '../../utils'; import { IOrderByFormulaFilterProps } from './types'; import { transformToOrderByStringValuesByFormula } from './utils'; diff --git a/frontend/src/container/QueryBuilder/filters/GroupByFilter/GroupByFilter.tsx b/frontend/src/container/QueryBuilder/filters/GroupByFilter/GroupByFilter.tsx index 239725ce822..bd60fe0db65 100644 --- a/frontend/src/container/QueryBuilder/filters/GroupByFilter/GroupByFilter.tsx +++ b/frontend/src/container/QueryBuilder/filters/GroupByFilter/GroupByFilter.tsx @@ -23,8 +23,8 @@ import { DataSource } from 'types/common/queryBuilder'; import { SelectOption } from 'types/common/select'; import { useSelectPopupContainer } from 'utils/selectPopupContainer'; -import { selectStyle } from '../QueryBuilderSearch/config'; -import OptionRenderer from '../QueryBuilderSearch/OptionRenderer'; +import { selectStyle } from '../QueryBuilderSearchV2/config'; +import OptionRenderer from '../OptionRenderer/OptionRenderer'; import { GroupByFilterProps } from './GroupByFilter.interfaces'; export const GroupByFilter = memo(function GroupByFilter({ diff --git a/frontend/src/container/QueryBuilder/filters/LimitFilter/LimitFilter.tsx b/frontend/src/container/QueryBuilder/filters/LimitFilter/LimitFilter.tsx deleted file mode 100644 index d87c6d49076..00000000000 --- a/frontend/src/container/QueryBuilder/filters/LimitFilter/LimitFilter.tsx +++ /dev/null @@ -1,31 +0,0 @@ -import { InputNumber } from 'antd'; -import { IBuilderQuery } from 'types/api/queryBuilder/queryBuilderData'; -import { DataSource } from 'types/common/queryBuilder'; - -import { selectStyle } from '../QueryBuilderSearch/config'; -import { handleKeyDownLimitFilter } from '../utils'; - -function LimitFilter({ onChange, query }: LimitFilterProps): JSX.Element { - const isMetricsDataSource = query.dataSource === DataSource.METRICS; - - const isDisabled = isMetricsDataSource && !query.aggregateAttribute?.key; - - return ( - - ); -} - -interface LimitFilterProps { - onChange: (values: number | null) => void; - query: IBuilderQuery; -} - -export default LimitFilter; diff --git a/frontend/src/container/QueryBuilder/filters/MetricNameSelector/MetricNameSelector.test.tsx b/frontend/src/container/QueryBuilder/filters/MetricNameSelector/MetricNameSelector.test.tsx index e9c229f70ed..1470a3969ac 100644 --- a/frontend/src/container/QueryBuilder/filters/MetricNameSelector/MetricNameSelector.test.tsx +++ b/frontend/src/container/QueryBuilder/filters/MetricNameSelector/MetricNameSelector.test.tsx @@ -31,7 +31,7 @@ jest.mock('hooks/useDebounce', () => ({ default: (value: T): T => value, })); -jest.mock('../QueryBuilderSearch/OptionRenderer', () => ({ +jest.mock('../OptionRenderer/OptionRenderer', () => ({ __esModule: true, default: ({ value }: { value: string }): JSX.Element => {value}, })); diff --git a/frontend/src/container/QueryBuilder/filters/MetricNameSelector/MetricNameSelector.tsx b/frontend/src/container/QueryBuilder/filters/MetricNameSelector/MetricNameSelector.tsx index 0cbe8d4b082..e3231d54c4f 100644 --- a/frontend/src/container/QueryBuilder/filters/MetricNameSelector/MetricNameSelector.tsx +++ b/frontend/src/container/QueryBuilder/filters/MetricNameSelector/MetricNameSelector.tsx @@ -16,8 +16,8 @@ import { MetricAggregation } from 'types/api/v5/queryRange'; import { ExtendedSelectOption } from 'types/common/select'; import { useSelectPopupContainer } from 'utils/selectPopupContainer'; -import { selectStyle } from '../QueryBuilderSearch/config'; -import OptionRenderer from '../QueryBuilderSearch/OptionRenderer'; +import { selectStyle } from '../QueryBuilderSearchV2/config'; +import OptionRenderer from '../OptionRenderer/OptionRenderer'; import './MetricNameSelector.styles.scss'; diff --git a/frontend/src/container/QueryBuilder/filters/OperatorsSelect/OperatorsSelect.tsx b/frontend/src/container/QueryBuilder/filters/OperatorsSelect/OperatorsSelect.tsx index c7027fee0a2..b924c83edc9 100644 --- a/frontend/src/container/QueryBuilder/filters/OperatorsSelect/OperatorsSelect.tsx +++ b/frontend/src/container/QueryBuilder/filters/OperatorsSelect/OperatorsSelect.tsx @@ -2,7 +2,7 @@ import { memo } from 'react'; import { Select } from 'antd'; // ** Types -import { selectStyle } from '../QueryBuilderSearch/config'; +import { selectStyle } from '../QueryBuilderSearchV2/config'; import { OperatorsSelectProps } from './OperatorsSelect.interfaces'; export const OperatorsSelect = memo(function OperatorsSelect({ diff --git a/frontend/src/container/QueryBuilder/filters/QueryBuilderSearch/OptionRenderer.styles.scss b/frontend/src/container/QueryBuilder/filters/OptionRenderer/OptionRenderer.styles.scss similarity index 100% rename from frontend/src/container/QueryBuilder/filters/QueryBuilderSearch/OptionRenderer.styles.scss rename to frontend/src/container/QueryBuilder/filters/OptionRenderer/OptionRenderer.styles.scss diff --git a/frontend/src/container/QueryBuilder/filters/QueryBuilderSearch/OptionRenderer.tsx b/frontend/src/container/QueryBuilder/filters/OptionRenderer/OptionRenderer.tsx similarity index 100% rename from frontend/src/container/QueryBuilder/filters/QueryBuilderSearch/OptionRenderer.tsx rename to frontend/src/container/QueryBuilder/filters/OptionRenderer/OptionRenderer.tsx diff --git a/frontend/src/container/QueryBuilder/filters/OptionRenderer/style.ts b/frontend/src/container/QueryBuilder/filters/OptionRenderer/style.ts new file mode 100644 index 00000000000..6bbdfbda0cc --- /dev/null +++ b/frontend/src/container/QueryBuilder/filters/OptionRenderer/style.ts @@ -0,0 +1,22 @@ +import { Badge } from '@signozhq/ui/badge'; +import styled from 'styled-components'; + +export const TagContainer = styled(Badge).attrs({ + color: 'secondary', + variant: 'outline', +})` + &&& { + display: flex; + font-weight: 300; + font-size: 0.6rem; + } +`; + +export const TagLabel = styled.span` + font-weight: 400; +`; + +export const TagValue = styled.span` + text-transform: capitalize; + font-weight: 400; +`; diff --git a/frontend/src/container/QueryBuilder/filters/OrderByFilter/OrderByFilter.tsx b/frontend/src/container/QueryBuilder/filters/OrderByFilter/OrderByFilter.tsx index 2f5b18080dc..2167c469b59 100644 --- a/frontend/src/container/QueryBuilder/filters/OrderByFilter/OrderByFilter.tsx +++ b/frontend/src/container/QueryBuilder/filters/OrderByFilter/OrderByFilter.tsx @@ -5,7 +5,7 @@ import { DataSource, MetricAggregateOperator } from 'types/common/queryBuilder'; import { getParsedAggregationOptionsForOrderBy } from 'utils/aggregationConverter'; import { useSelectPopupContainer } from 'utils/selectPopupContainer'; -import { selectStyle } from '../QueryBuilderSearch/config'; +import { selectStyle } from '../QueryBuilderSearchV2/config'; import { OrderByFilterProps } from './OrderByFilter.interfaces'; import { useOrderByFilter } from './useOrderByFilter'; diff --git a/frontend/src/container/QueryBuilder/filters/OrderByFilter/useOrderByFilter.ts b/frontend/src/container/QueryBuilder/filters/OrderByFilter/useOrderByFilter.ts index 3bc220e6c75..a5bac28e463 100644 --- a/frontend/src/container/QueryBuilder/filters/OrderByFilter/useOrderByFilter.ts +++ b/frontend/src/container/QueryBuilder/filters/OrderByFilter/useOrderByFilter.ts @@ -7,7 +7,7 @@ import { parse } from 'papaparse'; import { BaseAutocompleteData } from 'types/api/queryBuilder/queryAutocompleteResponse'; import { OrderByPayload } from 'types/api/queryBuilder/queryBuilderData'; -import { getRemoveOrderFromValue } from '../QueryBuilderSearch/utils'; +import { getRemoveOrderFromValue } from '../QueryBuilderSearchV2/utils'; import { getUniqueOrderByValues, getValidOrderByResult } from '../utils'; import { ORDERBY_FILTERS } from './config'; import { SIGNOZ_VALUE } from './constants'; diff --git a/frontend/src/container/QueryBuilder/filters/QueryBuilderSearch/OptionRendererForLogs.tsx b/frontend/src/container/QueryBuilder/filters/QueryBuilderSearch/OptionRendererForLogs.tsx deleted file mode 100644 index 392982a007e..00000000000 --- a/frontend/src/container/QueryBuilder/filters/QueryBuilderSearch/OptionRendererForLogs.tsx +++ /dev/null @@ -1,70 +0,0 @@ -import { Color } from '@signozhq/design-tokens'; -import { Tooltip } from 'antd'; -import { Typography } from '@signozhq/ui/typography'; -import cx from 'classnames'; -import { Zap } from '@signozhq/icons'; - -import { getOptionType } from './utils'; - -import './QueryBuilderSearch.styles.scss'; - -function OptionRendererForLogs({ - label, - value, - dataType, - isIndexed, - setDynamicPlaceholder, -}: OptionRendererProps): JSX.Element { - const optionType = getOptionType(label); - - return ( - setDynamicPlaceholder(value)} - onFocus={(): void => setDynamicPlaceholder(value)} - > - {optionType ? ( - -
-
- {isIndexed ? ( - - ) : ( -
- )} - - {value} - -
-
-
{dataType}
-
-
- {optionType} -
-
-
-
- ) : ( - -
-
- - {label} - -
- - )} - - ); -} - -interface OptionRendererProps { - label: string; - value: string; - dataType: string; - isIndexed: boolean; - setDynamicPlaceholder: React.Dispatch>; -} - -export default OptionRendererForLogs; diff --git a/frontend/src/container/QueryBuilder/filters/QueryBuilderSearch/QueryBuilderSearch.styles.scss b/frontend/src/container/QueryBuilder/filters/QueryBuilderSearch/QueryBuilderSearch.styles.scss deleted file mode 100644 index c0573415fe5..00000000000 --- a/frontend/src/container/QueryBuilder/filters/QueryBuilderSearch/QueryBuilderSearch.styles.scss +++ /dev/null @@ -1,298 +0,0 @@ -.query-builder-search-container { - position: relative; - display: flex; - align-items: center; - gap: 12px; -} - -.logs-popup { - &.hide-scroll { - .rc-virtual-list-holder { - height: 100px; - } - } -} - -.logs-explorer-popup { - padding: 0px; - .ant-select-item-group { - padding: 12px 14px 8px 14px; - color: var(--muted-foreground); - font-family: Inter; - font-size: 11px; - font-style: normal; - font-weight: 500; - line-height: 18px; /* 163.636% */ - letter-spacing: 0.88px; - text-transform: uppercase; - } - - .show-all-filter-props { - display: flex; - align-items: center; - justify-content: space-between; - margin-bottom: 13px; - width: 100%; - cursor: pointer; - - .content { - display: flex; - align-items: center; - justify-content: space-between; - width: 100%; - - .left-section { - display: flex; - align-items: center; - gap: 4px; - - .text { - color: var(--l2-foreground); - font-family: Inter; - font-size: 14px; - font-style: normal; - font-weight: 400; - line-height: 20px; /* 142.857% */ - letter-spacing: -0.07px; - } - - .text:hover { - color: var(--l1-foreground); - } - } - .right-section { - display: flex; - align-items: center; - gap: 4px; - .keyboard-shortcut-slash { - width: 16px; - height: 16px; - flex-shrink: 0; - border-radius: 2.286px; - border-top: 1.143px solid var(--l1-border); - border-right: 1.143px solid var(--l1-border); - border-bottom: 2.286px solid var(--l1-border); - border-left: 1.143px solid var(--l1-border); - background: var(--l2-background); - } - } - } - } - - .show-all-filter-props:hover { - background: color-mix( - in srgb, - var(--l1-foreground) 4%, - transparent - ) !important; - } - - .example-queries { - cursor: default; - .heading { - padding: 12px 14px 8px 14px; - color: var(--muted-foreground); - font-family: Inter; - font-size: 11px; - font-style: normal; - font-weight: 500; - line-height: 18px; /* 163.636% */ - letter-spacing: 0.88px; - text-transform: uppercase; - } - - .query-container { - display: flex; - flex-direction: column; - gap: 12px; - padding: 0px 12px 12px 12px; - cursor: pointer; - - .example-query { - display: flex; - padding: 4px 8px; - justify-content: center; - align-items: center; - gap: 10px; - border-radius: 2px; - background: var(--l3-background); - color: var(--l2-foreground); - font-family: Inter; - font-size: 14px; - font-style: normal; - font-weight: 400; - line-height: normal; - letter-spacing: -0.07px; - width: fit-content; - } - - .example-query:hover { - color: var(--l1-foreground); - } - } - } - - .ant-select-item-option-grouped { - padding-inline-start: 0px; - padding: 7px 13px; - } - - .keyboard-shortcuts { - display: flex; - align-items: center; - border-radius: 0px 0px 4px 4px; - border: 1px solid var(--l1-border); - background: var(--l2-background); - padding: 11px 16px; - cursor: default; - - .icons { - width: 16px; - height: 16px; - flex-shrink: 0; - border-radius: 2.286px; - border-top: 1.143px solid var(--l3-background); - border-right: 1.143px solid var(--l3-background); - border-bottom: 2.286px solid var(--l3-background); - border-left: 1.143px solid var(--l3-background); - background: var(--l2-background); - } - - .keyboard-text { - color: var(--l2-foreground); - font-family: Inter; - font-size: 12px; - font-style: normal; - font-weight: 400; - line-height: 18px; /* 142.857% */ - letter-spacing: -0.07px; - } - - .navigate { - display: flex; - align-items: center; - padding-right: 12px; - gap: 4px; - border-right: 1px solid var(--l1-border); - } - - .update-query { - display: flex; - align-items: center; - margin-left: 12px; - gap: 4px; - } - } - - .without-option-type { - display: flex; - gap: 8px; - align-items: center; - .dot { - height: 5px; - width: 5px; - border-radius: 50%; - background-color: var(--l3-background); - } - } - - .logs-options-select { - display: flex; - align-items: center; - justify-content: space-between; - - .text { - color: var(--l2-foreground); - font-family: Inter; - font-size: 14px; - font-style: normal; - font-weight: 400; - line-height: 20px; /* 142.857% */ - letter-spacing: -0.07px; - } - - .tags { - display: flex; - height: 20px; - padding: 4px 8px; - justify-content: center; - align-items: center; - gap: 4px; - border-radius: 20px; - } - - .dot { - height: 5px; - width: 5px; - border-radius: 50%; - flex-shrink: 0; - } - - .left-section { - display: flex; - align-items: center; - gap: 8px; - width: 90%; - - .dot { - background-color: var(--l3-background); - } - - .value { - width: 100%; - } - } - - .right-section { - display: flex; - align-items: center; - gap: 4px; - - .data-type-tag { - background: color-mix(in srgb, var(--l1-foreground) 8%, transparent); - } - - .option-type-tag { - display: flex; - gap: 4px; - align-items: center; - padding: 0px 6px; - text-transform: capitalize; - } - - .tag { - border-radius: 50px; - background: color-mix(in srgb, var(--bg-sienna-400) 10%, transparent); - color: var(--bg-sienna-400); - - .dot { - background-color: var(--bg-sienna-400); - } - } - - .resource { - border-radius: 50px; - background: color-mix(in srgb, var(--bg-sakura-400) 10%, transparent); - color: var(--bg-sakura-400); - - .dot { - background-color: var(--bg-sakura-400); - } - } - } - } - - .ant-select-item-option-active { - .logs-options-select { - .left-section { - .value { - color: var(--l1-foreground); - } - } - } - } -} - -.span-scope-selector { - width: 160px; -} diff --git a/frontend/src/container/QueryBuilder/filters/QueryBuilderSearch/index.tsx b/frontend/src/container/QueryBuilder/filters/QueryBuilderSearch/index.tsx deleted file mode 100644 index 9c65df022b3..00000000000 --- a/frontend/src/container/QueryBuilder/filters/QueryBuilderSearch/index.tsx +++ /dev/null @@ -1,558 +0,0 @@ -import { - KeyboardEvent, - ReactElement, - ReactNode, - useCallback, - useEffect, - useMemo, - useRef, - useState, -} from 'react'; -import { useLocation } from 'react-router-dom'; -import { Button, Select, Spin, Tooltip } from 'antd'; -import { Badge } from '@signozhq/ui/badge'; -import { Typography } from '@signozhq/ui/typography'; -import cx from 'classnames'; -import { - INFRA_LONG_TO_SHORT_OPERATOR_MAP, - OPERATORS, -} from 'constants/queryBuilder'; -import ROUTES from 'constants/routes'; -import { LogsExplorerShortcuts } from 'constants/shortcuts/logsExplorerShortcuts'; -import { InfraMonitoringEntity } from 'container/InfraMonitoringK8sV2/constants'; -import { getDataTypes } from 'container/LogDetailedView/utils'; -import { useKeyboardHotkeys } from 'hooks/hotkeys/useKeyboardHotkeys'; -import { - useAutoComplete, - WhereClauseConfig, -} from 'hooks/queryBuilder/useAutoComplete'; -import { useFetchKeysAndValues } from 'hooks/queryBuilder/useFetchKeysAndValues'; -import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder'; -import { isEqual, isUndefined } from 'lodash-es'; -import { - ArrowDown, - ArrowUp, - ChevronDown, - ChevronUp, - Command, - CornerDownLeft, - Filter, - Slash, -} from '@signozhq/icons'; -import type { BaseSelectRef } from 'rc-select'; -import { - BaseAutocompleteData, - DataTypes, -} from 'types/api/queryBuilder/queryAutocompleteResponse'; -import { - IBuilderQuery, - TagFilter, -} from 'types/api/queryBuilder/queryBuilderData'; -import { DataSource } from 'types/common/queryBuilder'; -import { getUserOperatingSystem, UserOperatingSystem } from 'utils/getUserOS'; -import { useSelectPopupContainer } from 'utils/selectPopupContainer'; -import { v4 as uuid } from 'uuid'; - -import { selectStyle } from './config'; -import { PLACEHOLDER } from './constant'; -import ExampleQueriesRendererForLogs from './ExampleQueriesRendererForLogs'; -import OptionRenderer from './OptionRenderer'; -import OptionRendererForLogs from './OptionRendererForLogs'; -import { StyledCheckOutlined, TypographyText } from './style'; -import { - convertExampleQueriesToOptions, - getOperatorValue, - getRemovePrefixFromKey, - getTagToken, - isExistsNotExistsOperator, - isInNInOperator, -} from './utils'; - -import './QueryBuilderSearch.styles.scss'; - -function getOperatorValueForContext( - op: string, - isInfraMonitoring?: boolean, -): string { - const mappedOp = - isInfraMonitoring && INFRA_LONG_TO_SHORT_OPERATOR_MAP[op] - ? INFRA_LONG_TO_SHORT_OPERATOR_MAP[op] - : op; - return getOperatorValue(mappedOp); -} - -function QueryBuilderSearch({ - query, - onChange, - whereClauseConfig, - className, - placeholder, - suffixIcon, - isInfraMonitoring, - isMetricsExplorer, - disableNavigationShortcuts, - entity, -}: QueryBuilderSearchProps): JSX.Element { - const getPopupContainer = useSelectPopupContainer(); - const { pathname } = useLocation(); - const isLogsExplorerPage = useMemo( - () => pathname === ROUTES.LOGS_EXPLORER, - [pathname], - ); - - const [isEditingTag, setIsEditingTag] = useState(false); - - const { - updateTag, - handleClearTag, - handleKeyDown, - handleOnBlur, - handleSearch, - handleSelect, - tags, - options, - searchValue, - isMulti, - isFetching, - setSearchKey, - setSearchValue, - searchKey, - key, - exampleQueries, - } = useAutoComplete( - query, - whereClauseConfig, - isLogsExplorerPage, - isInfraMonitoring, - entity, - isMetricsExplorer, - ); - - const [isOpen, setIsOpen] = useState(false); - const [showAllFilters, setShowAllFilters] = useState(false); - const [dynamicPlacholder, setDynamicPlaceholder] = useState( - placeholder || '', - ); - const selectRef = useRef(null); - - const { sourceKeys, handleRemoveSourceKey } = useFetchKeysAndValues( - searchValue, - query, - searchKey, - isLogsExplorerPage, - isInfraMonitoring, - entity, - isMetricsExplorer, - ); - - const { registerShortcut, deregisterShortcut } = useKeyboardHotkeys(); - - const { handleRunQuery, currentQuery } = useQueryBuilder(); - - const toggleEditMode = useCallback( - (value: boolean) => { - // Editing mode is required only in infra monitoring or metrics explorer - if (isInfraMonitoring || isMetricsExplorer) { - setIsEditingTag(value); - } - }, - [isInfraMonitoring, isMetricsExplorer], - ); - - const onTagRender = ({ - value, - closable, - onClose, - }: CustomTagProps): ReactElement => { - const { tagOperator } = getTagToken(value); - const isInNin = isInNInOperator(tagOperator); - const chipValue = isInNin - ? value?.trim()?.replace(/,\s*$/, '') - : value?.trim(); - - const onCloseHandler = (): void => { - onClose(); - // Editing is done after closing a tag - toggleEditMode(false); - handleSearch(''); - setSearchKey(''); - }; - - const tagEditHandler = (value: string): void => { - updateTag(value); - // Editing starts - toggleEditMode(true); - if (isInfraMonitoring || isMetricsExplorer) { - setSearchValue(value); - } else { - handleSearch(value); - } - }; - - const isDisabled = !!searchValue; - - return ( - { - e.preventDefault(); - onCloseHandler(); - }} - > - - { - if (!isDisabled) { - tagEditHandler(value); - } - }} - > - {chipValue} - - - - ); - }; - - const onChangeHandler = (value: string[]): void => { - if (!isMulti) { - handleSearch(value[value.length - 1]); - } - }; - - const onInputKeyDownHandler = (event: KeyboardEvent): void => { - if (isMulti || event.key === 'Backspace') { - handleKeyDown(event); - } - if (isExistsNotExistsOperator(searchValue)) { - handleKeyDown(event); - } - - // Editing is done after enter key press - if (event.key === 'Enter') { - toggleEditMode(false); - } - - if ( - !disableNavigationShortcuts && - (event.ctrlKey || event.metaKey) && - event.key === 'Enter' - ) { - event.preventDefault(); - event.stopPropagation(); - handleRunQuery(); - setIsOpen(false); - } - - if ( - !disableNavigationShortcuts && - (event.ctrlKey || event.metaKey) && - event.key === '/' - ) { - event.preventDefault(); - event.stopPropagation(); - setShowAllFilters((prev) => !prev); - } - }; - - const handleDeselect = useCallback( - (deselectedItem: string) => { - handleClearTag(deselectedItem); - handleRemoveSourceKey(deselectedItem); - }, - [handleClearTag, handleRemoveSourceKey], - ); - - const isMetricsDataSource = useMemo( - () => - query.dataSource === DataSource.METRICS && - !isInfraMonitoring && - !isMetricsExplorer, - [query.dataSource, isInfraMonitoring, isMetricsExplorer], - ); - - const fetchValueDataType = (value: unknown, operator: string): DataTypes => { - if (operator === OPERATORS.HAS || operator === OPERATORS.NHAS) { - return getDataTypes([value]); - } - - return DataTypes.EMPTY; - }; - - const queryTags = useMemo(() => { - if (!query.aggregateAttribute?.key && isMetricsDataSource) { - return []; - } - return tags; - }, [isMetricsDataSource, query.aggregateAttribute?.key, tags]); - - useEffect(() => { - const initialTagFilters: TagFilter = { items: [], op: 'AND' }; - const initialSourceKeys = query.filters?.items?.map( - (item) => item.key as BaseAutocompleteData, - ); - - initialTagFilters.items = tags.map((tag) => { - const { tagKey, tagOperator, tagValue } = getTagToken(tag); - - const filterAttribute = [ - ...(initialSourceKeys || []), - ...(sourceKeys || []), - ].find((key) => key?.key === getRemovePrefixFromKey(tagKey)); - - const computedTagValue = - tagValue && Array.isArray(tagValue) && tagValue[tagValue.length - 1] === '' - ? tagValue?.slice(0, -1) - : (tagValue ?? ''); - - return { - id: uuid().slice(0, 8), - key: filterAttribute ?? { - key: tagKey, - dataType: fetchValueDataType(computedTagValue, tagOperator), - type: '', - }, - op: getOperatorValueForContext(tagOperator, isInfraMonitoring), - value: computedTagValue, - }; - }); - - // If in infra monitoring or metrics explorer, only run the onChange query when editing is finsished. - if (isInfraMonitoring || isMetricsExplorer) { - if (!isEditingTag) { - onChange(initialTagFilters); - } - } else { - onChange(initialTagFilters); - } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [sourceKeys]); - - const isLastQuery = useMemo( - () => - isEqual( - currentQuery.builder.queryData[currentQuery.builder.queryData.length - 1], - query, - ), - [currentQuery, query], - ); - - useEffect(() => { - if (isLastQuery && !disableNavigationShortcuts) { - registerShortcut(LogsExplorerShortcuts.FocusTheSearchBar, () => { - // set timeout is needed here else the select treats the hotkey as input value - setTimeout(() => { - selectRef.current?.focus(); - }, 0); - }); - } - - return (): void => - deregisterShortcut(LogsExplorerShortcuts.FocusTheSearchBar); - }, [ - deregisterShortcut, - disableNavigationShortcuts, - isLastQuery, - registerShortcut, - ]); - - useEffect(() => { - if (!isOpen) { - setDynamicPlaceholder(placeholder || ''); - } - }, [isOpen, placeholder]); - - const userOs = getUserOperatingSystem(); - - // conditional changes here to use a seperate component to render the example queries based on the option group label - const customRendererForLogsExplorer = options.map((option) => ( - - - {option.selected && } - - )); - - return ( -
- -
- ); -} - -interface QueryBuilderSearchProps { - query: IBuilderQuery; - onChange: (value: TagFilter) => void; - whereClauseConfig?: WhereClauseConfig; - className?: string; - placeholder?: string; - suffixIcon?: React.ReactNode; - isInfraMonitoring?: boolean; - disableNavigationShortcuts?: boolean; - // TODO: Remove the dependency of InfraMonitoring from this code - entity?: InfraMonitoringEntity | null; - isMetricsExplorer?: boolean; -} - -QueryBuilderSearch.defaultProps = { - whereClauseConfig: undefined, - className: '', - placeholder: PLACEHOLDER, - suffixIcon: undefined, - isInfraMonitoring: false, - disableNavigationShortcuts: false, - entity: null, - isMetricsExplorer: false, -}; - -export interface CustomTagProps { - label: ReactNode; - value: string; - disabled: boolean; - onClose: () => void; - closable: boolean; -} - -export default QueryBuilderSearch; diff --git a/frontend/src/container/QueryBuilder/filters/QueryBuilderSearch/ExampleQueriesRendererForLogs.tsx b/frontend/src/container/QueryBuilder/filters/QueryBuilderSearchV2/ExampleQueriesRendererForLogs.tsx similarity index 92% rename from frontend/src/container/QueryBuilder/filters/QueryBuilderSearch/ExampleQueriesRendererForLogs.tsx rename to frontend/src/container/QueryBuilder/filters/QueryBuilderSearchV2/ExampleQueriesRendererForLogs.tsx index 3aeaec4f1eb..b30f8628f23 100644 --- a/frontend/src/container/QueryBuilder/filters/QueryBuilderSearch/ExampleQueriesRendererForLogs.tsx +++ b/frontend/src/container/QueryBuilder/filters/QueryBuilderSearchV2/ExampleQueriesRendererForLogs.tsx @@ -1,7 +1,5 @@ import { TagFilter } from 'types/api/queryBuilder/queryBuilderData'; -import './QueryBuilderSearch.styles.scss'; - function ExampleQueriesRendererForLogs({ label, value, diff --git a/frontend/src/container/QueryBuilder/filters/QueryBuilderSearchV2/QueryBuilderSearchDropdown.tsx b/frontend/src/container/QueryBuilder/filters/QueryBuilderSearchV2/QueryBuilderSearchDropdown.tsx index 37302663a07..c1b997f92d2 100644 --- a/frontend/src/container/QueryBuilder/filters/QueryBuilderSearchV2/QueryBuilderSearchDropdown.tsx +++ b/frontend/src/container/QueryBuilder/filters/QueryBuilderSearchV2/QueryBuilderSearchDropdown.tsx @@ -11,8 +11,8 @@ import { import { TagFilter } from 'types/api/queryBuilder/queryBuilderData'; import { getUserOperatingSystem, UserOperatingSystem } from 'utils/getUserOS'; -import ExampleQueriesRendererForLogs from '../QueryBuilderSearch/ExampleQueriesRendererForLogs'; -import { convertExampleQueriesToOptions } from '../QueryBuilderSearch/utils'; +import ExampleQueriesRendererForLogs from './ExampleQueriesRendererForLogs'; +import { convertExampleQueriesToOptions } from './utils'; import { ITag, Option } from './QueryBuilderSearchV2'; import './QueryBuilderSearchV2.styles.scss'; diff --git a/frontend/src/container/QueryBuilder/filters/QueryBuilderSearchV2/QueryBuilderSearchV2.tsx b/frontend/src/container/QueryBuilder/filters/QueryBuilderSearchV2/QueryBuilderSearchV2.tsx index 0af46293592..8ac8debf8f1 100644 --- a/frontend/src/container/QueryBuilder/filters/QueryBuilderSearchV2/QueryBuilderSearchV2.tsx +++ b/frontend/src/container/QueryBuilder/filters/QueryBuilderSearchV2/QueryBuilderSearchV2.tsx @@ -19,10 +19,10 @@ import { QUERY_BUILDER_SEARCH_VALUES, } from 'constants/queryBuilder'; import { DEBOUNCE_DELAY } from 'constants/queryBuilderFilterConfig'; +import type { WhereClauseConfig } from 'container/QueryBuilder/QueryBuilder.interfaces'; import { LogsExplorerShortcuts } from 'constants/shortcuts/logsExplorerShortcuts'; import { useDashboardVariablesByType } from 'hooks/dashboard/useDashboardVariablesByType'; import { useKeyboardHotkeys } from 'hooks/hotkeys/useKeyboardHotkeys'; -import { WhereClauseConfig } from 'hooks/queryBuilder/useAutoComplete'; import { useGetAggregateKeys } from 'hooks/queryBuilder/useGetAggregateKeys'; import { useGetAggregateValues } from 'hooks/queryBuilder/useGetAggregateValues'; import { useGetAttributeSuggestions } from 'hooks/queryBuilder/useGetAttributeSuggestions'; @@ -53,16 +53,16 @@ import { DataSource } from 'types/common/queryBuilder'; import { useSelectPopupContainer } from 'utils/selectPopupContainer'; import { v4 as uuid } from 'uuid'; -import { selectStyle } from '../QueryBuilderSearch/config'; -import { PLACEHOLDER } from '../QueryBuilderSearch/constant'; -import { TypographyText } from '../QueryBuilderSearch/style'; +import { selectStyle } from './config'; +import { PLACEHOLDER } from './constant'; +import { TypographyText } from './style'; import { checkCommaInValue, getOperatorFromValue, getOperatorValue, getTagToken, isInNInOperator, -} from '../QueryBuilderSearch/utils'; +} from './utils'; import { filterByOperatorConfig } from '../utils'; import QueryBuilderSearchDropdown from './QueryBuilderSearchDropdown'; import SpanScopeSelector from './SpanScopeSelector'; diff --git a/frontend/src/container/QueryBuilder/filters/QueryBuilderSearchV2/Suggestions.tsx b/frontend/src/container/QueryBuilder/filters/QueryBuilderSearchV2/Suggestions.tsx index 263da7b4018..111954a4b97 100644 --- a/frontend/src/container/QueryBuilder/filters/QueryBuilderSearchV2/Suggestions.tsx +++ b/frontend/src/container/QueryBuilder/filters/QueryBuilderSearchV2/Suggestions.tsx @@ -7,7 +7,7 @@ import { isEmpty, isObject } from 'lodash-es'; import { Check, Zap } from '@signozhq/icons'; import { BaseAutocompleteData } from 'types/api/queryBuilder/queryAutocompleteResponse'; -import { getTagToken } from '../QueryBuilderSearch/utils'; +import { getTagToken } from './utils'; import { DropdownState } from './QueryBuilderSearchV2'; import './Suggestions.styles.scss'; diff --git a/frontend/src/container/QueryBuilder/filters/QueryBuilderSearch/config.ts b/frontend/src/container/QueryBuilder/filters/QueryBuilderSearchV2/config.ts similarity index 100% rename from frontend/src/container/QueryBuilder/filters/QueryBuilderSearch/config.ts rename to frontend/src/container/QueryBuilder/filters/QueryBuilderSearchV2/config.ts diff --git a/frontend/src/container/QueryBuilder/filters/QueryBuilderSearch/constant.ts b/frontend/src/container/QueryBuilder/filters/QueryBuilderSearchV2/constant.ts similarity index 100% rename from frontend/src/container/QueryBuilder/filters/QueryBuilderSearch/constant.ts rename to frontend/src/container/QueryBuilder/filters/QueryBuilderSearchV2/constant.ts diff --git a/frontend/src/container/QueryBuilder/filters/QueryBuilderSearch/style.ts b/frontend/src/container/QueryBuilder/filters/QueryBuilderSearchV2/style.ts similarity index 53% rename from frontend/src/container/QueryBuilder/filters/QueryBuilderSearch/style.ts rename to frontend/src/container/QueryBuilder/filters/QueryBuilderSearchV2/style.ts index 782846116a2..80557e0502a 100644 --- a/frontend/src/container/QueryBuilder/filters/QueryBuilderSearch/style.ts +++ b/frontend/src/container/QueryBuilder/filters/QueryBuilderSearchV2/style.ts @@ -1,5 +1,3 @@ -import { Check } from '@signozhq/icons'; -import { Badge } from '@signozhq/ui/badge'; import styled from 'styled-components'; export const TypographyText = styled.span<{ @@ -17,27 +15,3 @@ export const TypographyText = styled.span<{ padding-left: 8px; ${({ $disabled }): string => ($disabled ? 'opacity: 0.6' : '')} `; - -export const StyledCheckOutlined = styled(Check)` - float: right; -`; - -export const TagContainer = styled(Badge).attrs({ - color: 'secondary', - variant: 'outline', -})` - &&& { - display: flex; - font-weight: 300; - font-size: 0.6rem; - } -`; - -export const TagLabel = styled.span` - font-weight: 400; -`; - -export const TagValue = styled.span` - text-transform: capitalize; - font-weight: 400; -`; diff --git a/frontend/src/container/QueryBuilder/filters/QueryBuilderSearch/utils.ts b/frontend/src/container/QueryBuilder/filters/QueryBuilderSearchV2/utils.ts similarity index 92% rename from frontend/src/container/QueryBuilder/filters/QueryBuilderSearch/utils.ts rename to frontend/src/container/QueryBuilder/filters/QueryBuilderSearchV2/utils.ts index 0be5afc7a5e..fcdd6279b1b 100644 --- a/frontend/src/container/QueryBuilder/filters/QueryBuilderSearch/utils.ts +++ b/frontend/src/container/QueryBuilder/filters/QueryBuilderSearchV2/utils.ts @@ -1,8 +1,7 @@ import { OPERATORS } from 'constants/queryBuilder'; -import { MetricsType } from 'container/MetricsApplication/constant'; import { queryFilterTags } from 'hooks/queryBuilder/useTag'; import { parse } from 'papaparse'; -import { TagFilter } from 'types/api/queryBuilder/queryBuilderData'; +import type { TagFilter } from 'types/api/queryBuilder/queryBuilderData'; import { orderByValueDelimiter } from '../OrderByFilter/utils'; @@ -191,18 +190,6 @@ export function getRemoveOrderFromValue(tag: string): string { return tag; } -export function getOptionType(label: string): MetricsType | undefined { - let optionType; - - if (label.startsWith('tag_')) { - optionType = MetricsType.Tag; - } else if (label.startsWith('resource_')) { - optionType = MetricsType.Resource; - } - - return optionType; -} - /** * * @param exampleQueries the example queries based on recommendation engine diff --git a/frontend/src/container/QueryBuilder/filters/utils.ts b/frontend/src/container/QueryBuilder/filters/utils.ts index ea6fa0ea96b..1950e95f464 100644 --- a/frontend/src/container/QueryBuilder/filters/utils.ts +++ b/frontend/src/container/QueryBuilder/filters/utils.ts @@ -1,4 +1,3 @@ -import { AttributeValuesMap } from 'components/ClientSideQBSearch/ClientSideQBSearch'; import { OperatorConfigKeys, OPERATORS_CONFIG } from 'constants/queryBuilder'; import { HAVING_FILTER_REGEXP } from 'constants/regExp'; import { IOption } from 'hooks/useResourceAttribute/types'; @@ -11,7 +10,7 @@ import { orderByValueDelimiter, splitOrderByFromString, } from './OrderByFilter/utils'; -import { getRemoveOrderFromValue } from './QueryBuilderSearch/utils'; +import { getRemoveOrderFromValue } from './QueryBuilderSearchV2/utils'; export const handleKeyDownLimitFilter: React.KeyboardEventHandler< HTMLInputElement @@ -101,23 +100,6 @@ export const getValidOrderByResult = (result: IOption[]): IOption[] => return acc; }, []); -export const transformKeyValuesToAttributeValuesMap = ( - attributeValuesMap: Record, -): AttributeValuesMap => - Object.fromEntries( - Object.entries(attributeValuesMap || {}).map(([key, values]) => [ - key, - { - stringAttributeValues: - typeof values[0] === 'string' ? (values as string[]) : [], - numberAttributeValues: - typeof values[0] === 'number' ? (values as number[]) : [], - boolAttributeValues: - typeof values[0] === 'boolean' ? (values as boolean[]) : [], - }, - ]), - ); - export const filterByOperatorConfig = ( options: IOption[], key?: OperatorConfigKeys, diff --git a/frontend/src/container/QueryTable/Drilldown/metricsCorrelationUtils.tsx b/frontend/src/container/QueryTable/Drilldown/metricsCorrelationUtils.tsx index 728cde2b23c..6bb985e9792 100644 --- a/frontend/src/container/QueryTable/Drilldown/metricsCorrelationUtils.tsx +++ b/frontend/src/container/QueryTable/Drilldown/metricsCorrelationUtils.tsx @@ -3,7 +3,7 @@ import { formatValueForExpression, removeKeysFromExpression, } from 'components/QueryBuilderV2/utils'; -import { getOperatorValue } from 'container/QueryBuilder/filters/QueryBuilderSearch/utils'; +import { getOperatorValue } from 'container/QueryBuilder/filters/QueryBuilderSearchV2/utils'; import { IQueryPair } from 'types/antlrQueryTypes'; import { extractQueryPairs } from 'utils/queryContextUtils'; import { isQuoted, unquote } from 'utils/stringUtils'; diff --git a/frontend/src/hooks/logs/useActiveLog.ts b/frontend/src/hooks/logs/useActiveLog.ts index f0ccc72cb12..1cc1f239070 100644 --- a/frontend/src/hooks/logs/useActiveLog.ts +++ b/frontend/src/hooks/logs/useActiveLog.ts @@ -9,7 +9,7 @@ import { QueryParams } from 'constants/query'; import { OPERATORS, QueryBuilderKeys } from 'constants/queryBuilder'; import ROUTES from 'constants/routes'; import { MetricsType } from 'container/MetricsApplication/constant'; -import { getOperatorValue } from 'container/QueryBuilder/filters/QueryBuilderSearch/utils'; +import { getOperatorValue } from 'container/QueryBuilder/filters/QueryBuilderSearchV2/utils'; import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder'; import { useNotifications } from 'hooks/useNotifications'; import useUrlQuery from 'hooks/useUrlQuery'; diff --git a/frontend/src/hooks/queryBuilder/useAutoComplete.ts b/frontend/src/hooks/queryBuilder/useAutoComplete.ts deleted file mode 100644 index 604380856f1..00000000000 --- a/frontend/src/hooks/queryBuilder/useAutoComplete.ts +++ /dev/null @@ -1,185 +0,0 @@ -import { KeyboardEvent, useCallback, useState } from 'react'; -import { OPERATORS } from 'constants/queryBuilder'; -import type { InfraMonitoringEntity } from 'container/InfraMonitoringK8sV2/constants'; -import { - getRemovePrefixFromKey, - getTagToken, - replaceStringWithMaxLength, - tagRegexp, -} from 'container/QueryBuilder/filters/QueryBuilderSearch/utils'; -import { Option } from 'container/QueryBuilder/type'; -import { parse } from 'papaparse'; -import { - IBuilderQuery, - TagFilter, -} from 'types/api/queryBuilder/queryBuilderData'; - -import { useFetchKeysAndValues } from './useFetchKeysAndValues'; -import { useOptions, WHERE_CLAUSE_CUSTOM_SUFFIX } from './useOptions'; -import { useSetCurrentKeyAndOperator } from './useSetCurrentKeyAndOperator'; -import { useTag } from './useTag'; -import { useTagValidation } from './useTagValidation'; - -export type WhereClauseConfig = { - customKey: string; - customOp: string; -}; - -export const useAutoComplete = ( - query: IBuilderQuery, - whereClauseConfig?: WhereClauseConfig, - shouldUseSuggestions?: boolean, - isInfraMonitoring?: boolean, - entity?: InfraMonitoringEntity | null, - isMetricsExplorer?: boolean, -): IAutoComplete => { - const [searchValue, setSearchValue] = useState(''); - const [searchKey, setSearchKey] = useState(''); - - const { keys, results, isFetching, exampleQueries } = useFetchKeysAndValues( - searchValue, - query, - searchKey, - shouldUseSuggestions, - isInfraMonitoring, - entity, - isMetricsExplorer, - ); - - const [key, operator, result] = useSetCurrentKeyAndOperator(searchValue, keys); - - const handleSearch = (value: string): void => { - const prefixFreeValue = getRemovePrefixFromKey(getTagToken(value).tagKey); - setSearchValue(value); - setSearchKey(prefixFreeValue); - }; - - const { isValidTag, isExist, isValidOperator, isMulti } = useTagValidation( - operator, - result, - ); - - const { handleAddTag, handleClearTag, tags, updateTag } = useTag( - isValidTag, - handleSearch, - query, - setSearchKey, - whereClauseConfig, - isInfraMonitoring, - ); - - const handleSelect = useCallback( - (value: string): void => { - if (isMulti) { - setSearchValue((prev: string) => { - const matches = prev?.matchAll(tagRegexp); - const [match] = matches ? Array.from(matches) : []; - const [, , , matchTagValue] = match; - const data = parse(matchTagValue).data.flat(); - return replaceStringWithMaxLength(prev, data as string[], value); - }); - } - if (!isMulti) { - handleAddTag(value); - } - }, - [handleAddTag, isMulti], - ); - - const handleKeyDown = useCallback( - (event: KeyboardEvent): void => { - if ( - event.key === ' ' && - (searchValue.endsWith(' ') || searchValue.length === 0) - ) { - event.preventDefault(); - } - - if (event.key === 'Enter' && searchValue && isValidTag) { - if (isMulti) { - event.stopPropagation(); - } - event.preventDefault(); - handleAddTag(searchValue); - } - - if (event.key === 'Backspace' && !searchValue) { - event.stopPropagation(); - const last = tags[tags.length - 1]; - handleClearTag(last); - } - }, - [handleAddTag, handleClearTag, isMulti, isValidTag, searchValue, tags], - ); - - const handleOnBlur = (event: React.FocusEvent): void => { - event.preventDefault(); - if (searchValue) { - if ( - key && - !operator && - whereClauseConfig?.customKey === 'body' && - whereClauseConfig.customOp === OPERATORS.CONTAINS - ) { - const value = `${searchValue}${WHERE_CLAUSE_CUSTOM_SUFFIX}`; - handleAddTag(value); - return; - } - handleAddTag(searchValue); - } - }; - - const options = useOptions( - key, - keys, - operator, - searchValue, - isMulti, - isValidOperator, - isExist, - results, - result, - isFetching, - whereClauseConfig, - isInfraMonitoring, - ); - - return { - updateTag, - handleSearch, - handleClearTag, - handleSelect, - handleKeyDown, - handleOnBlur, - options, - tags, - searchValue, - isMulti, - isFetching, - setSearchKey, - setSearchValue, - searchKey, - key, - exampleQueries, - }; -}; - -interface IAutoComplete { - updateTag: (value: string) => void; - handleSearch: (value: string) => void; - handleClearTag: (value: string) => void; - handleSelect: (value: string) => void; - handleKeyDown: (event: React.KeyboardEvent) => void; - handleOnBlur: (event: React.FocusEvent) => void; - options: Option[]; - tags: string[]; - searchValue: string; - isMulti: boolean; - isFetching: boolean; - setSearchKey: (value: string) => void; - setSearchValue: (value: string) => void; - searchKey: string; - key: string; - exampleQueries: TagFilter[]; - isInfraMonitoring?: boolean; -} diff --git a/frontend/src/hooks/queryBuilder/useFetchKeysAndValues.ts b/frontend/src/hooks/queryBuilder/useFetchKeysAndValues.ts deleted file mode 100644 index 89d7124d4fb..00000000000 --- a/frontend/src/hooks/queryBuilder/useFetchKeysAndValues.ts +++ /dev/null @@ -1,308 +0,0 @@ -/* eslint-disable sonarjs/cognitive-complexity */ -import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; -import { useDebounce } from 'react-use'; -import { getAttributesValues } from 'api/queryBuilder/getAttributesValues'; -import { DATA_TYPE_VS_ATTRIBUTE_VALUES_KEY } from 'constants/queryBuilder'; -import { DEBOUNCE_DELAY } from 'constants/queryBuilderFilterConfig'; -import { - GetK8sEntityToAggregateAttribute, - InfraMonitoringEntity, -} from 'container/InfraMonitoringK8sV2/constants'; -import { - getRemovePrefixFromKey, - getTagToken, - isInNInOperator, -} from 'container/QueryBuilder/filters/QueryBuilderSearch/utils'; -import useDebounceValue from 'hooks/useDebounce'; -import { cloneDeep, isEqual, uniqWith, unset } from 'lodash-es'; -import { IAttributeValuesResponse } from 'types/api/queryBuilder/getAttributesValues'; -import { - BaseAutocompleteData, - DataTypes, -} from 'types/api/queryBuilder/queryAutocompleteResponse'; -import { - IBuilderQuery, - TagFilter, -} from 'types/api/queryBuilder/queryBuilderData'; -import { DataSource } from 'types/common/queryBuilder'; - -import { useGetAggregateKeys } from './useGetAggregateKeys'; -import { useGetAttributeSuggestions } from './useGetAttributeSuggestions'; - -type IuseFetchKeysAndValues = { - keys: BaseAutocompleteData[]; - results: string[]; - isFetching: boolean; - sourceKeys: BaseAutocompleteData[]; - handleRemoveSourceKey: (newSourceKey: string) => void; - exampleQueries: TagFilter[]; -}; - -/** - * Custom hook to fetch attribute keys and values from an API - * @param searchValue - the search query value - * @param query - an object containing data for the query - * @returns an object containing the fetched attribute keys, results, and the status of the fetch - */ - -export const useFetchKeysAndValues = ( - searchValue: string, - query: IBuilderQuery, - searchKey: string, - shouldUseSuggestions?: boolean, - isInfraMonitoring?: boolean, - entity?: InfraMonitoringEntity | null, - isMetricsExplorer?: boolean, -): IuseFetchKeysAndValues => { - const [keys, setKeys] = useState([]); - const [exampleQueries, setExampleQueries] = useState([]); - const [sourceKeys, setSourceKeys] = useState([]); - const [results, setResults] = useState([]); - const [isAggregateFetching, setAggregateFetching] = useState(false); - - const memoizedSearchParams = useMemo( - () => [ - searchKey, - query.dataSource, - query.aggregateOperator, - query.aggregateAttribute?.key, - ], - [ - searchKey, - query.dataSource, - query.aggregateOperator, - query.aggregateAttribute?.key, - ], - ); - - const searchParams = useDebounceValue(memoizedSearchParams, DEBOUNCE_DELAY); - - const queryFiltersWithoutId = useMemo( - () => ({ - ...query.filters, - items: query.filters?.items?.map((item) => { - const filterWithoutId = cloneDeep(item); - unset(filterWithoutId, 'id'); - return filterWithoutId; - }), - }), - [query.filters], - ); - - const memoizedSuggestionsParams = useMemo( - () => [searchKey, query.dataSource, queryFiltersWithoutId], - [query.dataSource, queryFiltersWithoutId, searchKey], - ); - - const suggestionsParams = useDebounceValue( - memoizedSuggestionsParams, - DEBOUNCE_DELAY, - ); - - const isQueryEnabled = useMemo( - () => - query.dataSource === DataSource.METRICS && - !isInfraMonitoring && - !isMetricsExplorer - ? !!query.dataSource && !!query.aggregateAttribute?.dataType - : true, - [ - isInfraMonitoring, - isMetricsExplorer, - query.aggregateAttribute?.dataType, - query.dataSource, - ], - ); - - const { data, isFetching, status } = useGetAggregateKeys( - { - searchText: searchKey, - dataSource: query.dataSource, - aggregateOperator: query.aggregateOperator || '', - aggregateAttribute: - isInfraMonitoring && entity - ? GetK8sEntityToAggregateAttribute(entity) - : query.aggregateAttribute?.key || '', - tagType: query.aggregateAttribute?.type ?? null, - }, - { - queryKey: [searchParams], - enabled: isMetricsExplorer ? false : isQueryEnabled && !shouldUseSuggestions, - }, - isInfraMonitoring, // isInfraMonitoring - entity, // infraMonitoringEntity - ); - - const { - data: suggestionsData, - isFetching: isFetchingSuggestions, - status: fetchingSuggestionsStatus, - } = useGetAttributeSuggestions( - { - searchText: searchKey, - dataSource: query.dataSource, - filters: query.filters || { items: [], op: 'AND' }, - }, - { - queryKey: [suggestionsParams], - enabled: isQueryEnabled && shouldUseSuggestions, - }, - ); - - function isAttributeValuesResponse( - // eslint-disable-next-line @typescript-eslint/no-explicit-any - payload: any, - ): payload is IAttributeValuesResponse { - return ( - payload && - (Array.isArray(payload.stringAttributeValues) || - payload.stringAttributeValues === null || - Array.isArray(payload.numberAttributeValues) || - payload.numberAttributeValues === null || - Array.isArray(payload.boolAttributeValues) || - payload.boolAttributeValues === null) - ); - } - - /** - * Fetches the options to be displayed based on the selected value - * @param value - the selected value - * @param query - an object containing data for the query - */ - const handleFetchOption = async ( - value: string, - query: IBuilderQuery, - keys: BaseAutocompleteData[], - ): Promise => { - if (!value) { - return; - } - const { tagKey, tagOperator, tagValue } = getTagToken(value); - const filterAttributeKey = keys.find( - (item) => item.key === getRemovePrefixFromKey(tagKey), - ); - setResults([]); - - if (!tagKey || !tagOperator) { - return; - } - setAggregateFetching(true); - - try { - let payload; - if (isInfraMonitoring && entity) { - const response = await getAttributesValues({ - aggregateOperator: 'noop', - dataSource: query.dataSource, - aggregateAttribute: - GetK8sEntityToAggregateAttribute(entity) || - query.aggregateAttribute?.key || - '', - attributeKey: filterAttributeKey?.key ?? tagKey, - filterAttributeKeyDataType: - filterAttributeKey?.dataType ?? DataTypes.EMPTY, - tagType: filterAttributeKey?.type ?? '', - searchText: isInNInOperator(tagOperator) - ? (tagValue[tagValue.length - 1]?.toString() ?? '') - : (tagValue?.toString() ?? ''), - }); - payload = response.payload; - } else { - const response = await getAttributesValues({ - aggregateOperator: query.aggregateOperator || '', - dataSource: query.dataSource, - aggregateAttribute: query.aggregateAttribute?.key || '', - attributeKey: filterAttributeKey?.key ?? tagKey, - filterAttributeKeyDataType: - filterAttributeKey?.dataType ?? DataTypes.EMPTY, - tagType: filterAttributeKey?.type ?? '', - searchText: isInNInOperator(tagOperator) - ? (tagValue[tagValue.length - 1]?.toString() ?? '') - : (tagValue?.toString() ?? ''), - }); - payload = response.payload; - } - - if (payload && isAttributeValuesResponse(payload)) { - const dataType = filterAttributeKey?.dataType ?? DataTypes.String; - const key = DATA_TYPE_VS_ATTRIBUTE_VALUES_KEY[dataType]; - setResults(key ? payload[key] || [] : []); - return; - } - } catch (e) { - console.error(e); - } finally { - setAggregateFetching(false); - } - }; - - const handleRemoveSourceKey = useCallback((sourceKey: string) => { - setSourceKeys((prevState) => - prevState.filter((item) => item.key !== sourceKey), - ); - }, []); - - // creates a ref to the fetch function so that it doesn't change on every render - const clearFetcher = useRef(handleFetchOption).current; - - // debounces the fetch function to avoid excessive API calls - useDebounce(() => clearFetcher(searchValue, query, keys), 750, [ - clearFetcher, - searchValue, - query, - keys, - ]); - - // update the fetched keys when the fetch status changes - useEffect(() => { - if (status === 'success' && data?.payload?.attributeKeys) { - setKeys(data.payload.attributeKeys); - setSourceKeys((prevState) => - uniqWith([...(data.payload.attributeKeys ?? []), ...prevState], isEqual), - ); - } else { - setKeys([]); - } - }, [data?.payload?.attributeKeys, status]); - - useEffect(() => { - if ( - fetchingSuggestionsStatus === 'success' && - suggestionsData?.payload?.attributes - ) { - if (!isInfraMonitoring) { - setKeys(suggestionsData.payload.attributes); - setSourceKeys((prevState) => - uniqWith( - [...(suggestionsData.payload.attributes ?? []), ...prevState], - isEqual, - ), - ); - } - } else { - setKeys([]); - } - if ( - fetchingSuggestionsStatus === 'success' && - suggestionsData?.payload?.example_queries - ) { - setExampleQueries(suggestionsData.payload.example_queries); - } else { - setExampleQueries([]); - } - }, [ - suggestionsData?.payload?.attributes, - fetchingSuggestionsStatus, - suggestionsData?.payload?.example_queries, - isInfraMonitoring, - ]); - - return { - keys, - results, - isFetching: isFetching || isAggregateFetching || isFetchingSuggestions, - sourceKeys, - handleRemoveSourceKey, - exampleQueries, - }; -}; diff --git a/frontend/src/hooks/queryBuilder/useOperators.ts b/frontend/src/hooks/queryBuilder/useOperators.ts deleted file mode 100644 index 9392a0ad643..00000000000 --- a/frontend/src/hooks/queryBuilder/useOperators.ts +++ /dev/null @@ -1,31 +0,0 @@ -import { useMemo } from 'react'; -import { - OPERATORS, - QUERY_BUILDER_OPERATORS_BY_TYPES, -} from 'constants/queryBuilder'; -import { getRemovePrefixFromKey } from 'container/QueryBuilder/filters/QueryBuilderSearch/utils'; -import { BaseAutocompleteData } from 'types/api/queryBuilder/queryAutocompleteResponse'; - -type IOperators = - | typeof QUERY_BUILDER_OPERATORS_BY_TYPES.universal - | typeof QUERY_BUILDER_OPERATORS_BY_TYPES.string - | typeof QUERY_BUILDER_OPERATORS_BY_TYPES.bool - | typeof QUERY_BUILDER_OPERATORS_BY_TYPES.int64 - | typeof QUERY_BUILDER_OPERATORS_BY_TYPES.float64; - -export const useOperators = ( - key: string, - keys: BaseAutocompleteData[], -): IOperators => - useMemo(() => { - const currentKey = keys?.find((el) => el.key === getRemovePrefixFromKey(key)); - const strippedKey = key.split(' ')[0]; - - return currentKey?.dataType - ? QUERY_BUILDER_OPERATORS_BY_TYPES[ - currentKey.dataType as keyof typeof QUERY_BUILDER_OPERATORS_BY_TYPES - ] - : strippedKey.endsWith('[*]') && strippedKey.startsWith('body.') - ? [OPERATORS.HAS, OPERATORS.NHAS] - : QUERY_BUILDER_OPERATORS_BY_TYPES.universal; - }, [keys, key]); diff --git a/frontend/src/hooks/queryBuilder/useOptions.ts b/frontend/src/hooks/queryBuilder/useOptions.ts deleted file mode 100644 index 067b4b43cf6..00000000000 --- a/frontend/src/hooks/queryBuilder/useOptions.ts +++ /dev/null @@ -1,220 +0,0 @@ -import { useCallback, useEffect, useMemo, useState } from 'react'; -import { INFRA_SHORT_TO_LONG_OPERATOR_MAP } from 'constants/queryBuilder'; -import { - checkCommaInValue, - getTagToken, -} from 'container/QueryBuilder/filters/QueryBuilderSearch/utils'; -import { Option } from 'container/QueryBuilder/type'; -import { useDashboardVariablesByType } from 'hooks/dashboard/useDashboardVariablesByType'; -import { isEmpty } from 'lodash-es'; -import { BaseAutocompleteData } from 'types/api/queryBuilder/queryAutocompleteResponse'; - -import { WhereClauseConfig } from './useAutoComplete'; -import { useOperators } from './useOperators'; - -export const WHERE_CLAUSE_CUSTOM_SUFFIX = '-custom'; - -export const useOptions = ( - key: string, - keys: BaseAutocompleteData[], - operator: string, - searchValue: string, - isMulti: boolean, - isValidOperator: boolean, - isExist: boolean, - results: string[], - result: string[], - isFetching: boolean, - whereClauseConfig?: WhereClauseConfig, - isInfraMonitoring?: boolean, - // eslint-disable-next-line sonarjs/cognitive-complexity -): Option[] => { - const [options, setOptions] = useState([]); - const operators = useOperators(key, keys); - - // get matching dynamic variables to suggest - const dashboardDynamicVariables = useDashboardVariablesByType( - 'DYNAMIC', - 'values', - ); - - const variableName = dashboardDynamicVariables?.find( - (variable) => variable?.dynamicVariablesAttribute === key, - )?.name; - - const variableAsValue = variableName ? `$${variableName}` : ''; - - const getLabel = useCallback( - (data: BaseAutocompleteData): Option['label'] => data?.key, - [], - ); - - const getOptionsFromKeys = useCallback( - (items: BaseAutocompleteData[]): Option[] => - items?.map((item) => ({ - label: `${getLabel(item)}`, - value: item.key, - dataType: item.dataType, - isIndexed: item?.isIndexed, - type: item?.type || '', - })), - [getLabel], - ); - - const getKeyOpValue = useCallback( - (items: string[]): Option[] => - items?.map((item) => ({ - label: `${key} ${operator} ${item}`, - value: `${key} ${operator} ${item}`, - })), - [key, operator], - ); - - const getOptionsWithValidOperator = useCallback( - (key: string, results: string[], searchValue: string) => { - const hasAllResults = results.every((value) => result.includes(value)); - - let newResults = results; - if (!isEmpty(variableAsValue)) { - newResults = [variableAsValue, ...newResults]; - } - - const values = getKeyOpValue(newResults); - - return hasAllResults - ? [ - { - label: searchValue, - value: searchValue, - }, - ] - : [ - { - label: searchValue, - value: searchValue, - }, - ...values, - ]; - }, - [getKeyOpValue, result, variableAsValue], - ); - - const getKeyOperatorOptions = useCallback( - (key: string) => { - const keyOperator = key.split(' '); - const partialOperator = keyOperator?.[1]; - const partialKey = keyOperator?.[0]; - const filteredOperators = !isEmpty(partialOperator) - ? operators?.filter((operator) => - operator.startsWith(partialOperator?.toUpperCase()), - ) - : operators; - const operatorsOptions = filteredOperators?.map((op) => { - const labelOp = - isInfraMonitoring && INFRA_SHORT_TO_LONG_OPERATOR_MAP[op] - ? INFRA_SHORT_TO_LONG_OPERATOR_MAP[op] - : op; - return { - value: `${partialKey} ${op} `, - label: `${partialKey} ${labelOp} `, - }; - }); - if (whereClauseConfig) { - return [ - { - label: `${searchValue} `, - value: `${searchValue}${WHERE_CLAUSE_CUSTOM_SUFFIX}`, - }, - ...operatorsOptions, - ]; - } - return operatorsOptions; - }, - [isInfraMonitoring, operators, searchValue, whereClauseConfig], - ); - - useEffect(() => { - let newOptions: Option[] = []; - - if (!key) { - newOptions = searchValue - ? [ - { - label: `${searchValue} `, - value: `${searchValue} `, - }, - ...getOptionsFromKeys(keys), - ] - : getOptionsFromKeys(keys); - } else if (key && !operator) { - newOptions = getKeyOperatorOptions(key); - } else if (key && operator) { - if (isMulti) { - const resultsWithVariable = isEmpty(variableAsValue) - ? results - : [variableAsValue, ...results]; - newOptions = resultsWithVariable.map((item) => ({ - label: checkCommaInValue(String(item)), - value: String(item), - })); - } else if (isExist) { - newOptions = []; - } else if (isValidOperator) { - newOptions = getOptionsWithValidOperator(key, results, searchValue); - } - } - if (newOptions.length > 0) { - setOptions(newOptions); - } - if (isFetching) { - setOptions([]); - } - }, [ - whereClauseConfig, - getKeyOpValue, - getOptionsFromKeys, - isExist, - isMulti, - isValidOperator, - key, - keys, - operator, - operators, - result, - results, - searchValue, - getKeyOperatorOptions, - getOptionsWithValidOperator, - isFetching, - variableAsValue, - ]); - - return useMemo( - () => - ( - options.filter( - (option, index, self) => - index === - self.findIndex( - (o) => - o.label === option.label && - o.value === option.value && - (o.type || '') === (option.type || '') && - (o.dataType || '') === (option.dataType || ''), // keep entries with same key but different type/dataType - ) && option.value !== '', - ) || [] - ).map((option) => { - const { tagValue } = getTagToken(searchValue); - if (isMulti) { - return { - ...option, - selected: tagValue - .filter((i) => i.trim().replace(/^\s+/, '') === option.value) - .includes(option.value), - }; - } - return option; - }), - [isMulti, options, searchValue], - ); -}; diff --git a/frontend/src/hooks/queryBuilder/useSetCurrentKeyAndOperator.ts b/frontend/src/hooks/queryBuilder/useSetCurrentKeyAndOperator.ts deleted file mode 100644 index 99c22968dd8..00000000000 --- a/frontend/src/hooks/queryBuilder/useSetCurrentKeyAndOperator.ts +++ /dev/null @@ -1,32 +0,0 @@ -import { useMemo } from 'react'; -import { - getRemovePrefixFromKey, - getTagToken, -} from 'container/QueryBuilder/filters/QueryBuilderSearch/utils'; -import { BaseAutocompleteData } from 'types/api/queryBuilder/queryAutocompleteResponse'; - -type ICurrentKeyAndOperator = [string, string, string[]]; - -export const useSetCurrentKeyAndOperator = ( - value: string, - keys: BaseAutocompleteData[], -): ICurrentKeyAndOperator => { - const [key, operator, result] = useMemo(() => { - let key = ''; - let operator = ''; - let result: string[] = []; - const { tagKey, tagOperator, tagValue } = getTagToken(value); - const isSuggestKey = keys?.some( - (el) => el?.key === getRemovePrefixFromKey(tagKey), - ); - if (isSuggestKey || keys.length === 0) { - key = tagKey || ''; - operator = tagOperator || ''; - result = tagValue || []; - } - - return [key, operator, result]; - }, [value, keys]); - - return [key, operator, result]; -}; diff --git a/frontend/src/hooks/queryBuilder/useTag.ts b/frontend/src/hooks/queryBuilder/useTag.ts index 55831d36998..482fd77a03c 100644 --- a/frontend/src/hooks/queryBuilder/useTag.ts +++ b/frontend/src/hooks/queryBuilder/useTag.ts @@ -1,18 +1,10 @@ -import { useCallback, useEffect, useMemo, useState } from 'react'; import { INFRA_SHORT_TO_LONG_OPERATOR_MAP } from 'constants/queryBuilder'; import { getOperatorFromValue, - getTagToken, - isExistsNotExistsOperator, isInNInOperator, -} from 'container/QueryBuilder/filters/QueryBuilderSearch/utils'; +} from 'container/QueryBuilder/filters/QueryBuilderSearchV2/utils'; import { unparse } from 'papaparse'; -import { - IBuilderQuery, - TagFilter, -} from 'types/api/queryBuilder/queryBuilderData'; - -import { WhereClauseConfig } from './useAutoComplete'; +import type { TagFilter } from 'types/api/queryBuilder/queryBuilderData'; /** * Helper for formatting a TagFilter object into filter item strings @@ -42,95 +34,3 @@ export function queryFilterTags( return `${ele.key?.key} ${displayOp} ${ele.value}`; }); } - -type IUseTag = { - handleAddTag: (value: string) => void; - handleClearTag: (value: string) => void; - tags: string[]; - updateTag: (value: string) => void; -}; - -/** - * A custom React hook for handling tags. - * @param {string} key - A string value to identify tags. - * @param {boolean} isValidTag - A boolean value to indicate whether the tag is valid. - * @param {function} handleSearch - A callback function to handle search. - * @returns {IUseTag} The return object containing handlers and tags. - */ - -export const useTag = ( - isValidTag: boolean, - handleSearch: (value: string) => void, - query: IBuilderQuery, - setSearchKey: (value: string) => void, - whereClauseConfig?: WhereClauseConfig, - isInfraMonitoring?: boolean, -): IUseTag => { - const initTagsData = useMemo( - () => - queryFilterTags( - query?.filters || { items: [], op: 'AND' }, - isInfraMonitoring, - ), - [query?.filters, isInfraMonitoring], - ); - - const [tags, setTags] = useState(initTagsData); - - const updateTag = (value: string): void => { - const newTags = tags?.filter((item: string) => item !== value); - setTags(newTags); - }; - - /** - * Adds a new tag to the tag list. - * @param {string} value - The tag value to be added. - */ - - const handleAddTag = useCallback( - (value: string): void => { - const { tagKey } = getTagToken(value); - const parts = tagKey.split('-'); - // this is done to ensure that `hello-world` also gets converted to `body CONTAINS hello-world` - let id = parts[parts.length - 1]; - let key = parts.slice(0, -1).join('-'); - if (parts.length === 1) { - id = ''; - [key] = parts; - } - - if (id === 'custom') { - const customValue = whereClauseConfig - ? `${whereClauseConfig.customKey} ${whereClauseConfig.customOp} ${key}` - : ''; - setTags((prevTags) => - prevTags.includes(customValue) ? prevTags : [...prevTags, customValue], - ); - handleSearch(''); - setSearchKey(''); - return; - } - - if ((value && key && isValidTag) || isExistsNotExistsOperator(value)) { - setTags((prevTags) => [...prevTags, value]); - handleSearch(''); - setSearchKey(''); - } - }, - [whereClauseConfig, isValidTag, handleSearch, setSearchKey], - ); - - /** - * Removes a tag from the tag list. - * @param {string} value - The tag value to be removed. - */ - const handleClearTag = useCallback((value: string): void => { - setTags((prevTags) => prevTags.filter((v) => v !== value)); - }, []); - - useEffect(() => { - setTags(initTagsData); - }, [initTagsData]); - - return { handleAddTag, handleClearTag, tags, updateTag }; -}; diff --git a/frontend/src/hooks/trace/useTraceActions.ts b/frontend/src/hooks/trace/useTraceActions.ts index 172c5b566cc..f9c26faca93 100644 --- a/frontend/src/hooks/trace/useTraceActions.ts +++ b/frontend/src/hooks/trace/useTraceActions.ts @@ -6,7 +6,7 @@ import { convertFiltersToExpressionWithExistingQuery } from 'components/QueryBui import { SOMETHING_WENT_WRONG } from 'constants/api'; import { QueryBuilderKeys } from 'constants/queryBuilder'; import ROUTES from 'constants/routes'; -import { getOperatorValue } from 'container/QueryBuilder/filters/QueryBuilderSearch/utils'; +import { getOperatorValue } from 'container/QueryBuilder/filters/QueryBuilderSearchV2/utils'; import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder'; import { useNotifications } from 'hooks/useNotifications'; import { chooseAutocompleteFromCustomValue } from 'lib/newQueryBuilder/chooseAutocompleteFromCustomValue'; diff --git a/frontend/src/pages/TraceDetailsV3/SpanDetailsPanel/SpanLogs/SpanLogs.tsx b/frontend/src/pages/TraceDetailsV3/SpanDetailsPanel/SpanLogs/SpanLogs.tsx index 8b7e5aa7a5a..b52c88f3785 100644 --- a/frontend/src/pages/TraceDetailsV3/SpanDetailsPanel/SpanLogs/SpanLogs.tsx +++ b/frontend/src/pages/TraceDetailsV3/SpanDetailsPanel/SpanLogs/SpanLogs.tsx @@ -16,7 +16,7 @@ import LogsError from 'container/LogsError/LogsError'; import { EmptyLogsListConfig } from 'container/LogsExplorerList/utils'; import { LogsLoading } from 'container/LogsLoading/LogsLoading'; import { FontSize } from 'container/OptionsMenu/types'; -import { getOperatorValue } from 'container/QueryBuilder/filters/QueryBuilderSearch/utils'; +import { getOperatorValue } from 'container/QueryBuilder/filters/QueryBuilderSearchV2/utils'; import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder'; import createQueryParams from 'lib/createQueryParams'; import { Compass } from '@signozhq/icons'; diff --git a/frontend/src/pages/TraceDetailsV3/SpanDetailsPanel/SpanLogs/useSpanContextLogs.ts b/frontend/src/pages/TraceDetailsV3/SpanDetailsPanel/SpanLogs/useSpanContextLogs.ts index b6db739fed5..96485842819 100644 --- a/frontend/src/pages/TraceDetailsV3/SpanDetailsPanel/SpanLogs/useSpanContextLogs.ts +++ b/frontend/src/pages/TraceDetailsV3/SpanDetailsPanel/SpanLogs/useSpanContextLogs.ts @@ -4,7 +4,7 @@ import { convertFiltersToExpression } from 'components/QueryBuilderV2/utils'; import { ENTITY_VERSION_V5 } from 'constants/app'; import { OPERATORS } from 'constants/queryBuilder'; import { REACT_QUERY_KEY } from 'constants/reactQueryKeys'; -import { getOperatorValue } from 'container/QueryBuilder/filters/QueryBuilderSearch/utils'; +import { getOperatorValue } from 'container/QueryBuilder/filters/QueryBuilderSearchV2/utils'; import { GetMetricQueryRange } from 'lib/dashboard/getQueryResults'; import { ILog } from 'types/api/logs/log'; import { DataTypes } from 'types/api/queryBuilder/queryAutocompleteResponse'; diff --git a/frontend/src/pages/TraceDetailsV3/SpanDetailsPanel/hooks/useSpanAttributeActions.ts b/frontend/src/pages/TraceDetailsV3/SpanDetailsPanel/hooks/useSpanAttributeActions.ts index 3e9666c9a4d..12ff2d5cd76 100644 --- a/frontend/src/pages/TraceDetailsV3/SpanDetailsPanel/hooks/useSpanAttributeActions.ts +++ b/frontend/src/pages/TraceDetailsV3/SpanDetailsPanel/hooks/useSpanAttributeActions.ts @@ -7,7 +7,7 @@ import { convertFiltersToExpressionWithExistingQuery } from 'components/QueryBui import { SOMETHING_WENT_WRONG } from 'constants/api'; import { PANEL_TYPES, QueryBuilderKeys } from 'constants/queryBuilder'; import ROUTES from 'constants/routes'; -import { getOperatorValue } from 'container/QueryBuilder/filters/QueryBuilderSearch/utils'; +import { getOperatorValue } from 'container/QueryBuilder/filters/QueryBuilderSearchV2/utils'; import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder'; import { chooseAutocompleteFromCustomValue } from 'lib/newQueryBuilder/chooseAutocompleteFromCustomValue'; import { ArrowDownToDot, ArrowUpFromDot } from '@signozhq/icons'; From bb47550c016bb10783db7069b07aafba2a67f30b Mon Sep 17 00:00:00 2001 From: Vikrant Gupta Date: Wed, 19 Aug 2026 19:16:14 +0000 Subject: [PATCH 4/5] feat(authz): enable FGA for auth domains (#12588) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #### Description - Auth domain routes (`/api/v2/auth_domains`) now use `CheckResources` + `ResourceDef` instead of the coarse `AdminAccess` gate โ€” per-resource FGA checks on enterprise, admin role gate on community. - Create and update also check `attach` on the roles the request's `roleMapping` will grant at SSO login (mapped roles + default role, `signoz-viewer` when unset, `role:*` when `useRoleAttribute` is on); update additionally checks `detach` on the roles the stored mapping was granting, since a `PUT` replaces the mapping. - Migration `117_add_auth_domain_tuples` backfills the admin `auth-domain` tuples for existing organizations and re-syncs the stored managed-role transaction groups; new organizations get both from the registry at bootstrap. - Regenerated OpenAPI spec: the auth-domain operations advertise `auth-domain:*` and `role:attach`/`role:detach` scopes instead of `ADMIN`. - Added `callbackauthn/05_authz.py` covering managed-role gating, custom-role wildcard/instance grants, and the role-mapping attach/detach checks. #### Issues closed by this PR Closes SigNoz/platform-pod#2649 --- docs/api/openapi.yml | 32 +- pkg/apiserver/signozapiserver/authdomain.go | 310 ++++++++--- pkg/apiserver/signozapiserver/provider.go | 5 + pkg/signoz/openapi.go | 1 + pkg/signoz/provider.go | 2 + .../117_add_auth_domain_tuples.go | 167 ++++++ pkg/types/coretypes/registry_managed_role.go | 2 + .../role/managed_role_transactions.json | 4 +- .../tests/callbackauthn/05_authz.py | 511 ++++++++++++++++++ 9 files changed, 953 insertions(+), 81 deletions(-) create mode 100644 pkg/sqlmigration/117_add_auth_domain_tuples.go create mode 100644 tests/integration/tests/callbackauthn/05_authz.py diff --git a/docs/api/openapi.yml b/docs/api/openapi.yml index 115c6a4a4a7..a9f62f74332 100644 --- a/docs/api/openapi.yml +++ b/docs/api/openapi.yml @@ -14376,9 +14376,9 @@ paths: description: Internal Server Error security: - api_key: - - ADMIN + - auth-domain:list - tokenizer: - - ADMIN + - auth-domain:list summary: List all auth domains tags: - authdomains @@ -14438,9 +14438,13 @@ paths: description: Internal Server Error security: - api_key: - - ADMIN + - auth-domain:create + - auth-domain:attach + - role:attach - tokenizer: - - ADMIN + - auth-domain:create + - auth-domain:attach + - role:attach summary: Create auth domain tags: - authdomains @@ -14484,9 +14488,9 @@ paths: description: Internal Server Error security: - api_key: - - ADMIN + - auth-domain:delete - tokenizer: - - ADMIN + - auth-domain:delete summary: Delete auth domain tags: - authdomains @@ -14541,9 +14545,9 @@ paths: description: Internal Server Error security: - api_key: - - ADMIN + - auth-domain:read - tokenizer: - - ADMIN + - auth-domain:read summary: Get auth domain by ID tags: - authdomains @@ -14597,9 +14601,17 @@ paths: description: Internal Server Error security: - api_key: - - ADMIN + - auth-domain:update + - auth-domain:attach + - auth-domain:detach + - role:attach + - role:detach - tokenizer: - - ADMIN + - auth-domain:update + - auth-domain:attach + - auth-domain:detach + - role:attach + - role:detach summary: Update auth domain tags: - authdomains diff --git a/pkg/apiserver/signozapiserver/authdomain.go b/pkg/apiserver/signozapiserver/authdomain.go index 690bfb8b344..fbbd61774e4 100644 --- a/pkg/apiserver/signozapiserver/authdomain.go +++ b/pkg/apiserver/signozapiserver/authdomain.go @@ -1,99 +1,269 @@ package signozapiserver import ( + "encoding/json" "net/http" + "slices" + "github.com/SigNoz/signoz/pkg/errors" "github.com/SigNoz/signoz/pkg/http/handler" "github.com/SigNoz/signoz/pkg/types" "github.com/SigNoz/signoz/pkg/types/authtypes" + "github.com/SigNoz/signoz/pkg/types/coretypes" + "github.com/SigNoz/signoz/pkg/valuer" "github.com/gorilla/mux" + "github.com/tidwall/gjson" ) func (provider *provider) addAuthDomainRoutes(router *mux.Router) error { - if err := router.Handle("/api/v2/auth_domains", handler.New(provider.authzMiddleware.AdminAccess(provider.authDomainHandler.List), handler.OpenAPIDef{ - ID: "ListAuthDomains", - Tags: []string{"authdomains"}, - Summary: "List all auth domains", - Description: "This endpoint lists all auth domains", - Request: nil, - RequestContentType: "", - Response: make([]*authtypes.GettableAuthDomain, 0), - ResponseContentType: "application/json", - SuccessStatusCode: http.StatusOK, - ErrorStatusCodes: []int{}, - Deprecated: false, - SecuritySchemes: newSecuritySchemes(types.RoleAdmin), - })).Methods(http.MethodGet).GetError(); err != nil { + if err := router.Handle("/api/v2/auth_domains", handler.New( + provider.authzMiddleware.CheckResources(provider.authDomainHandler.List, authtypes.SigNozAdminRoleName), + handler.OpenAPIDef{ + ID: "ListAuthDomains", + Tags: []string{"authdomains"}, + Summary: "List all auth domains", + Description: "This endpoint lists all auth domains", + Request: nil, + RequestContentType: "", + Response: make([]*authtypes.GettableAuthDomain, 0), + ResponseContentType: "application/json", + SuccessStatusCode: http.StatusOK, + ErrorStatusCodes: []int{}, + Deprecated: false, + SecuritySchemes: newScopedSecuritySchemes([]string{coretypes.ResourceMetaResourceAuthDomain.Scope(coretypes.VerbList)}), + }, + handler.WithResourceDefs(handler.BasicResourceDef{ + Resource: coretypes.ResourceMetaResourceAuthDomain, + Verb: coretypes.VerbList, + Category: coretypes.ActionCategoryAccessControl, + Selector: coretypes.WildcardSelector, + }), + )).Methods(http.MethodGet).GetError(); err != nil { return err } - if err := router.Handle("/api/v2/auth_domains", handler.New(provider.authzMiddleware.AdminAccess(provider.authDomainHandler.Create), handler.OpenAPIDef{ - ID: "CreateAuthDomain", - Tags: []string{"authdomains"}, - Summary: "Create auth domain", - Description: "This endpoint creates an auth domain", - Request: new(authtypes.PostableAuthDomain), - RequestContentType: "application/json", - Response: new(types.Identifiable), - ResponseContentType: "application/json", - SuccessStatusCode: http.StatusCreated, - ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusConflict}, - Deprecated: false, - SecuritySchemes: newSecuritySchemes(types.RoleAdmin), - })).Methods(http.MethodPost).GetError(); err != nil { + if err := router.Handle("/api/v2/auth_domains", handler.New( + provider.authzMiddleware.CheckResources(provider.authDomainHandler.Create, authtypes.SigNozAdminRoleName), + handler.OpenAPIDef{ + ID: "CreateAuthDomain", + Tags: []string{"authdomains"}, + Summary: "Create auth domain", + Description: "This endpoint creates an auth domain", + Request: new(authtypes.PostableAuthDomain), + RequestContentType: "application/json", + Response: new(types.Identifiable), + ResponseContentType: "application/json", + SuccessStatusCode: http.StatusCreated, + ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusConflict}, + Deprecated: false, + SecuritySchemes: newScopedSecuritySchemes([]string{ + coretypes.ResourceMetaResourceAuthDomain.Scope(coretypes.VerbCreate), + coretypes.ResourceMetaResourceAuthDomain.Scope(coretypes.VerbAttach), + coretypes.ResourceRole.Scope(coretypes.VerbAttach), + }), + }, + handler.WithResourceDefs( + handler.BasicResourceDef{ + Resource: coretypes.ResourceMetaResourceAuthDomain, + Verb: coretypes.VerbCreate, + Category: coretypes.ActionCategoryAccessControl, + ID: coretypes.ResponseJSONPath("data.id"), + Selector: coretypes.WildcardSelector, + }, + handler.AttachDetachSiblingResourceDef{ + Verb: coretypes.VerbAttach, + Category: coretypes.ActionCategoryAccessControl, + SourceResource: coretypes.ResourceMetaResourceAuthDomain, + SourceIDs: coretypes.OneID(coretypes.ResponseJSONPath("data.id")), + SourceSelector: coretypes.WildcardSelector, + TargetResource: coretypes.ResourceRole, + TargetIDs: authDomainRoleNamesExtractor(), + TargetSelector: coretypes.IDSelector, + }, + ), + )).Methods(http.MethodPost).GetError(); err != nil { return err } - if err := router.Handle("/api/v2/auth_domains/{id}", handler.New(provider.authzMiddleware.AdminAccess(provider.authDomainHandler.Get), handler.OpenAPIDef{ - ID: "GetAuthDomain", - Tags: []string{"authdomains"}, - Summary: "Get auth domain by ID", - Description: "This endpoint returns an auth domain by ID", - Request: nil, - RequestContentType: "", - Response: new(authtypes.GettableAuthDomain), - ResponseContentType: "application/json", - SuccessStatusCode: http.StatusOK, - ErrorStatusCodes: []int{http.StatusNotFound}, - Deprecated: false, - SecuritySchemes: newSecuritySchemes(types.RoleAdmin), - })).Methods(http.MethodGet).GetError(); err != nil { + if err := router.Handle("/api/v2/auth_domains/{id}", handler.New( + provider.authzMiddleware.CheckResources(provider.authDomainHandler.Get, authtypes.SigNozAdminRoleName), + handler.OpenAPIDef{ + ID: "GetAuthDomain", + Tags: []string{"authdomains"}, + Summary: "Get auth domain by ID", + Description: "This endpoint returns an auth domain by ID", + Request: nil, + RequestContentType: "", + Response: new(authtypes.GettableAuthDomain), + ResponseContentType: "application/json", + SuccessStatusCode: http.StatusOK, + ErrorStatusCodes: []int{http.StatusNotFound}, + Deprecated: false, + SecuritySchemes: newScopedSecuritySchemes([]string{coretypes.ResourceMetaResourceAuthDomain.Scope(coretypes.VerbRead)}), + }, + handler.WithResourceDefs(handler.BasicResourceDef{ + Resource: coretypes.ResourceMetaResourceAuthDomain, + Verb: coretypes.VerbRead, + Category: coretypes.ActionCategoryAccessControl, + ID: coretypes.PathParam("id"), + Selector: coretypes.IDSelector, + }), + )).Methods(http.MethodGet).GetError(); err != nil { return err } - if err := router.Handle("/api/v2/auth_domains/{id}", handler.New(provider.authzMiddleware.AdminAccess(provider.authDomainHandler.Update), handler.OpenAPIDef{ - ID: "UpdateAuthDomain", - Tags: []string{"authdomains"}, - Summary: "Update auth domain", - Description: "This endpoint updates an auth domain", - Request: new(authtypes.UpdatableAuthDomain), - RequestContentType: "application/json", - Response: nil, - ResponseContentType: "", - SuccessStatusCode: http.StatusNoContent, - ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusConflict}, - Deprecated: false, - SecuritySchemes: newSecuritySchemes(types.RoleAdmin), - })).Methods(http.MethodPut).GetError(); err != nil { + if err := router.Handle("/api/v2/auth_domains/{id}", handler.New( + provider.authzMiddleware.CheckResources(provider.authDomainHandler.Update, authtypes.SigNozAdminRoleName), + handler.OpenAPIDef{ + ID: "UpdateAuthDomain", + Tags: []string{"authdomains"}, + Summary: "Update auth domain", + Description: "This endpoint updates an auth domain", + Request: new(authtypes.UpdatableAuthDomain), + RequestContentType: "application/json", + Response: nil, + ResponseContentType: "", + SuccessStatusCode: http.StatusNoContent, + ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusConflict}, + Deprecated: false, + SecuritySchemes: newScopedSecuritySchemes([]string{ + coretypes.ResourceMetaResourceAuthDomain.Scope(coretypes.VerbUpdate), + coretypes.ResourceMetaResourceAuthDomain.Scope(coretypes.VerbAttach), + coretypes.ResourceMetaResourceAuthDomain.Scope(coretypes.VerbDetach), + coretypes.ResourceRole.Scope(coretypes.VerbAttach), + coretypes.ResourceRole.Scope(coretypes.VerbDetach), + }), + }, + handler.WithResourceDefs( + handler.BasicResourceDef{ + Resource: coretypes.ResourceMetaResourceAuthDomain, + Verb: coretypes.VerbUpdate, + Category: coretypes.ActionCategoryAccessControl, + ID: coretypes.PathParam("id"), + Selector: coretypes.IDSelector, + }, + handler.AttachDetachSiblingResourceDef{ + Verb: coretypes.VerbAttach, + Category: coretypes.ActionCategoryAccessControl, + SourceResource: coretypes.ResourceMetaResourceAuthDomain, + SourceIDs: coretypes.OneID(coretypes.PathParam("id")), + SourceSelector: coretypes.IDSelector, + TargetResource: coretypes.ResourceRole, + TargetIDs: authDomainRoleNamesExtractor(), + TargetSelector: coretypes.IDSelector, + }, + handler.AttachDetachSiblingResourceDef{ + Verb: coretypes.VerbDetach, + Category: coretypes.ActionCategoryAccessControl, + SourceResource: coretypes.ResourceMetaResourceAuthDomain, + SourceIDs: coretypes.OneID(coretypes.PathParam("id")), + SourceSelector: coretypes.IDSelector, + TargetResource: coretypes.ResourceRole, + TargetIDs: provider.authDomainStoredRoleNamesExtractor(), + TargetSelector: coretypes.IDSelector, + }, + ), + )).Methods(http.MethodPut).GetError(); err != nil { return err } - if err := router.Handle("/api/v2/auth_domains/{id}", handler.New(provider.authzMiddleware.AdminAccess(provider.authDomainHandler.Delete), handler.OpenAPIDef{ - ID: "DeleteAuthDomain", - Tags: []string{"authdomains"}, - Summary: "Delete auth domain", - Description: "This endpoint deletes an auth domain", - Request: nil, - RequestContentType: "", - Response: nil, - ResponseContentType: "", - SuccessStatusCode: http.StatusNoContent, - ErrorStatusCodes: []int{http.StatusBadRequest}, - Deprecated: false, - SecuritySchemes: newSecuritySchemes(types.RoleAdmin), - })).Methods(http.MethodDelete).GetError(); err != nil { + if err := router.Handle("/api/v2/auth_domains/{id}", handler.New( + provider.authzMiddleware.CheckResources(provider.authDomainHandler.Delete, authtypes.SigNozAdminRoleName), + handler.OpenAPIDef{ + ID: "DeleteAuthDomain", + Tags: []string{"authdomains"}, + Summary: "Delete auth domain", + Description: "This endpoint deletes an auth domain", + Request: nil, + RequestContentType: "", + Response: nil, + ResponseContentType: "", + SuccessStatusCode: http.StatusNoContent, + ErrorStatusCodes: []int{http.StatusBadRequest}, + Deprecated: false, + SecuritySchemes: newScopedSecuritySchemes([]string{coretypes.ResourceMetaResourceAuthDomain.Scope(coretypes.VerbDelete)}), + }, + handler.WithResourceDefs(handler.BasicResourceDef{ + Resource: coretypes.ResourceMetaResourceAuthDomain, + Verb: coretypes.VerbDelete, + Category: coretypes.ActionCategoryAccessControl, + ID: coretypes.PathParam("id"), + Selector: coretypes.IDSelector, + }), + )).Methods(http.MethodDelete).GetError(); err != nil { return err } return nil } + +// The extracted names are the roles the request body's mapping grants at SSO +// login โ€” see authDomainEffectiveRoleNames. +func authDomainRoleNamesExtractor() coretypes.ResourceIDsExtractor { + return coretypes.ResourceIDsExtractor{Phase: coretypes.PhaseRequest, Fn: func(ec coretypes.ExtractorContext) ([]string, error) { + roleMappingJSON := gjson.GetBytes(ec.RequestBody, "roleMapping") + if !roleMappingJSON.Exists() || roleMappingJSON.Type == gjson.Null { + return authDomainEffectiveRoleNames(nil), nil + } + + roleMapping := new(authtypes.RoleMapping) + if err := json.Unmarshal([]byte(roleMappingJSON.Raw), roleMapping); err != nil { + return nil, errors.NewInvalidInputf(errors.CodeInvalidInput, "invalid role mapping: %v", err) + } + + return authDomainEffectiveRoleNames(roleMapping), nil + }} +} + +// The extracted names are the roles the stored domain's mapping grants at SSO +// login โ€” an update replaces that mapping, so the caller must be able to detach +// them. +func (provider *provider) authDomainStoredRoleNamesExtractor() coretypes.ResourceIDsExtractor { + return coretypes.ResourceIDsExtractor{Phase: coretypes.PhaseRequest, Fn: func(ec coretypes.ExtractorContext) ([]string, error) { + if ec.Request == nil { + return nil, nil + } + + claims, err := authtypes.ClaimsFromContext(ec.Request.Context()) + if err != nil { + return nil, err + } + + orgID, err := valuer.NewUUID(claims.OrgID) + if err != nil { + return nil, err + } + + id, err := valuer.NewUUID(mux.Vars(ec.Request)["id"]) + if err != nil { + return nil, err + } + + authDomain, err := provider.authDomainModule.GetByOrgIDAndID(ec.Request.Context(), orgID, id) + if err != nil { + return nil, err + } + + return authDomainEffectiveRoleNames(authDomain.RoleMapping()), nil + }} +} + +// The effective names are the roles a domain grants at SSO login: the mapped +// roles plus the default (signoz-viewer when unset), or every role when the IDP +// role attribute is trusted. Never empty โ€” a check with no selectors is forbidden. +func authDomainEffectiveRoleNames(roleMapping *authtypes.RoleMapping) []string { + if roleMapping == nil { + return []string{authtypes.SigNozViewerRoleName} + } + + if roleMapping.UseRoleAttribute { + return []string{coretypes.WildCardSelectorString} + } + + roleNames := roleMapping.RoleNames() + if !slices.Contains(roleNames, roleMapping.DefaultRoleName()) { + roleNames = append(roleNames, roleMapping.DefaultRoleName()) + } + + return roleNames +} diff --git a/pkg/apiserver/signozapiserver/provider.go b/pkg/apiserver/signozapiserver/provider.go index 4854bf01854..c3b79b858bc 100644 --- a/pkg/apiserver/signozapiserver/provider.go +++ b/pkg/apiserver/signozapiserver/provider.go @@ -51,6 +51,7 @@ type provider struct { userHandler user.Handler sessionHandler session.Handler authDomainHandler authdomain.Handler + authDomainModule authdomain.Module preferenceHandler preference.Handler globalHandler global.Handler promoteHandler promote.Handler @@ -88,6 +89,7 @@ func NewFactory( userHandler user.Handler, sessionHandler session.Handler, authDomainHandler authdomain.Handler, + authDomainModule authdomain.Module, preferenceHandler preference.Handler, globalHandler global.Handler, promoteHandler promote.Handler, @@ -128,6 +130,7 @@ func NewFactory( userHandler, sessionHandler, authDomainHandler, + authDomainModule, preferenceHandler, globalHandler, promoteHandler, @@ -170,6 +173,7 @@ func newProvider( userHandler user.Handler, sessionHandler session.Handler, authDomainHandler authdomain.Handler, + authDomainModule authdomain.Module, preferenceHandler preference.Handler, globalHandler global.Handler, promoteHandler promote.Handler, @@ -211,6 +215,7 @@ func newProvider( authzService: authzService, sessionHandler: sessionHandler, authDomainHandler: authDomainHandler, + authDomainModule: authDomainModule, preferenceHandler: preferenceHandler, globalHandler: globalHandler, promoteHandler: promoteHandler, diff --git a/pkg/signoz/openapi.go b/pkg/signoz/openapi.go index 064cc56abeb..52a325f132c 100644 --- a/pkg/signoz/openapi.go +++ b/pkg/signoz/openapi.go @@ -64,6 +64,7 @@ func NewOpenAPI(ctx context.Context, instrumentation instrumentation.Instrumenta struct{ user.Handler }{}, struct{ session.Handler }{}, struct{ authdomain.Handler }{}, + struct{ authdomain.Module }{}, struct{ preference.Handler }{}, struct{ global.Handler }{}, struct{ promote.Handler }{}, diff --git a/pkg/signoz/provider.go b/pkg/signoz/provider.go index 267c8b7112f..52d80b2e194 100644 --- a/pkg/signoz/provider.go +++ b/pkg/signoz/provider.go @@ -243,6 +243,7 @@ func NewSQLMigrationProviderFactories( sqlmigration.NewFixSavedViewSelectFieldsFactory(sqlstore), sqlmigration.NewDeleteOrphanUserRolesFactory(), sqlmigration.NewMigrateLambdaDashboardsFactory(), + sqlmigration.NewAddAuthDomainTuplesFactory(sqlstore), ) } @@ -317,6 +318,7 @@ func NewAPIServerProviderFactories(orgGetter organization.Getter, authz authz.Au impluser.NewHandler(modules.UserSetter, modules.UserGetter), implsession.NewHandler(modules.Session, globalConfig), implauthdomain.NewHandler(modules.AuthDomain), + modules.AuthDomain, implpreference.NewHandler(modules.Preference), handlers.Global, implpromote.NewHandler(modules.Promote), diff --git a/pkg/sqlmigration/117_add_auth_domain_tuples.go b/pkg/sqlmigration/117_add_auth_domain_tuples.go new file mode 100644 index 00000000000..3ebb2faa3c3 --- /dev/null +++ b/pkg/sqlmigration/117_add_auth_domain_tuples.go @@ -0,0 +1,167 @@ +package sqlmigration + +import ( + "context" + "database/sql" + "encoding/json" + "time" + + "github.com/SigNoz/signoz/pkg/factory" + "github.com/SigNoz/signoz/pkg/sqlstore" + "github.com/SigNoz/signoz/pkg/types/authtypes" + "github.com/SigNoz/signoz/pkg/types/coretypes" + "github.com/oklog/ulid/v2" + "github.com/uptrace/bun" + "github.com/uptrace/bun/dialect" + "github.com/uptrace/bun/migrate" +) + +type addAuthDomainTuples struct { + sqlstore sqlstore.SQLStore +} + +func NewAddAuthDomainTuplesFactory(sqlstore sqlstore.SQLStore) factory.ProviderFactory[SQLMigration, Config] { + return factory.NewProviderFactory(factory.MustNewName("add_auth_domain_tuples"), func(ctx context.Context, ps factory.ProviderSettings, c Config) (SQLMigration, error) { + return &addAuthDomainTuples{sqlstore: sqlstore}, nil + }) +} + +func (migration *addAuthDomainTuples) Register(migrations *migrate.Migrations) error { + return migrations.Register(migration.Up, migration.Down) +} + +func (migration *addAuthDomainTuples) Up(ctx context.Context, db *bun.DB) error { + tx, err := db.BeginTx(ctx, nil) + if err != nil { + return err + } + defer func() { _ = tx.Rollback() }() + + var storeID string + err = tx.QueryRowContext(ctx, `SELECT id FROM store WHERE name = ? LIMIT 1`, "signoz").Scan(&storeID) + if err != nil { + return err + } + + var orgIDs []string + err = tx.NewSelect(). + Table("organizations"). + Column("id"). + Scan(ctx, &orgIDs) + if err != nil && err != sql.ErrNoRows { + return err + } + + isPG := migration.sqlstore.BunDB().Dialect().Name() == dialect.PG + + // auth-domain moved from the legacy AdminAccess role gate to CheckResources, + // which on enterprise requires real tuples -- existing orgs never had these + // written, only new orgs get them from the registry at bootstrap. + tuples := []migrationTuple{ + {authtypes.SigNozAdminRoleName, "metaresource", "auth-domain", "create"}, + {authtypes.SigNozAdminRoleName, "metaresource", "auth-domain", "read"}, + {authtypes.SigNozAdminRoleName, "metaresource", "auth-domain", "update"}, + {authtypes.SigNozAdminRoleName, "metaresource", "auth-domain", "delete"}, + {authtypes.SigNozAdminRoleName, "metaresource", "auth-domain", "list"}, + {authtypes.SigNozAdminRoleName, "metaresource", "auth-domain", "attach"}, + {authtypes.SigNozAdminRoleName, "metaresource", "auth-domain", "detach"}, + } + + for _, orgID := range orgIDs { + for _, tuple := range tuples { + entropy := ulid.DefaultEntropy() + now := time.Now().UTC() + tupleID := ulid.MustNew(ulid.Timestamp(now), entropy).String() + + objectID := "organization/" + orgID + "/" + tuple.objectName + "/*" + roleSubject := "organization/" + orgID + "/role/" + tuple.roleName + + if isPG { + user := "role:" + roleSubject + "#assignee" + result, err := tx.ExecContext(ctx, ` + INSERT INTO tuple (store, object_type, object_id, relation, _user, user_type, ulid, inserted_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT (store, object_type, object_id, relation, _user) DO NOTHING`, + storeID, tuple.objectType, objectID, tuple.relation, user, "userset", tupleID, now, + ) + if err != nil { + return err + } + rowsAffected, err := result.RowsAffected() + if err != nil { + return err + } + if rowsAffected == 0 { + continue + } + _, err = tx.ExecContext(ctx, ` + INSERT INTO changelog (store, object_type, object_id, relation, _user, operation, ulid, inserted_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT (store, ulid, object_type) DO NOTHING`, + storeID, tuple.objectType, objectID, tuple.relation, user, 0, tupleID, now, + ) + if err != nil { + return err + } + } else { + result, err := tx.ExecContext(ctx, ` + INSERT INTO tuple (store, object_type, object_id, relation, user_object_type, user_object_id, user_relation, user_type, ulid, inserted_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT (store, object_type, object_id, relation, user_object_type, user_object_id, user_relation) DO NOTHING`, + storeID, tuple.objectType, objectID, tuple.relation, "role", roleSubject, "assignee", "userset", tupleID, now, + ) + if err != nil { + return err + } + rowsAffected, err := result.RowsAffected() + if err != nil { + return err + } + if rowsAffected == 0 { + continue + } + _, err = tx.ExecContext(ctx, ` + INSERT INTO changelog (store, object_type, object_id, relation, user_object_type, user_object_id, user_relation, operation, ulid, inserted_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT (store, ulid, object_type) DO NOTHING`, + storeID, tuple.objectType, objectID, tuple.relation, "role", roleSubject, "assignee", 0, tupleID, now, + ) + if err != nil { + return err + } + } + } + } + + // The auth-domain attach/detach transactions are new to the registry, so the + // managed-role transaction groups stored per org (served by the roles API) + // must be re-synced from it, like 105_update_role_transaction_groups did. + managedRoleGroups := make(map[string]string, len(coretypes.ManagedRoleToTransactions)) + for roleName, transactions := range coretypes.ManagedRoleToTransactions { + data, err := json.Marshal(authtypes.NewTransactionGroupsFromTransactions(transactions)) + if err != nil { + return err + } + managedRoleGroups[roleName] = string(data) + } + + for _, orgID := range orgIDs { + for roleName, data := range managedRoleGroups { + if _, err := tx.NewUpdate(). + Model(new(roles)). + Set("transaction_groups = ?", data). + Where("org_id = ?", orgID). + Where("type = ?", authtypes.RoleTypeManaged.StringValue()). + Where("name = ?", roleName). + Exec(ctx); err != nil { + return err + } + } + } + + return tx.Commit() +} + +func (migration *addAuthDomainTuples) Down(context.Context, *bun.DB) error { + return nil +} diff --git a/pkg/types/coretypes/registry_managed_role.go b/pkg/types/coretypes/registry_managed_role.go index 7c2ced052eb..3cb0e6d823a 100644 --- a/pkg/types/coretypes/registry_managed_role.go +++ b/pkg/types/coretypes/registry_managed_role.go @@ -33,6 +33,8 @@ var ManagedRoleToTransactions = map[string][]Transaction{ {Verb: VerbDelete, Object: *MustNewObject(ResourceRef{Type: TypeMetaResource, Kind: KindAuthDomain}, WildCardSelectorString)}, {Verb: VerbCreate, Object: *MustNewObject(ResourceRef{Type: TypeMetaResource, Kind: KindAuthDomain}, WildCardSelectorString)}, {Verb: VerbList, Object: *MustNewObject(ResourceRef{Type: TypeMetaResource, Kind: KindAuthDomain}, WildCardSelectorString)}, + {Verb: VerbAttach, Object: *MustNewObject(ResourceRef{Type: TypeMetaResource, Kind: KindAuthDomain}, WildCardSelectorString)}, + {Verb: VerbDetach, Object: *MustNewObject(ResourceRef{Type: TypeMetaResource, Kind: KindAuthDomain}, WildCardSelectorString)}, // cloud-integration โ€” admin only {Verb: VerbRead, Object: *MustNewObject(ResourceRef{Type: TypeMetaResource, Kind: KindCloudIntegration}, WildCardSelectorString)}, {Verb: VerbUpdate, Object: *MustNewObject(ResourceRef{Type: TypeMetaResource, Kind: KindCloudIntegration}, WildCardSelectorString)}, diff --git a/tests/integration/testdata/role/managed_role_transactions.json b/tests/integration/testdata/role/managed_role_transactions.json index 14b7a9f0922..ce196598273 100644 --- a/tests/integration/testdata/role/managed_role_transactions.json +++ b/tests/integration/testdata/role/managed_role_transactions.json @@ -47,7 +47,9 @@ "read", "update", "delete", - "list" + "list", + "attach", + "detach" ] }, { diff --git a/tests/integration/tests/callbackauthn/05_authz.py b/tests/integration/tests/callbackauthn/05_authz.py new file mode 100644 index 00000000000..29439c57006 --- /dev/null +++ b/tests/integration/tests/callbackauthn/05_authz.py @@ -0,0 +1,511 @@ +from collections.abc import Callable +from http import HTTPStatus + +import requests +from wiremock.resources.mappings import Mapping + +from fixtures import types +from fixtures.auth import ( + USER_ADMIN_EMAIL, + USER_ADMIN_PASSWORD, + add_license, + change_user_role, + create_active_user, +) +from fixtures.role import find_role_by_name, transaction_group + +_EDITOR_EMAIL = "editor+authdomainauthz@integration.test" +_EDITOR_PASSWORD = "password123Z$" +_VIEWER_EMAIL = "viewer+authdomainauthz@integration.test" +_VIEWER_PASSWORD = "password123Z$" + +_ACTOR_ROLE_NAME = "auth-domain-fga-actor" +_ACTOR_EMAIL = "customrole+authdomainauthz@integration.test" +_ACTOR_PASSWORD = "password123Z$" + +# Instance verbs are granted on _TARGET_A only; _TARGET_B must stay forbidden. +_TARGET_A = "target-a-authdomain.integration.test" +_TARGET_B = "target-b-authdomain.integration.test" +_ADMIN_DOMAIN = "admin-crud-authdomain.integration.test" +_ACTOR_DOMAIN = "actor-crud-authdomain.integration.test" + +_SAML_CONFIG = { + "kind": "saml", + "spec": { + "entityId": "saml-entity", + "location": "saml-idp", + "certificate": "saml-cert", + }, +} + + +def test_setup_managed_role_users_and_targets( + signoz: types.SigNoz, + create_user_admin: types.Operation, # pylint: disable=unused-argument + get_token: Callable[[str, str], str], +): + admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD) + + for email, role, password, name in ( + (_EDITOR_EMAIL, "signoz-editor", _EDITOR_PASSWORD, "auth domain authz editor"), + (_VIEWER_EMAIL, "signoz-viewer", _VIEWER_PASSWORD, "auth domain authz viewer"), + ): + create_active_user(signoz, admin_token, email=email, role=role, password=password, name=name) + + for name in (_TARGET_A, _TARGET_B): + response = requests.post( + signoz.self.host_configs["8080"].get("/api/v2/auth_domains"), + json={"name": name, "enabled": True, "config": _SAML_CONFIG}, + headers={"Authorization": f"Bearer {admin_token}"}, + timeout=5, + ) + assert response.status_code == HTTPStatus.CREATED, response.text + + +def test_admin_can_crud( + signoz: types.SigNoz, + create_user_admin: types.Operation, # pylint: disable=unused-argument + get_token: Callable[[str, str], str], +): + admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD) + + response = requests.post( + signoz.self.host_configs["8080"].get("/api/v2/auth_domains"), + json={"name": _ADMIN_DOMAIN, "enabled": True, "config": _SAML_CONFIG}, + headers={"Authorization": f"Bearer {admin_token}"}, + timeout=5, + ) + assert response.status_code == HTTPStatus.CREATED, response.text + domain_id = response.json()["data"]["id"] + + response = requests.get( + signoz.self.host_configs["8080"].get("/api/v2/auth_domains"), + headers={"Authorization": f"Bearer {admin_token}"}, + timeout=5, + ) + assert response.status_code == HTTPStatus.OK, response.text + assert _ADMIN_DOMAIN in {domain["name"] for domain in response.json()["data"]} + + response = requests.get( + signoz.self.host_configs["8080"].get(f"/api/v2/auth_domains/{domain_id}"), + headers={"Authorization": f"Bearer {admin_token}"}, + timeout=5, + ) + assert response.status_code == HTTPStatus.OK, response.text + + response = requests.put( + signoz.self.host_configs["8080"].get(f"/api/v2/auth_domains/{domain_id}"), + json={"enabled": False, "config": _SAML_CONFIG}, + headers={"Authorization": f"Bearer {admin_token}"}, + timeout=5, + ) + assert response.status_code == HTTPStatus.NO_CONTENT, response.text + + response = requests.delete( + signoz.self.host_configs["8080"].get(f"/api/v2/auth_domains/{domain_id}"), + headers={"Authorization": f"Bearer {admin_token}"}, + timeout=5, + ) + assert response.status_code == HTTPStatus.NO_CONTENT, response.text + + +def test_editor_and_viewer_forbidden( + signoz: types.SigNoz, + create_user_admin: types.Operation, # pylint: disable=unused-argument + get_token: Callable[[str, str], str], +): + admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD) + + response = requests.get( + signoz.self.host_configs["8080"].get("/api/v2/auth_domains"), + headers={"Authorization": f"Bearer {admin_token}"}, + timeout=5, + ) + assert response.status_code == HTTPStatus.OK, response.text + a_id = next(domain["id"] for domain in response.json()["data"] if domain["name"] == _TARGET_A) + + for email, password in ((_EDITOR_EMAIL, _EDITOR_PASSWORD), (_VIEWER_EMAIL, _VIEWER_PASSWORD)): + token = get_token(email, password) + + response = requests.get( + signoz.self.host_configs["8080"].get("/api/v2/auth_domains"), + headers={"Authorization": f"Bearer {token}"}, + timeout=5, + ) + assert response.status_code == HTTPStatus.FORBIDDEN, f"{email} list: expected 403, got {response.status_code}: {response.text}" + + response = requests.post( + signoz.self.host_configs["8080"].get("/api/v2/auth_domains"), + json={"name": _ACTOR_DOMAIN, "enabled": True, "config": _SAML_CONFIG}, + headers={"Authorization": f"Bearer {token}"}, + timeout=5, + ) + assert response.status_code == HTTPStatus.FORBIDDEN, f"{email} create: expected 403, got {response.status_code}: {response.text}" + + response = requests.get( + signoz.self.host_configs["8080"].get(f"/api/v2/auth_domains/{a_id}"), + headers={"Authorization": f"Bearer {token}"}, + timeout=5, + ) + assert response.status_code == HTTPStatus.FORBIDDEN, f"{email} get: expected 403, got {response.status_code}: {response.text}" + + response = requests.put( + signoz.self.host_configs["8080"].get(f"/api/v2/auth_domains/{a_id}"), + json={"enabled": True, "config": _SAML_CONFIG}, + headers={"Authorization": f"Bearer {token}"}, + timeout=5, + ) + assert response.status_code == HTTPStatus.FORBIDDEN, f"{email} update: expected 403, got {response.status_code}: {response.text}" + + response = requests.delete( + signoz.self.host_configs["8080"].get(f"/api/v2/auth_domains/{a_id}"), + headers={"Authorization": f"Bearer {token}"}, + timeout=5, + ) + assert response.status_code == HTTPStatus.FORBIDDEN, f"{email} delete: expected 403, got {response.status_code}: {response.text}" + + +def test_apply_license( + signoz: types.SigNoz, + create_user_admin: types.Operation, # pylint: disable=unused-argument + make_http_mocks: Callable[[types.TestContainerDocker, list[Mapping]], None], + get_token: Callable[[str, str], str], +) -> None: + add_license(signoz, make_http_mocks, get_token) + + +def test_setup_actor( + signoz: types.SigNoz, + create_user_admin: types.Operation, # pylint: disable=unused-argument + get_token: Callable[[str, str], str], + create_role: Callable[..., str], +): + admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD) + + create_role(admin_token, _ACTOR_ROLE_NAME) + + user_id = create_active_user( + signoz, + admin_token, + email=_ACTOR_EMAIL, + role="signoz-viewer", + password=_ACTOR_PASSWORD, + name="auth-domain-fga-test-user", + ) + change_user_role(signoz, admin_token, user_id, "signoz-viewer", _ACTOR_ROLE_NAME) + + +def test_actor_without_grants_forbidden( + signoz: types.SigNoz, + create_user_admin: types.Operation, # pylint: disable=unused-argument + get_token: Callable[[str, str], str], +): + admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD) + token = get_token(_ACTOR_EMAIL, _ACTOR_PASSWORD) + + response = requests.get( + signoz.self.host_configs["8080"].get("/api/v2/auth_domains"), + headers={"Authorization": f"Bearer {admin_token}"}, + timeout=5, + ) + assert response.status_code == HTTPStatus.OK, response.text + a_id = next(domain["id"] for domain in response.json()["data"] if domain["name"] == _TARGET_A) + + response = requests.get( + signoz.self.host_configs["8080"].get("/api/v2/auth_domains"), + headers={"Authorization": f"Bearer {token}"}, + timeout=5, + ) + assert response.status_code == HTTPStatus.FORBIDDEN, f"list without grant: expected 403, got {response.status_code}: {response.text}" + + response = requests.post( + signoz.self.host_configs["8080"].get("/api/v2/auth_domains"), + json={"name": _ACTOR_DOMAIN, "enabled": True, "config": _SAML_CONFIG}, + headers={"Authorization": f"Bearer {token}"}, + timeout=5, + ) + assert response.status_code == HTTPStatus.FORBIDDEN, f"create without grant: expected 403, got {response.status_code}: {response.text}" + + response = requests.get( + signoz.self.host_configs["8080"].get(f"/api/v2/auth_domains/{a_id}"), + headers={"Authorization": f"Bearer {token}"}, + timeout=5, + ) + assert response.status_code == HTTPStatus.FORBIDDEN, f"read without grant: expected 403, got {response.status_code}: {response.text}" + + +def test_create_requires_attach_on_mapped_roles( + signoz: types.SigNoz, + create_user_admin: types.Operation, # pylint: disable=unused-argument + get_token: Callable[[str, str], str], +): + admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD) + actor_id = find_role_by_name(signoz, admin_token, _ACTOR_ROLE_NAME) + + response = requests.put( + signoz.self.host_configs["8080"].get(f"/api/v1/roles/{actor_id}"), + json={ + "description": "", + "transactionGroups": [ + transaction_group("create", "metaresource", "auth-domain", ["*"]), + transaction_group("delete", "metaresource", "auth-domain", ["*"]), + transaction_group("list", "metaresource", "auth-domain", ["*"]), + transaction_group("attach", "metaresource", "auth-domain", ["*"]), + transaction_group("attach", "role", "role", ["signoz-viewer"]), + ], + }, + headers={"Authorization": f"Bearer {admin_token}"}, + timeout=5, + ) + assert response.status_code == HTTPStatus.NO_CONTENT, response.text + + token = get_token(_ACTOR_EMAIL, _ACTOR_PASSWORD) + + # No roleMapping means SSO users get signoz-viewer, which is granted. + response = requests.post( + signoz.self.host_configs["8080"].get("/api/v2/auth_domains"), + json={"name": _ACTOR_DOMAIN, "enabled": True, "config": _SAML_CONFIG}, + headers={"Authorization": f"Bearer {token}"}, + timeout=5, + ) + assert response.status_code == HTTPStatus.CREATED, f"create with default mapping: {response.text}" + domain_id = response.json()["data"]["id"] + + response = requests.delete( + signoz.self.host_configs["8080"].get(f"/api/v2/auth_domains/{domain_id}"), + headers={"Authorization": f"Bearer {token}"}, + timeout=5, + ) + assert response.status_code == HTTPStatus.NO_CONTENT, response.text + + response = requests.post( + signoz.self.host_configs["8080"].get("/api/v2/auth_domains"), + json={ + "name": _ACTOR_DOMAIN, + "enabled": True, + "config": _SAML_CONFIG, + "roleMapping": {"defaultRole": "EDITOR"}, + }, + headers={"Authorization": f"Bearer {token}"}, + timeout=5, + ) + assert response.status_code == HTTPStatus.FORBIDDEN, f"map to unattachable role: expected 403, got {response.status_code}: {response.text}" + + # Trusting the IDP role attribute can grant any role, so it needs attach on role "*". + response = requests.post( + signoz.self.host_configs["8080"].get("/api/v2/auth_domains"), + json={ + "name": _ACTOR_DOMAIN, + "enabled": True, + "config": _SAML_CONFIG, + "roleMapping": {"defaultRole": "VIEWER", "useRoleAttribute": True}, + }, + headers={"Authorization": f"Bearer {token}"}, + timeout=5, + ) + assert response.status_code == HTTPStatus.FORBIDDEN, f"role attribute without wildcard attach: expected 403, got {response.status_code}: {response.text}" + + response = requests.put( + signoz.self.host_configs["8080"].get(f"/api/v1/roles/{actor_id}"), + json={ + "description": "", + "transactionGroups": [ + transaction_group("create", "metaresource", "auth-domain", ["*"]), + transaction_group("delete", "metaresource", "auth-domain", ["*"]), + transaction_group("list", "metaresource", "auth-domain", ["*"]), + transaction_group("attach", "metaresource", "auth-domain", ["*"]), + transaction_group("attach", "role", "role", ["*"]), + ], + }, + headers={"Authorization": f"Bearer {admin_token}"}, + timeout=5, + ) + assert response.status_code == HTTPStatus.NO_CONTENT, response.text + + response = requests.post( + signoz.self.host_configs["8080"].get("/api/v2/auth_domains"), + json={ + "name": _ACTOR_DOMAIN, + "enabled": True, + "config": _SAML_CONFIG, + "roleMapping": {"defaultRole": "EDITOR", "groupMappings": {"platform-team": "ADMIN"}}, + }, + headers={"Authorization": f"Bearer {token}"}, + timeout=5, + ) + assert response.status_code == HTTPStatus.CREATED, f"create with wildcard role attach: {response.text}" + domain_id = response.json()["data"]["id"] + + response = requests.delete( + signoz.self.host_configs["8080"].get(f"/api/v2/auth_domains/{domain_id}"), + headers={"Authorization": f"Bearer {token}"}, + timeout=5, + ) + assert response.status_code == HTTPStatus.NO_CONTENT, response.text + + +def test_update_requires_detach_on_stored_roles( + signoz: types.SigNoz, + create_user_admin: types.Operation, # pylint: disable=unused-argument + get_token: Callable[[str, str], str], +): + admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD) + actor_id = find_role_by_name(signoz, admin_token, _ACTOR_ROLE_NAME) + + response = requests.post( + signoz.self.host_configs["8080"].get("/api/v2/auth_domains"), + json={ + "name": _ACTOR_DOMAIN, + "enabled": True, + "config": _SAML_CONFIG, + "roleMapping": {"defaultRole": "EDITOR"}, + }, + headers={"Authorization": f"Bearer {admin_token}"}, + timeout=5, + ) + assert response.status_code == HTTPStatus.CREATED, response.text + domain_id = response.json()["data"]["id"] + + response = requests.put( + signoz.self.host_configs["8080"].get(f"/api/v1/roles/{actor_id}"), + json={ + "description": "", + "transactionGroups": [ + transaction_group("update", "metaresource", "auth-domain", [domain_id]), + transaction_group("attach", "metaresource", "auth-domain", [domain_id]), + transaction_group("detach", "metaresource", "auth-domain", [domain_id]), + transaction_group("attach", "role", "role", ["signoz-viewer"]), + transaction_group("detach", "role", "role", ["signoz-viewer"]), + ], + }, + headers={"Authorization": f"Bearer {admin_token}"}, + timeout=5, + ) + assert response.status_code == HTTPStatus.NO_CONTENT, response.text + + token = get_token(_ACTOR_EMAIL, _ACTOR_PASSWORD) + + # Dropping the mapping detaches the stored signoz-editor grant, which the + # actor cannot detach. + response = requests.put( + signoz.self.host_configs["8080"].get(f"/api/v2/auth_domains/{domain_id}"), + json={"enabled": True, "config": _SAML_CONFIG}, + headers={"Authorization": f"Bearer {token}"}, + timeout=5, + ) + assert response.status_code == HTTPStatus.FORBIDDEN, f"drop mapping without detach on stored role: expected 403, got {response.status_code}: {response.text}" + + response = requests.put( + signoz.self.host_configs["8080"].get(f"/api/v1/roles/{actor_id}"), + json={ + "description": "", + "transactionGroups": [ + transaction_group("update", "metaresource", "auth-domain", [domain_id]), + transaction_group("attach", "metaresource", "auth-domain", [domain_id]), + transaction_group("detach", "metaresource", "auth-domain", [domain_id]), + transaction_group("attach", "role", "role", ["signoz-viewer"]), + transaction_group("detach", "role", "role", ["signoz-editor"]), + ], + }, + headers={"Authorization": f"Bearer {admin_token}"}, + timeout=5, + ) + assert response.status_code == HTTPStatus.NO_CONTENT, response.text + + response = requests.put( + signoz.self.host_configs["8080"].get(f"/api/v2/auth_domains/{domain_id}"), + json={"enabled": True, "config": _SAML_CONFIG}, + headers={"Authorization": f"Bearer {token}"}, + timeout=5, + ) + assert response.status_code == HTTPStatus.NO_CONTENT, f"drop mapping with detach on stored role: {response.text}" + + response = requests.delete( + signoz.self.host_configs["8080"].get(f"/api/v2/auth_domains/{domain_id}"), + headers={"Authorization": f"Bearer {admin_token}"}, + timeout=5, + ) + assert response.status_code == HTTPStatus.NO_CONTENT, response.text + + +def test_instance_verbs_scoped_to_granted_domain( + signoz: types.SigNoz, + create_user_admin: types.Operation, # pylint: disable=unused-argument + get_token: Callable[[str, str], str], +): + admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD) + actor_id = find_role_by_name(signoz, admin_token, _ACTOR_ROLE_NAME) + + response = requests.get( + signoz.self.host_configs["8080"].get("/api/v2/auth_domains"), + headers={"Authorization": f"Bearer {admin_token}"}, + timeout=5, + ) + assert response.status_code == HTTPStatus.OK, response.text + ids = {domain["name"]: domain["id"] for domain in response.json()["data"]} + a_id, b_id = ids[_TARGET_A], ids[_TARGET_B] + + response = requests.put( + signoz.self.host_configs["8080"].get(f"/api/v1/roles/{actor_id}"), + json={ + "description": "", + "transactionGroups": [ + transaction_group("read", "metaresource", "auth-domain", [a_id]), + transaction_group("update", "metaresource", "auth-domain", [a_id]), + transaction_group("delete", "metaresource", "auth-domain", [a_id]), + transaction_group("attach", "metaresource", "auth-domain", [a_id]), + transaction_group("detach", "metaresource", "auth-domain", [a_id]), + transaction_group("attach", "role", "role", ["signoz-viewer"]), + transaction_group("detach", "role", "role", ["signoz-viewer"]), + ], + }, + headers={"Authorization": f"Bearer {admin_token}"}, + timeout=5, + ) + assert response.status_code == HTTPStatus.NO_CONTENT, response.text + + token = get_token(_ACTOR_EMAIL, _ACTOR_PASSWORD) + + response = requests.get( + signoz.self.host_configs["8080"].get(f"/api/v2/auth_domains/{a_id}"), + headers={"Authorization": f"Bearer {token}"}, + timeout=5, + ) + assert response.status_code == HTTPStatus.OK, f"read granted domain: {response.text}" + + response = requests.get( + signoz.self.host_configs["8080"].get(f"/api/v2/auth_domains/{b_id}"), + headers={"Authorization": f"Bearer {token}"}, + timeout=5, + ) + assert response.status_code == HTTPStatus.FORBIDDEN, f"read other domain: expected 403, got {response.status_code}: {response.text}" + + response = requests.put( + signoz.self.host_configs["8080"].get(f"/api/v2/auth_domains/{a_id}"), + json={"enabled": False, "config": _SAML_CONFIG}, + headers={"Authorization": f"Bearer {token}"}, + timeout=5, + ) + assert response.status_code == HTTPStatus.NO_CONTENT, f"update granted domain: {response.text}" + + response = requests.put( + signoz.self.host_configs["8080"].get(f"/api/v2/auth_domains/{b_id}"), + json={"enabled": False, "config": _SAML_CONFIG}, + headers={"Authorization": f"Bearer {token}"}, + timeout=5, + ) + assert response.status_code == HTTPStatus.FORBIDDEN, f"update other domain: expected 403, got {response.status_code}: {response.text}" + + response = requests.delete( + signoz.self.host_configs["8080"].get(f"/api/v2/auth_domains/{b_id}"), + headers={"Authorization": f"Bearer {token}"}, + timeout=5, + ) + assert response.status_code == HTTPStatus.FORBIDDEN, f"delete other domain: expected 403, got {response.status_code}: {response.text}" + + response = requests.delete( + signoz.self.host_configs["8080"].get(f"/api/v2/auth_domains/{a_id}"), + headers={"Authorization": f"Bearer {token}"}, + timeout=5, + ) + assert response.status_code == HTTPStatus.NO_CONTENT, f"delete granted domain: {response.text}" From dca6aa497d73a179858ec1c005babfb64f865973 Mon Sep 17 00:00:00 2001 From: Vikrant Gupta Date: Wed, 19 Aug 2026 20:03:16 +0000 Subject: [PATCH 5/5] chore(serviceaccount): remove deprecated nested role endpoints (#12591) #### Description - Removes the deprecated `POST /api/v1/service_accounts/{id}/roles` and `DELETE /api/v1/service_accounts/{id}/roles/{rid}` routes, their HTTP handlers, and the `DeprecatedPostableServiceAccountRole` type, now that all consumers use `/api/v1/service_account_roles`. - Keeps the `GET /api/v1/service_accounts/{id}/roles` listing endpoint. - Regenerates `docs/api/openapi.yml` and the frontend client. #### Issues closed by this PR Closes SigNoz/platform-pod#2919 #### Additional Information - Final step of the migration; the frontend (#12589) and integration-test (#12590) consumer moves are already merged. --- docs/api/openapi.yml | 118 ----------- .../services/serviceaccount/index.ts | 192 ------------------ .../api/generated/services/sigNoz.schemas.ts | 22 -- .../signozapiserver/serviceaccount.go | 60 ------ .../implserviceaccount/handler.go | 58 ------ pkg/modules/serviceaccount/serviceaccount.go | 4 - .../serviceaccounttypes/service_acccount.go | 4 - 7 files changed, 458 deletions(-) diff --git a/docs/api/openapi.yml b/docs/api/openapi.yml index a9f62f74332..1b06632d1e2 100644 --- a/docs/api/openapi.yml +++ b/docs/api/openapi.yml @@ -8024,13 +8024,6 @@ components: - schemaVersion - spec type: object - ServiceaccounttypesDeprecatedPostableServiceAccountRole: - properties: - id: - type: string - required: - - id - type: object ServiceaccounttypesGettableFactorAPIKey: properties: createdAt: @@ -13262,117 +13255,6 @@ paths: summary: Gets service account roles tags: - serviceaccount - post: - deprecated: true - description: This endpoint assigns a role to a service account - operationId: CreateServiceAccountRoleDeprecated - parameters: - - in: path - name: id - required: true - schema: - type: string - requestBody: - content: - application/json: - schema: - $ref: '#/components/schemas/ServiceaccounttypesDeprecatedPostableServiceAccountRole' - responses: - "201": - content: - application/json: - schema: - properties: - data: - $ref: '#/components/schemas/TypesIdentifiable' - status: - type: string - required: - - status - - data - type: object - description: Created - "400": - content: - application/json: - schema: - $ref: '#/components/schemas/RenderErrorResponse' - description: Bad Request - "401": - content: - application/json: - schema: - $ref: '#/components/schemas/RenderErrorResponse' - description: Unauthorized - "403": - content: - application/json: - schema: - $ref: '#/components/schemas/RenderErrorResponse' - description: Forbidden - "500": - content: - application/json: - schema: - $ref: '#/components/schemas/RenderErrorResponse' - description: Internal Server Error - security: - - api_key: - - serviceaccount:attach - - role:attach - - tokenizer: - - serviceaccount:attach - - role:attach - summary: Create service account role - tags: - - serviceaccount - /api/v1/service_accounts/{id}/roles/{rid}: - delete: - deprecated: true - description: This endpoint revokes a role from service account - operationId: DeleteServiceAccountRoleDeprecated - parameters: - - in: path - name: id - required: true - schema: - type: string - - in: path - name: rid - required: true - schema: - type: string - responses: - "204": - description: No Content - "401": - content: - application/json: - schema: - $ref: '#/components/schemas/RenderErrorResponse' - description: Unauthorized - "403": - content: - application/json: - schema: - $ref: '#/components/schemas/RenderErrorResponse' - description: Forbidden - "500": - content: - application/json: - schema: - $ref: '#/components/schemas/RenderErrorResponse' - description: Internal Server Error - security: - - api_key: - - serviceaccount:detach - - role:detach - - tokenizer: - - serviceaccount:detach - - role:detach - summary: Delete service account role - tags: - - serviceaccount /api/v1/service_accounts/me: get: deprecated: false diff --git a/frontend/src/api/generated/services/serviceaccount/index.ts b/frontend/src/api/generated/services/serviceaccount/index.ts index 93b128f9792..1bd6ca2a47f 100644 --- a/frontend/src/api/generated/services/serviceaccount/index.ts +++ b/frontend/src/api/generated/services/serviceaccount/index.ts @@ -22,10 +22,7 @@ import type { CreateServiceAccountKey201, CreateServiceAccountKeyPathParameters, CreateServiceAccountRole201, - CreateServiceAccountRoleDeprecated201, - CreateServiceAccountRoleDeprecatedPathParameters, DeleteServiceAccountPathParameters, - DeleteServiceAccountRoleDeprecatedPathParameters, DeleteServiceAccountRolePathParameters, GetMyServiceAccount200, GetServiceAccount200, @@ -39,7 +36,6 @@ import type { ListServiceAccounts200, RenderErrorResponseDTO, RevokeServiceAccountKeyPathParameters, - ServiceaccounttypesDeprecatedPostableServiceAccountRoleDTO, ServiceaccounttypesPostableFactorAPIKeyDTO, ServiceaccounttypesPostableServiceAccountDTO, ServiceaccounttypesPostableServiceAccountRoleDTO, @@ -1253,194 +1249,6 @@ export const invalidateGetServiceAccountRoles = async ( return queryClient; }; -/** - * This endpoint assigns a role to a service account - * @deprecated - * @summary Create service account role - */ -export const createServiceAccountRoleDeprecated = ( - { id }: CreateServiceAccountRoleDeprecatedPathParameters, - serviceaccounttypesDeprecatedPostableServiceAccountRoleDTO?: BodyType, - signal?: AbortSignal, -) => { - return GeneratedAPIInstance({ - url: `/api/v1/service_accounts/${id}/roles`, - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - data: serviceaccounttypesDeprecatedPostableServiceAccountRoleDTO, - signal, - }); -}; - -export const getCreateServiceAccountRoleDeprecatedMutationOptions = < - TError = ErrorType, - TContext = unknown, ->(options?: { - mutation?: UseMutationOptions< - Awaited>, - TError, - { - pathParams: CreateServiceAccountRoleDeprecatedPathParameters; - data?: BodyType; - }, - TContext - >; -}): UseMutationOptions< - Awaited>, - TError, - { - pathParams: CreateServiceAccountRoleDeprecatedPathParameters; - data?: BodyType; - }, - TContext -> => { - const mutationKey = ['createServiceAccountRoleDeprecated']; - const { mutation: mutationOptions } = options - ? options.mutation && - 'mutationKey' in options.mutation && - options.mutation.mutationKey - ? options - : { ...options, mutation: { ...options.mutation, mutationKey } } - : { mutation: { mutationKey } }; - - const mutationFn: MutationFunction< - Awaited>, - { - pathParams: CreateServiceAccountRoleDeprecatedPathParameters; - data?: BodyType; - } - > = (props) => { - const { pathParams, data } = props ?? {}; - - return createServiceAccountRoleDeprecated(pathParams, data); - }; - - return { mutationFn, ...mutationOptions }; -}; - -export type CreateServiceAccountRoleDeprecatedMutationResult = NonNullable< - Awaited> ->; -export type CreateServiceAccountRoleDeprecatedMutationBody = - | BodyType - | undefined; -export type CreateServiceAccountRoleDeprecatedMutationError = - ErrorType; - -/** - * @deprecated - * @summary Create service account role - */ -export const useCreateServiceAccountRoleDeprecated = < - TError = ErrorType, - TContext = unknown, ->(options?: { - mutation?: UseMutationOptions< - Awaited>, - TError, - { - pathParams: CreateServiceAccountRoleDeprecatedPathParameters; - data?: BodyType; - }, - TContext - >; -}): UseMutationResult< - Awaited>, - TError, - { - pathParams: CreateServiceAccountRoleDeprecatedPathParameters; - data?: BodyType; - }, - TContext -> => { - return useMutation( - getCreateServiceAccountRoleDeprecatedMutationOptions(options), - ); -}; -/** - * This endpoint revokes a role from service account - * @deprecated - * @summary Delete service account role - */ -export const deleteServiceAccountRoleDeprecated = ( - { id, rid }: DeleteServiceAccountRoleDeprecatedPathParameters, - signal?: AbortSignal, -) => { - return GeneratedAPIInstance({ - url: `/api/v1/service_accounts/${id}/roles/${rid}`, - method: 'DELETE', - signal, - }); -}; - -export const getDeleteServiceAccountRoleDeprecatedMutationOptions = < - TError = ErrorType, - TContext = unknown, ->(options?: { - mutation?: UseMutationOptions< - Awaited>, - TError, - { pathParams: DeleteServiceAccountRoleDeprecatedPathParameters }, - TContext - >; -}): UseMutationOptions< - Awaited>, - TError, - { pathParams: DeleteServiceAccountRoleDeprecatedPathParameters }, - TContext -> => { - const mutationKey = ['deleteServiceAccountRoleDeprecated']; - const { mutation: mutationOptions } = options - ? options.mutation && - 'mutationKey' in options.mutation && - options.mutation.mutationKey - ? options - : { ...options, mutation: { ...options.mutation, mutationKey } } - : { mutation: { mutationKey } }; - - const mutationFn: MutationFunction< - Awaited>, - { pathParams: DeleteServiceAccountRoleDeprecatedPathParameters } - > = (props) => { - const { pathParams } = props ?? {}; - - return deleteServiceAccountRoleDeprecated(pathParams); - }; - - return { mutationFn, ...mutationOptions }; -}; - -export type DeleteServiceAccountRoleDeprecatedMutationResult = NonNullable< - Awaited> ->; - -export type DeleteServiceAccountRoleDeprecatedMutationError = - ErrorType; - -/** - * @deprecated - * @summary Delete service account role - */ -export const useDeleteServiceAccountRoleDeprecated = < - TError = ErrorType, - TContext = unknown, ->(options?: { - mutation?: UseMutationOptions< - Awaited>, - TError, - { pathParams: DeleteServiceAccountRoleDeprecatedPathParameters }, - TContext - >; -}): UseMutationResult< - Awaited>, - TError, - { pathParams: DeleteServiceAccountRoleDeprecatedPathParameters }, - TContext -> => { - return useMutation( - getDeleteServiceAccountRoleDeprecatedMutationOptions(options), - ); -}; /** * This endpoint gets my service account * @summary Gets my service account diff --git a/frontend/src/api/generated/services/sigNoz.schemas.ts b/frontend/src/api/generated/services/sigNoz.schemas.ts index 65ef281cb5b..331e7ffabbc 100644 --- a/frontend/src/api/generated/services/sigNoz.schemas.ts +++ b/frontend/src/api/generated/services/sigNoz.schemas.ts @@ -9092,13 +9092,6 @@ export interface SavedviewtypesUpdatableSavedViewDTO { spec: SavedviewtypesSavedViewSpecDTO; } -export interface ServiceaccounttypesDeprecatedPostableServiceAccountRoleDTO { - /** - * @type string - */ - id: string; -} - export interface ServiceaccounttypesGettableFactorAPIKeyDTO { /** * @type string @@ -11020,21 +11013,6 @@ export type GetServiceAccountRoles200 = { status: string; }; -export type CreateServiceAccountRoleDeprecatedPathParameters = { - id: string; -}; -export type CreateServiceAccountRoleDeprecated201 = { - data: TypesIdentifiableDTO; - /** - * @type string - */ - status: string; -}; - -export type DeleteServiceAccountRoleDeprecatedPathParameters = { - id: string; - rid: string; -}; export type GetMyServiceAccount200 = { data: ServiceaccounttypesServiceAccountWithRolesDTO; /** diff --git a/pkg/apiserver/signozapiserver/serviceaccount.go b/pkg/apiserver/signozapiserver/serviceaccount.go index 62671599fec..561caabae5c 100644 --- a/pkg/apiserver/signozapiserver/serviceaccount.go +++ b/pkg/apiserver/signozapiserver/serviceaccount.go @@ -138,66 +138,6 @@ func (provider *provider) addServiceAccountRoutes(router *mux.Router) error { return err } - if err := router.Handle("/api/v1/service_accounts/{id}/roles", handler.New( - provider.authzMiddleware.CheckResources(provider.serviceAccountHandler.SetRole, authtypes.SigNozAdminRoleName), - handler.OpenAPIDef{ - ID: "CreateServiceAccountRoleDeprecated", - Tags: []string{"serviceaccount"}, - Summary: "Create service account role", - Description: "This endpoint assigns a role to a service account", - Request: new(serviceaccounttypes.DeprecatedPostableServiceAccountRole), - RequestContentType: "", - Response: new(types.Identifiable), - ResponseContentType: "application/json", - SuccessStatusCode: http.StatusCreated, - ErrorStatusCodes: []int{http.StatusBadRequest}, - Deprecated: true, - SecuritySchemes: newScopedSecuritySchemes([]string{coretypes.ResourceServiceAccount.Scope(coretypes.VerbAttach), coretypes.ResourceRole.Scope(coretypes.VerbAttach)}), - }, - handler.WithResourceDefs(handler.AttachDetachSiblingResourceDef{ - Verb: coretypes.VerbAttach, - Category: coretypes.ActionCategoryAccessControl, - SourceResource: coretypes.ResourceServiceAccount, - SourceIDs: coretypes.OneID(coretypes.PathParam("id")), - SourceSelector: coretypes.IDSelector, - TargetResource: coretypes.ResourceRole, - TargetIDs: coretypes.OneID(coretypes.BodyJSONPath("id")), - TargetSelector: provider.roleSelector, - }), - )).Methods(http.MethodPost).GetError(); err != nil { - return err - } - - if err := router.Handle("/api/v1/service_accounts/{id}/roles/{rid}", handler.New( - provider.authzMiddleware.CheckResources(provider.serviceAccountHandler.DeleteRole, authtypes.SigNozAdminRoleName), - handler.OpenAPIDef{ - ID: "DeleteServiceAccountRoleDeprecated", - Tags: []string{"serviceaccount"}, - Summary: "Delete service account role", - Description: "This endpoint revokes a role from service account", - Request: nil, - RequestContentType: "", - Response: nil, - ResponseContentType: "application/json", - SuccessStatusCode: http.StatusNoContent, - ErrorStatusCodes: []int{}, - Deprecated: true, - SecuritySchemes: newScopedSecuritySchemes([]string{coretypes.ResourceServiceAccount.Scope(coretypes.VerbDetach), coretypes.ResourceRole.Scope(coretypes.VerbDetach)}), - }, - handler.WithResourceDefs(handler.AttachDetachSiblingResourceDef{ - Verb: coretypes.VerbDetach, - Category: coretypes.ActionCategoryAccessControl, - SourceResource: coretypes.ResourceServiceAccount, - SourceIDs: coretypes.OneID(coretypes.PathParam("id")), - SourceSelector: coretypes.IDSelector, - TargetResource: coretypes.ResourceRole, - TargetIDs: coretypes.OneID(coretypes.PathParam("rid")), - TargetSelector: provider.roleSelector, - }), - )).Methods(http.MethodDelete).GetError(); err != nil { - return err - } - if err := router.Handle("/api/v1/service_accounts/me", handler.New(provider.authzMiddleware.OpenAccess(provider.serviceAccountHandler.UpdateMe), handler.OpenAPIDef{ ID: "UpdateMyServiceAccount", Tags: []string{"serviceaccount"}, diff --git a/pkg/modules/serviceaccount/implserviceaccount/handler.go b/pkg/modules/serviceaccount/implserviceaccount/handler.go index 37cb444dbe8..9c1dfc74679 100644 --- a/pkg/modules/serviceaccount/implserviceaccount/handler.go +++ b/pkg/modules/serviceaccount/implserviceaccount/handler.go @@ -214,64 +214,6 @@ func (handler *handler) UpdateMe(rw http.ResponseWriter, r *http.Request) { render.Success(rw, http.StatusNoContent, nil) } -func (handler *handler) SetRole(rw http.ResponseWriter, r *http.Request) { - ctx := r.Context() - claims, err := authtypes.ClaimsFromContext(ctx) - if err != nil { - render.Error(rw, err) - return - } - - id, err := valuer.NewUUID(mux.Vars(r)["id"]) - if err != nil { - render.Error(rw, err) - return - } - - req := new(serviceaccounttypes.DeprecatedPostableServiceAccountRole) - if err := binding.JSON.BindBody(r.Body, req); err != nil { - render.Error(rw, err) - return - } - - _, err = handler.module.SetRole(ctx, valuer.MustNewUUID(claims.OrgID), id, req.ID) - if err != nil { - render.Error(rw, err) - return - } - - render.Success(rw, http.StatusNoContent, nil) -} - -func (handler *handler) DeleteRole(rw http.ResponseWriter, r *http.Request) { - ctx := r.Context() - claims, err := authtypes.ClaimsFromContext(ctx) - if err != nil { - render.Error(rw, err) - return - } - - id, err := valuer.NewUUID(mux.Vars(r)["id"]) - if err != nil { - render.Error(rw, err) - return - } - - roleID, err := valuer.NewUUID(mux.Vars(r)["rid"]) - if err != nil { - render.Error(rw, err) - return - } - - err = handler.module.DeleteRole(ctx, valuer.MustNewUUID(claims.OrgID), id, roleID) - if err != nil { - render.Error(rw, err) - return - } - - render.Success(rw, http.StatusNoContent, nil) -} - func (handler *handler) CreateServiceAccountRole(rw http.ResponseWriter, r *http.Request) { ctx := r.Context() claims, err := authtypes.ClaimsFromContext(ctx) diff --git a/pkg/modules/serviceaccount/serviceaccount.go b/pkg/modules/serviceaccount/serviceaccount.go index 828a570eba5..b9bc8381890 100644 --- a/pkg/modules/serviceaccount/serviceaccount.go +++ b/pkg/modules/serviceaccount/serviceaccount.go @@ -94,10 +94,6 @@ type Handler interface { UpdateMe(http.ResponseWriter, *http.Request) - SetRole(http.ResponseWriter, *http.Request) - - DeleteRole(http.ResponseWriter, *http.Request) - CreateServiceAccountRole(http.ResponseWriter, *http.Request) GetServiceAccountRole(http.ResponseWriter, *http.Request) diff --git a/pkg/types/serviceaccounttypes/service_acccount.go b/pkg/types/serviceaccounttypes/service_acccount.go index c1c03ff8f2d..5809d64af19 100644 --- a/pkg/types/serviceaccounttypes/service_acccount.go +++ b/pkg/types/serviceaccounttypes/service_acccount.go @@ -69,10 +69,6 @@ type PostableServiceAccount struct { Name string `json:"name" required:"true"` } -type DeprecatedPostableServiceAccountRole struct { - ID valuer.UUID `json:"id" required:"true"` -} - type PostableServiceAccountRole struct { ServiceAccountID valuer.UUID `json:"serviceAccountId" required:"true"` RoleID valuer.UUID `json:"roleId" required:"true"`