Compare commits
42 Commits
issue_5267
...
refactor/u
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8ed9e520f1 | ||
|
|
8b56f39261 | ||
|
|
2be1063602 | ||
|
|
6546602242 | ||
|
|
ba49b28c5a | ||
|
|
adc3909b71 | ||
|
|
e00f47c812 | ||
|
|
b2f048770e | ||
|
|
0711786701 | ||
|
|
aeda0a5144 | ||
|
|
b71c49d01f | ||
|
|
570bc0aef1 | ||
|
|
a22e7b7b16 | ||
|
|
fc83f91058 | ||
|
|
0d47f02100 | ||
|
|
810bf5d9a0 | ||
|
|
7d8a00ab8c | ||
|
|
348fca1b62 | ||
|
|
83e0e974fe | ||
|
|
10217274b8 | ||
|
|
7c3ac5b221 | ||
|
|
c1d40d7359 | ||
|
|
c5c1913f97 | ||
|
|
5ab6636863 | ||
|
|
3680cc7779 | ||
|
|
7cfedc46c4 | ||
|
|
69ba25d82e | ||
|
|
e42159f257 | ||
|
|
1e887dc9c9 | ||
|
|
7646aabb2b | ||
|
|
18851afb6d | ||
|
|
d5221a6ff3 | ||
|
|
19712c3579 | ||
|
|
e31683be11 | ||
|
|
1700ad06e6 | ||
|
|
ee3b45b80d | ||
|
|
4771e30c03 | ||
|
|
e933fa74c7 | ||
|
|
853397a79e | ||
|
|
bd526df11d | ||
|
|
8ac07d3d37 | ||
|
|
9bab8e0ae2 |
7
.github/workflows/build-enterprise.yaml
vendored
@@ -61,13 +61,6 @@ jobs:
|
||||
echo 'VITE_SENTRY_AUTH_TOKEN="${{ secrets.SENTRY_AUTH_TOKEN }}"' >> frontend/.env
|
||||
echo 'VITE_SENTRY_ORG="${{ secrets.SENTRY_ORG }}"' >> frontend/.env
|
||||
echo 'VITE_SENTRY_PROJECT_ID="${{ secrets.SENTRY_PROJECT_ID }}"' >> frontend/.env
|
||||
echo 'VITE_SENTRY_DSN="${{ secrets.SENTRY_DSN }}"' >> frontend/.env
|
||||
echo 'VITE_TUNNEL_URL="${{ secrets.TUNNEL_URL }}"' >> frontend/.env
|
||||
echo 'VITE_TUNNEL_DOMAIN="${{ secrets.TUNNEL_DOMAIN }}"' >> frontend/.env
|
||||
echo 'VITE_POSTHOG_KEY="${{ secrets.POSTHOG_KEY }}"' >> frontend/.env
|
||||
echo 'VITE_PYLON_APP_ID="${{ secrets.PYLON_APP_ID }}"' >> frontend/.env
|
||||
echo 'VITE_APPCUES_APP_ID="${{ secrets.APPCUES_APP_ID }}"' >> frontend/.env
|
||||
echo 'VITE_PYLON_IDENTITY_SECRET="${{ secrets.PYLON_IDENTITY_SECRET }}"' >> frontend/.env
|
||||
echo 'VITE_DOCS_BASE_URL="https://signoz.io"' >> frontend/.env
|
||||
echo 'VITE_ENVIRONMENT="production"' >> frontend/.env
|
||||
echo 'VITE_VERSION="${{ steps.build-info.outputs.version }}"' >> frontend/.env
|
||||
|
||||
6
.github/workflows/build-staging.yaml
vendored
@@ -67,12 +67,6 @@ jobs:
|
||||
echo 'VITE_SENTRY_AUTH_TOKEN="${{ secrets.SENTRY_AUTH_TOKEN }}"' >> frontend/.env
|
||||
echo 'VITE_SENTRY_ORG="${{ secrets.SENTRY_ORG }}"' >> frontend/.env
|
||||
echo 'VITE_SENTRY_PROJECT_ID="${{ secrets.SENTRY_PROJECT_ID }}"' >> frontend/.env
|
||||
echo 'VITE_SENTRY_DSN="${{ secrets.SENTRY_DSN }}"' >> frontend/.env
|
||||
echo 'VITE_TUNNEL_URL="${{ secrets.NP_TUNNEL_URL }}"' >> frontend/.env
|
||||
echo 'VITE_TUNNEL_DOMAIN="${{ secrets.NP_TUNNEL_DOMAIN }}"' >> frontend/.env
|
||||
echo 'VITE_PYLON_APP_ID="${{ secrets.NP_PYLON_APP_ID }}"' >> frontend/.env
|
||||
echo 'VITE_APPCUES_APP_ID="${{ secrets.NP_APPCUES_APP_ID }}"' >> frontend/.env
|
||||
echo 'VITE_PYLON_IDENTITY_SECRET="${{ secrets.NP_PYLON_IDENTITY_SECRET }}"' >> frontend/.env
|
||||
echo 'VITE_DOCS_BASE_URL="https://staging.signoz.io"' >> frontend/.env
|
||||
echo 'VITE_ENVIRONMENT="staging"' >> frontend/.env
|
||||
echo 'VITE_VERSION="${{ steps.build-info.outputs.version }}"' >> frontend/.env
|
||||
|
||||
6
.github/workflows/e2eci.yaml
vendored
@@ -70,7 +70,11 @@ jobs:
|
||||
cd tests/e2e && pnpm install --frozen-lockfile
|
||||
- name: playwright-browsers
|
||||
run: |
|
||||
cd tests/e2e && pnpm playwright install --with-deps ${{ matrix.project }}
|
||||
docker create --name pw mcr.microsoft.com/playwright:v1.57.0-noble
|
||||
docker cp pw:/ms-playwright "$RUNNER_TEMP/ms-playwright"
|
||||
docker rm pw
|
||||
echo "PLAYWRIGHT_BROWSERS_PATH=$RUNNER_TEMP/ms-playwright" >> "$GITHUB_ENV"
|
||||
cd tests/e2e && pnpm playwright install-deps ${{ matrix.project }}
|
||||
- name: bring-up-stack
|
||||
run: |
|
||||
cd tests && \
|
||||
|
||||
7
.github/workflows/gor-signoz.yaml
vendored
@@ -27,13 +27,6 @@ jobs:
|
||||
echo 'VITE_SENTRY_AUTH_TOKEN="${{ secrets.SENTRY_AUTH_TOKEN }}"' >> .env
|
||||
echo 'VITE_SENTRY_ORG="${{ secrets.SENTRY_ORG }}"' >> .env
|
||||
echo 'VITE_SENTRY_PROJECT_ID="${{ secrets.SENTRY_PROJECT_ID }}"' >> .env
|
||||
echo 'VITE_SENTRY_DSN="${{ secrets.SENTRY_DSN }}"' >> .env
|
||||
echo 'VITE_TUNNEL_URL="${{ secrets.TUNNEL_URL }}"' >> .env
|
||||
echo 'VITE_TUNNEL_DOMAIN="${{ secrets.TUNNEL_DOMAIN }}"' >> .env
|
||||
echo 'VITE_POSTHOG_KEY="${{ secrets.POSTHOG_KEY }}"' >> .env
|
||||
echo 'VITE_PYLON_APP_ID="${{ secrets.PYLON_APP_ID }}"' >> .env
|
||||
echo 'VITE_APPCUES_APP_ID="${{ secrets.APPCUES_APP_ID }}"' >> .env
|
||||
echo 'VITE_PYLON_IDENTITY_SECRET="${{ secrets.PYLON_IDENTITY_SECRET }}"' >> .env
|
||||
echo 'VITE_DOCS_BASE_URL="https://signoz.io"' >> .env
|
||||
echo 'VITE_ENVIRONMENT="production"' >> .env
|
||||
echo 'VITE_VERSION="${{ github.ref_name }}"' >> .env
|
||||
|
||||
1
.github/workflows/integrationci.yaml
vendored
@@ -38,7 +38,6 @@ jobs:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
suite:
|
||||
- spanmapper
|
||||
- alerts
|
||||
- basepath
|
||||
- callbackauthn
|
||||
|
||||
11
.github/workflows/jsci.yaml
vendored
@@ -56,17 +56,6 @@ jobs:
|
||||
PRIMUS_REF: main
|
||||
JS_SRC: frontend
|
||||
JS_PKG_MANAGER: pnpm
|
||||
languages:
|
||||
if: |
|
||||
github.event_name == 'merge_group' ||
|
||||
(github.event_name == 'pull_request' && ! github.event.pull_request.head.repo.fork && github.event.pull_request.user.login != 'dependabot[bot]' && ! contains(github.event.pull_request.labels.*.name, 'safe-to-test')) ||
|
||||
(github.event_name == 'pull_request_target' && contains(github.event.pull_request.labels.*.name, 'safe-to-test'))
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: self-checkout
|
||||
uses: actions/checkout@v4
|
||||
- name: run
|
||||
run: bash frontend/scripts/validate-md-languages.sh
|
||||
openapi:
|
||||
if: |
|
||||
github.event_name == 'merge_group' ||
|
||||
|
||||
233
README.de-de.md
@@ -1,197 +1,190 @@
|
||||
<p align="center">
|
||||
<img src="https://res.cloudinary.com/dcv3epinx/image/upload/v1618904450/signoz-images/LogoGithub_sigfbu.svg" alt="SigNoz-logo" width="240" />
|
||||
|
||||
<p align="center">Überwache deine Anwendungen und behebe Probleme in deinen bereitgestellten Anwendungen. SigNoz ist eine Open Source Alternative zu DataDog, New Relic, etc.</p>
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="docs/readme-assets/signoz-hero-dark.png" width="900">
|
||||
<source media="(prefers-color-scheme: light)" srcset="docs/readme-assets/signoz-hero-light.png" width="900">
|
||||
<img alt="SigNoz - Observability nach deinen Bedingungen, basierend auf offenen Standards." src="docs/readme-assets/signoz-hero-light.png" width="900">
|
||||
</picture>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<img alt="Downloads" src="https://img.shields.io/docker/pulls/signoz/query-service?label=Downloads"> </a>
|
||||
<img alt="GitHub issues" src="https://img.shields.io/github/issues/signoz/signoz"> </a>
|
||||
<a href="https://twitter.com/intent/tweet?text=Monitor%20your%20applications%20and%20troubleshoot%20problems%20with%20SigNoz,%20an%20open-source%20alternative%20to%20DataDog,%20NewRelic.&url=https://signoz.io/&via=SigNozHQ&hashtags=opensource,signoz,observability">
|
||||
<img alt="tweet" src="https://img.shields.io/twitter/url/http/shields.io.svg?style=social"> </a>
|
||||
<a href="README.md">English</a> ·
|
||||
<a href="README.zh-cn.md">中文</a> ·
|
||||
<a href="README.pt-br.md">Português</a>
|
||||
</p>
|
||||
|
||||
<h3 align="center">
|
||||
<a href="https://signoz.io/docs"><b>Dokumentation</b></a> •
|
||||
<a href="https://github.com/SigNoz/signoz/blob/main/README.md"><b>Readme auf Englisch </b></a> •
|
||||
<a href="https://github.com/SigNoz/signoz/blob/main/README.zh-cn.md"><b>ReadMe auf Chinesisch</b></a> •
|
||||
<a href="https://github.com/SigNoz/signoz/blob/main/README.pt-br.md"><b>ReadMe auf Portugiesisch</b></a> •
|
||||
<a href="https://signoz.io/slack"><b>Slack Community</b></a> •
|
||||
<a href="https://twitter.com/SigNozHq"><b>Twitter</b></a>
|
||||
</h3>
|
||||
<p align="center">
|
||||
<a href="https://github.com/SigNoz/signoz/issues"><img alt="GitHub issues" src="https://img.shields.io/github/issues/SigNoz/signoz"></a>
|
||||
<a href="https://github.com/SigNoz/signoz/releases"><img alt="GitHub release" src="https://img.shields.io/github/v/release/SigNoz/signoz?label=release"></a>
|
||||
<a href="https://signoz.io/slack"><img alt="Slack community" src="https://img.shields.io/badge/slack-community-4A154B?logo=slack&logoColor=white"></a>
|
||||
<a href="https://www.linkedin.com/company/signozio/"><img alt="LinkedIn" src="https://img.shields.io/badge/linkedin-SigNoz-0A66C2?logo=linkedin&logoColor=white"></a>
|
||||
<a href="https://twitter.com/intent/tweet?text=Monitor%20your%20applications%20and%20troubleshoot%20problems%20with%20SigNoz,%20an%20open-source%20alternative%20to%20DataDog,%20NewRelic.&url=https://signoz.io/&via=SigNozHQ&hashtags=opensource,signoz,observability"><img alt="Tweet" src="https://img.shields.io/twitter/url/http/shields.io.svg?style=social"></a>
|
||||
</p>
|
||||
|
||||
##
|
||||
SigNoz ist eine Open-Source-Observability-Plattform auf Basis von OpenTelemetry. Wir bauen eine Enterprise-taugliche Alternative zu fragmentierten Monitoring-Stacks, mit Logs, Metriken, Traces, Alerts und Dashboards an einem Ort.
|
||||
|
||||
SigNoz hilft Entwicklern, Anwendungen zu überwachen und Probleme in ihren bereitgestellten Anwendungen zu beheben. Mit SigNoz können Sie Folgendes tun:
|
||||
### Wähle, wie du SigNoz betreibst
|
||||
|
||||
👉 Visualisieren Sie Metriken, Traces und Logs in einer einzigen Oberfläche.
|
||||
#### SigNoz Cloud (empfohlen)
|
||||
|
||||
👉 Sie können Metriken wie die p99-Latenz, Fehlerquoten für Ihre Dienste, externe API-Aufrufe und individuelle Endpunkte anzeigen.
|
||||
Vollständig verwaltetes SigNoz mit 30 Tagen kostenloser Testphase, ohne Kreditkarte, nutzungsbasierter Preisgestaltung ab 49 USD und regionalem Datenhosting.
|
||||
|
||||
👉 Sie können die Ursache des Problems ermitteln, indem Sie zu den genauen Traces gehen, die das Problem verursachen, und detaillierte Flammenbilder einzelner Anfragetraces anzeigen.
|
||||
[**Kostenlos starten →**](https://signoz.io/teams/)
|
||||
|
||||
👉 Führen Sie Aggregationen auf Trace-Daten durch, um geschäftsrelevante Metriken zu erhalten.
|
||||
#### Enterprise
|
||||
|
||||
👉 Filtern und Abfragen von Logs, Erstellen von Dashboards und Benachrichtigungen basierend auf Attributen in den Logs.
|
||||
Enterprise Cloud, BYOC oder Enterprise Self-Hosted mit Compliance, Support, benutzerdefinierter Aufbewahrung, RBAC, Ingestion Controls, Datenresidenz und Regionsauswahl.
|
||||
|
||||
👉 Automatische Aufzeichnung von Ausnahmen in Python, Java, Ruby und Javascript.
|
||||
[**Enterprise entdecken →**](https://signoz.io/enterprise/)
|
||||
|
||||
👉 Einfache Einrichtung von Benachrichtigungen mit dem selbst erstellbaren Abfrage-Builder.
|
||||
#### Community
|
||||
|
||||
##
|
||||
Kostenloses Open-Source-SigNoz, das in deiner eigenen Infrastruktur läuft. Deployment mit Docker, Kubernetes oder Linux, während du die volle Kontrolle über deine Datenebene behältst.
|
||||
|
||||
### Anwendung Metriken
|
||||
[**SigNoz installieren →**](https://signoz.io/docs/install/self-host/)
|
||||
|
||||

|
||||
### Was kannst du überwachen?
|
||||
|
||||
### Verteiltes Tracing
|
||||
SigNoz hilft Teams, Produktionsprobleme schneller zu debuggen, indem Logs, Metriken, Traces, Alerts, Dashboards, Exceptions und agent-native Workflows an einem Ort verbunden werden.
|
||||
|
||||
<img width="2068" alt="distributed_tracing_2 2" src="https://user-images.githubusercontent.com/83692067/226536447-bae58321-6a22-4ed3-af80-e3e964cb3489.png">
|
||||
#### APM-Überblick
|
||||
|
||||
<img width="2068" alt="distributed_tracing_1" src="https://user-images.githubusercontent.com/83692067/226536462-939745b6-4f9d-45a6-8016-814837e7f7b4.png">
|
||||
Überwache Service-Latenz, Fehlerrate, Durchsatz, Apdex, wichtige Endpunkte, Datenbankaufrufe und externe Aufrufe.
|
||||
|
||||
### Log Verwaltung
|
||||
<p align="center">
|
||||
<img alt="SigNoz APM-Dashboard mit Latenz, Durchsatz, Apdex und wichtigen Operationen" src="docs/readme-assets/monitor/apm.png" width="900">
|
||||
</p>
|
||||
|
||||
<img width="2068" alt="logs_management" src="https://user-images.githubusercontent.com/83692067/226536482-b8a5c4af-b69c-43d5-969c-338bd5eaf1a5.png">
|
||||
Mehr erfahren: [APM-Dokumentation](https://signoz.io/docs/instrumentation/overview/)
|
||||
|
||||
### Infrastruktur Überwachung
|
||||
#### Log-Management
|
||||
|
||||
<img width="2068" alt="infrastructure_monitoring" src="https://user-images.githubusercontent.com/83692067/226536496-f38c4dbf-e03c-4158-8be0-32d4a61158c7.png">
|
||||
Erfasse, suche, aggregiere und korreliere Logs mit Traces und Metriken über einen visuellen Query Builder.
|
||||
|
||||
### Exceptions Monitoring
|
||||
<p align="center">
|
||||
<img alt="SigNoz Logs Explorer mit Filtern, Frequenzdiagramm und Log-Zeilen" src="docs/readme-assets/monitor/log-management.svg" width="900">
|
||||
</p>
|
||||
|
||||

|
||||
Mehr erfahren: [Log-Management-Dokumentation](https://signoz.io/docs/logs-management/overview/)
|
||||
|
||||
### Alarme
|
||||
#### Metriken und Dashboards
|
||||
|
||||
<img width="2068" alt="alerts_management" src="https://user-images.githubusercontent.com/83692067/226536548-2c81e2e8-c12d-47e8-bad7-c6be79055def.png">
|
||||
Erstelle Dashboards für Anwendungs-, Infrastruktur- und benutzerdefinierte Metriken mit Query Builder, PromQL oder ClickHouse SQL.
|
||||
|
||||
<br /><br />
|
||||
<p align="center">
|
||||
<img alt="SigNoz Host-Metrics-Dashboard mit Systemlast- und Netzwerkdiagrammen" src="docs/readme-assets/monitor/metrics.png" width="900">
|
||||
</p>
|
||||
|
||||
## Werde Teil unserer Slack Community
|
||||
Mehr erfahren: [Metriken-Dokumentation](https://signoz.io/docs/metrics-management/overview/)
|
||||
|
||||
Sag Hi zu uns auf [Slack](https://signoz.io/slack) 👋
|
||||
#### Infrastruktur-Monitoring
|
||||
|
||||
<br /><br />
|
||||
Überwache Kubernetes-Cluster, Pods, Nodes, Workloads sowie Host-CPU, Arbeitsspeicher, Festplatten, Netzwerk, Logs und Traces.
|
||||
|
||||
## Funktionen:
|
||||
<p align="center">
|
||||
<img alt="SigNoz Kubernetes-Infrastruktur-Dashboard mit Pod- und Node-Metriken" src="docs/readme-assets/monitor/infrastructure.png" width="900">
|
||||
</p>
|
||||
|
||||
- Einheitliche Benutzeroberfläche für Metriken, Traces und Logs. Keine Notwendigkeit, zwischen Prometheus und Jaeger zu wechseln, um Probleme zu debuggen oder ein separates Log-Tool wie Elastic neben Ihrer Metriken- und Traces-Stack zu verwenden.
|
||||
- Überblick über Anwendungsmetriken wie RPS, Latenzzeiten des 50tes/90tes/99tes Perzentils und Fehlerquoten.
|
||||
- Langsamste Endpunkte in Ihrer Anwendung.
|
||||
- Zeigen Sie genaue Anfragetraces an, um Probleme in nachgelagerten Diensten, langsamen Datenbankabfragen oder Aufrufen von Drittanbieterdiensten wie Zahlungsgateways zu identifizieren.
|
||||
- Filtern Sie Traces nach Dienstname, Operation, Latenz, Fehler, Tags/Annotationen.
|
||||
- Führen Sie Aggregationen auf Trace-Daten (Ereignisse/Spans) durch, um geschäftsrelevante Metriken zu erhalten. Beispielsweise können Sie die Fehlerquote und die 99tes Perzentillatenz für `customer_type: gold` oder `deployment_version: v2` oder `external_call: paypal` erhalten.
|
||||
- Native Unterstützung für OpenTelemetry-Logs, erweiterten Log-Abfrage-Builder und automatische Log-Sammlung aus dem Kubernetes-Cluster.
|
||||
- Blitzschnelle Log-Analytik ([Logs Perf. Benchmark](https://signoz.io/blog/logs-performance-benchmark/))
|
||||
- End-to-End-Sichtbarkeit der Infrastrukturleistung, Aufnahme von Metriken aus allen Arten von Host-Umgebungen.
|
||||
- Einfache Einrichtung von Benachrichtigungen mit dem selbst erstellbaren Abfrage-Builder.
|
||||
Mehr erfahren: [Infrastruktur-Monitoring-Dokumentation](https://signoz.io/docs/infrastructure-monitoring/overview/)
|
||||
|
||||
<br /><br />
|
||||
#### LLM- und AI-Observability
|
||||
|
||||
## Wieso SigNoz?
|
||||
Verfolge LLM-Apps, RAG-Pipelines, Prompts, Tool Calls, Tokens, Latenz und Kosten zusammen mit Anwendungs- und Infrastruktur-Telemetrie.
|
||||
|
||||
Als Entwickler fanden wir es anstrengend, uns für jede kleine Funktion, die wir haben wollten, auf Closed Source SaaS Anbieter verlassen zu müssen. Closed Source Anbieter überraschen ihre Kunden zum Monatsende oft mit hohen Rechnungen, die keine Transparenz bzgl. der Kostenaufteilung bieten.
|
||||
<p align="center">
|
||||
<img alt="SigNoz LLM-Observability-Dashboard für Traces, Token-Nutzung, Latenz und Kosten" src="docs/readme-assets/monitor/llm.png" width="900">
|
||||
</p>
|
||||
|
||||
Wir wollten eine selbst gehostete, Open Source Variante von Lösungen wie DataDog, NewRelic für Firmen anbieten, die Datenschutz und Sicherheitsbedenken haben, bei der Weitergabe von Kundendaten an Drittanbieter.
|
||||
Mehr erfahren: [LLM-Observability-Dokumentation](https://signoz.io/docs/llm-observability/)
|
||||
|
||||
Open Source gibt dir außerdem die totale Kontrolle über deine Konfiguration, Stichprobenentnahme und Betriebszeit. Du kannst des Weiteren neue Module auf Basis von SigNoz bauen, die erweiterte, geschäftsspezifische Funktionen anbieten.
|
||||
#### Agent-Native Observability und MCP
|
||||
|
||||
### Languages supported:
|
||||
Nutze den SigNoz MCP-Server, um Telemetrie in Coding Agents zu bringen, oder nutze Noz in SigNoz, um Incidents zu untersuchen, Alerts zu verbessern und Dashboards mit Produktionskontext zu erstellen. [Noz](https://signoz.io/docs/ai/noz/) ist nur in SigNoz Cloud verfügbar.
|
||||
|
||||
Wir unterstützen [OpenTelemetry](https://opentelemetry.io) als Bibliothek, mit der Sie Ihre Anwendungen instrumentieren können. Daher wird jedes von OpenTelemetry unterstützte Framework und jede Sprache auch von SignNoz unterstützt. Einige der wichtigsten unterstützten Sprachen sind:
|
||||
<p align="center">
|
||||
<img alt="SigNoz Noz-Oberfläche neben einem MCP-gestützten Agent-Workflow" src="docs/readme-assets/monitor/agent-native.png" width="900">
|
||||
</p>
|
||||
|
||||
- Java
|
||||
- Python
|
||||
- NodeJS
|
||||
- Go
|
||||
- PHP
|
||||
- .NET
|
||||
- Ruby
|
||||
- Elixir
|
||||
- Rust
|
||||
Mehr erfahren: [SigNoz MCP-Server-Dokumentation](https://signoz.io/docs/ai/signoz-mcp-server/) · [Agent-Skills-Dokumentation](https://signoz.io/docs/ai/agent-skills/#install-the-plugin)
|
||||
|
||||
Hier findest du die vollständige Liste von unterstützten Programmiersprachen - https://opentelemetry.io/docs/
|
||||
#### Distributed Tracing
|
||||
|
||||
<br /><br />
|
||||
Verfolge Requests über Services hinweg mit Flamegraphs, Waterfalls, Span Events, Filtern und Trace Analytics.
|
||||
|
||||
## Erste Schritte mit SigNoz
|
||||
<p align="center">
|
||||
<img alt="SigNoz Distributed-Tracing-Ansicht mit Flamegraph und Waterfall-Spans" src="docs/readme-assets/monitor/distributed-tracing.png" width="900">
|
||||
</p>
|
||||
|
||||
### Bereitstellung mit Docker
|
||||
Mehr erfahren: [Distributed-Tracing-Dokumentation](https://signoz.io/docs/instrumentation/)
|
||||
|
||||
Bitte folge den [hier](https://signoz.io/docs/install/docker/) aufgelisteten Schritten um deine Anwendung mit Docker bereitzustellen.
|
||||
#### Trace Funnels
|
||||
|
||||
Die [Anleitungen zur Fehlerbehebung](https://signoz.io/docs/install/troubleshooting/) könnten hilfreich sein, falls du auf irgendwelche Schwierigkeiten stößt.
|
||||
Erstelle Funnels aus Traces, um Drop-offs im Request-Flow, fehlgeschlagene Übergänge und systemische Workflow-Probleme zu verstehen.
|
||||
|
||||
<p>  </p>
|
||||
<p align="center">
|
||||
<img alt="SigNoz Trace Funnels mit Request-Flow-Drop-offs und fehlgeschlagenen Übergängen" src="docs/readme-assets/monitor/trace-funnels.png" width="900">
|
||||
</p>
|
||||
|
||||
### Deploy in Kubernetes using Helm
|
||||
Mehr erfahren: [Trace-Funnels-Dokumentation](https://signoz.io/docs/trace-funnels/overview/)
|
||||
|
||||
Bitte folge den [hier](https://signoz.io/docs/deployment/helm_chart) aufgelisteten Schritten, um deine Anwendung mit Helm Charts bereitzustellen.
|
||||
Du kannst außerdem [**Exceptions**](https://signoz.io/docs/userguide/exceptions/), [**Alerts**](https://signoz.io/docs/alerts/), [**externe APIs**](https://signoz.io/docs/external-api-monitoring/overview/) und [**Integrationen**](https://signoz.io/docs/integrations/integrations-list/) für OpenTelemetry, Prometheus, Kubernetes, Cloud-Anbieter, Sprach-SDKs, Application Frameworks, Datenbanken und LLM-Tools überwachen.
|
||||
|
||||
<br /><br />
|
||||
### Warum Teams SigNoz nutzen
|
||||
|
||||
## Vergleiche mit bekannten Tools
|
||||
1. **OpenTelemetry-native**<br>
|
||||
Einmal mit offenen Standards instrumentieren und die Kontrolle über deine Telemetrie behalten.
|
||||
2. **Korrelierte Signale**<br>
|
||||
Von Service-Charts zu Traces, Logs, Infrastrukturmetriken und Exceptions wechseln, ohne das Tool zu wechseln.
|
||||
3. **Eine einzelne spaltenorientierte Datenbank**<br>
|
||||
Gebaut für hochkardinale Observability-Workloads mit hohem Volumen.
|
||||
4. **Vorhersehbare Preise**<br>
|
||||
Keine Preise pro Host, keine Preise pro Nutzerplatz und keine Sonderpreise für Custom Metrics.
|
||||
5. **Enterprise-ready**<br>
|
||||
SOC 2 Type II und HIPAA Compliance, RBAC, Ingestion Controls, benutzerdefinierte Aufbewahrung, Support, BYOC und Self-Hosting.
|
||||
|
||||
### SigNoz vs Prometheus
|
||||
### Erste Schritte
|
||||
|
||||
Prometheus ist gut, falls du dich nur für Metriken interessierst. Wenn du eine nahtlose Integration von Metriken und Einzelschritt-Fehlersuchen haben möchtest, ist die Kombination aus Prometheus und Jaeger nicht das Richtige für dich.
|
||||
#### Mit Cloud starten
|
||||
|
||||
Unser Ziel ist es, eine integrierte Benutzeroberfläche aus Metriken und Einzelschritt-Fehlersuchen anzubieten, ähnlich wie es SaaS Anbieter wie Datadog tun, mit der Möglichkeit von erweitertem filtern und aggregieren von Fehlersuchen. Etwas, was in Jaeger aktuell fehlt.
|
||||
Erstelle einen verwalteten SigNoz-Workspace und erhalte dein erstes Dashboard, ohne Observability-Infrastruktur betreiben zu müssen.
|
||||
|
||||
<p>  </p>
|
||||
[**Kostenlos mit SigNoz Cloud starten**](https://signoz.io/teams/)
|
||||
|
||||
### SigNoz vs Jaeger
|
||||
#### SigNoz selbst hosten
|
||||
|
||||
Jaeger kümmert sich nur um verteilte Einzelschritt-Fehlersuche. SigNoz erstellt sowohl Metriken als auch Einzelschritt-Fehlersuche, daneben haben wir auch Protokoll Verwaltung auf unserem Plan.
|
||||
Betreibe SigNoz in deiner eigenen Infrastruktur mit Foundry, Docker, Kubernetes oder Linux.
|
||||
|
||||
Außerdem hat SigNoz noch mehr spezielle Funktionen im Vergleich zu Jaeger:
|
||||
[**Foundry**](https://github.com/SigNoz/foundry) · [**Docker**](https://signoz.io/docs/install/docker/) · [**Kubernetes**](https://signoz.io/docs/install/kubernetes/) · [**Linux**](https://signoz.io/docs/install/linux/)
|
||||
|
||||
- Jaeger UI zeigt keine Metriken für Einzelschritt-Fehlersuchen oder für gefilterte Einzelschritt-Fehlersuchen an.
|
||||
- Jaeger erstellt keine Aggregate für gefilterte Einzelschritt-Fehlersuchen, z. B. die P99 Latenz von Abfragen mit dem Tag `customer_type=premium`, was hingegen mit SigNoz leicht umsetzbar ist.
|
||||
#### Daten senden
|
||||
|
||||
<p>  </p>
|
||||
Instrumentiere Anwendungen und Infrastruktur mit OpenTelemetry, Prometheus, Sprach-SDKs und Integrationen.
|
||||
|
||||
### SigNoz vs Elastic
|
||||
[**Instrumentation**](https://signoz.io/docs/instrumentation/) · [**Integrationen**](https://signoz.io/docs/integrations/integrations-list/)
|
||||
|
||||
- Die Verwaltung von SigNoz-Protokollen basiert auf 'ClickHouse', einem spaltenbasierten OLAP-Datenspeicher, der aggregierte Protokollanalyseabfragen wesentlich effizienter macht.
|
||||
- 50 % geringerer Ressourcenbedarf im Vergleich zu Elastic während der Aufnahme.
|
||||
### Vergleich mit bekannten Tools
|
||||
|
||||
Wir haben Benchmarks veröffentlicht, die Elastic mit SignNoz vergleichen. Schauen Sie es sich [hier](https://signoz.io/blog/logs-performance-benchmark/?utm_source=github-readme&utm_medium=logs-benchmark)
|
||||
SigNoz wird häufig von Teams eingeführt, die von einzelnen Spezialtools oder kommerziellen Plattformen mit unvorhersehbarer Preisgestaltung wechseln.
|
||||
|
||||
<p>  </p>
|
||||
**Prometheus**<br>
|
||||
Gut, wenn du nur Metriken brauchst. SigNoz hält Metriken, Logs, Traces, Dashboards und Alerts zusammen, damit Teams mit korreliertem Kontext debuggen können.
|
||||
|
||||
### SigNoz vs Loki
|
||||
**Jaeger**<br>
|
||||
Jaeger macht ausschließlich Distributed Tracing. SigNoz ergänzt Metriken, Logs, Trace Analytics, Dashboards, Alerts, Exceptions und Trace-to-Log-Workflows.
|
||||
|
||||
- SigNoz unterstützt Aggregationen von Daten mit hoher Kardinalität über ein großes Volumen, Loki hingegen nicht.
|
||||
- SigNoz unterstützt Indizes über Daten mit hoher Kardinalität und hat keine Beschränkungen hinsichtlich der Anzahl der Indizes, während Loki maximale Streams erreicht, wenn ein paar Indizes hinzugefügt werden.
|
||||
- Das Durchsuchen großer Datenmengen ist in Loki im Vergleich zu SigNoz schwierig und langsam.
|
||||
**Elastic**<br>
|
||||
SigNoz nutzt eine spaltenorientierte Datenbank für effiziente Observability-Analysen und hochkardinale Log-Workloads, mit 50 % geringerem Ressourcenbedarf gegenüber Elastic während der Ingestion. Lies die [detaillierte Studie](https://signoz.io/blog/logs-performance-benchmark/?utm_source=github-readme&utm_medium=logs-benchmark).
|
||||
|
||||
Wir haben Benchmarks veröffentlicht, die Loki mit SigNoz vergleichen. Schauen Sie es sich [hier](https://signoz.io/blog/logs-performance-benchmark/?utm_source=github-readme&utm_medium=logs-benchmark)
|
||||
**Loki**<br>
|
||||
Im verlinkten Benchmark indexierte SigNoz alle Keys im Test-Setup, während Loki beim Hinzufügen weiterer Labels Max-Stream-Fehler erreichte. Lies die [detaillierte Studie](https://signoz.io/blog/logs-performance-benchmark/?utm_source=github-readme&utm_medium=logs-benchmark).
|
||||
|
||||
<br /><br />
|
||||
## Mitwirken
|
||||
|
||||
## Zum Projekt beitragen
|
||||
Wir freuen uns über große und kleine Beiträge. Lies bitte [CONTRIBUTING.md](CONTRIBUTING.md), um mit Beiträgen zu SigNoz loszulegen.
|
||||
|
||||
Wir ❤️ Beiträge zum Projekt, egal ob große oder kleine. Bitte lies dir zuerst die [CONTRIBUTING.md](CONTRIBUTING.md), durch, bevor du anfängst, Beiträge zu SigNoz zu machen.
|
||||
Du bist dir nicht sicher, wie du anfangen sollst? Schreib uns einfach auf dem #contributing Kanal in unserer [slack community](https://signoz.io/slack)
|
||||
Nicht sicher, wie du anfangen sollst? **Schreib uns einfach im Channel `#contributing` in unserer [Slack Community](https://signoz.io/slack).**
|
||||
|
||||
<br /><br />
|
||||
|
||||
## Dokumentation
|
||||
|
||||
Du findest unsere Dokumentation unter https://signoz.io/docs/. Falls etwas unverständlich ist oder fehlt, öffne gerne ein Github Issue mit dem Label `documentation` oder schreib uns über den Community Slack Channel.
|
||||
|
||||
<br /><br />
|
||||
|
||||
## Gemeinschaft
|
||||
|
||||
Werde Teil der [slack community](https://signoz.io/slack) um mehr über verteilte Einzelschritt-Fehlersuche, Messung von Systemzuständen oder SigNoz zu erfahren und sich mit anderen Nutzern und Mitwirkenden in Verbindung zu setzen.
|
||||
|
||||
Falls du irgendwelche Ideen, Fragen oder Feedback hast, kannst du sie gerne über unsere [Github Discussions](https://github.com/SigNoz/signoz/discussions) mit uns teilen.
|
||||
|
||||
Wie immer, Dank an unsere großartigen Mitwirkenden!
|
||||
Wie immer: Danke an unsere großartigen Contributors!
|
||||
|
||||
<a href="https://github.com/signoz/signoz/graphs/contributors">
|
||||
<img src="https://contrib.rocks/image?repo=signoz/signoz" />
|
||||
<img alt="SigNoz Contributors" src="https://contrib.rocks/image?repo=signoz/signoz" />
|
||||
</a>
|
||||
|
||||
278
README.md
@@ -1,244 +1,190 @@
|
||||
<h1 align="center" style="border-bottom: none">
|
||||
<a href="https://signoz.io" target="_blank">
|
||||
<img alt="SigNoz" src="https://github.com/user-attachments/assets/ef9a33f7-12d7-4c94-8908-0a02b22f0c18" width="100" height="100">
|
||||
</a>
|
||||
<br>SigNoz
|
||||
</h1>
|
||||
|
||||
<p align="center">All your logs, metrics, and traces in one place. Monitor your application, spot issues before they occur and troubleshoot downtime quickly with rich context. SigNoz is a cost-effective open-source alternative to Datadog and New Relic. Visit <a href="https://signoz.io" target="_blank">signoz.io</a> for the full documentation, tutorials, and guide.</p>
|
||||
<p align="center">
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="docs/readme-assets/signoz-hero-dark.png" width="900">
|
||||
<source media="(prefers-color-scheme: light)" srcset="docs/readme-assets/signoz-hero-light.png" width="900">
|
||||
<img alt="SigNoz - Observability on Your Terms, Powered by Open Standards." src="docs/readme-assets/signoz-hero-light.png" width="900">
|
||||
</picture>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<img alt="GitHub issues" src="https://img.shields.io/github/issues/signoz/signoz"> </a>
|
||||
<a href="https://twitter.com/intent/tweet?text=Monitor%20your%20applications%20and%20troubleshoot%20problems%20with%20SigNoz,%20an%20open-source%20alternative%20to%20DataDog,%20NewRelic.&url=https://signoz.io/&via=SigNozHQ&hashtags=opensource,signoz,observability">
|
||||
<img alt="tweet" src="https://img.shields.io/twitter/url/http/shields.io.svg?style=social"> </a>
|
||||
<a href="README.zh-cn.md">中文</a> ·
|
||||
<a href="README.de-de.md">Deutsch</a> ·
|
||||
<a href="README.pt-br.md">Português</a>
|
||||
</p>
|
||||
|
||||
|
||||
<h3 align="center">
|
||||
<a href="https://signoz.io/docs"><b>Documentation</b></a> •
|
||||
<a href="https://github.com/SigNoz/signoz/blob/main/README.zh-cn.md"><b>ReadMe in Chinese</b></a> •
|
||||
<a href="https://github.com/SigNoz/signoz/blob/main/README.de-de.md"><b>ReadMe in German</b></a> •
|
||||
<a href="https://github.com/SigNoz/signoz/blob/main/README.pt-br.md"><b>ReadMe in Portuguese</b></a> •
|
||||
<a href="https://signoz.io/slack"><b>Slack Community</b></a> •
|
||||
<a href="https://twitter.com/SigNozHq"><b>Twitter</b></a>
|
||||
</h3>
|
||||
|
||||
## Features
|
||||
<p align="center">
|
||||
<a href="https://github.com/SigNoz/signoz/issues"><img alt="GitHub issues" src="https://img.shields.io/github/issues/SigNoz/signoz"></a>
|
||||
<a href="https://github.com/SigNoz/signoz/releases"><img alt="GitHub release" src="https://img.shields.io/github/v/release/SigNoz/signoz?label=release"></a>
|
||||
<a href="https://signoz.io/slack"><img alt="Slack community" src="https://img.shields.io/badge/slack-community-4A154B?logo=slack&logoColor=white"></a>
|
||||
<a href="https://www.linkedin.com/company/signozio/"><img alt="LinkedIn" src="https://img.shields.io/badge/linkedin-SigNoz-0A66C2?logo=linkedin&logoColor=white"></a>
|
||||
<a href="https://twitter.com/intent/tweet?text=Monitor%20your%20applications%20and%20troubleshoot%20problems%20with%20SigNoz,%20an%20open-source%20alternative%20to%20DataDog,%20NewRelic.&url=https://signoz.io/&via=SigNozHQ&hashtags=opensource,signoz,observability"><img alt="Tweet" src="https://img.shields.io/twitter/url/http/shields.io.svg?style=social"></a>
|
||||
</p>
|
||||
|
||||
SigNoz is an open-source observability platform built on OpenTelemetry. We’re building an enterprise-grade alternative to fragmented monitoring stacks, with logs, metrics, traces, alerts, and dashboards in one place.
|
||||
|
||||
### Application Performance Monitoring
|
||||
### Choose how to run SigNoz
|
||||
|
||||
Use SigNoz APM to monitor your applications and services. It comes with out-of-box charts for key application metrics like p99 latency, error rate, Apdex and operations per second. You can also monitor the database and external calls made from your application. Read [more](https://signoz.io/application-performance-monitoring/).
|
||||
#### SigNoz Cloud (Recommended)
|
||||
|
||||
You can [instrument](https://signoz.io/docs/instrumentation/) your application with OpenTelemetry to get started.
|
||||
Fully managed SigNoz with a 30-day free trial, no credit card required, usage-based pricing that starts at $49, and regional data hosting.
|
||||
|
||||

|
||||
[**Start free →**](https://signoz.io/teams/)
|
||||
|
||||
#### Enterprise
|
||||
|
||||
### Logs Management
|
||||
Enterprise Cloud, BYOC, or Enterprise Self-Hosted with compliance, support, custom retention, RBAC, ingestion controls, data residency, and region selection.
|
||||
|
||||
SigNoz can be used as a centralized log management solution. We use ClickHouse (used by likes of Uber & Cloudflare) as a datastore, ⎯ an extremely fast and highly optimized storage for logs data. Instantly search through all your logs using quick filters and a powerful query builder.
|
||||
[**Explore Enterprise →**](https://signoz.io/enterprise/)
|
||||
|
||||
You can also create charts on your logs and monitor them with customized dashboards. Read [more](https://signoz.io/log-management/).
|
||||
#### Community
|
||||
|
||||

|
||||
Free open-source SigNoz that runs in your own infrastructure. Deploy with Docker, Kubernetes, or Linux and keep full control of your data plane.
|
||||
|
||||
[**Install SigNoz →**](https://signoz.io/docs/install/self-host/)
|
||||
|
||||
### Distributed Tracing
|
||||
### What can you monitor?
|
||||
|
||||
Distributed Tracing is essential to troubleshoot issues in microservices applications. Powered by OpenTelemetry, distributed tracing in SigNoz can help you track user requests across services to help you identify performance bottlenecks.
|
||||
SigNoz helps teams debug production issues faster by connecting logs, metrics, traces, alerts, dashboards, exceptions, and agent-native workflows in one place.
|
||||
|
||||
See user requests in a detailed breakdown with the help of Flamegraphs and Gantt Charts. Click on any span to see the entire trace represented beautifully, which will help you make sense of where issues actually occurred in the flow of requests.
|
||||
#### APM Overview
|
||||
|
||||
Read [more](https://signoz.io/distributed-tracing/).
|
||||
Monitor service latency, error rate, throughput, Apdex, top endpoints, database calls, and external calls.
|
||||
|
||||

|
||||
<p align="center">
|
||||
<img alt="SigNoz APM dashboard showing latency, throughput, Apdex, and key operations" src="docs/readme-assets/monitor/apm.png" width="900">
|
||||
</p>
|
||||
|
||||
Learn more: [APM documentation](https://signoz.io/docs/instrumentation/overview/)
|
||||
|
||||
#### Log Management
|
||||
|
||||
### Metrics and Dashboards
|
||||
Ingest, search, aggregate, and correlate logs with traces and metrics using a visual query builder.
|
||||
|
||||
Ingest metrics from your infrastructure or applications and create customized dashboards to monitor them. Create visualization that suits your needs with a variety of panel types like pie chart, time-series, bar chart, etc.
|
||||
<p align="center">
|
||||
<img alt="SigNoz logs explorer with filters, frequency chart, and log lines" src="docs/readme-assets/monitor/log-management.svg" width="900">
|
||||
</p>
|
||||
|
||||
Create queries on your metrics data quickly with an easy-to-use metrics query builder. Add multiple queries and combine those queries with formulae to create really complex queries quickly.
|
||||
Learn more: [Log management documentation](https://signoz.io/docs/logs-management/overview/)
|
||||
|
||||
Read [more](https://signoz.io/metrics-and-dashboards/).
|
||||
#### Metrics and Dashboards
|
||||
|
||||

|
||||
Build dashboards for application, infrastructure, and custom metrics using Query Builder, PromQL, or ClickHouse SQL.
|
||||
|
||||
### LLM Observability
|
||||
<p align="center">
|
||||
<img alt="SigNoz host metrics dashboard with system load and network charts" src="docs/readme-assets/monitor/metrics.png" width="900">
|
||||
</p>
|
||||
|
||||
Monitor and debug your LLM applications with comprehensive observability. Track LLM calls, analyze token usage, monitor performance, and gain insights into your AI application's behavior in production.
|
||||
Learn more: [Metrics documentation](https://signoz.io/docs/metrics-management/overview/)
|
||||
|
||||
SigNoz LLM observability helps you understand how your language models are performing, identify issues with prompts and responses, track token usage and costs, and optimize your AI applications for better performance and reliability.
|
||||
#### Infrastructure Monitoring
|
||||
|
||||
[Get started with LLM Observability →](https://signoz.io/docs/llm-observability/)
|
||||
Monitor Kubernetes clusters, pods, nodes, workloads, and host-level CPU, memory, disk, network, logs, and traces.
|
||||
|
||||

|
||||
<p align="center">
|
||||
<img alt="SigNoz Kubernetes infrastructure dashboard with pod and node metrics" src="docs/readme-assets/monitor/infrastructure.png" width="900">
|
||||
</p>
|
||||
|
||||
Learn more: [Infrastructure monitoring documentation](https://signoz.io/docs/infrastructure-monitoring/overview/)
|
||||
|
||||
### Alerts
|
||||
#### LLM and AI Observability
|
||||
|
||||
Use alerts in SigNoz to get notified when anything unusual happens in your application. You can set alerts on any type of telemetry signal (logs, metrics, traces), create thresholds and set up a notification channel to get notified. Advanced features like alert history and anomaly detection can help you create smarter alerts.
|
||||
Trace LLM apps, RAG pipelines, prompts, tool calls, tokens, latency, and costs alongside application and infrastructure telemetry.
|
||||
|
||||
Alerts in SigNoz help you identify issues proactively so that you can address them before they reach your customers.
|
||||
<p align="center">
|
||||
<img alt="SigNoz LLM observability dashboard for traces, token usage, latency, and costs" src="docs/readme-assets/monitor/llm.png" width="900">
|
||||
</p>
|
||||
|
||||
Read [more](https://signoz.io/alerts-management/).
|
||||
Learn more: [LLM observability documentation](https://signoz.io/docs/llm-observability/)
|
||||
|
||||

|
||||
#### Agent-Native Observability and MCP
|
||||
|
||||
### Exceptions Monitoring
|
||||
Use the SigNoz MCP server to bring telemetry into coding agents, or use Noz inside SigNoz to investigate incidents, tune alerts, and build dashboards with production context. [Noz](https://signoz.io/docs/ai/noz/) is available only on SigNoz Cloud.
|
||||
|
||||
Monitor exceptions automatically in Python, Java, Ruby, and Javascript. For other languages, just drop in a few lines of code and start monitoring exceptions.
|
||||
<p align="center">
|
||||
<img alt="SigNoz Noz interface alongside MCP-powered agent workflow" src="docs/readme-assets/monitor/agent-native.png" width="900">
|
||||
</p>
|
||||
|
||||
See the detailed stack trace for all exceptions caught in your application. You can also log in custom attributes to add more context to your exceptions. For example, you can add attributes to identify users for which exceptions occurred.
|
||||
Learn more: [SigNoz MCP server docs](https://signoz.io/docs/ai/signoz-mcp-server/) · [Agent skills docs](https://signoz.io/docs/ai/agent-skills/#install-the-plugin)
|
||||
|
||||
Read [more](https://signoz.io/exceptions-monitoring/).
|
||||
#### Distributed Tracing
|
||||
|
||||
Follow requests across services with flamegraphs, waterfalls, span events, filters, and trace analytics.
|
||||
|
||||

|
||||
<p align="center">
|
||||
<img alt="SigNoz distributed trace view with flamegraph and waterfall spans" src="docs/readme-assets/monitor/distributed-tracing.png" width="900">
|
||||
</p>
|
||||
|
||||
Learn more: [Distributed tracing documentation](https://signoz.io/docs/instrumentation/)
|
||||
|
||||
<br /><br />
|
||||
#### Trace Funnels
|
||||
|
||||
## Why SigNoz?
|
||||
Create funnels from traces to understand request-flow drop-offs, failed transitions, and systemic workflow issues.
|
||||
|
||||
SigNoz is a single tool for all your monitoring and observability needs. Here are a few reasons why you should choose SigNoz:
|
||||
<p align="center">
|
||||
<img alt="SigNoz trace funnels showing request-flow drop-offs and failed transitions" src="docs/readme-assets/monitor/trace-funnels.png" width="900">
|
||||
</p>
|
||||
|
||||
- Single tool for observability(logs, metrics, and traces)
|
||||
Learn more: [Trace funnels documentation](https://signoz.io/docs/trace-funnels/overview/)
|
||||
|
||||
- Built on top of [OpenTelemetry](https://opentelemetry.io/), the open-source standard which frees you from any type of vendor lock-in
|
||||
Also monitor: [**exceptions**](https://signoz.io/docs/userguide/exceptions/), [**alerts**](https://signoz.io/docs/alerts/), [**external APIs**](https://signoz.io/docs/external-api-monitoring/overview/), and [**integrations**](https://signoz.io/docs/integrations/integrations-list/) for OpenTelemetry, Prometheus, Kubernetes, cloud providers, language SDKs, application frameworks, databases, and LLM tools.
|
||||
|
||||
- Correlated logs, metrics and traces for much richer context while debugging
|
||||
### Why teams use SigNoz
|
||||
|
||||
- Uses ClickHouse (used by likes of Uber & Cloudflare) as datastore - an extremely fast and highly optimized storage for observability data
|
||||
1. **OpenTelemetry-native**<br>
|
||||
Instrument once with open standards and keep ownership of your telemetry.
|
||||
2. **Correlated signals**<br>
|
||||
Move from service charts to traces, logs, infra metrics, and exceptions without switching tools.
|
||||
3. **Single columnar database**<br>
|
||||
Built for high-cardinality, high-volume observability workloads.
|
||||
4. **Predictable pricing**<br>
|
||||
No per-host pricing, no user-seat pricing, and no special pricing for custom metrics.
|
||||
5. **Enterprise ready**<br>
|
||||
SOC 2 Type II and HIPAA compliance, RBAC, ingestion controls, custom retention, support, BYOC, and self-hosting.
|
||||
|
||||
- DIY Query builder, PromQL, and ClickHouse queries to fulfill all your use-cases around querying observability data
|
||||
### Getting started
|
||||
|
||||
- Open-Source - you can use open-source, our [cloud service](https://signoz.io/teams/) or a mix of both based on your use case
|
||||
#### Start on Cloud
|
||||
|
||||
Create a managed SigNoz workspace and get your first dashboard without running observability infrastructure.
|
||||
|
||||
## Getting Started
|
||||
[**Start free on SigNoz Cloud**](https://signoz.io/teams/)
|
||||
|
||||
### Create a SigNoz Cloud Account
|
||||
#### Self-host SigNoz
|
||||
|
||||
SigNoz cloud is the easiest way to get started with SigNoz. Our cloud service is for those users who want to spend more time in getting insights for their application performance without worrying about maintenance.
|
||||
Run SigNoz in your own infrastructure with Foundry, Docker, Kubernetes, or Linux.
|
||||
|
||||
[Get started for free](https://signoz.io/teams/)
|
||||
[**Foundry**](https://github.com/SigNoz/foundry) · [**Docker**](https://signoz.io/docs/install/docker/) · [**Kubernetes**](https://signoz.io/docs/install/kubernetes/) · [**Linux**](https://signoz.io/docs/install/linux/)
|
||||
|
||||
### Deploy using Docker(self-hosted)
|
||||
#### Send data
|
||||
|
||||
Please follow the steps listed [here](https://signoz.io/docs/install/docker/) to install using docker
|
||||
Instrument applications and infrastructure with OpenTelemetry, Prometheus, language SDKs, and integrations.
|
||||
|
||||
The [troubleshooting instructions](https://signoz.io/docs/install/troubleshooting/) may be helpful if you face any issues.
|
||||
[**Instrumentation**](https://signoz.io/docs/instrumentation/) · [**Integrations**](https://signoz.io/docs/integrations/integrations-list/)
|
||||
|
||||
<p>  </p>
|
||||
|
||||
|
||||
### Deploy in Kubernetes using Helm(self-hosted)
|
||||
### Comparisons to familiar tools
|
||||
|
||||
Please follow the steps listed [here](https://signoz.io/docs/deployment/helm_chart) to install using helm charts
|
||||
SigNoz is often adopted by teams moving from a stack of single-purpose tools or commercial platforms with unpredictable pricing.
|
||||
|
||||
<br /><br />
|
||||
**Prometheus**<br>
|
||||
Good if you just need metrics. SigNoz keeps metrics, logs, traces, dashboards, and alerts together so teams can debug with correlated context.
|
||||
|
||||
We also offer managed services in your infra. Check our [pricing plans](https://signoz.io/pricing/) for all details.
|
||||
**Jaeger**<br>
|
||||
Jaeger only does distributed tracing. SigNoz adds metrics, logs, trace analytics, dashboards, alerts, exceptions, and trace-to-log workflows.
|
||||
|
||||
**Elastic**<br>
|
||||
SigNoz uses columnar database for efficient observability analytics and high-cardinality log workloads, with 50% lower resource requirement compared to Elastic during ingestion. Check the [detailed study](https://signoz.io/blog/logs-performance-benchmark/?utm_source=github-readme&utm_medium=logs-benchmark).
|
||||
|
||||
## Join our Slack community
|
||||
|
||||
Come say Hi to us on [Slack](https://signoz.io/slack) 👋
|
||||
|
||||
<br /><br />
|
||||
|
||||
|
||||
### Languages supported:
|
||||
|
||||
SigNoz supports all major programming languages for monitoring. Any framework and language supported by OpenTelemetry is supported by SigNoz. Find instructions for instrumenting different languages below:
|
||||
|
||||
- [Java](https://signoz.io/docs/instrumentation/java/)
|
||||
- [Python](https://signoz.io/docs/instrumentation/python/)
|
||||
- [Node.js or Javascript](https://signoz.io/docs/instrumentation/javascript/)
|
||||
- [Go](https://signoz.io/docs/instrumentation/golang/)
|
||||
- [PHP](https://signoz.io/docs/instrumentation/php/)
|
||||
- [.NET](https://signoz.io/docs/instrumentation/dotnet/)
|
||||
- [Ruby](https://signoz.io/docs/instrumentation/ruby-on-rails/)
|
||||
- [Elixir](https://signoz.io/docs/instrumentation/elixir/)
|
||||
- [Rust](https://signoz.io/docs/instrumentation/rust/)
|
||||
- [Swift](https://signoz.io/docs/instrumentation/swift/)
|
||||
|
||||
You can find our entire documentation [here](https://signoz.io/docs/introduction/).
|
||||
|
||||
<br /><br />
|
||||
|
||||
|
||||
## Comparisons to Familiar Tools
|
||||
|
||||
### SigNoz vs Prometheus
|
||||
|
||||
Prometheus is good if you want to do just metrics. But if you want to have a seamless experience between metrics, logs and traces, then current experience of stitching together Prometheus & other tools is not great.
|
||||
|
||||
SigNoz is a one-stop solution for metrics and other telemetry signals. And because you will use the same standard(OpenTelemetry) to collect all telemetry signals, you can also correlate these signals to troubleshoot quickly.
|
||||
|
||||
For example, if you see that there are issues with infrastructure metrics of your k8s cluster at a timestamp, you can jump to other signals like logs and traces to understand the issue quickly.
|
||||
|
||||
<p>  </p>
|
||||
|
||||
### SigNoz vs Jaeger
|
||||
|
||||
Jaeger only does distributed tracing. SigNoz supports metrics, traces and logs - all the 3 pillars of observability.
|
||||
|
||||
Moreover, SigNoz has few more advanced features wrt Jaeger:
|
||||
|
||||
- Jaegar UI doesn’t show any metrics on traces or on filtered traces
|
||||
- Jaeger can’t get aggregates on filtered traces. For example, p99 latency of requests which have tag - customer_type='premium'. This can be done easily on SigNoz
|
||||
- You can also go from traces to logs easily in SigNoz
|
||||
|
||||
<p>  </p>
|
||||
|
||||
### SigNoz vs Elastic
|
||||
|
||||
- SigNoz Logs management are based on ClickHouse, a columnar OLAP datastore which makes aggregate log analytics queries much more efficient
|
||||
- 50% lower resource requirement compared to Elastic during ingestion
|
||||
|
||||
We have published benchmarks comparing Elastic with SigNoz. Check it out [here](https://signoz.io/blog/logs-performance-benchmark/?utm_source=github-readme&utm_medium=logs-benchmark)
|
||||
|
||||
<p>  </p>
|
||||
|
||||
### SigNoz vs Loki
|
||||
|
||||
- SigNoz supports aggregations on high-cardinality data over a huge volume while loki doesn’t.
|
||||
- SigNoz supports indexes over high cardinality data and has no limitations on the number of indexes, while Loki reaches max streams with a few indexes added to it.
|
||||
- Searching over a huge volume of data is difficult and slow in Loki compared to SigNoz
|
||||
|
||||
We have published benchmarks comparing Loki with SigNoz. Check it out [here](https://signoz.io/blog/logs-performance-benchmark/?utm_source=github-readme&utm_medium=logs-benchmark)
|
||||
|
||||
<br /><br />
|
||||
|
||||
**Loki**<br>
|
||||
In the linked benchmark, SigNoz indexed all keys in the test setup, while Loki hit max stream errors when more labels were added. Check the [detailed study](https://signoz.io/blog/logs-performance-benchmark/?utm_source=github-readme&utm_medium=logs-benchmark).
|
||||
|
||||
## Contributing
|
||||
|
||||
We ❤️ contributions big or small. Please read [CONTRIBUTING.md](CONTRIBUTING.md) to get started with making contributions to SigNoz.
|
||||
|
||||
Not sure how to get started? Just ping us on `#contributing` in our [slack community](https://signoz.io/slack)
|
||||
|
||||
<br /><br />
|
||||
|
||||
|
||||
## Documentation
|
||||
|
||||
You can find docs at https://signoz.io/docs/. If you need any clarification or find something missing, feel free to raise a GitHub issue with the label `documentation` or reach out to us at the community slack channel.
|
||||
|
||||
<br /><br />
|
||||
|
||||
|
||||
## Community
|
||||
|
||||
Join the [slack community](https://signoz.io/slack) to know more about distributed tracing, observability, or SigNoz and to connect with other users and contributors.
|
||||
|
||||
If you have any ideas, questions, or any feedback, please share on our [Github Discussions](https://github.com/SigNoz/signoz/discussions)
|
||||
Not sure how to get started? **Just ping us on `#contributing` in our [slack community](https://signoz.io/slack).**
|
||||
|
||||
As always, thanks to our amazing contributors!
|
||||
|
||||
<a href="https://github.com/signoz/signoz/graphs/contributors">
|
||||
<img src="https://contrib.rocks/image?repo=signoz/signoz" />
|
||||
<img alt="SigNoz contributors" src="https://contrib.rocks/image?repo=signoz/signoz" />
|
||||
</a>
|
||||
|
||||
232
README.pt-br.md
@@ -1,158 +1,190 @@
|
||||
<p align="center">
|
||||
<img src="https://res.cloudinary.com/dcv3epinx/image/upload/v1618904450/signoz-images/LogoGithub_sigfbu.svg" alt="SigNoz-logo" width="240" />
|
||||
|
||||
<p align="center">Monitore seus aplicativos e solucione problemas em seus aplicativos implantados, uma alternativa de código aberto para soluções como DataDog, New Relic, entre outras.</p>
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="docs/readme-assets/signoz-hero-dark.png" width="900">
|
||||
<source media="(prefers-color-scheme: light)" srcset="docs/readme-assets/signoz-hero-light.png" width="900">
|
||||
<img alt="SigNoz - Observabilidade nos seus termos, baseada em padrões abertos." src="docs/readme-assets/signoz-hero-light.png" width="900">
|
||||
</picture>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<img alt="Downloads" src="https://img.shields.io/docker/pulls/signoz/frontend?label=Downloads"> </a>
|
||||
<img alt="GitHub issues" src="https://img.shields.io/github/issues/signoz/signoz"> </a>
|
||||
<a href="https://twitter.com/intent/tweet?text=Monitor%20your%20applications%20and%20troubleshoot%20problems%20with%20SigNoz,%20an%20open-source%20alternative%20to%20DataDog,%20NewRelic.&url=https://signoz.io/&via=SigNozHQ&hashtags=opensource,signoz,observability">
|
||||
<img alt="tweet" src="https://img.shields.io/twitter/url/http/shields.io.svg?style=social"> </a>
|
||||
<a href="README.md">English</a> ·
|
||||
<a href="README.zh-cn.md">中文</a> ·
|
||||
<a href="README.de-de.md">Deutsch</a>
|
||||
</p>
|
||||
|
||||
|
||||
<h3 align="center">
|
||||
<a href="https://signoz.io/docs"><b>Documentação</b></a> •
|
||||
<a href="https://signoz.io/slack"><b>Comunidade no Slack</b></a> •
|
||||
<a href="https://twitter.com/SigNozHq"><b>Twitter</b></a>
|
||||
</h3>
|
||||
|
||||
##
|
||||
<p align="center">
|
||||
<a href="https://github.com/SigNoz/signoz/issues"><img alt="GitHub issues" src="https://img.shields.io/github/issues/SigNoz/signoz"></a>
|
||||
<a href="https://github.com/SigNoz/signoz/releases"><img alt="GitHub release" src="https://img.shields.io/github/v/release/SigNoz/signoz?label=release"></a>
|
||||
<a href="https://signoz.io/slack"><img alt="Slack community" src="https://img.shields.io/badge/slack-community-4A154B?logo=slack&logoColor=white"></a>
|
||||
<a href="https://www.linkedin.com/company/signozio/"><img alt="LinkedIn" src="https://img.shields.io/badge/linkedin-SigNoz-0A66C2?logo=linkedin&logoColor=white"></a>
|
||||
<a href="https://twitter.com/intent/tweet?text=Monitor%20your%20applications%20and%20troubleshoot%20problems%20with%20SigNoz,%20an%20open-source%20alternative%20to%20DataDog,%20NewRelic.&url=https://signoz.io/&via=SigNozHQ&hashtags=opensource,signoz,observability"><img alt="Tweet" src="https://img.shields.io/twitter/url/http/shields.io.svg?style=social"></a>
|
||||
</p>
|
||||
|
||||
SigNoz auxilia os desenvolvedores a monitorarem aplicativos e solucionar problemas em seus aplicativos implantados. SigNoz usa rastreamento distribuído para obter visibilidade em sua pilha de software.
|
||||
SigNoz é uma plataforma de observabilidade open-source construída sobre OpenTelemetry. Estamos criando uma alternativa de nível empresarial a stacks de monitoramento fragmentadas, com logs, métricas, traces, alertas e dashboards em um só lugar.
|
||||
|
||||
👉 Você pode verificar métricas como latência p99, taxas de erro em seus serviços, requisições às APIs externas e endpoints individuais.
|
||||
### Escolha como executar o SigNoz
|
||||
|
||||
👉 Você pode encontrar a causa raiz do problema acessando os rastreamentos exatos que estão causando o problema e verificar os quadros detalhados de cada requisição individual.
|
||||
#### SigNoz Cloud (recomendado)
|
||||
|
||||
👉 Execute agregações em dados de rastreamento para obter métricas de negócios relevantes.
|
||||
SigNoz totalmente gerenciado, com teste gratuito de 30 dias, sem cartão de crédito, preço baseado em uso a partir de US$ 49 e hospedagem de dados por região.
|
||||
|
||||
[**Comece gratuitamente →**](https://signoz.io/teams/)
|
||||
|
||||

|
||||
#### Enterprise
|
||||
|
||||
<br /><br />
|
||||
Enterprise Cloud, BYOC ou Enterprise Self-Hosted com compliance, suporte, retenção personalizada, RBAC, controles de ingestão, residência de dados e seleção de região.
|
||||
|
||||
<img align="left" src="https://signoz-public.s3.us-east-2.amazonaws.com/Contributing.svg" width="50px" />
|
||||
[**Conheça o Enterprise →**](https://signoz.io/enterprise/)
|
||||
|
||||
## Junte-se à nossa comunidade no Slack
|
||||
#### Community
|
||||
|
||||
Venha dizer oi para nós no [Slack](https://signoz.io/slack) 👋
|
||||
SigNoz open-source gratuito, executado na sua própria infraestrutura. Faça o deploy com Docker, Kubernetes ou Linux e mantenha controle total sobre o seu plano de dados.
|
||||
|
||||
<br /><br />
|
||||
[**Instale o SigNoz →**](https://signoz.io/docs/install/self-host/)
|
||||
|
||||
<img align="left" src="https://signoz-public.s3.us-east-2.amazonaws.com/Features.svg" width="50px" />
|
||||
### O que você pode monitorar?
|
||||
|
||||
## Funções:
|
||||
O SigNoz ajuda equipes a depurar problemas de produção mais rapidamente ao conectar logs, métricas, traces, alertas, dashboards, exceções e fluxos agent-native em um só lugar.
|
||||
|
||||
- Métricas de visão geral do aplicativo, como RPS, latências de percentual 50/90/99 e taxa de erro
|
||||
- Endpoints mais lentos em seu aplicativo
|
||||
- Visualize o rastreamento preciso de requisições de rede para descobrir problemas em serviços downstream, consultas lentas de banco de dados, chamadas para serviços de terceiros, como gateways de pagamento, etc.
|
||||
- Filtre os rastreamentos por nome de serviço, operação, latência, erro, tags / anotações.
|
||||
- Execute agregações em dados de rastreamento (eventos / extensões) para obter métricas de negócios relevantes, como por exemplo, você pode obter a taxa de erro e a latência do 99º percentil de `customer_type: gold` or `deployment_version: v2` or `external_call: paypal`
|
||||
- Interface de Usuário unificada para métricas e rastreios. Não há necessidade de mudar de Prometheus para Jaeger para depurar problemas.
|
||||
#### Visão geral de APM
|
||||
|
||||
<br /><br />
|
||||
Monitore latência de serviço, taxa de erro, throughput, Apdex, principais endpoints, chamadas ao banco de dados e chamadas externas.
|
||||
|
||||
<img align="left" src="https://signoz-public.s3.us-east-2.amazonaws.com/WhatsCool.svg" width="50px" />
|
||||
<p align="center">
|
||||
<img alt="Dashboard de APM do SigNoz mostrando latência, throughput, Apdex e operações principais" src="docs/readme-assets/monitor/apm.png" width="900">
|
||||
</p>
|
||||
|
||||
## Por que escolher SigNoz?
|
||||
Saiba mais: [documentação de APM](https://signoz.io/docs/instrumentation/overview/)
|
||||
|
||||
Sendo desenvolvedores, achamos irritante contar com fornecedores de SaaS de código fechado para cada pequeno recurso que queríamos. Fornecedores de código fechado costumam surpreendê-lo com enormes contas no final do mês de uso sem qualquer transparência .
|
||||
#### Gerenciamento de logs
|
||||
|
||||
Queríamos fazer uma versão auto-hospedada e de código aberto de ferramentas como DataDog, NewRelic para empresas que têm preocupações com privacidade e segurança em ter dados de clientes indo para serviços de terceiros.
|
||||
Ingira, pesquise, agregue e correlacione logs com traces e métricas usando um construtor visual de consultas.
|
||||
|
||||
Ser open source também oferece controle completo de sua configuração, amostragem e tempos de atividade. Você também pode construir módulos sobre o SigNoz para estender recursos específicos do negócio.
|
||||
<p align="center">
|
||||
<img alt="Explorador de logs do SigNoz com filtros, gráfico de frequência e linhas de log" src="docs/readme-assets/monitor/log-management.svg" width="900">
|
||||
</p>
|
||||
|
||||
### Linguagens Suportadas:
|
||||
Saiba mais: [documentação de gerenciamento de logs](https://signoz.io/docs/logs-management/overview/)
|
||||
|
||||
Nós apoiamos a biblioteca [OpenTelemetry](https://opentelemetry.io) como a biblioteca que você pode usar para instrumentar seus aplicativos. Em outras palavras, SigNoz oferece suporte a qualquer framework e linguagem que suporte a biblioteca OpenTelemetry. As principais linguagens suportadas incluem:
|
||||
#### Métricas e dashboards
|
||||
|
||||
- Java
|
||||
- Python
|
||||
- NodeJS
|
||||
- Go
|
||||
Crie dashboards para métricas de aplicação, infraestrutura e métricas personalizadas usando Query Builder, PromQL ou ClickHouse SQL.
|
||||
|
||||
Você pode encontrar a lista completa de linguagens aqui - https://opentelemetry.io/docs/
|
||||
<p align="center">
|
||||
<img alt="Dashboard de métricas de host do SigNoz com gráficos de carga do sistema e rede" src="docs/readme-assets/monitor/metrics.png" width="900">
|
||||
</p>
|
||||
|
||||
<br /><br />
|
||||
Saiba mais: [documentação de métricas](https://signoz.io/docs/metrics-management/overview/)
|
||||
|
||||
<img align="left" src="https://signoz-public.s3.us-east-2.amazonaws.com/Philosophy.svg" width="50px" />
|
||||
#### Monitoramento de infraestrutura
|
||||
|
||||
## Iniciando
|
||||
|
||||
|
||||
### Implantar usando Docker
|
||||
Monitore clusters Kubernetes, pods, nodes, workloads e CPU, memória, disco, rede, logs e traces em nível de host.
|
||||
|
||||
Siga as etapas listadas [aqui](https://signoz.io/docs/install/docker/) para instalar usando o Docker.
|
||||
<p align="center">
|
||||
<img alt="Dashboard de infraestrutura Kubernetes do SigNoz com métricas de pods e nodes" src="docs/readme-assets/monitor/infrastructure.png" width="900">
|
||||
</p>
|
||||
|
||||
Esse [guia para solução de problemas](https://signoz.io/docs/install/troubleshooting/) pode ser útil se você enfrentar quaisquer problemas.
|
||||
Saiba mais: [documentação de monitoramento de infraestrutura](https://signoz.io/docs/infrastructure-monitoring/overview/)
|
||||
|
||||
<p>  </p>
|
||||
|
||||
|
||||
### Implentar no Kubernetes usando Helm
|
||||
#### Observabilidade de LLM e AI
|
||||
|
||||
Siga as etapas listadas [aqui](https://signoz.io/docs/deployment/helm_chart) para instalar usando helm charts.
|
||||
|
||||
Rastreie apps LLM, pipelines RAG, prompts, chamadas de ferramentas, tokens, latência e custos junto com telemetria de aplicação e infraestrutura.
|
||||
|
||||
<br /><br />
|
||||
<p align="center">
|
||||
<img alt="Dashboard de observabilidade de LLM do SigNoz para traces, uso de tokens, latência e custos" src="docs/readme-assets/monitor/llm.png" width="900">
|
||||
</p>
|
||||
|
||||
<img align="left" src="https://signoz-public.s3.us-east-2.amazonaws.com/UseSigNoz.svg" width="50px" />
|
||||
Saiba mais: [documentação de observabilidade de LLM](https://signoz.io/docs/llm-observability/)
|
||||
|
||||
## Comparações com ferramentas similares
|
||||
#### Observabilidade agent-native e MCP
|
||||
|
||||
### SigNoz ou Prometheus
|
||||
Use o servidor MCP do SigNoz para levar telemetria aos agentes de programação, ou use o Noz dentro do SigNoz para investigar incidentes, ajustar alertas e criar dashboards com contexto de produção. O [Noz](https://signoz.io/docs/ai/noz/) está disponível apenas no SigNoz Cloud.
|
||||
|
||||
Prometheus é bom se você quiser apenas fazer métricas. Mas se você quiser ter uma experiência perfeita entre métricas e rastreamentos, a experiência atual de unir Prometheus e Jaeger não é ótima.
|
||||
<p align="center">
|
||||
<img alt="Interface Noz do SigNoz ao lado de um fluxo agent via MCP" src="docs/readme-assets/monitor/agent-native.png" width="900">
|
||||
</p>
|
||||
|
||||
Nosso objetivo é fornecer uma interface do usuário integrada entre métricas e rastreamentos - semelhante ao que fornecedores de SaaS como o Datadog fornecem - e fornecer filtragem e agregação avançada sobre rastreamentos, algo que a Jaeger atualmente carece.
|
||||
Saiba mais: [documentação do servidor MCP do SigNoz](https://signoz.io/docs/ai/signoz-mcp-server/) · [documentação de agent skills](https://signoz.io/docs/ai/agent-skills/#install-the-plugin)
|
||||
|
||||
<p>  </p>
|
||||
#### Tracing distribuído
|
||||
|
||||
### SigNoz ou Jaeger
|
||||
Acompanhe requisições entre serviços com flamegraphs, waterfalls, eventos de span, filtros e análise de traces.
|
||||
|
||||
Jaeger só faz rastreamento distribuído. SigNoz faz métricas e rastreia, e também temos gerenciamento de log em nossos planos.
|
||||
<p align="center">
|
||||
<img alt="Visualização de tracing distribuído do SigNoz com flamegraph e spans em waterfall" src="docs/readme-assets/monitor/distributed-tracing.png" width="900">
|
||||
</p>
|
||||
|
||||
Além disso, SigNoz tem alguns recursos mais avançados do que Jaeger:
|
||||
Saiba mais: [documentação de tracing distribuído](https://signoz.io/docs/instrumentation/)
|
||||
|
||||
- A interface de usuário do Jaegar não mostra nenhuma métrica em traces ou em traces filtrados
|
||||
- Jaeger não pode obter agregados em rastros filtrados. Por exemplo, latência p99 de solicitações que possuem tag - customer_type='premium'. Isso pode ser feito facilmente com SigNoz.
|
||||
#### Trace Funnels
|
||||
|
||||
<br /><br />
|
||||
Crie funis a partir de traces para entender quedas no fluxo de requisições, transições com falha e problemas sistêmicos de workflow.
|
||||
|
||||
<img align="left" src="https://signoz-public.s3.us-east-2.amazonaws.com/Contributors.svg" width="50px" />
|
||||
<p align="center">
|
||||
<img alt="Trace Funnels do SigNoz mostrando quedas no fluxo de requisições e transições com falha" src="docs/readme-assets/monitor/trace-funnels.png" width="900">
|
||||
</p>
|
||||
|
||||
Saiba mais: [documentação de Trace Funnels](https://signoz.io/docs/trace-funnels/overview/)
|
||||
|
||||
Também monitore: [**exceções**](https://signoz.io/docs/userguide/exceptions/), [**alertas**](https://signoz.io/docs/alerts/), [**APIs externas**](https://signoz.io/docs/external-api-monitoring/overview/) e [**integrações**](https://signoz.io/docs/integrations/integrations-list/) para OpenTelemetry, Prometheus, Kubernetes, provedores de nuvem, SDKs de linguagem, frameworks de aplicação, bancos de dados e ferramentas de LLM.
|
||||
|
||||
### Por que equipes usam o SigNoz
|
||||
|
||||
1. **Nativo em OpenTelemetry**<br>
|
||||
Instrumente uma vez com padrões abertos e mantenha a posse da sua telemetria.
|
||||
2. **Sinais correlacionados**<br>
|
||||
Vá de gráficos de serviço para traces, logs, métricas de infraestrutura e exceções sem trocar de ferramenta.
|
||||
3. **Um único banco de dados colunar**<br>
|
||||
Construído para workloads de observabilidade de alto volume e alta cardinalidade.
|
||||
4. **Preço previsível**<br>
|
||||
Sem cobrança por host, sem cobrança por usuário e sem preço especial para métricas personalizadas.
|
||||
5. **Pronto para enterprise**<br>
|
||||
Compliance SOC 2 Type II e HIPAA, RBAC, controles de ingestão, retenção personalizada, suporte, BYOC e self-hosting.
|
||||
|
||||
### Primeiros passos
|
||||
|
||||
#### Comece na Cloud
|
||||
|
||||
Crie um workspace gerenciado do SigNoz e obtenha seu primeiro dashboard sem operar infraestrutura de observabilidade.
|
||||
|
||||
[**Comece gratuitamente no SigNoz Cloud**](https://signoz.io/teams/)
|
||||
|
||||
#### Self-host SigNoz
|
||||
|
||||
Execute o SigNoz na sua própria infraestrutura com Foundry, Docker, Kubernetes ou Linux.
|
||||
|
||||
[**Foundry**](https://github.com/SigNoz/foundry) · [**Docker**](https://signoz.io/docs/install/docker/) · [**Kubernetes**](https://signoz.io/docs/install/kubernetes/) · [**Linux**](https://signoz.io/docs/install/linux/)
|
||||
|
||||
#### Envie dados
|
||||
|
||||
Instrumente aplicações e infraestrutura com OpenTelemetry, Prometheus, SDKs de linguagem e integrações.
|
||||
|
||||
[**Instrumentação**](https://signoz.io/docs/instrumentation/) · [**Integrações**](https://signoz.io/docs/integrations/integrations-list/)
|
||||
|
||||
### Comparações com ferramentas conhecidas
|
||||
|
||||
O SigNoz é frequentemente adotado por equipes que estão migrando de ferramentas de propósito único ou plataformas comerciais com preços imprevisíveis.
|
||||
|
||||
**Prometheus**<br>
|
||||
Bom se você precisa apenas de métricas. O SigNoz mantém métricas, logs, traces, dashboards e alertas juntos para que equipes possam depurar com contexto correlacionado.
|
||||
|
||||
**Jaeger**<br>
|
||||
Jaeger faz apenas tracing distribuído. O SigNoz adiciona métricas, logs, análise de traces, dashboards, alertas, exceções e fluxos de trace para log.
|
||||
|
||||
**Elastic**<br>
|
||||
O SigNoz usa banco de dados colunar para análises de observabilidade eficientes e workloads de logs de alta cardinalidade, com 50% menos necessidade de recursos em comparação ao Elastic durante a ingestão. Confira o [estudo detalhado](https://signoz.io/blog/logs-performance-benchmark/?utm_source=github-readme&utm_medium=logs-benchmark).
|
||||
|
||||
**Loki**<br>
|
||||
No benchmark vinculado, o SigNoz indexou todas as chaves na configuração de teste, enquanto o Loki atingiu erros de max streams ao adicionar mais labels. Confira o [estudo detalhado](https://signoz.io/blog/logs-performance-benchmark/?utm_source=github-readme&utm_medium=logs-benchmark).
|
||||
|
||||
## Contribuindo
|
||||
|
||||
Adoramos contribuições grandes ou pequenas. Leia [CONTRIBUTING.md](CONTRIBUTING.md) para começar a contribuir com o SigNoz.
|
||||
|
||||
Nós ❤️ contribuições grandes ou pequenas. Leia [CONTRIBUTING.md](CONTRIBUTING.md) para começar a fazer contribuições para o SigNoz.
|
||||
Não sabe como começar? **Fale conosco no `#contributing` na nossa [comunidade Slack](https://signoz.io/slack).**
|
||||
|
||||
Não sabe como começar? Basta enviar um sinal para nós no canal `#contributing` em nossa [comunidade no Slack.](https://signoz.io/slack)
|
||||
|
||||
<br /><br />
|
||||
|
||||
<img align="left" src="https://signoz-public.s3.us-east-2.amazonaws.com/DevelopingLocally.svg" width="50px" />
|
||||
|
||||
## Documentação
|
||||
|
||||
Você pode encontrar a documentação em https://signoz.io/docs/. Se você tiver alguma dúvida ou sentir falta de algo, sinta-se à vontade para criar uma issue com a tag `documentation` no GitHub ou entre em contato conosco no canal da comunidade no Slack.
|
||||
|
||||
<br /><br />
|
||||
|
||||
<img align="left" src="https://signoz-public.s3.us-east-2.amazonaws.com/Contributing.svg" width="50px" />
|
||||
|
||||
## Comunidade
|
||||
|
||||
Junte-se a [comunidade no Slack](https://signoz.io/slack) para saber mais sobre rastreamento distribuído, observabilidade ou SigNoz e para se conectar com outros usuários e colaboradores.
|
||||
|
||||
Se você tiver alguma ideia, pergunta ou feedback, compartilhe em nosso [Github Discussões](https://github.com/SigNoz/signoz/discussions)
|
||||
|
||||
Como sempre, obrigado aos nossos incríveis colaboradores!
|
||||
Como sempre, obrigado aos nossos incríveis contribuidores!
|
||||
|
||||
<a href="https://github.com/signoz/signoz/graphs/contributors">
|
||||
<img src="https://contrib.rocks/image?repo=signoz/signoz" />
|
||||
<img alt="Contribuidores do SigNoz" src="https://contrib.rocks/image?repo=signoz/signoz" />
|
||||
</a>
|
||||
|
||||
|
||||
|
||||
|
||||
244
README.zh-cn.md
@@ -1,208 +1,190 @@
|
||||
<img src="https://res.cloudinary.com/dcv3epinx/image/upload/v1618904450/signoz-images/LogoGithub_sigfbu.svg" alt="SigNoz-logo" width="240" />
|
||||
|
||||
<p align="center">监控你的应用,并且可排查已部署应用的问题,这是一个可替代 DataDog、NewRelic 的开源方案</p>
|
||||
<p align="center">
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="docs/readme-assets/signoz-hero-dark.png" width="900">
|
||||
<source media="(prefers-color-scheme: light)" srcset="docs/readme-assets/signoz-hero-light.png" width="900">
|
||||
<img alt="SigNoz - 按你的方式运行的可观测性,由开放标准驱动。" src="docs/readme-assets/signoz-hero-light.png" width="900">
|
||||
</picture>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<img alt="Downloads" src="https://img.shields.io/docker/pulls/signoz/query-service?label=Docker Downloads"> </a>
|
||||
<img alt="GitHub issues" src="https://img.shields.io/github/issues/signoz/signoz"> </a>
|
||||
<a href="https://twitter.com/intent/tweet?text=Monitor%20your%20applications%20and%20troubleshoot%20problems%20with%20SigNoz,%20an%20open-source%20alternative%20to%20DataDog,%20NewRelic.&url=https://signoz.io/&via=SigNozHQ&hashtags=opensource,signoz,observability">
|
||||
<img alt="tweet" src="https://img.shields.io/twitter/url/http/shields.io.svg?style=social"> </a>
|
||||
<a href="README.md">English</a> ·
|
||||
<a href="README.de-de.md">Deutsch</a> ·
|
||||
<a href="README.pt-br.md">Português</a>
|
||||
</p>
|
||||
|
||||
<h3 align="center">
|
||||
<a href="https://signoz.io/docs"><b>文档</b></a> •
|
||||
<a href="https://github.com/SigNoz/signoz/blob/main/README.zh-cn.md"><b>中文ReadMe</b></a> •
|
||||
<a href="https://github.com/SigNoz/signoz/blob/main/README.de-de.md"><b>德文ReadMe</b></a> •
|
||||
<a href="https://github.com/SigNoz/signoz/blob/main/README.pt-br.md"><b>葡萄牙语ReadMe</b></a> •
|
||||
<a href="https://signoz.io/slack"><b>Slack 社区</b></a> •
|
||||
<a href="https://twitter.com/SigNozHq"><b>Twitter</b></a>
|
||||
</h3>
|
||||
<p align="center">
|
||||
<a href="https://github.com/SigNoz/signoz/issues"><img alt="GitHub issues" src="https://img.shields.io/github/issues/SigNoz/signoz"></a>
|
||||
<a href="https://github.com/SigNoz/signoz/releases"><img alt="GitHub release" src="https://img.shields.io/github/v/release/SigNoz/signoz?label=release"></a>
|
||||
<a href="https://signoz.io/slack"><img alt="Slack community" src="https://img.shields.io/badge/slack-community-4A154B?logo=slack&logoColor=white"></a>
|
||||
<a href="https://www.linkedin.com/company/signozio/"><img alt="LinkedIn" src="https://img.shields.io/badge/linkedin-SigNoz-0A66C2?logo=linkedin&logoColor=white"></a>
|
||||
<a href="https://twitter.com/intent/tweet?text=Monitor%20your%20applications%20and%20troubleshoot%20problems%20with%20SigNoz,%20an%20open-source%20alternative%20to%20DataDog,%20NewRelic.&url=https://signoz.io/&via=SigNozHQ&hashtags=opensource,signoz,observability"><img alt="Tweet" src="https://img.shields.io/twitter/url/http/shields.io.svg?style=social"></a>
|
||||
</p>
|
||||
|
||||
##
|
||||
SigNoz 是一个基于 OpenTelemetry 构建的开源可观测性平台。我们正在构建一个企业级替代方案,用来替代分散的监控工具栈,把日志、指标、链路追踪、告警和仪表盘放在同一个地方。
|
||||
|
||||
SigNoz 帮助开发人员监控应用并排查已部署应用的问题。你可以使用 SigNoz 实现如下能力:
|
||||
### 选择 SigNoz 的运行方式
|
||||
|
||||
👉 在同一块面板上,可视化 Metrics, Traces 和 Logs 内容。
|
||||
#### SigNoz Cloud(推荐)
|
||||
|
||||
👉 你可以关注服务的 p99 延迟和错误率, 包括外部 API 调用和个别的端点。
|
||||
完全托管的 SigNoz,提供 30 天免费试用,无需信用卡,按用量计费,起价为 49 美元,并支持区域化数据托管。
|
||||
|
||||
👉 你可以找到问题的根因,通过提取相关问题的 traces 日志、单独查看请求 traces 的火焰图详情。
|
||||
[**免费开始 →**](https://signoz.io/teams/)
|
||||
|
||||
👉 执行 trace 数据聚合,以获取业务相关的 metrics
|
||||
#### 企业版
|
||||
|
||||
👉 对日志过滤和查询,通过日志的属性建立看板和告警
|
||||
Enterprise Cloud、BYOC 或 Enterprise Self-Hosted,提供合规、支持、自定义保留期、RBAC、摄取控制、数据驻留和区域选择。
|
||||
|
||||
👉 通过 Python,java,Ruby 和 Javascript 自动记录异常
|
||||
[**了解企业版 →**](https://signoz.io/enterprise/)
|
||||
|
||||
👉 轻松的自定义查询和设置告警
|
||||
#### 社区版
|
||||
|
||||
### 应用 Metrics 展示
|
||||
免费的开源 SigNoz,可运行在你自己的基础设施中。使用 Docker、Kubernetes 或 Linux 部署,并完全掌控你的数据平面。
|
||||
|
||||

|
||||
[**安装 SigNoz →**](https://signoz.io/docs/install/self-host/)
|
||||
|
||||
### 分布式追踪
|
||||
### 你可以监控什么?
|
||||
|
||||
<img width="2068" alt="distributed_tracing_2 2" src="https://user-images.githubusercontent.com/83692067/226536447-bae58321-6a22-4ed3-af80-e3e964cb3489.png">
|
||||
SigNoz 将日志、指标、链路追踪、告警、仪表盘、异常和面向 Agent 的工作流连接在一起,帮助团队更快地调试生产问题。
|
||||
|
||||
<img width="2068" alt="distributed_tracing_1" src="https://user-images.githubusercontent.com/83692067/226536462-939745b6-4f9d-45a6-8016-814837e7f7b4.png">
|
||||
#### APM 概览
|
||||
|
||||
### 日志管理
|
||||
监控服务延迟、错误率、吞吐量、Apdex、核心端点、数据库调用和外部调用。
|
||||
|
||||
<img width="2068" alt="logs_management" src="https://user-images.githubusercontent.com/83692067/226536482-b8a5c4af-b69c-43d5-969c-338bd5eaf1a5.png">
|
||||
<p align="center">
|
||||
<img alt="SigNoz APM 仪表盘,展示延迟、吞吐量、Apdex 和关键操作" src="docs/readme-assets/monitor/apm.png" width="900">
|
||||
</p>
|
||||
|
||||
### 基础设施监控
|
||||
了解更多:[APM 文档](https://signoz.io/docs/instrumentation/overview/)
|
||||
|
||||
<img width="2068" alt="infrastructure_monitoring" src="https://user-images.githubusercontent.com/83692067/226536496-f38c4dbf-e03c-4158-8be0-32d4a61158c7.png">
|
||||
#### 日志管理
|
||||
|
||||
### 异常监控
|
||||
摄取、搜索、聚合日志,并通过可视化查询构建器将日志与链路追踪和指标关联起来。
|
||||
|
||||

|
||||
<p align="center">
|
||||
<img alt="SigNoz 日志浏览器,包含过滤器、频率图和日志行" src="docs/readme-assets/monitor/log-management.svg" width="900">
|
||||
</p>
|
||||
|
||||
### 告警
|
||||
了解更多:[日志管理文档](https://signoz.io/docs/logs-management/overview/)
|
||||
|
||||
<img width="2068" alt="alerts_management" src="https://user-images.githubusercontent.com/83692067/226536548-2c81e2e8-c12d-47e8-bad7-c6be79055def.png">
|
||||
#### 指标和仪表盘
|
||||
|
||||
<br /><br />
|
||||
使用 Query Builder、PromQL 或 ClickHouse SQL 为应用、基础设施和自定义指标构建仪表盘。
|
||||
|
||||
## 加入我们 Slack 社区
|
||||
<p align="center">
|
||||
<img alt="SigNoz 主机指标仪表盘,展示系统负载和网络图表" src="docs/readme-assets/monitor/metrics.png" width="900">
|
||||
</p>
|
||||
|
||||
来 [Slack](https://signoz.io/slack) 和我们打招呼吧 👋
|
||||
了解更多:[指标文档](https://signoz.io/docs/metrics-management/overview/)
|
||||
|
||||
<br /><br />
|
||||
#### 基础设施监控
|
||||
|
||||
## 特性:
|
||||
监控 Kubernetes 集群、Pod、节点、工作负载,以及主机级 CPU、内存、磁盘、网络、日志和链路追踪。
|
||||
|
||||
- 为 metrics, traces and logs 制定统一的 UI。 无需切换 Prometheus 到 Jaeger 去查找问题,也无需使用想 Elastic 这样的日志工具分开你的 metrics 和 traces
|
||||
<p align="center">
|
||||
<img alt="SigNoz Kubernetes 基础设施仪表盘,展示 Pod 和节点指标" src="docs/readme-assets/monitor/infrastructure.png" width="900">
|
||||
</p>
|
||||
|
||||
- 默认统计应用的 metrics 数据,像 RPS (每秒请求数), 50th/90th/99th 的分位数延迟数据,还有相关的错误率
|
||||
了解更多:[基础设施监控文档](https://signoz.io/docs/infrastructure-monitoring/overview/)
|
||||
|
||||
- 找到应用中最慢的端点
|
||||
#### LLM 和 AI 可观测性
|
||||
|
||||
- 查看准确的请求跟踪数据,找到下游服务的问题了,比如 DB 慢查询,或者调用第三方的支付网关等
|
||||
追踪 LLM 应用、RAG 流水线、Prompt、工具调用、Token、延迟和成本,并与应用和基础设施遥测数据放在一起分析。
|
||||
|
||||
- 通过 服务名、操作方式、延迟、错误、标签/注释 过滤 traces 数据
|
||||
<p align="center">
|
||||
<img alt="SigNoz LLM 可观测性仪表盘,展示链路追踪、Token 使用、延迟和成本" src="docs/readme-assets/monitor/llm.png" width="900">
|
||||
</p>
|
||||
|
||||
- 通过聚合 trace 数据而获得业务相关的 metrics。 比如你可以通过 `customer_type: gold` 或者 `deployment_version: v2` 或者 `external_call: paypal` 获取错误率和 P99 延迟数据
|
||||
了解更多:[LLM 可观测性文档](https://signoz.io/docs/llm-observability/)
|
||||
|
||||
- 原生支持 OpenTelemetry 日志,高级日志查询,自动收集 k8s 相关日志
|
||||
#### Agent 原生可观测性和 MCP
|
||||
|
||||
- 快如闪电的日志分析 ([Logs Perf. Benchmark](https://signoz.io/blog/logs-performance-benchmark/))
|
||||
使用 SigNoz MCP server 将遥测数据带入编程 Agent,或在 SigNoz 中使用 Noz,基于生产上下文调查事故、优化告警并构建仪表盘。[Noz](https://signoz.io/docs/ai/noz/) 仅适用于 SigNoz Cloud。
|
||||
|
||||
- 可视化点到点的基础设施性能,提取有所有类型机器的 metrics 数据
|
||||
<p align="center">
|
||||
<img alt="SigNoz Noz 界面与基于 MCP 的 Agent 工作流" src="docs/readme-assets/monitor/agent-native.png" width="900">
|
||||
</p>
|
||||
|
||||
- 轻易自定义告警查询
|
||||
了解更多:[SigNoz MCP server 文档](https://signoz.io/docs/ai/signoz-mcp-server/) · [Agent skills 文档](https://signoz.io/docs/ai/agent-skills/#install-the-plugin)
|
||||
|
||||
<br /><br />
|
||||
#### 分布式链路追踪
|
||||
|
||||
## 为什么使用 SigNoz?
|
||||
通过火焰图、瀑布图、Span 事件、过滤器和 Trace 分析,跟踪请求在各个服务之间的流转。
|
||||
|
||||
作为开发者, 我们发现 SaaS 厂商对一些大家想要的小功能都是闭源的,这种行为真的让人有点恼火。 闭源厂商还会在月底给你一张没有明细的巨额账单。
|
||||
<p align="center">
|
||||
<img alt="SigNoz 分布式链路追踪视图,包含火焰图和瀑布图 Span" src="docs/readme-assets/monitor/distributed-tracing.png" width="900">
|
||||
</p>
|
||||
|
||||
我们想做一个自托管并且可开源的工具,像 DataDog 和 NewRelic 那样, 为那些担心数据隐私和安全的公司提供第三方服务。
|
||||
了解更多:[分布式链路追踪文档](https://signoz.io/docs/instrumentation/)
|
||||
|
||||
作为开源的项目,你完全可以自己掌控你的配置、样本和更新。你同样可以基于 SigNoz 拓展特定的业务模块。
|
||||
#### Trace Funnels
|
||||
|
||||
### 支持的编程语言:
|
||||
基于链路追踪创建漏斗,用于理解请求流中的掉点、失败转换和系统性工作流问题。
|
||||
|
||||
我们支持 [OpenTelemetry](https://opentelemetry.io)。作为一个观测你应用的库文件。所以任何 OpenTelemetry 支持的框架和语言,对于 SigNoz 也同样支持。 一些主要支持的语言如下:
|
||||
<p align="center">
|
||||
<img alt="SigNoz Trace Funnels,展示请求流掉点和失败转换" src="docs/readme-assets/monitor/trace-funnels.png" width="900">
|
||||
</p>
|
||||
|
||||
- Java
|
||||
- Python
|
||||
- NodeJS
|
||||
- Go
|
||||
- PHP
|
||||
- .NET
|
||||
- Ruby
|
||||
- Elixir
|
||||
- Rust
|
||||
了解更多:[Trace Funnels 文档](https://signoz.io/docs/trace-funnels/overview/)
|
||||
|
||||
你可以在这里找到全部支持的语言列表 - https://opentelemetry.io/docs/
|
||||
你还可以监控:[**异常**](https://signoz.io/docs/userguide/exceptions/)、[**告警**](https://signoz.io/docs/alerts/)、[**外部 API**](https://signoz.io/docs/external-api-monitoring/overview/),以及面向 OpenTelemetry、Prometheus、Kubernetes、云服务商、语言 SDK、应用框架、数据库和 LLM 工具的[**集成**](https://signoz.io/docs/integrations/integrations-list/)。
|
||||
|
||||
<br /><br />
|
||||
### 为什么团队选择 SigNoz
|
||||
|
||||
## 让我们开始吧
|
||||
1. **OpenTelemetry 原生**<br>
|
||||
用开放标准完成一次接入,并保持对遥测数据的所有权。
|
||||
2. **信号关联**<br>
|
||||
在服务图表、链路追踪、日志、基础设施指标和异常之间切换时,不需要更换工具。
|
||||
3. **单一列式数据库**<br>
|
||||
为高基数、高吞吐量的可观测性工作负载而构建。
|
||||
4. **可预测的定价**<br>
|
||||
不按主机收费,不按用户席位收费,也不对自定义指标设置特殊价格。
|
||||
5. **企业就绪**<br>
|
||||
SOC 2 Type II 和 HIPAA 合规、RBAC、摄取控制、自定义保留期、支持、BYOC 和自托管。
|
||||
|
||||
### 使用 Docker 部署
|
||||
### 快速开始
|
||||
|
||||
请一步步跟随 [这里](https://signoz.io/docs/install/docker/) 通过 docker 来安装。
|
||||
#### 从 Cloud 开始
|
||||
|
||||
这个 [排障说明书](https://signoz.io/docs/install/troubleshooting/) 可以帮助你解决碰到的问题。
|
||||
创建一个托管的 SigNoz 工作区,无需运行可观测性基础设施,即可获得第一个仪表盘。
|
||||
|
||||
<p>  </p>
|
||||
[**免费开始使用 SigNoz Cloud**](https://signoz.io/teams/)
|
||||
|
||||
### 使用 Helm 在 Kubernetes 部署
|
||||
#### 自托管 SigNoz
|
||||
|
||||
请一步步跟随 [这里](https://signoz.io/docs/deployment/helm_chart) 通过 helm 来安装
|
||||
在你自己的基础设施中通过 Foundry、Docker、Kubernetes 或 Linux 运行 SigNoz。
|
||||
|
||||
<br /><br />
|
||||
[**Foundry**](https://github.com/SigNoz/foundry) · [**Docker**](https://signoz.io/docs/install/docker/) · [**Kubernetes**](https://signoz.io/docs/install/kubernetes/) · [**Linux**](https://signoz.io/docs/install/linux/)
|
||||
|
||||
## 比较相似的工具
|
||||
#### 发送数据
|
||||
|
||||
### SigNoz vs Prometheus
|
||||
使用 OpenTelemetry、Prometheus、语言 SDK 和集成来接入应用与基础设施。
|
||||
|
||||
Prometheus 是一个针对 metrics 监控的强大工具。但是如果你想无缝的切换 metrics 和 traces 查询,你当前大概率需要在 Prometheus 和 Jaeger 之间切换。
|
||||
[**接入文档**](https://signoz.io/docs/instrumentation/) · [**集成列表**](https://signoz.io/docs/integrations/integrations-list/)
|
||||
|
||||
我们的目标是提供一个客户观测 metrics 和 traces 整合的 UI。就像 SaaS 供应商 DataDog,它提供很多 jaeger 缺失的功能,比如针对 traces 过滤功能和聚合功能。
|
||||
### 与常见工具的对比
|
||||
|
||||
<p>  </p>
|
||||
许多团队在从单一用途工具或价格不可预测的商业平台迁移时,会选择 SigNoz。
|
||||
|
||||
### SigNoz vs Jaeger
|
||||
**Prometheus**<br>
|
||||
如果你只需要指标,Prometheus 很合适。SigNoz 将指标、日志、链路追踪、仪表盘和告警放在一起,让团队可以通过关联上下文进行调试。
|
||||
|
||||
Jaeger 仅仅是一个分布式追踪系统。 但是 SigNoz 可以提供 metrics, traces 和 logs 所有的观测。
|
||||
**Jaeger**<br>
|
||||
Jaeger 只做分布式链路追踪。SigNoz 增加了指标、日志、Trace 分析、仪表盘、告警、异常和 Trace 到日志的工作流。
|
||||
|
||||
而且, SigNoz 相较于 Jaeger 拥有更对的高级功能:
|
||||
**Elastic**<br>
|
||||
SigNoz 使用列式数据库来高效处理可观测性分析和高基数日志工作负载。在摄取阶段,相比 Elastic 可降低 50% 的资源需求。查看[详细评测](https://signoz.io/blog/logs-performance-benchmark/?utm_source=github-readme&utm_medium=logs-benchmark)。
|
||||
|
||||
- Jaegar UI 不能提供任何基于 traces 的 metrics 查询和过滤。
|
||||
|
||||
- Jaeger 不能针对过滤的 traces 做聚合。 比如, p99 延迟的请求有个标签是 customer_type='premium'。 而这些在 SigNoz 可以轻松做到。
|
||||
|
||||
<p>  </p>
|
||||
|
||||
### SigNoz vs Elastic
|
||||
|
||||
- SigNoz 的日志管理是基于 ClickHouse 实现的,可以使日志的聚合更加高效,因为它是基于 OLAP 的数据仓储。
|
||||
|
||||
- 与 Elastic 相比,可以节省 50% 的资源成本
|
||||
|
||||
我们已经公布了 Elastic 和 SigNoz 的性能对比。 请点击 [这里](https://signoz.io/blog/logs-performance-benchmark/?utm_source=github-readme&utm_medium=logs-benchmark)
|
||||
|
||||
<p>  </p>
|
||||
|
||||
### SigNoz vs Loki
|
||||
|
||||
- SigNoz 支持大容量高基数的聚合,但是 loki 是不支持的。
|
||||
|
||||
- SigNoz 支持索引的高基数查询,并且对索引没有数量限制,而 Loki 会在添加部分索引后到达最大上限。
|
||||
|
||||
- 相较于 SigNoz,Loki 在搜索大量数据下既困难又缓慢。
|
||||
|
||||
我们已经发布了基准测试对比 Loki 和 SigNoz 性能。请点击 [这里](https://signoz.io/blog/logs-performance-benchmark/?utm_source=github-readme&utm_medium=logs-benchmark)
|
||||
|
||||
<br /><br />
|
||||
**Loki**<br>
|
||||
在链接的评测中,SigNoz 在测试设置中索引了所有键,而 Loki 在增加更多标签时遇到了 max stream 错误。查看[详细评测](https://signoz.io/blog/logs-performance-benchmark/?utm_source=github-readme&utm_medium=logs-benchmark)。
|
||||
|
||||
## 贡献
|
||||
|
||||
我们 ❤️ 你的贡献,无论大小。 请先阅读 [CONTRIBUTING.md](CONTRIBUTING.md) 再开始给 SigNoz 做贡献。
|
||||
无论贡献大小,我们都非常欢迎。请阅读 [CONTRIBUTING.md](CONTRIBUTING.md),开始为 SigNoz 做贡献。
|
||||
|
||||
如果你不知道如何开始? 只需要在 [slack 社区](https://signoz.io/slack) 通过 `#contributing` 频道联系我们。
|
||||
不确定如何开始?**可以在我们的 [Slack 社区](https://signoz.io/slack)中通过 `#contributing` 联系我们。**
|
||||
|
||||
<br /><br />
|
||||
|
||||
## 文档
|
||||
|
||||
你可以通过 https://signoz.io/docs/ 找到相关文档。如果你需要阐述问题或者发现一些确实的事件, 通过标签为 `documentation` 提交 Github 问题。或者通过 slack 社区频道。
|
||||
|
||||
<br /><br />
|
||||
|
||||
## 社区
|
||||
|
||||
加入 [slack 社区](https://signoz.io/slack) 去了解更多关于分布式追踪、可观测性系统 。或者与 SigNoz 其他用户和贡献者交流。
|
||||
|
||||
如果你有任何想法、问题、或者任何反馈, 请通过 [Github Discussions](https://github.com/SigNoz/signoz/discussions) 分享。
|
||||
|
||||
不管怎么样,感谢这个项目的所有贡献者!
|
||||
一如既往,感谢所有出色的贡献者!
|
||||
|
||||
<a href="https://github.com/signoz/signoz/graphs/contributors">
|
||||
<img src="https://contrib.rocks/image?repo=signoz/signoz" />
|
||||
<img alt="SigNoz 贡献者" src="https://contrib.rocks/image?repo=signoz/signoz" />
|
||||
</a>
|
||||
|
||||
@@ -29,6 +29,8 @@ import (
|
||||
"github.com/SigNoz/signoz/pkg/modules/cloudintegration/implcloudintegration"
|
||||
"github.com/SigNoz/signoz/pkg/modules/dashboard"
|
||||
"github.com/SigNoz/signoz/pkg/modules/dashboard/impldashboard"
|
||||
"github.com/SigNoz/signoz/pkg/modules/metricreductionrule"
|
||||
"github.com/SigNoz/signoz/pkg/modules/metricreductionrule/implmetricreductionrule"
|
||||
"github.com/SigNoz/signoz/pkg/modules/organization"
|
||||
"github.com/SigNoz/signoz/pkg/modules/retention"
|
||||
"github.com/SigNoz/signoz/pkg/modules/rulestatehistory"
|
||||
@@ -119,6 +121,9 @@ func runServer(ctx context.Context, config signoz.Config, logger *slog.Logger) e
|
||||
func(_ sqlstore.SQLStore, _ dashboard.Module, _ global.Global, _ zeus.Zeus, _ gateway.Gateway, _ licensing.Licensing, _ serviceaccount.Module, _ cloudintegration.Config) (cloudintegration.Module, error) {
|
||||
return implcloudintegration.NewModule(), nil
|
||||
},
|
||||
func(_ sqlstore.SQLStore, _ telemetrystore.TelemetryStore, _ dashboard.Module, _ queryparser.QueryParser, _ licensing.Licensing, _ flagger.Flagger, _ telemetrytypes.MetadataStore, _ factory.ProviderSettings, _ int) metricreductionrule.Module {
|
||||
return implmetricreductionrule.NewModule()
|
||||
},
|
||||
func(c cache.Cache, am alertmanager.Alertmanager, ss sqlstore.SQLStore, ts telemetrystore.TelemetryStore, ms telemetrytypes.MetadataStore, p prometheus.Prometheus, og organization.Getter, rsh rulestatehistory.Module, q querier.Querier, qp queryparser.QueryParser) factory.NamedMap[factory.ProviderFactory[ruler.Ruler, ruler.Config]] {
|
||||
return factory.MustNewNamedMap(signozruler.NewFactory(c, am, ss, ts, ms, p, og, rsh, q, qp, nil, nil))
|
||||
},
|
||||
|
||||
@@ -24,6 +24,7 @@ import (
|
||||
"github.com/SigNoz/signoz/ee/modules/cloudintegration/implcloudintegration"
|
||||
"github.com/SigNoz/signoz/ee/modules/cloudintegration/implcloudintegration/implcloudprovider"
|
||||
"github.com/SigNoz/signoz/ee/modules/dashboard/impldashboard"
|
||||
eeimplmetricreductionrule "github.com/SigNoz/signoz/ee/modules/metricreductionrule/implmetricreductionrule"
|
||||
eequerier "github.com/SigNoz/signoz/ee/querier"
|
||||
enterpriseapp "github.com/SigNoz/signoz/ee/query-service/app"
|
||||
eerules "github.com/SigNoz/signoz/ee/query-service/rules"
|
||||
@@ -46,6 +47,7 @@ import (
|
||||
pkgcloudintegration "github.com/SigNoz/signoz/pkg/modules/cloudintegration/implcloudintegration"
|
||||
"github.com/SigNoz/signoz/pkg/modules/dashboard"
|
||||
pkgimpldashboard "github.com/SigNoz/signoz/pkg/modules/dashboard/impldashboard"
|
||||
"github.com/SigNoz/signoz/pkg/modules/metricreductionrule"
|
||||
"github.com/SigNoz/signoz/pkg/modules/organization"
|
||||
"github.com/SigNoz/signoz/pkg/modules/retention"
|
||||
"github.com/SigNoz/signoz/pkg/modules/rulestatehistory"
|
||||
@@ -182,6 +184,9 @@ func runServer(ctx context.Context, config signoz.Config, logger *slog.Logger) e
|
||||
|
||||
return implcloudintegration.NewModule(pkgcloudintegration.NewStore(sqlStore), dashboardModule, global, zeus, gateway, licensing, serviceAccount, cloudProvidersMap, config)
|
||||
},
|
||||
func(sqlStore sqlstore.SQLStore, ts telemetrystore.TelemetryStore, dashboardModule dashboard.Module, queryParser queryparser.QueryParser, lic licensing.Licensing, flgr pkgflagger.Flagger, ms telemetrytypes.MetadataStore, ps factory.ProviderSettings, threads int) metricreductionrule.Module {
|
||||
return eeimplmetricreductionrule.NewModule(sqlStore, ts, dashboardModule, queryParser, lic, flgr, ms, ps, threads)
|
||||
},
|
||||
func(c cache.Cache, am alertmanager.Alertmanager, ss sqlstore.SQLStore, ts telemetrystore.TelemetryStore, ms telemetrytypes.MetadataStore, p prometheus.Prometheus, og organization.Getter, rsh rulestatehistory.Module, q querier.Querier, qp queryparser.QueryParser) factory.NamedMap[factory.ProviderFactory[ruler.Ruler, ruler.Config]] {
|
||||
return factory.MustNewNamedMap(signozruler.NewFactory(c, am, ss, ts, ms, p, og, rsh, q, qp, eerules.PrepareTaskFunc, eerules.TestNotification))
|
||||
},
|
||||
|
||||
@@ -65,15 +65,31 @@ web:
|
||||
posthog:
|
||||
# Whether to enable PostHog in web.
|
||||
enabled: false
|
||||
# The PostHog project API key.
|
||||
key: ""
|
||||
# The PostHog API host. Defaults to https://us.i.posthog.com when empty.
|
||||
api_host: ""
|
||||
# The PostHog UI host. Used when api_host points at a reverse proxy.
|
||||
ui_host: ""
|
||||
appcues:
|
||||
# Whether to enable Appcues in web.
|
||||
enabled: false
|
||||
# The Appcues account/app ID.
|
||||
app_id: ""
|
||||
sentry:
|
||||
# Whether to enable Sentry in web.
|
||||
enabled: false
|
||||
# The Sentry DSN.
|
||||
dsn: ""
|
||||
# The Sentry tunnel URL.
|
||||
tunnel: ""
|
||||
pylon:
|
||||
# Whether to enable Pylon in web.
|
||||
enabled: false
|
||||
# The Pylon app ID.
|
||||
app_id: ""
|
||||
# The Pylon identity verification secret.
|
||||
identity_secret: ""
|
||||
|
||||
##################### Cache #####################
|
||||
cache:
|
||||
@@ -141,6 +157,10 @@ querier:
|
||||
flux_interval: 5m
|
||||
# The maximum number of concurrent queries for missing ranges.
|
||||
max_concurrent_queries: 4
|
||||
# When filtering logs by trace_id, clamp the query window to the trace time
|
||||
# range with padding to include slightly delayed log exports. Logs only; set
|
||||
# to 0 to disable.
|
||||
log_trace_id_window_padding: 5m
|
||||
|
||||
##################### TelemetryStore #####################
|
||||
telemetrystore:
|
||||
|
||||
@@ -1,48 +1,76 @@
|
||||
# Migrating from the install script to Foundry
|
||||
# Migrating from the install script and `deploy/` to Foundry
|
||||
|
||||
The install script (`install.sh`) and the bundled Compose and Swarm files
|
||||
under `deploy/` are deprecated in favor of [Foundry][foundry], the supported
|
||||
way to install and manage SigNoz. This guide moves an existing Docker Compose
|
||||
or Docker Swarm deployment to Foundry and reattaches your existing volumes, so
|
||||
your data is preserved.
|
||||
|
||||
> [!IMPORTANT]
|
||||
> The install script is now deprecated and will no longer receive updates.
|
||||
> This guide is only for **existing** `install.sh` / `deploy/` deployments.
|
||||
> Setting up SigNoz for the first time? Skip migration and install Foundry
|
||||
> directly: [SigNoz install docs][install-docs].
|
||||
|
||||
This guide walks you through migrating an existing SigNoz deployment running via
|
||||
Docker Compose to [Foundry](https://signoz.io/docs/install/docker/).
|
||||
## How it works
|
||||
|
||||
> [!NOTE]
|
||||
> Setting up SigNoz for the first time? You don't need this guide — follow the [SigNoz installation docs](https://signoz.io/docs/install/) instead.
|
||||
Foundry splits a deployment into two commands:
|
||||
|
||||
## Overview
|
||||
To stay up to date on new installation platforms and patterns, please refer to [Foundry](https://github.com/SigNoz/foundry).
|
||||
- `foundryctl forge` generates the deployment manifests from a `casting.yaml`.
|
||||
It never touches running containers, so it is safe to re-run while you
|
||||
iterate.
|
||||
- `foundryctl cast` applies those manifests: it (re)creates the containers and
|
||||
reuses the volumes you point it at.
|
||||
|
||||
Two `foundryctl` commands are used throughout this guide:
|
||||
- **`forge`** — generates deployment manifests from your `casting.yaml`. It does not touch running containers, so it is safe to re-run while you iterate.
|
||||
- **`cast`** — applies the generated manifests: it creates and starts the containers (and pulls new images).
|
||||
You write one `casting.yaml`, point a few patches at your existing data
|
||||
volumes, then cast. The steps below are the same for Compose and Swarm; they
|
||||
differ only in the casting (step 3) and how you stop the old stack (step 5).
|
||||
|
||||
## Prerequisites
|
||||
- [ ] Install Foundry - `curl -fsSL https://signoz.io/foundry.sh | bash`
|
||||
|
||||
## Migration Steps
|
||||
> [!WARNING]
|
||||
> **Before proceeding, back up both:**
|
||||
> - **Your docker volumes** — these hold your data.
|
||||
> - **Your existing `docker-compose.yaml` (and any config it references)** — keep a copy somewhere safe. The compose manifests are no longer distributed by SigNoz, so this backup is your only way to roll back to your previous setup.
|
||||
- An existing SigNoz deployment from `install.sh` or `deploy/` (Compose or
|
||||
Swarm).
|
||||
- `foundryctl` (installed in step 1).
|
||||
|
||||
1. Make a note of the volume names used by your existing deployment for the following components:
|
||||
- ClickHouse
|
||||
- SigNoz
|
||||
- ZooKeeper
|
||||
## Migrate
|
||||
|
||||
> If you used the docker compose file we provided, the volumes will be `signoz-clickhouse`, `signoz-sqlite`, and `signoz-zookeeper-1`.
|
||||
### 1. Install Foundry
|
||||
|
||||
2. Generate your `casting.yaml`. Based on internal testing, the following casting should generate the manifests that mimic the legacy docker compose setup (compare against your backed-up `docker-compose.yaml`). Once created, run `foundryctl forge -f casting.yaml`.
|
||||
```bash
|
||||
curl -fsSL https://signoz.io/foundry.sh | bash
|
||||
```
|
||||
|
||||
Foundry has a [Docker Compose example](https://github.com/SigNoz/foundry/tree/main/docs/examples/docker/compose). Please use it as a reference.
|
||||
### 2. Keep your rollback path
|
||||
|
||||
> [!WARNING]
|
||||
> If your deployment had more than 1 shard or replica, you will need to adjust your manifest volumes accordingly.
|
||||
This migration reattaches your existing volumes in place; it does not move or
|
||||
delete your data. The only destructive action is passing `--volumes` / `-v`
|
||||
when you stop the old stack (step 5), so avoid that flag.
|
||||
|
||||
> [!IMPORTANT]
|
||||
> The `replica` and `shard` macros below are placeholders. Replace them with the values from your existing ClickHouse configuration (check the `macros` section of your current ClickHouse config, e.g. `config.xml`/`metrika.xml`), otherwise the generated manifests will not match your existing data.
|
||||
> Keep a copy of your existing `docker-compose.yaml` / stack file (and any
|
||||
> config it references). SigNoz no longer distributes these files, so this copy
|
||||
> is your only way to roll back.
|
||||
|
||||
### 3. Write your `casting.yaml`
|
||||
|
||||
Use the casting for your deployment. Both reproduce the legacy single-node
|
||||
setup (ClickHouse + ZooKeeper + SQLite) and reattach your existing volumes;
|
||||
they differ only in `spec.deployment.flavor` and the volume-reuse patch
|
||||
(Compose volumes have a `name` to replace; Swarm volumes are bare, so the whole
|
||||
entry is replaced). If your deployment ran more than one shard or replica,
|
||||
adjust the volume patches accordingly. The
|
||||
[Docker Compose example][compose-example] is a useful reference.
|
||||
|
||||
> [!IMPORTANT]
|
||||
> The `replica` and `shard` macros are placeholders. Replace them with the
|
||||
> values from your existing ClickHouse config (the `macros` section of
|
||||
> `config.xml` / `metrika.xml`), or the generated manifests will not match your
|
||||
> existing data.
|
||||
|
||||
<details>
|
||||
<summary><b>Docker Compose</b> casting.yaml</summary>
|
||||
|
||||
```yaml
|
||||
# casting.yaml
|
||||
apiVersion: v1alpha1
|
||||
kind: Installation
|
||||
metadata:
|
||||
@@ -61,8 +89,8 @@ spec:
|
||||
data:
|
||||
config-0-0.yaml: |
|
||||
macros:
|
||||
replica: "example01-01-1" # replace with your existing ClickHouse replica macro (see legacy configuration files for reference)
|
||||
shard: "01" # replace with your existing ClickHouse shard macro (see legacy configuration files for reference)
|
||||
replica: "example01-01-1" # replace with your replica macro
|
||||
shard: "01" # replace with your shard macro
|
||||
patches:
|
||||
- target: "deployment/compose.yaml"
|
||||
operations:
|
||||
@@ -80,50 +108,163 @@ spec:
|
||||
value: root
|
||||
```
|
||||
|
||||
> [!NOTE]
|
||||
> The `user: root` patch on the ZooKeeper service is required so the container can read/write the data in your reused ZooKeeper volume, which was created with `root`-owned files by the legacy compose setup. Without it, ZooKeeper may fail to start with permission errors.
|
||||
</details>
|
||||
|
||||
If you had custom configurations for features like SMTP or additional ingestion processors/receivers, you will need to include those in your casting file via [patches](https://github.com/SigNoz/foundry/blob/main/docs/concepts/patches.md), [custom configuration](https://github.com/SigNoz/foundry/blob/main/docs/concepts/moldings.md#custom-config-files) or [environment variables](https://github.com/SigNoz/foundry/blob/main/docs/reference/casting-file.md#molding-spec) based on your previous configuration.
|
||||
<details>
|
||||
<summary><b>Docker Swarm</b> casting.yaml</summary>
|
||||
|
||||
3. Review your manifests, we suggest executing the following checks on your manifests before proceeding:
|
||||
- [ ] Validate the container images match what your deployment had, Foundry uses `latest` on generation by default.
|
||||
- [ ] If your signoz version was older than latest, please check the [upgrade path](https://signoz.io/docs/operate/upgrade/) first.
|
||||
- [ ] Check the produced manifests in `pours/deployment` match your older configurations. Extra consideration and review needs to be done on `compose.yaml` as this will be the main entry point for your new deployment.
|
||||
- [ ] The configuration files for clickhouse are now in YAML so validate your custom settings are present.
|
||||
```yaml
|
||||
# casting.yaml
|
||||
apiVersion: v1alpha1
|
||||
kind: Installation
|
||||
metadata:
|
||||
name: signoz
|
||||
spec:
|
||||
deployment:
|
||||
flavor: swarm
|
||||
mode: docker
|
||||
metastore:
|
||||
kind: sqlite
|
||||
telemetrykeeper:
|
||||
kind: zookeeper
|
||||
telemetrystore:
|
||||
spec:
|
||||
config:
|
||||
data:
|
||||
config-0-0.yaml: |
|
||||
macros:
|
||||
replica: "example01-01-1" # replace with your replica macro
|
||||
shard: "01" # replace with your shard macro
|
||||
patches:
|
||||
- target: "deployment/compose.yaml"
|
||||
operations:
|
||||
- op: replace
|
||||
path: /volumes/signoz-telemetrykeeper-0-data
|
||||
value:
|
||||
name: signoz-zookeeper-1
|
||||
- op: replace
|
||||
path: /volumes/signoz-telemetrystore-0-0-data
|
||||
value:
|
||||
name: signoz-clickhouse
|
||||
- op: replace
|
||||
path: /volumes/signoz-metastore-sqlite-0-data
|
||||
value:
|
||||
name: signoz-sqlite
|
||||
- op: add
|
||||
path: /services/signoz-telemetrykeeper-zookeeper-0/user
|
||||
value: root
|
||||
```
|
||||
|
||||
4. Execute a `docker compose down`. **Do not** include parameters such as `--volumes` (or `-v`), as it will wipe the volumes we need to maintain and reuse to avoid data loss.
|
||||
</details>
|
||||
|
||||
> [!NOTE]
|
||||
> This will generate downtime so please plan accordingly.
|
||||
> The `user: root` patch on the ZooKeeper service lets the container read and
|
||||
> write the data in your reused ZooKeeper volume, whose files the legacy setup
|
||||
> created as `root`. Without it, ZooKeeper may fail to start with permission
|
||||
> errors.
|
||||
|
||||
5. Validate the SigNoz containers are down with `docker ps -a`. Multiple containers cannot bind the same volume.
|
||||
If you had custom configuration (SMTP, extra ingestion receivers/processors,
|
||||
or custom ClickHouse settings), carry it over via [patches][patches],
|
||||
[custom config files][custom-config], or [environment variables][env-vars].
|
||||
|
||||
6. Run `foundryctl cast -f casting.yaml`. This will recreate the containers based on the spec. This process will download new container images.
|
||||
### 4. Generate and review the manifests
|
||||
|
||||
```bash
|
||||
foundryctl forge -f casting.yaml
|
||||
```
|
||||
|
||||
Review `pours/deployment/` before deploying:
|
||||
|
||||
- [ ] Container images match your current deployment. Foundry generates with
|
||||
`latest` by default; if your SigNoz version was older than latest, check the
|
||||
[upgrade path][upgrade-path] first.
|
||||
- [ ] The generated manifests match your previous configuration, especially
|
||||
`compose.yaml` (the new entry point for your deployment).
|
||||
- [ ] The ClickHouse config is now YAML rather than XML; confirm your custom
|
||||
settings carried over (see [ClickHouse configuration files][ch-config] for
|
||||
the XML-to-YAML mapping).
|
||||
|
||||
### 5. Stop the old deployment
|
||||
|
||||
Use the command for your deployment. Do **not** pass `--volumes` / `-v`; that
|
||||
would delete the data you are migrating.
|
||||
|
||||
```bash
|
||||
docker compose down # Compose
|
||||
docker stack rm signoz # Swarm
|
||||
```
|
||||
|
||||
> [!NOTE]
|
||||
> When `cast` is run, the migration container will execute its migrations.
|
||||
> This causes downtime, so plan accordingly.
|
||||
|
||||
## Verifying the Migration
|
||||
- SigNoz containers will be up and running.
|
||||
- Log in to the SigNoz UI and verify that data is present.
|
||||
- Signoz will run on localhost:8080
|
||||
- Validate that your data ingestion is receiving data.
|
||||
- Ingesters will receive data on localhost:4317(grpc) and localhost:4318(http)
|
||||
- Review the logs from both ClickHouse and ZooKeeper; no errors should be present.
|
||||
Confirm nothing is still bound to the volumes before continuing:
|
||||
|
||||
## Rolling Back
|
||||
Because step 4 brought the legacy stack down *without* `-v`, your original volumes
|
||||
are untouched and still hold your data. To roll back:
|
||||
```bash
|
||||
docker ps -a
|
||||
```
|
||||
|
||||
- Stop and remove the containers created by Foundry (`docker compose down`, again without `-v`).
|
||||
- Confirm the containers are gone with `docker ps -a` so nothing else is bound to the volumes.
|
||||
- Reapply your original docker compose file (`docker compose up -d`). It will reattach to the
|
||||
existing volumes and restore your prior state.
|
||||
### 6. Deploy with Foundry
|
||||
|
||||
```bash
|
||||
foundryctl cast -f casting.yaml
|
||||
```
|
||||
|
||||
This recreates the containers against your existing volumes and pulls the
|
||||
images. The migration container runs the schema migrations as part of `cast`.
|
||||
|
||||
**Prefer not to use `cast`?** The manifests in `pours/deployment/` are standard
|
||||
Docker artifacts you can apply yourself. Run the command from that directory so
|
||||
the relative config paths resolve:
|
||||
|
||||
```bash
|
||||
cd pours/deployment
|
||||
docker compose up -d # Compose
|
||||
docker stack deploy -c compose.yaml signoz # Swarm
|
||||
```
|
||||
|
||||
## Verify
|
||||
|
||||
- All SigNoz containers are running.
|
||||
- The UI is reachable on `http://localhost:8080`, and OTLP on `4317` (gRPC)
|
||||
and `4318` (HTTP), so already-instrumented apps and saved bookmarks keep
|
||||
working.
|
||||
- Your existing data is present in the UI, and new data is being ingested.
|
||||
- ClickHouse and ZooKeeper logs show no errors.
|
||||
|
||||
## Roll back
|
||||
|
||||
Step 5 left your volumes untouched, so your data is intact. To return to the
|
||||
previous setup:
|
||||
|
||||
1. Bring down the Foundry deployment (`docker compose down` or
|
||||
`docker stack rm signoz`, again without `-v`).
|
||||
2. Confirm the containers are gone with `docker ps -a`.
|
||||
3. Re-apply your backed-up stack: `docker compose up -d` (Compose) or
|
||||
`docker stack deploy -c docker-compose.yaml signoz` (Swarm). It reattaches
|
||||
the same volumes and restores your prior state.
|
||||
|
||||
## Troubleshooting
|
||||
- Please reach out to our community on [Slack](https://signoz.io/slack).
|
||||
|
||||
If the migration runs into trouble, reach out on [Slack][slack] or open a
|
||||
[Foundry issue][foundry-issues].
|
||||
|
||||
## References
|
||||
- [SigNoz Docker installation docs](https://signoz.io/docs/install/docker/)
|
||||
- [SigNoz documentation](https://signoz.io/docs)
|
||||
- [Foundry](https://github.com/SigNoz/foundry)
|
||||
|
||||
- [Foundry][foundry]
|
||||
- [Casting file reference][casting-ref]
|
||||
- [Custom config files][custom-config]
|
||||
- [Patches][patches]
|
||||
- [SigNoz documentation][signoz-docs]
|
||||
|
||||
[foundry]: https://github.com/SigNoz/foundry
|
||||
[install-docs]: https://signoz.io/docs/install/
|
||||
[compose-example]: https://github.com/SigNoz/foundry/tree/main/docs/examples/docker/compose
|
||||
[patches]: https://github.com/SigNoz/foundry/blob/main/docs/concepts/patches.md
|
||||
[custom-config]: https://github.com/SigNoz/foundry/blob/main/docs/concepts/moldings.md#custom-config-files
|
||||
[env-vars]: https://github.com/SigNoz/foundry/blob/main/docs/reference/casting-file.md#molding-spec
|
||||
[casting-ref]: https://github.com/SigNoz/foundry/blob/main/docs/reference/casting-file.md
|
||||
[ch-config]: https://clickhouse.com/docs/operations/configuration-files
|
||||
[upgrade-path]: https://signoz.io/docs/operate/upgrade/
|
||||
[slack]: https://signoz.io/slack
|
||||
[foundry-issues]: https://github.com/SigNoz/foundry/issues
|
||||
[signoz-docs]: https://signoz.io/docs
|
||||
|
||||
1605
docs/api/openapi.yml
@@ -36,6 +36,55 @@ var (
|
||||
|
||||
> 💡 **Note**: Error codes must match the regex `^[a-z_]+$` otherwise the code will panic.
|
||||
|
||||
### Message
|
||||
The primary, human-readable summary of what went wrong, set when the error is created via `errors.New` / `errors.Newf`. Note there are two distinct `message` fields in the response: this top-level one states the overall failure, while each entry under [Additional](#additional) carries its own [message](#message-1) explaining one specific facet of it.
|
||||
|
||||
### Url
|
||||
An optional link to documentation that explains the error in more depth, set with `WithUrl`. It is left empty when the error has no associated doc.
|
||||
|
||||
```go
|
||||
return errors.New(errors.TypeInvalidInput, CodeBadThing, "bad thing").
|
||||
WithUrl("https://signoz.io/docs/...")
|
||||
```
|
||||
|
||||
### Additional
|
||||
`errors` is a list of supplementary details that explain the top-level `message`. Each entry has its own `message` and `suggestions`, so a single error can surface several distinct problems individually. Attach details with `WithAdditional` (message only) or `WithSuggestiveAdditional` (message plus the suggestions that belong to it):
|
||||
|
||||
#### Message
|
||||
A single, self-contained sentence describing one specific facet of the error (e.g. ``field `filed` not found``), distinct from the top-level [Message](#message). Prefer one detail per distinct problem over concatenating several into one message.
|
||||
|
||||
#### Suggestions
|
||||
The suggestions tied to that specific detail — typically a ``did you mean: `x` `` correction for the value the detail is about. These are distinct from the error-wide [Suggestions](#suggestions) below: detail-scoped suggestions never leak into the top-level list.
|
||||
|
||||
```go
|
||||
return errors.NewInvalidInputf(errors.CodeInvalidInput, "unknown field %q", field).
|
||||
WithAdditional("field `field` not found")
|
||||
return errors.NewInvalidInputf(errors.CodeInvalidInput, "unknown field %q", field).
|
||||
WithSuggestiveAdditional("field `filed` not found", "did you mean: `field`")
|
||||
```
|
||||
|
||||
### Retry
|
||||
Carries the `delay` the client should wait before retrying, set with `WithRetryAfter`. It is `null` when the error is not retryable.
|
||||
|
||||
```go
|
||||
return errors.NewTimeoutf(CodeSlow, "upstream timed out").
|
||||
WithRetryAfter(5 * time.Second)
|
||||
```
|
||||
|
||||
### Suggestions
|
||||
`WithSuggestions` sets the error-wide `suggestions` list — hints about the error as a whole (e.g. "narrow the time range window"), as opposed to suggestions tied to a single detail. Prefer the builders in [pkg/errors/suggestions.go](/pkg/errors/suggestions.go) over hand-writing the strings so the phrasing stays consistent:
|
||||
|
||||
- `NewSuggestionsOnLevenshteinDistance(invalidInput, noun, validInputs)` — returns a ``did you mean: `x` `` correction (when a close typo match exists) followed by the valid-references list.
|
||||
- `NewValidReferences(noun, values...)` — formats a capped list as ``valid <noun> are `a`, `b` `` (e.g. `"valid fields are"`, `"valid keys are"`). Returns `""` for an empty set.
|
||||
- `NewSuggestionsFromFunc(produce)` — wraps a caller-computed correction string as a one-element ``did you mean: `x` `` slice (or nil when it returns `""`), for callers with their own matching strategy.
|
||||
|
||||
`noun` names the kind of value being suggested. Use one of the exported `Noun*` constants (`errors.NounFields`, `errors.NounKeys`, `errors.NounServices`, …) so the wording stays uniform across the codebase.
|
||||
|
||||
```go
|
||||
return errors.NewInvalidInputf(errors.CodeInvalidInput, "unknown field %q", field).
|
||||
WithSuggestions(errors.NewSuggestionsOnLevenshteinDistance(field, errors.NounFields, validFields)...)
|
||||
```
|
||||
|
||||
## Show me some examples
|
||||
|
||||
### Using the error
|
||||
|
||||
BIN
docs/readme-assets/monitor/agent-native.png
Normal file
|
After Width: | Height: | Size: 629 KiB |
BIN
docs/readme-assets/monitor/apm.png
Normal file
|
After Width: | Height: | Size: 274 KiB |
BIN
docs/readme-assets/monitor/distributed-tracing.png
Normal file
|
After Width: | Height: | Size: 250 KiB |
BIN
docs/readme-assets/monitor/infrastructure.png
Normal file
|
After Width: | Height: | Size: 563 KiB |
BIN
docs/readme-assets/monitor/llm.png
Normal file
|
After Width: | Height: | Size: 434 KiB |
630
docs/readme-assets/monitor/log-management.svg
Normal file
|
After Width: | Height: | Size: 783 KiB |
BIN
docs/readme-assets/monitor/metrics.png
Normal file
|
After Width: | Height: | Size: 482 KiB |
BIN
docs/readme-assets/monitor/trace-funnels.png
Normal file
|
After Width: | Height: | Size: 254 KiB |
BIN
docs/readme-assets/signoz-hero-dark.png
Normal file
|
After Width: | Height: | Size: 171 KiB |
BIN
docs/readme-assets/signoz-hero-light.png
Normal file
|
After Width: | Height: | Size: 142 KiB |
@@ -143,6 +143,10 @@ func (provider *provider) List(ctx context.Context, orgID valuer.UUID) ([]*autht
|
||||
return provider.pkgAuthzService.List(ctx, orgID)
|
||||
}
|
||||
|
||||
func (provider *provider) Collect(ctx context.Context, orgID valuer.UUID) (map[string]any, error) {
|
||||
return provider.pkgAuthzService.Collect(ctx, orgID)
|
||||
}
|
||||
|
||||
func (provider *provider) ListByOrgIDAndNames(ctx context.Context, orgID valuer.UUID, names []string) ([]*authtypes.Role, error) {
|
||||
return provider.pkgAuthzService.ListByOrgIDAndNames(ctx, orgID, names)
|
||||
}
|
||||
@@ -370,7 +374,7 @@ func (provider *provider) Delete(ctx context.Context, orgID valuer.UUID, id valu
|
||||
}
|
||||
|
||||
for _, cb := range provider.onBeforeRoleDelete {
|
||||
if err := cb(ctx, orgID, id); err != nil {
|
||||
if err := cb(ctx, orgID, id, role.Name); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
@@ -290,6 +290,10 @@ func (module *module) GetByMetricNames(ctx context.Context, orgID valuer.UUID, m
|
||||
return module.pkgDashboardModule.GetByMetricNames(ctx, orgID, metricNames)
|
||||
}
|
||||
|
||||
func (module *module) GetByMetricNamesV2(ctx context.Context, orgID valuer.UUID, metricNames []string) (map[string][]dashboardtypes.DashboardPanelRef, error) {
|
||||
return module.pkgDashboardModule.GetByMetricNamesV2(ctx, orgID, metricNames)
|
||||
}
|
||||
|
||||
func (module *module) List(ctx context.Context, orgID valuer.UUID) ([]*dashboardtypes.Dashboard, error) {
|
||||
return module.pkgDashboardModule.List(ctx, orgID)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,565 @@
|
||||
package implmetricreductionrule
|
||||
|
||||
import (
|
||||
"context"
|
||||
"slices"
|
||||
"time"
|
||||
|
||||
sqlbuilder "github.com/huandu/go-sqlbuilder"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
"github.com/SigNoz/signoz/pkg/telemetrymetrics"
|
||||
"github.com/SigNoz/signoz/pkg/telemetrystore"
|
||||
"github.com/SigNoz/signoz/pkg/types/ctxtypes"
|
||||
"github.com/SigNoz/signoz/pkg/types/metricreductionruletypes"
|
||||
)
|
||||
|
||||
var (
|
||||
reductionRulesTable = telemetrymetrics.DBName + "." + telemetrymetrics.ReductionRulesTableName
|
||||
metadataTable = telemetrymetrics.DBName + "." + telemetrymetrics.AttributesMetadataTableName
|
||||
bufferSeriesTable = telemetrymetrics.DBName + "." + telemetrymetrics.TimeseriesV4BufferTableName
|
||||
)
|
||||
|
||||
const timeSeriesBucketMilli = int64(time.Hour / time.Millisecond)
|
||||
|
||||
type volumeRow struct {
|
||||
MetricName string
|
||||
Ingested uint64
|
||||
Reduced uint64
|
||||
}
|
||||
|
||||
type volumePoint struct {
|
||||
TimestampMs int64
|
||||
Ingested uint64
|
||||
Reduced uint64
|
||||
}
|
||||
|
||||
type clickhouse struct {
|
||||
telemetryStore telemetrystore.TelemetryStore
|
||||
threads int
|
||||
}
|
||||
|
||||
func newClickhouse(telemetryStore telemetrystore.TelemetryStore, threads int) *clickhouse {
|
||||
return &clickhouse{telemetryStore: telemetryStore, threads: threads}
|
||||
}
|
||||
|
||||
func (c *clickhouse) withThreads(ctx context.Context) context.Context {
|
||||
return ctxtypes.SetClickhouseMaxThreads(ctx, c.threads)
|
||||
}
|
||||
|
||||
func floorToTimeSeriesBucket(ms int64) int64 {
|
||||
return ms - (ms % timeSeriesBucketMilli)
|
||||
}
|
||||
|
||||
func strictEffectiveFrom(sb *sqlbuilder.SelectBuilder, metricNames []string, effectiveFrom map[string]int64) string {
|
||||
names := make([]any, 0, len(metricNames))
|
||||
froms := make([]any, 0, len(metricNames))
|
||||
for _, name := range metricNames {
|
||||
names = append(names, name)
|
||||
froms = append(froms, effectiveFrom[name])
|
||||
}
|
||||
return "unix_milli >= transform(metric_name, " + sb.Var(names) + ", " + sb.Var(froms) + ", 0)"
|
||||
}
|
||||
|
||||
func (c *clickhouse) Sync(ctx context.Context, metricName string, labels []string, matchType string, effectiveFromMs int64, deleted bool, updatedAt time.Time) error {
|
||||
ctx = c.withThreads(ctx)
|
||||
|
||||
ib := sqlbuilder.NewInsertBuilder()
|
||||
ib.InsertInto(reductionRulesTable)
|
||||
ib.Cols("metric_name", "labels", "match_type", "effective_from_unix_milli", "deleted", "updated_at")
|
||||
ib.Values(metricName, labels, matchType, effectiveFromMs, deleted, updatedAt)
|
||||
|
||||
query, args := ib.BuildWithFlavor(sqlbuilder.ClickHouse)
|
||||
if err := c.telemetryStore.ClickhouseDB().Exec(ctx, query, args...); err != nil {
|
||||
return errors.WrapInternalf(err, errors.CodeInternal, "failed to sync reduction rule to clickhouse")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *clickhouse) AttributeKeys(ctx context.Context, metricName string, startMs, endMs int64) ([]string, error) {
|
||||
ctx = c.withThreads(ctx)
|
||||
|
||||
sb := sqlbuilder.NewSelectBuilder()
|
||||
sb.Select("attr_name")
|
||||
sb.Distinct()
|
||||
sb.From(metadataTable)
|
||||
sb.Where(
|
||||
sb.E("metric_name", metricName),
|
||||
"NOT startsWith(attr_name, '__')",
|
||||
sb.GE("last_reported_unix_milli", startMs),
|
||||
sb.LE("first_reported_unix_milli", endMs),
|
||||
)
|
||||
|
||||
query, args := sb.BuildWithFlavor(sqlbuilder.ClickHouse)
|
||||
rows, err := c.telemetryStore.ClickhouseDB().Query(ctx, query, args...)
|
||||
if err != nil {
|
||||
return nil, errors.WrapInternalf(err, errors.CodeInternal, "failed to fetch metric attribute keys")
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
keys := make([]string, 0)
|
||||
for rows.Next() {
|
||||
var key string
|
||||
if err := rows.Scan(&key); err != nil {
|
||||
return nil, errors.WrapInternalf(err, errors.CodeInternal, "failed to scan attribute key")
|
||||
}
|
||||
keys = append(keys, key)
|
||||
}
|
||||
return keys, rows.Err()
|
||||
}
|
||||
|
||||
func (c *clickhouse) EstimateCardinality(ctx context.Context, metricName string, keptLabels []string, startMs, endMs int64) (uint64, uint64, error) {
|
||||
ctx = c.withThreads(ctx)
|
||||
startMs = floorToTimeSeriesBucket(startMs)
|
||||
|
||||
sb := sqlbuilder.NewSelectBuilder()
|
||||
|
||||
reducedExpr := "1"
|
||||
if len(keptLabels) > 0 {
|
||||
reducedExpr = "uniq(("
|
||||
for i, label := range keptLabels {
|
||||
if i > 0 {
|
||||
reducedExpr += ", "
|
||||
}
|
||||
reducedExpr += "JSONExtractString(labels, " + sb.Var(label) + ")"
|
||||
}
|
||||
reducedExpr += "))"
|
||||
}
|
||||
|
||||
sb.Select("uniq(fingerprint)", reducedExpr)
|
||||
sb.From(bufferSeriesTable)
|
||||
conds := []string{
|
||||
sb.E("metric_name", metricName),
|
||||
sb.GE("unix_milli", startMs),
|
||||
sb.LT("unix_milli", endMs),
|
||||
sb.E("is_reduced", false),
|
||||
}
|
||||
sb.Where(conds...)
|
||||
|
||||
query, args := sb.BuildWithFlavor(sqlbuilder.ClickHouse)
|
||||
var current, reduced uint64
|
||||
if err := c.telemetryStore.ClickhouseDB().QueryRow(ctx, query, args...).Scan(¤t, &reduced); err != nil {
|
||||
return 0, 0, errors.WrapInternalf(err, errors.CodeInternal, "failed to estimate reduction impact")
|
||||
}
|
||||
if len(keptLabels) == 0 && current == 0 {
|
||||
reduced = 0
|
||||
}
|
||||
if reduced > current {
|
||||
reduced = current
|
||||
}
|
||||
return current, reduced, nil
|
||||
}
|
||||
|
||||
// VolumeByMetric returns ingested vs reduced series counts per metric.
|
||||
func (c *clickhouse) VolumeByMetric(ctx context.Context, metricNames []string, effectiveFrom map[string]int64, startMs, endMs int64) (map[string]volumeRow, error) {
|
||||
if len(metricNames) == 0 {
|
||||
return map[string]volumeRow{}, nil
|
||||
}
|
||||
ctx = c.withThreads(ctx)
|
||||
|
||||
ingested, err := c.ingestedSeriesCount(ctx, metricNames, effectiveFrom, startMs, endMs)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
reduced, err := c.reducedSeriesCount(ctx, metricNames, effectiveFrom, startMs, endMs)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
out := make(map[string]volumeRow, len(metricNames))
|
||||
for metricName, count := range ingested {
|
||||
out[metricName] = volumeRow{MetricName: metricName, Ingested: count, Reduced: out[metricName].Reduced}
|
||||
}
|
||||
for metricName, count := range reduced {
|
||||
row := out[metricName]
|
||||
row.MetricName = metricName
|
||||
row.Reduced = count
|
||||
out[metricName] = row
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// ingestedSeriesCount counts distinct raw fingerprints per metric from the samples buffer over the
|
||||
// window.
|
||||
func (c *clickhouse) ingestedSeriesCount(ctx context.Context, metricNames []string, effectiveFrom map[string]int64, startMs, endMs int64) (map[string]uint64, error) {
|
||||
names := make([]any, len(metricNames))
|
||||
for i, name := range metricNames {
|
||||
names[i] = name
|
||||
}
|
||||
|
||||
sb := sqlbuilder.NewSelectBuilder()
|
||||
sb.Select("metric_name", "uniq(fingerprint)")
|
||||
sb.From(telemetrymetrics.DBName + "." + telemetrymetrics.SamplesV4BufferTableName)
|
||||
conds := []string{
|
||||
sb.In("metric_name", names...),
|
||||
sb.GE("unix_milli", startMs),
|
||||
sb.LT("unix_milli", endMs),
|
||||
}
|
||||
if len(effectiveFrom) > 0 {
|
||||
conds = append(conds, strictEffectiveFrom(sb, metricNames, effectiveFrom))
|
||||
}
|
||||
sb.Where(conds...)
|
||||
sb.GroupBy("metric_name")
|
||||
|
||||
query, args := sb.BuildWithFlavor(sqlbuilder.ClickHouse)
|
||||
rows, err := c.telemetryStore.ClickhouseDB().Query(ctx, query, args...)
|
||||
if err != nil {
|
||||
return nil, errors.WrapInternalf(err, errors.CodeInternal, "failed to count ingested series")
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
out := make(map[string]uint64, len(metricNames))
|
||||
for rows.Next() {
|
||||
var (
|
||||
metricName string
|
||||
count uint64
|
||||
)
|
||||
if err := rows.Scan(&metricName, &count); err != nil {
|
||||
return nil, errors.WrapInternalf(err, errors.CodeInternal, "failed to scan series count")
|
||||
}
|
||||
out[metricName] = count
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// reducedSeriesCount counts distinct reduced_fingerprints per metric, summed across the two 60s
|
||||
// reduced sample tables.
|
||||
func (c *clickhouse) reducedSeriesCount(ctx context.Context, metricNames []string, effectiveFrom map[string]int64, startMs, endMs int64) (map[string]uint64, error) {
|
||||
out := make(map[string]uint64, len(metricNames))
|
||||
for _, table := range []string{telemetrymetrics.SamplesV4ReducedLastTableName, telemetrymetrics.SamplesV4ReducedSumTableName} {
|
||||
counts, err := c.reducedSeriesCountForTable(ctx, telemetrymetrics.DBName+"."+table, metricNames, effectiveFrom, startMs, endMs)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for metricName, count := range counts {
|
||||
out[metricName] += count
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (c *clickhouse) reducedSeriesCountForTable(ctx context.Context, table string, metricNames []string, effectiveFrom map[string]int64, startMs, endMs int64) (map[string]uint64, error) {
|
||||
names := make([]any, len(metricNames))
|
||||
for i, name := range metricNames {
|
||||
names[i] = name
|
||||
}
|
||||
|
||||
sb := sqlbuilder.NewSelectBuilder()
|
||||
sb.Select("metric_name", "uniq(reduced_fingerprint)")
|
||||
sb.From(table)
|
||||
conds := []string{
|
||||
sb.In("metric_name", names...),
|
||||
sb.GE("unix_milli", startMs),
|
||||
sb.LT("unix_milli", endMs),
|
||||
}
|
||||
if len(effectiveFrom) > 0 {
|
||||
conds = append(conds, strictEffectiveFrom(sb, metricNames, effectiveFrom))
|
||||
}
|
||||
sb.Where(conds...)
|
||||
sb.GroupBy("metric_name")
|
||||
|
||||
query, args := sb.BuildWithFlavor(sqlbuilder.ClickHouse)
|
||||
rows, err := c.telemetryStore.ClickhouseDB().Query(ctx, query, args...)
|
||||
if err != nil {
|
||||
return nil, errors.WrapInternalf(err, errors.CodeInternal, "failed to count reduced series")
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
out := make(map[string]uint64, len(metricNames))
|
||||
for rows.Next() {
|
||||
var (
|
||||
metricName string
|
||||
count uint64
|
||||
)
|
||||
if err := rows.Scan(&metricName, &count); err != nil {
|
||||
return nil, errors.WrapInternalf(err, errors.CodeInternal, "failed to scan series count")
|
||||
}
|
||||
out[metricName] = count
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// RankByVolume ranks metrics by ingested/reduced series volume. Like VolumeByMetric, the counts read
|
||||
// the samples tables with a strict effective_from gate; the reduced count sums distinct
|
||||
// reduced_fingerprints across the two 60s reduced sample tables.
|
||||
func (c *clickhouse) RankByVolume(ctx context.Context, metricNames []string, effectiveFrom map[string]int64, orderBy metricreductionruletypes.ReductionRuleOrderBy, order metricreductionruletypes.Order, startMs, endMs int64, offset, limit int) ([]volumeRow, error) {
|
||||
if len(metricNames) == 0 {
|
||||
return []volumeRow{}, nil
|
||||
}
|
||||
ctx = c.withThreads(ctx)
|
||||
|
||||
orderExpr := "ingested"
|
||||
switch orderBy {
|
||||
case metricreductionruletypes.OrderByReducedVolume:
|
||||
orderExpr = "reduced"
|
||||
case metricreductionruletypes.OrderByReduction:
|
||||
orderExpr = "if(ingested = 0, 0, (toFloat64(ingested) - toFloat64(reduced)) / toFloat64(ingested))"
|
||||
}
|
||||
direction := "ASC"
|
||||
if order == metricreductionruletypes.OrderDesc {
|
||||
direction = "DESC"
|
||||
}
|
||||
|
||||
ingestedTable := telemetrymetrics.DBName + "." + telemetrymetrics.SamplesV4BufferTableName
|
||||
reducedLast := telemetrymetrics.DBName + "." + telemetrymetrics.SamplesV4ReducedLastTableName
|
||||
reducedSum := telemetrymetrics.DBName + "." + telemetrymetrics.SamplesV4ReducedSumTableName
|
||||
|
||||
sb := sqlbuilder.NewSelectBuilder()
|
||||
sb.Select("base.metric_name AS metric_name", "ifNull(i.cnt, 0) AS ingested", "ifNull(d.cnt, 0) AS reduced")
|
||||
sb.From("(SELECT arrayJoin(" + sb.Var(metricNames) + ") AS metric_name) AS base")
|
||||
sb.JoinWithOption(
|
||||
sqlbuilder.LeftJoin,
|
||||
"(SELECT metric_name, uniq(fingerprint) AS cnt FROM "+ingestedTable+" WHERE has("+sb.Var(metricNames)+", metric_name) AND unix_milli >= "+sb.Var(startMs)+" AND unix_milli < "+sb.Var(endMs)+" AND "+strictEffectiveFrom(sb, metricNames, effectiveFrom)+" GROUP BY metric_name) AS i",
|
||||
"base.metric_name = i.metric_name",
|
||||
)
|
||||
// Reduced series are spread across two type-specific tables; union the per-table distinct
|
||||
// reduced_fingerprints and sum per metric (a metric only lands in the table matching its type).
|
||||
sb.JoinWithOption(
|
||||
sqlbuilder.LeftJoin,
|
||||
"(SELECT metric_name, sum(cnt) AS cnt FROM ("+
|
||||
"SELECT metric_name, uniq(reduced_fingerprint) AS cnt FROM "+reducedLast+" WHERE has("+sb.Var(metricNames)+", metric_name) AND unix_milli >= "+sb.Var(startMs)+" AND unix_milli < "+sb.Var(endMs)+" AND "+strictEffectiveFrom(sb, metricNames, effectiveFrom)+" GROUP BY metric_name"+
|
||||
" UNION ALL "+
|
||||
"SELECT metric_name, uniq(reduced_fingerprint) AS cnt FROM "+reducedSum+" WHERE has("+sb.Var(metricNames)+", metric_name) AND unix_milli >= "+sb.Var(startMs)+" AND unix_milli < "+sb.Var(endMs)+" AND "+strictEffectiveFrom(sb, metricNames, effectiveFrom)+" GROUP BY metric_name"+
|
||||
") GROUP BY metric_name) AS d",
|
||||
"base.metric_name = d.metric_name",
|
||||
)
|
||||
sb.OrderBy(orderExpr + " " + direction)
|
||||
if limit > 0 {
|
||||
sb.Limit(limit).Offset(offset)
|
||||
}
|
||||
|
||||
query, args := sb.BuildWithFlavor(sqlbuilder.ClickHouse)
|
||||
rows, err := c.telemetryStore.ClickhouseDB().Query(ctx, query, args...)
|
||||
if err != nil {
|
||||
return nil, errors.WrapInternalf(err, errors.CodeInternal, "failed to rank reduction rules by volume")
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
out := make([]volumeRow, 0, len(metricNames))
|
||||
for rows.Next() {
|
||||
var row volumeRow
|
||||
if err := rows.Scan(&row.MetricName, &row.Ingested, &row.Reduced); err != nil {
|
||||
return nil, errors.WrapInternalf(err, errors.CodeInternal, "failed to scan volume row")
|
||||
}
|
||||
out = append(out, row)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
func (c *clickhouse) SampleVolume(ctx context.Context, metricNames []string, effectiveFrom map[string]int64, startMs, endMs int64) (uint64, uint64, error) {
|
||||
if len(metricNames) == 0 {
|
||||
return 0, 0, nil
|
||||
}
|
||||
ctx = c.withThreads(ctx)
|
||||
|
||||
ingested, err := c.countRawSamples(ctx, telemetrymetrics.DBName+"."+telemetrymetrics.SamplesV4BufferTableName, metricNames, effectiveFrom, startMs, endMs)
|
||||
if err != nil {
|
||||
return 0, 0, err
|
||||
}
|
||||
|
||||
last, err := c.countReducedSamples(ctx, telemetrymetrics.DBName+"."+telemetrymetrics.SamplesV4ReducedLastTableName, metricNames, effectiveFrom, startMs, endMs)
|
||||
if err != nil {
|
||||
return 0, 0, err
|
||||
}
|
||||
sum, err := c.countReducedSamples(ctx, telemetrymetrics.DBName+"."+telemetrymetrics.SamplesV4ReducedSumTableName, metricNames, effectiveFrom, startMs, endMs)
|
||||
if err != nil {
|
||||
return 0, 0, err
|
||||
}
|
||||
|
||||
return ingested, min(last+sum, ingested), nil
|
||||
}
|
||||
|
||||
func (c *clickhouse) countRawSamples(ctx context.Context, table string, metricNames []string, effectiveFrom map[string]int64, startMs, endMs int64) (uint64, error) {
|
||||
names := make([]any, len(metricNames))
|
||||
for i, name := range metricNames {
|
||||
names[i] = name
|
||||
}
|
||||
|
||||
sb := sqlbuilder.NewSelectBuilder()
|
||||
sb.Select("count()")
|
||||
sb.From(table)
|
||||
conds := []string{sb.In("metric_name", names...), sb.GE("unix_milli", startMs), sb.LT("unix_milli", endMs)}
|
||||
if len(effectiveFrom) > 0 {
|
||||
conds = append(conds, strictEffectiveFrom(sb, metricNames, effectiveFrom))
|
||||
}
|
||||
sb.Where(conds...)
|
||||
|
||||
query, args := sb.BuildWithFlavor(sqlbuilder.ClickHouse)
|
||||
var count uint64
|
||||
if err := c.telemetryStore.ClickhouseDB().QueryRow(ctx, query, args...).Scan(&count); err != nil {
|
||||
return 0, errors.WrapInternalf(err, errors.CodeInternal, "failed to count ingested samples")
|
||||
}
|
||||
return count, nil
|
||||
}
|
||||
|
||||
func (c *clickhouse) countReducedSamples(ctx context.Context, table string, metricNames []string, effectiveFrom map[string]int64, startMs, endMs int64) (uint64, error) {
|
||||
names := make([]any, len(metricNames))
|
||||
for i, name := range metricNames {
|
||||
names[i] = name
|
||||
}
|
||||
|
||||
sb := sqlbuilder.NewSelectBuilder()
|
||||
// Reduced tables key the series on reduced_fingerprint (not fingerprint); dedupe ReplacingMergeTree recomputes.
|
||||
sb.Select("uniq(reduced_fingerprint, unix_milli)")
|
||||
sb.From(table)
|
||||
conds := []string{sb.In("metric_name", names...), sb.GE("unix_milli", startMs), sb.LT("unix_milli", endMs)}
|
||||
if len(effectiveFrom) > 0 {
|
||||
conds = append(conds, strictEffectiveFrom(sb, metricNames, effectiveFrom))
|
||||
}
|
||||
sb.Where(conds...)
|
||||
|
||||
query, args := sb.BuildWithFlavor(sqlbuilder.ClickHouse)
|
||||
var count uint64
|
||||
if err := c.telemetryStore.ClickhouseDB().QueryRow(ctx, query, args...).Scan(&count); err != nil {
|
||||
return 0, errors.WrapInternalf(err, errors.CodeInternal, "failed to count reduced samples")
|
||||
}
|
||||
return count, nil
|
||||
}
|
||||
|
||||
// SeriesTimeseries returns ingested vs reduced series per 60s bucket from the samples tables, gated
|
||||
// to each metric's strict effective_from (see strictEffectiveFrom).
|
||||
func (c *clickhouse) SeriesTimeseries(ctx context.Context, allMetrics, reducedMetrics []string, effectiveFrom map[string]int64, startMs, endMs int64) ([]volumePoint, error) {
|
||||
if len(allMetrics) == 0 {
|
||||
return []volumePoint{}, nil
|
||||
}
|
||||
ctx = c.withThreads(ctx)
|
||||
|
||||
ingested, err := c.ingestedSeriesByBucket(ctx, allMetrics, effectiveFrom, startMs, endMs)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
retained := make(map[int64]uint64)
|
||||
if len(reducedMetrics) > 0 {
|
||||
reduced, err := c.reducedSeriesByBucket(ctx, reducedMetrics, effectiveFrom, startMs, endMs)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for ts, count := range reduced {
|
||||
retained[ts] += count
|
||||
}
|
||||
}
|
||||
reducedSet := make(map[string]struct{}, len(reducedMetrics))
|
||||
for _, name := range reducedMetrics {
|
||||
reducedSet[name] = struct{}{}
|
||||
}
|
||||
nonReduced := make([]string, 0, len(allMetrics))
|
||||
for _, name := range allMetrics {
|
||||
if _, ok := reducedSet[name]; !ok {
|
||||
nonReduced = append(nonReduced, name)
|
||||
}
|
||||
}
|
||||
if len(nonReduced) > 0 {
|
||||
nonReducedIngested, err := c.ingestedSeriesByBucket(ctx, nonReduced, effectiveFrom, startMs, endMs)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for ts, count := range nonReducedIngested {
|
||||
retained[ts] += count
|
||||
}
|
||||
}
|
||||
|
||||
return mergeVolumePoints(ingested, retained), nil
|
||||
}
|
||||
|
||||
func mergeVolumePoints(ingested, reduced map[int64]uint64) []volumePoint {
|
||||
buckets := make(map[int64]struct{}, len(ingested))
|
||||
for ts := range ingested {
|
||||
buckets[ts] = struct{}{}
|
||||
}
|
||||
for ts := range reduced {
|
||||
buckets[ts] = struct{}{}
|
||||
}
|
||||
timestamps := make([]int64, 0, len(buckets))
|
||||
for ts := range buckets {
|
||||
timestamps = append(timestamps, ts)
|
||||
}
|
||||
slices.Sort(timestamps)
|
||||
|
||||
points := make([]volumePoint, 0, len(timestamps))
|
||||
for _, ts := range timestamps {
|
||||
points = append(points, volumePoint{
|
||||
TimestampMs: ts,
|
||||
Ingested: ingested[ts],
|
||||
Reduced: reduced[ts],
|
||||
})
|
||||
}
|
||||
return points
|
||||
}
|
||||
|
||||
// ingestedSeriesByBucket counts distinct raw fingerprints per hourly bucket from the samples buffer.
|
||||
func (c *clickhouse) ingestedSeriesByBucket(ctx context.Context, metricNames []string, effectiveFrom map[string]int64, startMs, endMs int64) (map[int64]uint64, error) {
|
||||
names := make([]any, len(metricNames))
|
||||
for i, name := range metricNames {
|
||||
names[i] = name
|
||||
}
|
||||
|
||||
sb := sqlbuilder.NewSelectBuilder()
|
||||
bucketExpr := "toInt64(toUnixTimestamp(toStartOfInterval(toDateTime(intDiv(unix_milli, 1000)), toIntervalHour(1)))) * 1000 AS bucket"
|
||||
sb.Select(bucketExpr, "uniq(fingerprint)")
|
||||
sb.From(telemetrymetrics.DBName + "." + telemetrymetrics.SamplesV4BufferTableName)
|
||||
conds := []string{sb.In("metric_name", names...), sb.GE("unix_milli", startMs), sb.LT("unix_milli", endMs)}
|
||||
if len(effectiveFrom) > 0 {
|
||||
conds = append(conds, strictEffectiveFrom(sb, metricNames, effectiveFrom))
|
||||
}
|
||||
sb.Where(conds...)
|
||||
sb.GroupBy("bucket")
|
||||
|
||||
return c.scanBuckets(ctx, sb)
|
||||
}
|
||||
|
||||
// reducedSeriesByBucket counts distinct reduced_fingerprints per hourly bucket, summed across the two
|
||||
// reduced sample tables (a metric only lands in the table matching its type, so per-bucket sums are
|
||||
// exact).
|
||||
func (c *clickhouse) reducedSeriesByBucket(ctx context.Context, metricNames []string, effectiveFrom map[string]int64, startMs, endMs int64) (map[int64]uint64, error) {
|
||||
out := make(map[int64]uint64)
|
||||
for _, table := range []string{telemetrymetrics.SamplesV4ReducedLastTableName, telemetrymetrics.SamplesV4ReducedSumTableName} {
|
||||
names := make([]any, len(metricNames))
|
||||
for i, name := range metricNames {
|
||||
names[i] = name
|
||||
}
|
||||
|
||||
sb := sqlbuilder.NewSelectBuilder()
|
||||
bucketExpr := "toInt64(toUnixTimestamp(toStartOfInterval(toDateTime(intDiv(unix_milli, 1000)), toIntervalHour(1)))) * 1000 AS bucket"
|
||||
sb.Select(bucketExpr, "uniq(reduced_fingerprint)")
|
||||
sb.From(telemetrymetrics.DBName + "." + table)
|
||||
conds := []string{sb.In("metric_name", names...), sb.GE("unix_milli", startMs), sb.LT("unix_milli", endMs)}
|
||||
if len(effectiveFrom) > 0 {
|
||||
conds = append(conds, strictEffectiveFrom(sb, metricNames, effectiveFrom))
|
||||
}
|
||||
sb.Where(conds...)
|
||||
sb.GroupBy("bucket")
|
||||
|
||||
counts, err := c.scanBuckets(ctx, sb)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for ts, count := range counts {
|
||||
out[ts] += count
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (c *clickhouse) scanBuckets(ctx context.Context, sb *sqlbuilder.SelectBuilder) (map[int64]uint64, error) {
|
||||
query, args := sb.BuildWithFlavor(sqlbuilder.ClickHouse)
|
||||
rows, err := c.telemetryStore.ClickhouseDB().Query(ctx, query, args...)
|
||||
if err != nil {
|
||||
return nil, errors.WrapInternalf(err, errors.CodeInternal, "failed to bucket series by time")
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
out := make(map[int64]uint64)
|
||||
for rows.Next() {
|
||||
var (
|
||||
ts int64
|
||||
count uint64
|
||||
)
|
||||
if err := rows.Scan(&ts, &count); err != nil {
|
||||
return nil, errors.WrapInternalf(err, errors.CodeInternal, "failed to scan series bucket")
|
||||
}
|
||||
out[ts] = count
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
571
ee/modules/metricreductionrule/implmetricreductionrule/module.go
Normal file
@@ -0,0 +1,571 @@
|
||||
package implmetricreductionrule
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log/slog"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
"github.com/SigNoz/signoz/pkg/factory"
|
||||
"github.com/SigNoz/signoz/pkg/flagger"
|
||||
"github.com/SigNoz/signoz/pkg/licensing"
|
||||
"github.com/SigNoz/signoz/pkg/modules/dashboard"
|
||||
"github.com/SigNoz/signoz/pkg/modules/metricreductionrule"
|
||||
"github.com/SigNoz/signoz/pkg/queryparser"
|
||||
"github.com/SigNoz/signoz/pkg/ruler/rulestore/sqlrulestore"
|
||||
"github.com/SigNoz/signoz/pkg/sqlstore"
|
||||
"github.com/SigNoz/signoz/pkg/telemetrystore"
|
||||
"github.com/SigNoz/signoz/pkg/types/featuretypes"
|
||||
"github.com/SigNoz/signoz/pkg/types/metricreductionruletypes"
|
||||
"github.com/SigNoz/signoz/pkg/types/metrictypes"
|
||||
"github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
|
||||
"github.com/SigNoz/signoz/pkg/types/ruletypes"
|
||||
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
|
||||
"github.com/SigNoz/signoz/pkg/valuer"
|
||||
)
|
||||
|
||||
const (
|
||||
// effectiveFromMargin delays effective_from so the collector picks up the synced rule before it
|
||||
// goes live; it must be >= the collector's rule-refresh interval (see signoz-otel-collector#839).
|
||||
effectiveFromMargin = 5 * time.Minute
|
||||
defaultPreviewLookback = 24 * time.Hour
|
||||
|
||||
pricePerMillionSamplesUSD = 0.1
|
||||
monthDuration = 30 * 24 * time.Hour
|
||||
)
|
||||
|
||||
type module struct {
|
||||
store metricreductionruletypes.Store
|
||||
ch *clickhouse
|
||||
dashboard dashboard.Module
|
||||
ruleStore ruletypes.RuleStore
|
||||
licensing licensing.Licensing
|
||||
flagger flagger.Flagger
|
||||
metadataStore telemetrytypes.MetadataStore
|
||||
logger *slog.Logger
|
||||
}
|
||||
|
||||
func NewModule(sqlStore sqlstore.SQLStore, telemetryStore telemetrystore.TelemetryStore, dashboardModule dashboard.Module, queryParser queryparser.QueryParser, licensing licensing.Licensing, flagger flagger.Flagger, metadataStore telemetrytypes.MetadataStore, providerSettings factory.ProviderSettings, threads int) metricreductionrule.Module {
|
||||
scoped := factory.NewScopedProviderSettings(providerSettings, "github.com/SigNoz/signoz/ee/modules/metricreductionrule/implmetricreductionrule")
|
||||
return &module{
|
||||
store: NewStore(sqlStore),
|
||||
ch: newClickhouse(telemetryStore, threads),
|
||||
dashboard: dashboardModule,
|
||||
ruleStore: sqlrulestore.NewRuleStore(sqlStore, queryParser, providerSettings),
|
||||
licensing: licensing,
|
||||
flagger: flagger,
|
||||
metadataStore: metadataStore,
|
||||
logger: scoped.Logger(),
|
||||
}
|
||||
}
|
||||
|
||||
func (m *module) checkAccess(ctx context.Context, orgID valuer.UUID) error {
|
||||
if !m.flagger.BooleanOrEmpty(ctx, flagger.FeatureEnableMetricsReduction, featuretypes.NewFlaggerEvaluationContext(orgID)) {
|
||||
return errors.Newf(errors.TypeUnsupported, metricreductionruletypes.ErrCodeMetricReductionRuleUnsupported, "metric volume control is not enabled")
|
||||
}
|
||||
if _, err := m.licensing.GetActive(ctx, orgID); err != nil {
|
||||
return errors.New(errors.TypeLicenseUnavailable, errors.CodeLicenseUnavailable, "metric volume control requires a valid license").WithAdditional(err.Error())
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *module) List(ctx context.Context, orgID valuer.UUID, params *metricreductionruletypes.ListReductionRulesParams) (*metricreductionruletypes.GettableReductionRules, error) {
|
||||
if err := m.checkAccess(ctx, orgID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := params.Validate(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
startMs := now.Add(-defaultPreviewLookback).UnixMilli()
|
||||
endMs := now.UnixMilli()
|
||||
|
||||
switch params.OrderBy {
|
||||
case metricreductionruletypes.OrderByMetricName, metricreductionruletypes.OrderByLastUpdated:
|
||||
return m.listSortedByColumn(ctx, orgID, params, startMs, endMs)
|
||||
default:
|
||||
return m.listSortedByVolume(ctx, orgID, params, startMs, endMs)
|
||||
}
|
||||
}
|
||||
|
||||
func (m *module) listSortedByColumn(ctx context.Context, orgID valuer.UUID, params *metricreductionruletypes.ListReductionRulesParams, startMs, endMs int64) (*metricreductionruletypes.GettableReductionRules, error) {
|
||||
domainRules, total, err := m.store.List(ctx, orgID, params)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
metricNames := make([]string, len(domainRules))
|
||||
effectiveFrom := make(map[string]int64, len(domainRules))
|
||||
for i, rule := range domainRules {
|
||||
metricNames[i] = rule.MetricName
|
||||
effectiveFrom[rule.MetricName] = rule.EffectiveFrom.UnixMilli()
|
||||
}
|
||||
volumes, err := m.ch.VolumeByMetric(ctx, metricNames, effectiveFrom, startMs, endMs)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
rules := make([]metricreductionruletypes.GettableReductionRule, 0, len(domainRules))
|
||||
for _, rule := range domainRules {
|
||||
rules = append(rules, withVolume(toGettableReductionRule(rule), volumes[rule.MetricName]))
|
||||
}
|
||||
|
||||
return &metricreductionruletypes.GettableReductionRules{Rules: rules, Total: total}, nil
|
||||
}
|
||||
|
||||
func (m *module) listSortedByVolume(ctx context.Context, orgID valuer.UUID, params *metricreductionruletypes.ListReductionRulesParams, startMs, endMs int64) (*metricreductionruletypes.GettableReductionRules, error) {
|
||||
allRules, total, err := m.store.List(ctx, orgID, &metricreductionruletypes.ListReductionRulesParams{Search: params.Search, MetricName: params.MetricName})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if total == 0 {
|
||||
return &metricreductionruletypes.GettableReductionRules{Rules: []metricreductionruletypes.GettableReductionRule{}, Total: 0}, nil
|
||||
}
|
||||
|
||||
metricNames := make([]string, len(allRules))
|
||||
effectiveFrom := make(map[string]int64, len(allRules))
|
||||
ruleByMetric := make(map[string]*metricreductionruletypes.ReductionRule, len(allRules))
|
||||
for i, rule := range allRules {
|
||||
metricNames[i] = rule.MetricName
|
||||
effectiveFrom[rule.MetricName] = rule.EffectiveFrom.UnixMilli()
|
||||
ruleByMetric[rule.MetricName] = rule
|
||||
}
|
||||
|
||||
ranked, err := m.ch.RankByVolume(ctx, metricNames, effectiveFrom, params.OrderBy, params.Order, startMs, endMs, params.Offset, params.Limit)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
rules := make([]metricreductionruletypes.GettableReductionRule, 0, len(ranked))
|
||||
for _, row := range ranked {
|
||||
rule, ok := ruleByMetric[row.MetricName]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
rules = append(rules, withVolume(toGettableReductionRule(rule), row))
|
||||
}
|
||||
|
||||
return &metricreductionruletypes.GettableReductionRules{Rules: rules, Total: total}, nil
|
||||
}
|
||||
|
||||
func (m *module) Create(ctx context.Context, orgID valuer.UUID, userEmail string, req *metricreductionruletypes.PostableReductionRule) (*metricreductionruletypes.GettableReductionRule, error) {
|
||||
if err := m.checkAccess(ctx, orgID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := req.Validate(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := m.validateMetricForReduction(ctx, orgID, req.MetricName); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
now := time.Now()
|
||||
rule := metricreductionruletypes.NewReductionRule(orgID, req.MetricName, req.MatchType, req.Labels, now.Add(effectiveFromMargin), userEmail)
|
||||
|
||||
if err := m.store.RunInTx(ctx, func(ctx context.Context) error {
|
||||
if err := m.store.Create(ctx, rule); err != nil {
|
||||
return err
|
||||
}
|
||||
return m.ch.Sync(ctx, rule.MetricName, rule.Labels, rule.MatchType.StringValue(), rule.EffectiveFrom.UnixMilli(), false, rule.UpdatedAt)
|
||||
}); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
gettable := toGettableReductionRule(rule)
|
||||
return &gettable, nil
|
||||
}
|
||||
|
||||
func (m *module) GetByID(ctx context.Context, orgID valuer.UUID, id valuer.UUID) (*metricreductionruletypes.GettableReductionRule, error) {
|
||||
if err := m.checkAccess(ctx, orgID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
rule, err := m.store.GetByID(ctx, orgID, id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
gettable := toGettableReductionRule(rule)
|
||||
return &gettable, nil
|
||||
}
|
||||
|
||||
func (m *module) UpdateByID(ctx context.Context, orgID valuer.UUID, userEmail string, id valuer.UUID, req *metricreductionruletypes.UpdatableReductionRule) (*metricreductionruletypes.GettableReductionRule, error) {
|
||||
if err := m.checkAccess(ctx, orgID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
existing, err := m.store.GetByID(ctx, orgID, id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := req.Validate(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
existing.MatchType = req.MatchType
|
||||
existing.Labels = metricreductionruletypes.LabelList(req.Labels)
|
||||
existing.EffectiveFrom = now.Add(effectiveFromMargin)
|
||||
existing.UpdatedAt = now
|
||||
existing.UpdatedBy = userEmail
|
||||
|
||||
if err := m.store.RunInTx(ctx, func(ctx context.Context) error {
|
||||
if err := m.store.Upsert(ctx, existing); err != nil {
|
||||
return err
|
||||
}
|
||||
return m.ch.Sync(ctx, existing.MetricName, existing.Labels, existing.MatchType.StringValue(), existing.EffectiveFrom.UnixMilli(), false, existing.UpdatedAt)
|
||||
}); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
gettable := toGettableReductionRule(existing)
|
||||
return &gettable, nil
|
||||
}
|
||||
|
||||
func (m *module) DeleteByID(ctx context.Context, orgID valuer.UUID, id valuer.UUID) error {
|
||||
if err := m.checkAccess(ctx, orgID); err != nil {
|
||||
return err
|
||||
}
|
||||
rule, err := m.store.GetByID(ctx, orgID, id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
effectiveFromMs := now.Add(effectiveFromMargin).UnixMilli()
|
||||
return m.store.RunInTx(ctx, func(ctx context.Context) error {
|
||||
if err := m.store.DeleteByID(ctx, orgID, id); err != nil {
|
||||
return err
|
||||
}
|
||||
return m.ch.Sync(ctx, rule.MetricName, []string{}, metricreductionruletypes.MatchTypeDrop.StringValue(), effectiveFromMs, true, now)
|
||||
})
|
||||
}
|
||||
|
||||
func (m *module) Preview(ctx context.Context, orgID valuer.UUID, req *metricreductionruletypes.PostableReductionRulePreview) (*metricreductionruletypes.GettableReductionRulePreview, error) {
|
||||
if err := m.checkAccess(ctx, orgID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := req.Validate(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := m.validateMetricForReduction(ctx, orgID, req.MetricName); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
lookback := time.Duration(req.LookbackMs) * time.Millisecond
|
||||
if lookback <= 0 {
|
||||
lookback = defaultPreviewLookback
|
||||
}
|
||||
now := time.Now()
|
||||
startMs := now.Add(-lookback).UnixMilli()
|
||||
endMs := now.UnixMilli()
|
||||
current, reduced, reductionPercent, dropped, err := m.estimateVolume(ctx, req.MetricName, req.MatchType, req.Labels, startMs, endMs)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Baseline is what the metric keeps today (its current rule, or raw if none) so the preview reads
|
||||
// as current -> proposed.
|
||||
currentReduced := current
|
||||
if existing, gerr := m.store.Get(ctx, orgID, req.MetricName); gerr == nil {
|
||||
if _, existingReduced, _, _, eerr := m.estimateVolume(ctx, req.MetricName, existing.MatchType, existing.Labels, startMs, endMs); eerr == nil {
|
||||
currentReduced = existingReduced
|
||||
}
|
||||
}
|
||||
|
||||
return &metricreductionruletypes.GettableReductionRulePreview{
|
||||
IngestedSeries: current,
|
||||
CurrentRetainedSeries: currentReduced,
|
||||
RetainedSeries: reduced,
|
||||
ReductionPercent: reductionPercent,
|
||||
DroppedLabels: dropped,
|
||||
AffectedAssets: m.relatedAssetImpact(ctx, orgID, req.MetricName, dropped),
|
||||
EffectiveFrom: now.Add(effectiveFromMargin),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (m *module) Stats(ctx context.Context, orgID valuer.UUID) (*metricreductionruletypes.GettableReductionRuleStats, error) {
|
||||
if err := m.checkAccess(ctx, orgID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
startMs := now.Add(-defaultPreviewLookback).UnixMilli()
|
||||
endMs := now.UnixMilli()
|
||||
|
||||
allRules, total, err := m.store.List(ctx, orgID, &metricreductionruletypes.ListReductionRulesParams{})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if total == 0 {
|
||||
return &metricreductionruletypes.GettableReductionRuleStats{}, nil
|
||||
}
|
||||
|
||||
metricNames := make([]string, len(allRules))
|
||||
effectiveFrom := make(map[string]int64, len(allRules))
|
||||
for i, rule := range allRules {
|
||||
metricNames[i] = rule.MetricName
|
||||
effectiveFrom[rule.MetricName] = rule.EffectiveFrom.UnixMilli()
|
||||
}
|
||||
|
||||
volumes, err := m.ch.VolumeByMetric(ctx, metricNames, effectiveFrom, startMs, endMs)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var ingestedSeries, retainedSeries uint64
|
||||
reducedMetricNames := make([]string, 0, len(volumes))
|
||||
reducedEffectiveFrom := make(map[string]int64, len(volumes))
|
||||
for name, volume := range volumes {
|
||||
ingestedSeries += volume.Ingested
|
||||
retained := effectiveRetained(volume.Ingested, volume.Reduced)
|
||||
retainedSeries += retained
|
||||
if retained < volume.Ingested {
|
||||
reducedMetricNames = append(reducedMetricNames, name)
|
||||
reducedEffectiveFrom[name] = effectiveFrom[name]
|
||||
}
|
||||
}
|
||||
|
||||
ingestedSamples, reducedSamples, err := m.ch.SampleVolume(ctx, reducedMetricNames, reducedEffectiveFrom, startMs, endMs)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &metricreductionruletypes.GettableReductionRuleStats{
|
||||
IngestedSeries: ingestedSeries,
|
||||
RetainedSeries: retainedSeries,
|
||||
EstimatedMonthlySavingsUsd: monthlySavingsUSD(ingestedSamples, reducedSamples, startMs, endMs),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// monthlySavingsUSD extrapolates the windowed sample reduction to a monthly figure at the per-sample
|
||||
// list price. Ingested is gated to effective_from upstream, so pre-activation hours don't inflate it.
|
||||
func monthlySavingsUSD(ingestedSamples, reducedSamples uint64, startMs, endMs int64) float64 {
|
||||
if reducedSamples >= ingestedSamples || endMs <= startMs {
|
||||
return 0
|
||||
}
|
||||
savedSamples := float64(ingestedSamples - reducedSamples)
|
||||
monthlySamples := savedSamples * float64(monthDuration.Milliseconds()) / float64(endMs-startMs)
|
||||
return monthlySamples / 1_000_000 * pricePerMillionSamplesUSD
|
||||
}
|
||||
|
||||
func (m *module) Timeseries(ctx context.Context, orgID valuer.UUID) (*querybuildertypesv5.QueryRangeResponse, error) {
|
||||
if err := m.checkAccess(ctx, orgID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
startMs := now.Add(-defaultPreviewLookback).UnixMilli()
|
||||
endMs := now.UnixMilli()
|
||||
|
||||
allRules, _, err := m.store.List(ctx, orgID, &metricreductionruletypes.ListReductionRulesParams{})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
metricNames := make([]string, len(allRules))
|
||||
effectiveFrom := make(map[string]int64, len(allRules))
|
||||
for i, rule := range allRules {
|
||||
metricNames[i] = rule.MetricName
|
||||
effectiveFrom[rule.MetricName] = rule.EffectiveFrom.UnixMilli()
|
||||
}
|
||||
|
||||
volumes, err := m.ch.VolumeByMetric(ctx, metricNames, effectiveFrom, startMs, endMs)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
reducedNames := make([]string, 0, len(volumes))
|
||||
for name, volume := range volumes {
|
||||
if effectiveRetained(volume.Ingested, volume.Reduced) < volume.Ingested {
|
||||
reducedNames = append(reducedNames, name)
|
||||
}
|
||||
}
|
||||
|
||||
points, err := m.ch.SeriesTimeseries(ctx, metricNames, reducedNames, effectiveFrom, startMs, endMs)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return buildVolumeTimeseries(points), nil
|
||||
}
|
||||
|
||||
func buildVolumeTimeseries(points []volumePoint) *querybuildertypesv5.QueryRangeResponse {
|
||||
ingested := make([]*querybuildertypesv5.TimeSeriesValue, 0, len(points))
|
||||
reduced := make([]*querybuildertypesv5.TimeSeriesValue, 0, len(points))
|
||||
for _, point := range points {
|
||||
ingested = append(ingested, &querybuildertypesv5.TimeSeriesValue{Timestamp: point.TimestampMs, Value: float64(point.Ingested)})
|
||||
reduced = append(reduced, &querybuildertypesv5.TimeSeriesValue{Timestamp: point.TimestampMs, Value: float64(point.Reduced)})
|
||||
}
|
||||
|
||||
return &querybuildertypesv5.QueryRangeResponse{
|
||||
Type: querybuildertypesv5.RequestTypeTimeSeries,
|
||||
Data: querybuildertypesv5.QueryData{
|
||||
Results: []any{
|
||||
&querybuildertypesv5.TimeSeriesData{
|
||||
QueryName: "reduction_volume",
|
||||
Aggregations: []*querybuildertypesv5.AggregationBucket{
|
||||
{
|
||||
Series: []*querybuildertypesv5.TimeSeries{
|
||||
{Labels: []*querybuildertypesv5.Label{{Key: telemetrytypes.TelemetryFieldKey{Name: "series"}, Value: "ingested"}}, Values: ingested},
|
||||
{Labels: []*querybuildertypesv5.Label{{Key: telemetrytypes.TelemetryFieldKey{Name: "series"}, Value: "retained"}}, Values: reduced},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func (m *module) validateMetricForReduction(ctx context.Context, orgID valuer.UUID, metricName string) error {
|
||||
lastSeen, err := m.metadataStore.FetchLastSeenInfoMulti(ctx, metricName)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if lastSeen[metricName] == 0 {
|
||||
return errors.NewNotFoundf(errors.CodeNotFound, "metric not found: %q", metricName)
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
startTs := uint64(now.Add(-defaultPreviewLookback).UnixMilli())
|
||||
endTs := uint64(now.UnixMilli())
|
||||
_, types, _, err := m.metadataStore.FetchTemporalityAndTypeMulti(ctx, orgID, startTs, endTs, metricName)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if types[metricName] == metrictypes.ExpHistogramType {
|
||||
return errors.Newf(errors.TypeInvalidInput, metricreductionruletypes.ErrCodeMetricReductionRuleUnsupportedMetricType,
|
||||
"exponential histogram metrics cannot be reduced in v1")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *module) relatedAssetImpact(ctx context.Context, orgID valuer.UUID, metricName string, dropped []string) []metricreductionruletypes.AffectedAsset {
|
||||
affected := make([]metricreductionruletypes.AffectedAsset, 0)
|
||||
droppedSet := make(map[string]struct{}, len(dropped))
|
||||
for _, label := range dropped {
|
||||
droppedSet[label] = struct{}{}
|
||||
}
|
||||
|
||||
if dashboards, err := m.dashboard.GetByMetricNames(ctx, orgID, []string{metricName}); err != nil {
|
||||
m.logger.WarnContext(ctx, "failed to fetch related dashboards for reduction preview", slog.String("metric_name", metricName), errors.Attr(err))
|
||||
} else {
|
||||
for _, item := range dashboards[metricName] {
|
||||
usedLabels := append(splitCSV(item["group_by"]), splitCSV(item["filter_by"])...)
|
||||
affected = append(affected, metricreductionruletypes.AffectedAsset{
|
||||
Type: metricreductionruletypes.AssetTypeDashboard,
|
||||
ID: item["dashboard_id"],
|
||||
Name: item["dashboard_name"],
|
||||
Widget: &metricreductionruletypes.AffectedWidget{ID: item["widget_id"], Name: item["widget_name"]},
|
||||
ImpactedLabels: intersectLabels(usedLabels, droppedSet),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
if alerts, err := m.ruleStore.GetStoredRulesByMetricName(ctx, orgID.String(), metricName); err != nil {
|
||||
m.logger.WarnContext(ctx, "failed to fetch related alerts for reduction preview", slog.String("metric_name", metricName), errors.Attr(err))
|
||||
} else {
|
||||
for _, a := range alerts {
|
||||
affected = append(affected, metricreductionruletypes.AffectedAsset{
|
||||
Type: metricreductionruletypes.AssetTypeAlert,
|
||||
ID: a.AlertID,
|
||||
Name: a.AlertName,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return affected
|
||||
}
|
||||
|
||||
func toGettableReductionRule(rule *metricreductionruletypes.ReductionRule) metricreductionruletypes.GettableReductionRule {
|
||||
return metricreductionruletypes.GettableReductionRule{
|
||||
Identifiable: rule.Identifiable,
|
||||
TimeAuditable: rule.TimeAuditable,
|
||||
UserAuditable: rule.UserAuditable,
|
||||
MetricName: rule.MetricName,
|
||||
MatchType: rule.MatchType,
|
||||
Labels: rule.Labels,
|
||||
EffectiveFrom: rule.EffectiveFrom,
|
||||
Active: !rule.EffectiveFrom.After(time.Now()),
|
||||
}
|
||||
}
|
||||
|
||||
func effectiveRetained(ingested, reduced uint64) uint64 {
|
||||
if reduced == 0 || reduced > ingested {
|
||||
return ingested
|
||||
}
|
||||
return reduced
|
||||
}
|
||||
|
||||
func withVolume(rule metricreductionruletypes.GettableReductionRule, volume volumeRow) metricreductionruletypes.GettableReductionRule {
|
||||
rule.IngestedSeries = volume.Ingested
|
||||
rule.RetainedSeries = effectiveRetained(volume.Ingested, volume.Reduced)
|
||||
if volume.Ingested > 0 {
|
||||
rule.ReductionPercent = (1 - float64(rule.RetainedSeries)/float64(volume.Ingested)) * 100
|
||||
}
|
||||
return rule
|
||||
}
|
||||
|
||||
func intersectLabels(keys []string, droppedSet map[string]struct{}) []string {
|
||||
seen := make(map[string]struct{})
|
||||
var out []string
|
||||
for _, key := range keys {
|
||||
if _, ok := droppedSet[key]; !ok {
|
||||
continue
|
||||
}
|
||||
if _, dup := seen[key]; dup {
|
||||
continue
|
||||
}
|
||||
seen[key] = struct{}{}
|
||||
out = append(out, key)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func splitCSV(s string) []string {
|
||||
if s == "" {
|
||||
return nil
|
||||
}
|
||||
return strings.Split(s, ",")
|
||||
}
|
||||
|
||||
func resolveDroppedKept(matchType metricreductionruletypes.MatchType, ruleLabels, keys []string) (dropped, kept []string) {
|
||||
ruleSet := make(map[string]struct{}, len(ruleLabels))
|
||||
for _, l := range ruleLabels {
|
||||
ruleSet[l] = struct{}{}
|
||||
}
|
||||
|
||||
for _, k := range keys {
|
||||
if metricreductionruletypes.IsProtectedLabel(k) {
|
||||
kept = append(kept, k)
|
||||
continue
|
||||
}
|
||||
_, listed := ruleSet[k]
|
||||
drop := listed
|
||||
if matchType == metricreductionruletypes.MatchTypeKeep {
|
||||
drop = !listed
|
||||
}
|
||||
if drop {
|
||||
dropped = append(dropped, k)
|
||||
} else {
|
||||
kept = append(kept, k)
|
||||
}
|
||||
}
|
||||
|
||||
sort.Strings(dropped)
|
||||
sort.Strings(kept)
|
||||
return dropped, kept
|
||||
}
|
||||
|
||||
func (m *module) estimateVolume(ctx context.Context, metricName string, matchType metricreductionruletypes.MatchType, labels []string, startMs, endMs int64) (current uint64, reduced uint64, reductionPercent float64, dropped []string, err error) {
|
||||
keys, err := m.ch.AttributeKeys(ctx, metricName, startMs, endMs)
|
||||
if err != nil {
|
||||
return 0, 0, 0, nil, err
|
||||
}
|
||||
dropped, kept := resolveDroppedKept(matchType, labels, keys)
|
||||
|
||||
current, reduced, err = m.ch.EstimateCardinality(ctx, metricName, kept, startMs, endMs)
|
||||
if err != nil {
|
||||
return 0, 0, 0, nil, err
|
||||
}
|
||||
if current > 0 && reduced <= current {
|
||||
reductionPercent = (1 - float64(reduced)/float64(current)) * 100
|
||||
}
|
||||
return current, reduced, reductionPercent, dropped, nil
|
||||
}
|
||||
145
ee/modules/metricreductionrule/implmetricreductionrule/store.go
Normal file
@@ -0,0 +1,145 @@
|
||||
package implmetricreductionrule
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
"github.com/SigNoz/signoz/pkg/sqlstore"
|
||||
"github.com/SigNoz/signoz/pkg/types/metricreductionruletypes"
|
||||
"github.com/SigNoz/signoz/pkg/valuer"
|
||||
)
|
||||
|
||||
type store struct {
|
||||
sqlstore sqlstore.SQLStore
|
||||
}
|
||||
|
||||
func NewStore(sqlstore sqlstore.SQLStore) metricreductionruletypes.Store {
|
||||
return &store{sqlstore: sqlstore}
|
||||
}
|
||||
|
||||
func (s *store) List(ctx context.Context, orgID valuer.UUID, params *metricreductionruletypes.ListReductionRulesParams) ([]*metricreductionruletypes.ReductionRule, int, error) {
|
||||
column := "metric_name"
|
||||
if params.OrderBy == metricreductionruletypes.OrderByLastUpdated {
|
||||
column = "updated_at"
|
||||
}
|
||||
direction := "ASC"
|
||||
if params.Order == metricreductionruletypes.OrderDesc {
|
||||
direction = "DESC"
|
||||
}
|
||||
|
||||
rules := make([]*metricreductionruletypes.ReductionRule, 0)
|
||||
query := s.sqlstore.
|
||||
BunDBCtx(ctx).
|
||||
NewSelect().
|
||||
Model(&rules).
|
||||
Where("org_id = ?", orgID).
|
||||
Order(column + " " + direction)
|
||||
if params.Search != "" {
|
||||
query = query.Where("metric_name LIKE ?", "%"+params.Search+"%")
|
||||
}
|
||||
if params.MetricName != "" {
|
||||
query = query.Where("metric_name = ?", params.MetricName)
|
||||
}
|
||||
if params.Limit > 0 {
|
||||
query = query.Limit(params.Limit).Offset(params.Offset)
|
||||
}
|
||||
|
||||
total, err := query.ScanAndCount(ctx)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
return rules, total, nil
|
||||
}
|
||||
|
||||
func (s *store) Get(ctx context.Context, orgID valuer.UUID, metricName string) (*metricreductionruletypes.ReductionRule, error) {
|
||||
rule := new(metricreductionruletypes.ReductionRule)
|
||||
err := s.sqlstore.
|
||||
BunDBCtx(ctx).
|
||||
NewSelect().
|
||||
Model(rule).
|
||||
Where("org_id = ?", orgID).
|
||||
Where("metric_name = ?", metricName).
|
||||
Scan(ctx)
|
||||
if err != nil {
|
||||
return nil, s.sqlstore.WrapNotFoundErrf(err, metricreductionruletypes.ErrCodeMetricReductionRuleNotFound, "no reduction rule found for metric %q", metricName)
|
||||
}
|
||||
return rule, nil
|
||||
}
|
||||
|
||||
func (s *store) GetByID(ctx context.Context, orgID valuer.UUID, id valuer.UUID) (*metricreductionruletypes.ReductionRule, error) {
|
||||
rule := new(metricreductionruletypes.ReductionRule)
|
||||
err := s.sqlstore.
|
||||
BunDBCtx(ctx).
|
||||
NewSelect().
|
||||
Model(rule).
|
||||
Where("org_id = ?", orgID).
|
||||
Where("id = ?", id).
|
||||
Scan(ctx)
|
||||
if err != nil {
|
||||
return nil, s.sqlstore.WrapNotFoundErrf(err, metricreductionruletypes.ErrCodeMetricReductionRuleNotFound, "no reduction rule found with id %q", id.String())
|
||||
}
|
||||
return rule, nil
|
||||
}
|
||||
|
||||
func (s *store) Create(ctx context.Context, rule *metricreductionruletypes.ReductionRule) error {
|
||||
res, err := s.sqlstore.
|
||||
BunDBCtx(ctx).
|
||||
NewInsert().
|
||||
Model(rule).
|
||||
On("CONFLICT (org_id, metric_name) DO NOTHING").
|
||||
Exec(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
rowsAffected, err := res.RowsAffected()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if rowsAffected == 0 {
|
||||
return errors.Newf(errors.TypeAlreadyExists, metricreductionruletypes.ErrCodeMetricReductionRuleAlreadyExists,
|
||||
"a reduction rule for metric %q already exists", rule.MetricName)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *store) Upsert(ctx context.Context, rule *metricreductionruletypes.ReductionRule) error {
|
||||
_, err := s.sqlstore.
|
||||
BunDBCtx(ctx).
|
||||
NewInsert().
|
||||
Model(rule).
|
||||
On("CONFLICT (org_id, metric_name) DO UPDATE").
|
||||
Set("match_type = EXCLUDED.match_type").
|
||||
Set("labels = EXCLUDED.labels").
|
||||
Set("effective_from = EXCLUDED.effective_from").
|
||||
Set("updated_at = EXCLUDED.updated_at").
|
||||
Set("updated_by = EXCLUDED.updated_by").
|
||||
Exec(ctx)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *store) DeleteByID(ctx context.Context, orgID valuer.UUID, id valuer.UUID) error {
|
||||
res, err := s.sqlstore.
|
||||
BunDBCtx(ctx).
|
||||
NewDelete().
|
||||
Model((*metricreductionruletypes.ReductionRule)(nil)).
|
||||
Where("org_id = ?", orgID).
|
||||
Where("id = ?", id).
|
||||
Exec(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
rowsAffected, err := res.RowsAffected()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if rowsAffected == 0 {
|
||||
return errors.Newf(errors.TypeNotFound, metricreductionruletypes.ErrCodeMetricReductionRuleNotFound, "no reduction rule found with id %q", id.String())
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *store) RunInTx(ctx context.Context, cb func(ctx context.Context) error) error {
|
||||
return s.sqlstore.RunInTxCtx(ctx, nil, cb)
|
||||
}
|
||||
@@ -101,6 +101,10 @@ func (h *handler) QueryRange(rw http.ResponseWriter, req *http.Request) {
|
||||
h.community.QueryRange(rw, req)
|
||||
}
|
||||
|
||||
func (h *handler) QueryRangePreview(rw http.ResponseWriter, req *http.Request) {
|
||||
h.community.QueryRangePreview(rw, req)
|
||||
}
|
||||
|
||||
func (h *handler) QueryRawStream(rw http.ResponseWriter, req *http.Request) {
|
||||
h.community.QueryRawStream(rw, req)
|
||||
}
|
||||
|
||||
@@ -107,6 +107,15 @@ func (ah *APIHandler) getFeatureFlags(w http.ResponseWriter, r *http.Request) {
|
||||
Route: "",
|
||||
})
|
||||
|
||||
metricsReduction := ah.Signoz.Flagger.BooleanOrEmpty(ctx, flagger.FeatureEnableMetricsReduction, evalCtx)
|
||||
featureSet = append(featureSet, &licensetypes.Feature{
|
||||
Name: valuer.NewString(flagger.FeatureEnableMetricsReduction.String()),
|
||||
Active: metricsReduction,
|
||||
Usage: 0,
|
||||
UsageLimit: -1,
|
||||
Route: "",
|
||||
})
|
||||
|
||||
if constants.IsDotMetricsEnabled {
|
||||
for idx, feature := range featureSet {
|
||||
if feature.Name == licensetypes.DotMetricsEnabled {
|
||||
|
||||
@@ -152,3 +152,7 @@ func (f *formatter) LowerExpression(expression string) []byte {
|
||||
sql = append(sql, ')')
|
||||
return sql
|
||||
}
|
||||
|
||||
func (f *formatter) EscapeLikePattern(value string) string {
|
||||
return strings.NewReplacer(`\`, `\\`, `%`, `\%`, `_`, `\_`).Replace(value)
|
||||
}
|
||||
|
||||
@@ -1,8 +1,38 @@
|
||||
NODE_ENV="development"
|
||||
BUNDLE_ANALYSER="true"
|
||||
VITE_FRONTEND_API_ENDPOINT="http://localhost:8080"
|
||||
VITE_PYLON_APP_ID="pylon-app-id"
|
||||
VITE_APPCUES_APP_ID="appcess-app-id"
|
||||
VITE_PYLON_IDENTITY_SECRET="pylon-identity-secret"
|
||||
|
||||
CI="1"
|
||||
|
||||
# API
|
||||
VITE_BASE_PATH=""
|
||||
VITE_FRONTEND_API_ENDPOINT="http://localhost:8080"
|
||||
VITE_WEBSOCKET_API_ENDPOINT=""
|
||||
|
||||
# Pylon
|
||||
VITE_PYLON_ENABLED="false"
|
||||
VITE_PYLON_APP_ID=""
|
||||
VITE_PYLON_IDENTITY_SECRET=""
|
||||
|
||||
# Appcues
|
||||
VITE_APPCUES_ENABLED="false"
|
||||
VITE_APPCUES_APP_ID=""
|
||||
|
||||
# PostHog
|
||||
VITE_POSTHOG_ENABLED="false"
|
||||
VITE_POSTHOG_API_HOST=""
|
||||
VITE_POSTHOG_KEY=""
|
||||
VITE_POSTHOG_UI_HOST=""
|
||||
|
||||
# Sentry
|
||||
VITE_SENTRY_ENABLED="false"
|
||||
VITE_SENTRY_AUTH_TOKEN=""
|
||||
VITE_SENTRY_ORG=""
|
||||
VITE_SENTRY_PROJECT_ID=""
|
||||
VITE_SENTRY_TUNNEL=""
|
||||
VITE_SENTRY_DSN=""
|
||||
|
||||
# Docs
|
||||
VITE_DOCS_BASE_URL="https://signoz.io"
|
||||
|
||||
# Build info
|
||||
VITE_ENVIRONMENT="development"
|
||||
VITE_VERSION=""
|
||||
|
||||
@@ -111,11 +111,10 @@
|
||||
<div id="root"></div>
|
||||
|
||||
<script>
|
||||
var PYLON_APP_ID = '<%- PYLON_APP_ID %>';
|
||||
var pylonSettings =
|
||||
((window.signozBootData || {}).settings || {}).pylon || {};
|
||||
var pylonEnabled = pylonSettings.enabled !== false;
|
||||
if (PYLON_APP_ID && pylonEnabled) {
|
||||
var pylonEnabled = pylonSettings.enabled === true;
|
||||
if (pylonSettings.appId && pylonEnabled) {
|
||||
(function () {
|
||||
var e = window;
|
||||
var t = document;
|
||||
@@ -133,7 +132,7 @@
|
||||
e.setAttribute('async', 'true');
|
||||
e.setAttribute(
|
||||
'src',
|
||||
'https://widget.usepylon.com/widget/' + PYLON_APP_ID,
|
||||
'https://widget.usepylon.com/widget/' + pylonSettings.appId,
|
||||
);
|
||||
var n = t.getElementsByTagName('script')[0];
|
||||
n.parentNode.insertBefore(e, n);
|
||||
@@ -150,15 +149,14 @@
|
||||
window.AppcuesSettings = { enableURLDetection: true };
|
||||
</script>
|
||||
<script>
|
||||
var APPCUES_APP_ID = '<%- APPCUES_APP_ID %>';
|
||||
var appcuesSettings =
|
||||
((window.signozBootData || {}).settings || {}).appcues || {};
|
||||
var appcuesEnabled = appcuesSettings.enabled !== false;
|
||||
if (APPCUES_APP_ID && appcuesEnabled) {
|
||||
var appcuesEnabled = appcuesSettings.enabled === true;
|
||||
if (appcuesSettings.appId && appcuesEnabled) {
|
||||
(function (d, t) {
|
||||
var a = d.createElement(t);
|
||||
a.async = 1;
|
||||
a.src = '//fast.appcues.com/' + APPCUES_APP_ID + '.js';
|
||||
a.src = '//fast.appcues.com/' + appcuesSettings.appId + '.js';
|
||||
var s = d.getElementsByTagName(t)[0];
|
||||
s.parentNode.insertBefore(a, s);
|
||||
})(document, 'script');
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
"project": ["src/**/*.ts", "src/**/*.tsx"],
|
||||
"ignore": ["src/api/generated/**/*.ts", "src/typings/*.ts"],
|
||||
"ignoreDependencies": [
|
||||
"http-proxy-middleware",
|
||||
"@typescript/native-preview"
|
||||
]
|
||||
}
|
||||
@@ -79,7 +79,6 @@
|
||||
"event-source-polyfill": "1.0.31",
|
||||
"eventemitter3": "5.0.1",
|
||||
"history": "4.10.1",
|
||||
"http-proxy-middleware": "4.1.1",
|
||||
"http-status-codes": "2.3.0",
|
||||
"i18next": "^21.6.12",
|
||||
"i18next-browser-languagedetector": "^6.1.3",
|
||||
|
||||
22
frontend/pnpm-lock.yaml
generated
@@ -164,9 +164,6 @@ importers:
|
||||
history:
|
||||
specifier: 4.10.1
|
||||
version: 4.10.1
|
||||
http-proxy-middleware:
|
||||
specifier: 4.1.1
|
||||
version: 4.1.1
|
||||
http-status-codes:
|
||||
specifier: 2.3.0
|
||||
version: 2.3.0
|
||||
@@ -5461,10 +5458,6 @@ packages:
|
||||
resolution: {integrity: sha512-n2hY8YdoRE1i7r6M0w9DIw5GgZN0G25P8zLCRQ8rjXtTU3vsNFBI/vWK/UIeE6g5MUUz6avwAPXmL6Fy9D/90w==}
|
||||
engines: {node: '>= 6'}
|
||||
|
||||
http-proxy-middleware@4.1.1:
|
||||
resolution: {integrity: sha512-KX5ZofGXLFXqFAkQoOWZ+rTtaLTut7m0gyL+QzJrdejtIZ+F4bPPDoe7reISg2+v0CAz5OfVwEJEhty7X+e57g==}
|
||||
engines: {node: ^22.15.0 || ^24.0.0 || >=26.0.0}
|
||||
|
||||
http-status-codes@2.3.0:
|
||||
resolution: {integrity: sha512-RJ8XvFvpPM/Dmc5SV+dC4y5PCeOhT3x1Hq0NU3rjGeg5a/CqlhZ7uudknPwZFz4aeAXDcbAyaeP7GAo9lvngtA==}
|
||||
|
||||
@@ -5472,9 +5465,6 @@ packages:
|
||||
resolution: {integrity: sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==}
|
||||
engines: {node: '>= 6'}
|
||||
|
||||
httpxy@0.5.3:
|
||||
resolution: {integrity: sha512-SMS9V6Sn7VWaS11lYhoAr0ceoaiolTWf4jYdJn0NJhCdKMu9R2H9Fh0LBDWBHQF6HRLI1PmaePYsjanSpE5PEw==}
|
||||
|
||||
human-signals@2.1.0:
|
||||
resolution: {integrity: sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw==}
|
||||
engines: {node: '>=10.17.0'}
|
||||
@@ -14515,16 +14505,6 @@ snapshots:
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
http-proxy-middleware@4.1.1:
|
||||
dependencies:
|
||||
debug: 4.3.4(supports-color@5.5.0)
|
||||
httpxy: 0.5.3
|
||||
is-glob: 4.0.3
|
||||
is-plain-obj: 4.1.0
|
||||
micromatch: 4.0.8
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
http-status-codes@2.3.0: {}
|
||||
|
||||
https-proxy-agent@5.0.1:
|
||||
@@ -14534,8 +14514,6 @@ snapshots:
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
httpxy@0.5.3: {}
|
||||
|
||||
human-signals@2.1.0: {}
|
||||
|
||||
human-signals@8.0.1: {}
|
||||
|
||||
@@ -15,6 +15,8 @@
|
||||
"logs_to_metrics": "Logs To Metrics",
|
||||
"roles": "Roles",
|
||||
"role_details": "Role Details",
|
||||
"role_edit": "Edit Role",
|
||||
"role_create": "Create Role",
|
||||
"members": "Members",
|
||||
"service_accounts": "Service Accounts",
|
||||
"mcp_server": "MCP Server"
|
||||
|
||||
@@ -82,6 +82,8 @@
|
||||
"TRACE_DETAIL_OLD": "SigNoz | Trace Detail",
|
||||
"SERVICE_TOP_LEVEL_OPERATIONS": "SigNoz | Service Operations",
|
||||
"ROLE_DETAILS": "SigNoz | Role Details",
|
||||
"ROLE_CREATE": "SigNoz | Create Role",
|
||||
"ROLE_EDIT": "SigNoz | Edit Role",
|
||||
"TRACES_FUNNELS_DETAIL": "SigNoz | Funnel",
|
||||
"INTEGRATIONS_DETAIL": "SigNoz | Integration",
|
||||
"PUBLIC_DASHBOARD": "SigNoz | Dashboard"
|
||||
|
||||
@@ -1,13 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
# Extracts unique fenced code block language identifiers from all .md files under frontend/src/
|
||||
# Usage: bash frontend/scripts/extract-md-languages.sh
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
SRC_DIR="$SCRIPT_DIR/../src"
|
||||
|
||||
grep -roh '```[a-zA-Z0-9_+-]*' "$SRC_DIR" --include='*.md' \
|
||||
| sed 's/^```//' \
|
||||
| grep -v '^$' \
|
||||
| sort -u
|
||||
@@ -1,41 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
# Validates that all fenced code block languages used in .md files are registered
|
||||
# in the syntax highlighter.
|
||||
# Usage: bash frontend/scripts/validate-md-languages.sh
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
SYNTAX_HIGHLIGHTER="$SCRIPT_DIR/../src/components/MarkdownRenderer/syntaxHighlighter.ts"
|
||||
|
||||
# Get all languages used in .md files
|
||||
md_languages=$("$SCRIPT_DIR/extract-md-languages.sh")
|
||||
|
||||
# Get all registered languages from syntaxHighlighter.ts
|
||||
registered_languages=$(grep -oP "registerLanguage\('\K[^']+" "$SYNTAX_HIGHLIGHTER" | sort -u)
|
||||
|
||||
missing_languages=()
|
||||
|
||||
for lang in $md_languages; do
|
||||
# Skip ai-* block markers — these are custom AI block types rendered by
|
||||
# RichCodeBlock as React components (e.g. ActionBlock, LineChartBlock),
|
||||
# not real syntax languages, so they don't need highlighter registration.
|
||||
if [[ "$lang" == ai-* ]]; then
|
||||
continue
|
||||
fi
|
||||
if ! echo "$registered_languages" | grep -qx "$lang"; then
|
||||
missing_languages+=("$lang")
|
||||
fi
|
||||
done
|
||||
|
||||
if [ ${#missing_languages[@]} -gt 0 ]; then
|
||||
echo "Error: The following languages are used in .md files but not registered in syntaxHighlighter.ts:"
|
||||
for lang in "${missing_languages[@]}"; do
|
||||
echo " - $lang"
|
||||
done
|
||||
echo ""
|
||||
echo "Please add them to: frontend/src/components/MarkdownRenderer/syntaxHighlighter.ts"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "All markdown code block languages are registered in syntaxHighlighter.ts"
|
||||
@@ -3,12 +3,12 @@ import { matchPath, Redirect, useLocation } from 'react-router-dom';
|
||||
import getLocalStorageApi from 'api/browser/localstorage/get';
|
||||
import setLocalStorageApi from 'api/browser/localstorage/set';
|
||||
import { useListUsers } from 'api/generated/services/users';
|
||||
import { FeatureKeys } from 'constants/features';
|
||||
import { LOCALSTORAGE } from 'constants/localStorage';
|
||||
import { ORG_PREFERENCES } from 'constants/orgPreferences';
|
||||
import ROUTES from 'constants/routes';
|
||||
import { useGetTenantLicense } from 'hooks/useGetTenantLicense';
|
||||
import { useIsAIAssistantEnabled } from 'hooks/useIsAIAssistantEnabled';
|
||||
import { useIsAIObservabilityEnabled } from 'hooks/useIsAIObservabilityEnabled';
|
||||
import { isEmpty } from 'lodash-es';
|
||||
import { useAppContext } from 'providers/App/App';
|
||||
import { LicensePlatform, LicenseState } from 'types/api/licensesV3/getActive';
|
||||
@@ -37,11 +37,11 @@ function PrivateRoute({ children }: PrivateRouteProps): JSX.Element {
|
||||
activeLicense,
|
||||
isFetchingActiveLicense,
|
||||
trialInfo,
|
||||
featureFlags,
|
||||
} = useAppContext();
|
||||
|
||||
const isAdmin = user.role === USER_ROLES.ADMIN;
|
||||
const isAIAssistantEnabled = useIsAIAssistantEnabled();
|
||||
const isAIObservabilityEnabled = useIsAIObservabilityEnabled();
|
||||
|
||||
const mapRoutes = useMemo(
|
||||
() =>
|
||||
@@ -133,6 +133,14 @@ function PrivateRoute({ children }: PrivateRouteProps): JSX.Element {
|
||||
return <Redirect to={ROUTES.HOME} />;
|
||||
}
|
||||
|
||||
if (
|
||||
(pathname.startsWith(`${ROUTES.LLM_OBSERVABILITY_BASE}/`) ||
|
||||
pathname === ROUTES.LLM_OBSERVABILITY_BASE) &&
|
||||
!isAIObservabilityEnabled
|
||||
) {
|
||||
return <Redirect to={ROUTES.HOME} />;
|
||||
}
|
||||
|
||||
// Check for workspace access restriction (cloud only)
|
||||
const isCloudPlatform = activeLicense?.platform === LicensePlatform.CLOUD;
|
||||
|
||||
@@ -212,14 +220,6 @@ function PrivateRoute({ children }: PrivateRouteProps): JSX.Element {
|
||||
}
|
||||
}
|
||||
|
||||
// Check for GET_STARTED → GET_STARTED_WITH_CLOUD redirect (feature flag)
|
||||
if (
|
||||
currentRoute?.path === ROUTES.GET_STARTED &&
|
||||
featureFlags?.find((e) => e.name === FeatureKeys.ONBOARDING_V3)?.active
|
||||
) {
|
||||
return <Redirect to={ROUTES.GET_STARTED_WITH_CLOUD} />;
|
||||
}
|
||||
|
||||
// Main routing logic
|
||||
if (currentRoute) {
|
||||
const { isPrivate, key } = currentRoute;
|
||||
|
||||
@@ -2,7 +2,6 @@ import { ReactElement } from 'react';
|
||||
import { QueryClient, QueryClientProvider } from 'react-query';
|
||||
import { MemoryRouter, Route, Switch, useLocation } from 'react-router-dom';
|
||||
import { act, render, screen, waitFor } from '@testing-library/react';
|
||||
import { FeatureKeys } from 'constants/features';
|
||||
import { LOCALSTORAGE } from 'constants/localStorage';
|
||||
import { ORG_PREFERENCES } from 'constants/orgPreferences';
|
||||
import ROUTES from 'constants/routes';
|
||||
@@ -1263,80 +1262,6 @@ describe('PrivateRoute', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('Get Started Route Redirect', () => {
|
||||
it('should redirect to GET_STARTED_WITH_CLOUD when on GET_STARTED and ONBOARDING_V3 feature flag is active', async () => {
|
||||
renderPrivateRoute({
|
||||
initialRoute: ROUTES.GET_STARTED,
|
||||
appContext: {
|
||||
isLoggedIn: true,
|
||||
featureFlags: [
|
||||
{
|
||||
name: FeatureKeys.ONBOARDING_V3,
|
||||
active: true,
|
||||
usage: 0,
|
||||
usage_limit: -1,
|
||||
route: '',
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
await assertRedirectsTo(ROUTES.GET_STARTED_WITH_CLOUD);
|
||||
});
|
||||
|
||||
it('should not redirect when on GET_STARTED and ONBOARDING_V3 feature flag is inactive', () => {
|
||||
renderPrivateRoute({
|
||||
initialRoute: ROUTES.GET_STARTED,
|
||||
appContext: {
|
||||
isLoggedIn: true,
|
||||
featureFlags: [
|
||||
{
|
||||
name: FeatureKeys.ONBOARDING_V3,
|
||||
active: false,
|
||||
usage: 0,
|
||||
usage_limit: -1,
|
||||
route: '',
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
assertStaysOnRoute(ROUTES.GET_STARTED);
|
||||
});
|
||||
|
||||
it('should not redirect when on GET_STARTED and ONBOARDING_V3 feature flag is not present', () => {
|
||||
renderPrivateRoute({
|
||||
initialRoute: ROUTES.GET_STARTED,
|
||||
appContext: {
|
||||
isLoggedIn: true,
|
||||
featureFlags: [],
|
||||
},
|
||||
});
|
||||
|
||||
assertStaysOnRoute(ROUTES.GET_STARTED);
|
||||
});
|
||||
|
||||
it('should not redirect when on different route even if ONBOARDING_V3 is active', () => {
|
||||
renderPrivateRoute({
|
||||
initialRoute: ROUTES.HOME,
|
||||
appContext: {
|
||||
isLoggedIn: true,
|
||||
featureFlags: [
|
||||
{
|
||||
name: FeatureKeys.ONBOARDING_V3,
|
||||
active: true,
|
||||
usage: 0,
|
||||
usage_limit: -1,
|
||||
route: '',
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
assertStaysOnRoute(ROUTES.HOME);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Loading States', () => {
|
||||
it('should not redirect while license is still being fetched', () => {
|
||||
renderPrivateRoute({
|
||||
@@ -1496,16 +1421,16 @@ describe('PrivateRoute', () => {
|
||||
await assertRedirectsTo(ROUTES.UN_AUTHORIZED);
|
||||
});
|
||||
|
||||
it('should allow EDITOR to access /get-started route', () => {
|
||||
it('should allow EDITOR to access /get-started-with-signoz-cloud route', () => {
|
||||
renderPrivateRoute({
|
||||
initialRoute: ROUTES.GET_STARTED,
|
||||
initialRoute: ROUTES.GET_STARTED_WITH_CLOUD,
|
||||
appContext: {
|
||||
isLoggedIn: true,
|
||||
user: createMockUser({ role: USER_ROLES.EDITOR as ROLES }),
|
||||
},
|
||||
});
|
||||
|
||||
assertStaysOnRoute(ROUTES.GET_STARTED);
|
||||
assertStaysOnRoute(ROUTES.GET_STARTED_WITH_CLOUD);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -292,10 +292,10 @@ function App(): JSX.Element {
|
||||
isChatSupportEnabled &&
|
||||
!showAddCreditCardModal &&
|
||||
(isCloudUser || isEnterpriseSelfHostedUser) &&
|
||||
(window.signozBootData?.settings?.pylon.enabled ?? true)
|
||||
window.signozBootData?.settings?.pylon?.enabled
|
||||
) {
|
||||
const email = user.email || '';
|
||||
const secret = process.env.PYLON_IDENTITY_SECRET || '';
|
||||
const secret = window.signozBootData?.settings?.pylon?.identitySecret || '';
|
||||
let emailHash = '';
|
||||
|
||||
if (email && secret) {
|
||||
@@ -304,7 +304,7 @@ function App(): JSX.Element {
|
||||
|
||||
window.pylon = {
|
||||
chat_settings: {
|
||||
app_id: process.env.PYLON_APP_ID,
|
||||
app_id: window.signozBootData?.settings?.pylon?.appId,
|
||||
email: user.email,
|
||||
name: user.displayName || user.email,
|
||||
email_hash: emailHash,
|
||||
@@ -335,22 +335,23 @@ function App(): JSX.Element {
|
||||
useEffect(() => {
|
||||
if (isCloudUser || isEnterpriseSelfHostedUser) {
|
||||
if (
|
||||
(window.signozBootData?.settings?.posthog.enabled ?? true) &&
|
||||
process.env.POSTHOG_KEY
|
||||
window.signozBootData?.settings?.posthog?.enabled &&
|
||||
window.signozBootData?.settings?.posthog?.key
|
||||
) {
|
||||
posthog.init(process.env.POSTHOG_KEY, {
|
||||
api_host: 'https://us.i.posthog.com',
|
||||
posthog.init(window.signozBootData.settings.posthog.key, {
|
||||
api_host: window.signozBootData.settings.posthog.apiHost,
|
||||
ui_host: window.signozBootData.settings.posthog.uiHost,
|
||||
person_profiles: 'identified_only', // or 'always' to create profiles for anonymous users as well
|
||||
});
|
||||
}
|
||||
|
||||
if (
|
||||
!isSentryInitialized &&
|
||||
(window.signozBootData?.settings?.sentry.enabled ?? true)
|
||||
window.signozBootData?.settings?.sentry?.enabled
|
||||
) {
|
||||
Sentry.init({
|
||||
dsn: process.env.SENTRY_DSN,
|
||||
tunnel: process.env.TUNNEL_URL,
|
||||
dsn: window.signozBootData.settings.sentry.dsn,
|
||||
tunnel: window.signozBootData.settings.sentry.tunnel,
|
||||
environment: process.env.ENVIRONMENT,
|
||||
release: process.env.VERSION,
|
||||
integrations: [
|
||||
|
||||
@@ -57,13 +57,6 @@ export const TraceFilter = Loadable(
|
||||
() => import(/* webpackChunkName: "Trace Filter Page" */ 'pages/Trace'),
|
||||
);
|
||||
|
||||
export const TraceDetail = Loadable(
|
||||
() =>
|
||||
import(
|
||||
/* webpackChunkName: "TraceDetail Page" */ 'pages/TraceDetailV2/index'
|
||||
),
|
||||
);
|
||||
|
||||
export const TraceDetailOldRedirect = Loadable(
|
||||
() =>
|
||||
import(
|
||||
@@ -90,14 +83,6 @@ export const SettingsPage = Loadable(
|
||||
() => import(/* webpackChunkName: "SettingsPage" */ 'pages/Settings'),
|
||||
);
|
||||
|
||||
export const GettingStarted = Loadable(
|
||||
() => import(/* webpackChunkName: "GettingStarted" */ 'pages/GettingStarted'),
|
||||
);
|
||||
|
||||
export const Onboarding = Loadable(
|
||||
() => import(/* webpackChunkName: "Onboarding" */ 'pages/OnboardingPage'),
|
||||
);
|
||||
|
||||
export const OrgOnboarding = Loadable(
|
||||
() => import(/* webpackChunkName: "OrgOnboarding" */ 'pages/OrgOnboarding'),
|
||||
);
|
||||
@@ -337,3 +322,17 @@ export const AIAssistantPage = Loadable(
|
||||
/* webpackChunkName: "AI Assistant Page" */ 'pages/AIAssistantPage/AIAssistantPage'
|
||||
),
|
||||
);
|
||||
|
||||
export const LLMObservabilityPage = Loadable(
|
||||
() =>
|
||||
import(
|
||||
/* webpackChunkName: "LLM Observability Page" */ 'pages/LLMObservability'
|
||||
),
|
||||
);
|
||||
|
||||
export const LLMObservabilityModelPricingPage = Loadable(
|
||||
() =>
|
||||
import(
|
||||
/* webpackChunkName: "LLM Observability Model Pricing Page" */ 'pages/LLMObservabilityModelPricing'
|
||||
),
|
||||
);
|
||||
|
||||
@@ -23,6 +23,8 @@ import {
|
||||
IntegrationsDetailsPage,
|
||||
LicensePage,
|
||||
ListAllALertsPage,
|
||||
LLMObservabilityPage,
|
||||
LLMObservabilityModelPricingPage,
|
||||
LiveLogs,
|
||||
Login,
|
||||
Logs,
|
||||
@@ -33,7 +35,6 @@ import {
|
||||
MeterExplorerPage,
|
||||
MetricsExplorer,
|
||||
OldLogsExplorer,
|
||||
Onboarding,
|
||||
OnboardingV2,
|
||||
OrgOnboarding,
|
||||
PasswordReset,
|
||||
@@ -70,13 +71,6 @@ const routes: AppRoutes[] = [
|
||||
isPrivate: false,
|
||||
key: 'SIGN_UP',
|
||||
},
|
||||
{
|
||||
path: ROUTES.GET_STARTED,
|
||||
exact: false,
|
||||
component: Onboarding,
|
||||
isPrivate: true,
|
||||
key: 'GET_STARTED',
|
||||
},
|
||||
{
|
||||
path: ROUTES.GET_STARTED_WITH_CLOUD,
|
||||
exact: false,
|
||||
@@ -477,6 +471,13 @@ const routes: AppRoutes[] = [
|
||||
key: 'METRICS_EXPLORER_VIEWS',
|
||||
isPrivate: true,
|
||||
},
|
||||
{
|
||||
path: ROUTES.METRICS_EXPLORER_VOLUME_CONTROL,
|
||||
exact: true,
|
||||
component: MetricsExplorer,
|
||||
key: 'METRICS_EXPLORER_VOLUME_CONTROL',
|
||||
isPrivate: true,
|
||||
},
|
||||
|
||||
{
|
||||
path: ROUTES.METER,
|
||||
@@ -513,6 +514,20 @@ const routes: AppRoutes[] = [
|
||||
key: 'AI_ASSISTANT',
|
||||
isPrivate: true,
|
||||
},
|
||||
{
|
||||
path: ROUTES.LLM_OBSERVABILITY_BASE,
|
||||
exact: true,
|
||||
component: LLMObservabilityPage,
|
||||
key: 'LLM_OBSERVABILITY_BASE',
|
||||
isPrivate: true,
|
||||
},
|
||||
{
|
||||
path: ROUTES.LLM_OBSERVABILITY_MODEL_PRICING,
|
||||
exact: true,
|
||||
component: LLMObservabilityModelPricingPage,
|
||||
key: 'LLM_OBSERVABILITY_MODEL_PRICING',
|
||||
isPrivate: true,
|
||||
},
|
||||
];
|
||||
|
||||
export const SUPPORT_ROUTE: AppRoutes = {
|
||||
|
||||
@@ -4,14 +4,22 @@
|
||||
* * regenerate with 'pnpm generate:api'
|
||||
* SigNoz
|
||||
*/
|
||||
import { useMutation } from 'react-query';
|
||||
import { useMutation, useQuery } from 'react-query';
|
||||
import type {
|
||||
InvalidateOptions,
|
||||
MutationFunction,
|
||||
QueryClient,
|
||||
QueryFunction,
|
||||
QueryKey,
|
||||
UseMutationOptions,
|
||||
UseMutationResult,
|
||||
UseQueryOptions,
|
||||
UseQueryResult,
|
||||
} from 'react-query';
|
||||
|
||||
import type {
|
||||
GetChecks200,
|
||||
GetChecksParams,
|
||||
InframonitoringtypesPostableClustersDTO,
|
||||
InframonitoringtypesPostableDaemonSetsDTO,
|
||||
InframonitoringtypesPostableDeploymentsDTO,
|
||||
@@ -38,6 +46,93 @@ import type {
|
||||
import { GeneratedAPIInstance } from '../../../generatedAPIInstance';
|
||||
import type { ErrorType, BodyType } from '../../../generatedAPIInstance';
|
||||
|
||||
/**
|
||||
* Checks whether the metrics and attributes required to power the infra-monitoring section selected by the 'type' query parameter (hosts, processes, pods, nodes, deployments, daemonsets, statefulsets, jobs, namespaces, clusters, volumes) are being received. For each collector receiver or processor that contributes required metrics or attributes, lists what is present and what is missing, with a prebuilt user-facing message and a docs link per missing component. Default-enabled metrics are those expected as soon as the receiver is configured; optional metrics require 'enabled: true' in receiver config. 'ready' is true only when every missing list is empty.
|
||||
* @summary Run Infra Monitoring Setup Checks
|
||||
*/
|
||||
export const getChecks = (params: GetChecksParams, signal?: AbortSignal) => {
|
||||
return GeneratedAPIInstance<GetChecks200>({
|
||||
url: `/api/v2/infra_monitoring/checks`,
|
||||
method: 'GET',
|
||||
params,
|
||||
signal,
|
||||
});
|
||||
};
|
||||
|
||||
export const getGetChecksQueryKey = (params?: GetChecksParams) => {
|
||||
return [
|
||||
`/api/v2/infra_monitoring/checks`,
|
||||
...(params ? [params] : []),
|
||||
] as const;
|
||||
};
|
||||
|
||||
export const getGetChecksQueryOptions = <
|
||||
TData = Awaited<ReturnType<typeof getChecks>>,
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
>(
|
||||
params: GetChecksParams,
|
||||
options?: {
|
||||
query?: UseQueryOptions<Awaited<ReturnType<typeof getChecks>>, TError, TData>;
|
||||
},
|
||||
) => {
|
||||
const { query: queryOptions } = options ?? {};
|
||||
|
||||
const queryKey = queryOptions?.queryKey ?? getGetChecksQueryKey(params);
|
||||
|
||||
const queryFn: QueryFunction<Awaited<ReturnType<typeof getChecks>>> = ({
|
||||
signal,
|
||||
}) => getChecks(params, signal);
|
||||
|
||||
return { queryKey, queryFn, ...queryOptions } as UseQueryOptions<
|
||||
Awaited<ReturnType<typeof getChecks>>,
|
||||
TError,
|
||||
TData
|
||||
> & { queryKey: QueryKey };
|
||||
};
|
||||
|
||||
export type GetChecksQueryResult = NonNullable<
|
||||
Awaited<ReturnType<typeof getChecks>>
|
||||
>;
|
||||
export type GetChecksQueryError = ErrorType<RenderErrorResponseDTO>;
|
||||
|
||||
/**
|
||||
* @summary Run Infra Monitoring Setup Checks
|
||||
*/
|
||||
|
||||
export function useGetChecks<
|
||||
TData = Awaited<ReturnType<typeof getChecks>>,
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
>(
|
||||
params: GetChecksParams,
|
||||
options?: {
|
||||
query?: UseQueryOptions<Awaited<ReturnType<typeof getChecks>>, TError, TData>;
|
||||
},
|
||||
): UseQueryResult<TData, TError> & { queryKey: QueryKey } {
|
||||
const queryOptions = getGetChecksQueryOptions(params, options);
|
||||
|
||||
const query = useQuery(queryOptions) as UseQueryResult<TData, TError> & {
|
||||
queryKey: QueryKey;
|
||||
};
|
||||
|
||||
return { ...query, queryKey: queryOptions.queryKey };
|
||||
}
|
||||
|
||||
/**
|
||||
* @summary Run Infra Monitoring Setup Checks
|
||||
*/
|
||||
export const invalidateGetChecks = async (
|
||||
queryClient: QueryClient,
|
||||
params: GetChecksParams,
|
||||
options?: InvalidateOptions,
|
||||
): Promise<QueryClient> => {
|
||||
await queryClient.invalidateQueries(
|
||||
{ queryKey: getGetChecksQueryKey(params) },
|
||||
options,
|
||||
);
|
||||
|
||||
return queryClient;
|
||||
};
|
||||
|
||||
/**
|
||||
* Returns a paginated list of Kubernetes clusters with key aggregated metrics derived by summing per-node values within the group: CPU usage, CPU allocatable, memory working set, memory allocatable. Each row also reports per-group nodeCountsByReadiness ({ ready, notReady } from each node's latest k8s.node.condition_ready value) and per-group podCountsByPhase ({ pending, running, succeeded, failed, unknown } from each pod's latest k8s.pod.phase value). Each cluster includes metadata attributes (k8s.cluster.name). The response type is 'list' for the default k8s.cluster.name grouping or 'grouped_list' for custom groupBy keys; in both modes every row aggregates nodes and pods in the group. Supports filtering via a filter expression, custom groupBy, ordering by cpu / cpu_allocatable / memory / memory_allocatable, and pagination via offset/limit. Also reports whether the requested time range falls before the data retention boundary. Numeric metric fields (clusterCPU, clusterCPUAllocatable, clusterMemory, clusterMemoryAllocatable) return -1 as a sentinel when no data is available for that field.
|
||||
* @summary List Clusters for Infra Monitoring
|
||||
|
||||
@@ -18,32 +18,776 @@ import type {
|
||||
} from 'react-query';
|
||||
|
||||
import type {
|
||||
CreateMetricReductionRule201,
|
||||
DeleteMetricReductionRuleByIDPathParameters,
|
||||
GetMetricAlerts200,
|
||||
GetMetricAlertsParams,
|
||||
GetMetricAttributes200,
|
||||
GetMetricAttributesParams,
|
||||
GetMetricDashboards200,
|
||||
GetMetricDashboardsParams,
|
||||
GetMetricDashboardsV2200,
|
||||
GetMetricDashboardsV2Params,
|
||||
GetMetricHighlights200,
|
||||
GetMetricHighlightsParams,
|
||||
GetMetricMetadata200,
|
||||
GetMetricMetadataParams,
|
||||
GetMetricReductionRuleByID200,
|
||||
GetMetricReductionRuleByIDPathParameters,
|
||||
GetMetricReductionRuleStats200,
|
||||
GetMetricReductionRuleTimeseries200,
|
||||
GetMetricsOnboardingStatus200,
|
||||
GetMetricsStats200,
|
||||
GetMetricsTreemap200,
|
||||
InspectMetrics200,
|
||||
ListMetricReductionRules200,
|
||||
ListMetricReductionRulesParams,
|
||||
ListMetrics200,
|
||||
ListMetricsParams,
|
||||
MetricreductionruletypesPostableReductionRuleDTO,
|
||||
MetricreductionruletypesPostableReductionRulePreviewDTO,
|
||||
MetricreductionruletypesUpdatableReductionRuleDTO,
|
||||
MetricsexplorertypesInspectMetricsRequestDTO,
|
||||
MetricsexplorertypesStatsRequestDTO,
|
||||
MetricsexplorertypesTreemapRequestDTO,
|
||||
MetricsexplorertypesUpdateMetricMetadataRequestDTO,
|
||||
PreviewMetricReductionRule200,
|
||||
RenderErrorResponseDTO,
|
||||
UpdateMetricReductionRuleByID200,
|
||||
UpdateMetricReductionRuleByIDPathParameters,
|
||||
} from '../sigNoz.schemas';
|
||||
|
||||
import { GeneratedAPIInstance } from '../../../generatedAPIInstance';
|
||||
import type { ErrorType, BodyType } from '../../../generatedAPIInstance';
|
||||
|
||||
/**
|
||||
* Returns active metric volume-control (label reduction) rules.
|
||||
* @summary List metric reduction rules
|
||||
*/
|
||||
export const listMetricReductionRules = (
|
||||
params?: ListMetricReductionRulesParams,
|
||||
signal?: AbortSignal,
|
||||
) => {
|
||||
return GeneratedAPIInstance<ListMetricReductionRules200>({
|
||||
url: `/api/v2/metric_reduction_rules`,
|
||||
method: 'GET',
|
||||
params,
|
||||
signal,
|
||||
});
|
||||
};
|
||||
|
||||
export const getListMetricReductionRulesQueryKey = (
|
||||
params?: ListMetricReductionRulesParams,
|
||||
) => {
|
||||
return [
|
||||
`/api/v2/metric_reduction_rules`,
|
||||
...(params ? [params] : []),
|
||||
] as const;
|
||||
};
|
||||
|
||||
export const getListMetricReductionRulesQueryOptions = <
|
||||
TData = Awaited<ReturnType<typeof listMetricReductionRules>>,
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
>(
|
||||
params?: ListMetricReductionRulesParams,
|
||||
options?: {
|
||||
query?: UseQueryOptions<
|
||||
Awaited<ReturnType<typeof listMetricReductionRules>>,
|
||||
TError,
|
||||
TData
|
||||
>;
|
||||
},
|
||||
) => {
|
||||
const { query: queryOptions } = options ?? {};
|
||||
|
||||
const queryKey =
|
||||
queryOptions?.queryKey ?? getListMetricReductionRulesQueryKey(params);
|
||||
|
||||
const queryFn: QueryFunction<
|
||||
Awaited<ReturnType<typeof listMetricReductionRules>>
|
||||
> = ({ signal }) => listMetricReductionRules(params, signal);
|
||||
|
||||
return { queryKey, queryFn, ...queryOptions } as UseQueryOptions<
|
||||
Awaited<ReturnType<typeof listMetricReductionRules>>,
|
||||
TError,
|
||||
TData
|
||||
> & { queryKey: QueryKey };
|
||||
};
|
||||
|
||||
export type ListMetricReductionRulesQueryResult = NonNullable<
|
||||
Awaited<ReturnType<typeof listMetricReductionRules>>
|
||||
>;
|
||||
export type ListMetricReductionRulesQueryError =
|
||||
ErrorType<RenderErrorResponseDTO>;
|
||||
|
||||
/**
|
||||
* @summary List metric reduction rules
|
||||
*/
|
||||
|
||||
export function useListMetricReductionRules<
|
||||
TData = Awaited<ReturnType<typeof listMetricReductionRules>>,
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
>(
|
||||
params?: ListMetricReductionRulesParams,
|
||||
options?: {
|
||||
query?: UseQueryOptions<
|
||||
Awaited<ReturnType<typeof listMetricReductionRules>>,
|
||||
TError,
|
||||
TData
|
||||
>;
|
||||
},
|
||||
): UseQueryResult<TData, TError> & { queryKey: QueryKey } {
|
||||
const queryOptions = getListMetricReductionRulesQueryOptions(params, options);
|
||||
|
||||
const query = useQuery(queryOptions) as UseQueryResult<TData, TError> & {
|
||||
queryKey: QueryKey;
|
||||
};
|
||||
|
||||
return { ...query, queryKey: queryOptions.queryKey };
|
||||
}
|
||||
|
||||
/**
|
||||
* @summary List metric reduction rules
|
||||
*/
|
||||
export const invalidateListMetricReductionRules = async (
|
||||
queryClient: QueryClient,
|
||||
params?: ListMetricReductionRulesParams,
|
||||
options?: InvalidateOptions,
|
||||
): Promise<QueryClient> => {
|
||||
await queryClient.invalidateQueries(
|
||||
{ queryKey: getListMetricReductionRulesQueryKey(params) },
|
||||
options,
|
||||
);
|
||||
|
||||
return queryClient;
|
||||
};
|
||||
|
||||
/**
|
||||
* Creates a volume-control rule for a metric and returns it with its id; fails if the metric already has a rule.
|
||||
* @summary Create a metric reduction rule
|
||||
*/
|
||||
export const createMetricReductionRule = (
|
||||
metricreductionruletypesPostableReductionRuleDTO?: BodyType<MetricreductionruletypesPostableReductionRuleDTO>,
|
||||
signal?: AbortSignal,
|
||||
) => {
|
||||
return GeneratedAPIInstance<CreateMetricReductionRule201>({
|
||||
url: `/api/v2/metric_reduction_rules`,
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
data: metricreductionruletypesPostableReductionRuleDTO,
|
||||
signal,
|
||||
});
|
||||
};
|
||||
|
||||
export const getCreateMetricReductionRuleMutationOptions = <
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
TContext = unknown,
|
||||
>(options?: {
|
||||
mutation?: UseMutationOptions<
|
||||
Awaited<ReturnType<typeof createMetricReductionRule>>,
|
||||
TError,
|
||||
{ data?: BodyType<MetricreductionruletypesPostableReductionRuleDTO> },
|
||||
TContext
|
||||
>;
|
||||
}): UseMutationOptions<
|
||||
Awaited<ReturnType<typeof createMetricReductionRule>>,
|
||||
TError,
|
||||
{ data?: BodyType<MetricreductionruletypesPostableReductionRuleDTO> },
|
||||
TContext
|
||||
> => {
|
||||
const mutationKey = ['createMetricReductionRule'];
|
||||
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<ReturnType<typeof createMetricReductionRule>>,
|
||||
{ data?: BodyType<MetricreductionruletypesPostableReductionRuleDTO> }
|
||||
> = (props) => {
|
||||
const { data } = props ?? {};
|
||||
|
||||
return createMetricReductionRule(data);
|
||||
};
|
||||
|
||||
return { mutationFn, ...mutationOptions };
|
||||
};
|
||||
|
||||
export type CreateMetricReductionRuleMutationResult = NonNullable<
|
||||
Awaited<ReturnType<typeof createMetricReductionRule>>
|
||||
>;
|
||||
export type CreateMetricReductionRuleMutationBody =
|
||||
| BodyType<MetricreductionruletypesPostableReductionRuleDTO>
|
||||
| undefined;
|
||||
export type CreateMetricReductionRuleMutationError =
|
||||
ErrorType<RenderErrorResponseDTO>;
|
||||
|
||||
/**
|
||||
* @summary Create a metric reduction rule
|
||||
*/
|
||||
export const useCreateMetricReductionRule = <
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
TContext = unknown,
|
||||
>(options?: {
|
||||
mutation?: UseMutationOptions<
|
||||
Awaited<ReturnType<typeof createMetricReductionRule>>,
|
||||
TError,
|
||||
{ data?: BodyType<MetricreductionruletypesPostableReductionRuleDTO> },
|
||||
TContext
|
||||
>;
|
||||
}): UseMutationResult<
|
||||
Awaited<ReturnType<typeof createMetricReductionRule>>,
|
||||
TError,
|
||||
{ data?: BodyType<MetricreductionruletypesPostableReductionRuleDTO> },
|
||||
TContext
|
||||
> => {
|
||||
return useMutation(getCreateMetricReductionRuleMutationOptions(options));
|
||||
};
|
||||
/**
|
||||
* Deletes a volume-control rule by its id.
|
||||
* @summary Delete a metric reduction rule by id
|
||||
*/
|
||||
export const deleteMetricReductionRuleByID = (
|
||||
{ id }: DeleteMetricReductionRuleByIDPathParameters,
|
||||
signal?: AbortSignal,
|
||||
) => {
|
||||
return GeneratedAPIInstance<void>({
|
||||
url: `/api/v2/metric_reduction_rules/${id}`,
|
||||
method: 'DELETE',
|
||||
signal,
|
||||
});
|
||||
};
|
||||
|
||||
export const getDeleteMetricReductionRuleByIDMutationOptions = <
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
TContext = unknown,
|
||||
>(options?: {
|
||||
mutation?: UseMutationOptions<
|
||||
Awaited<ReturnType<typeof deleteMetricReductionRuleByID>>,
|
||||
TError,
|
||||
{ pathParams: DeleteMetricReductionRuleByIDPathParameters },
|
||||
TContext
|
||||
>;
|
||||
}): UseMutationOptions<
|
||||
Awaited<ReturnType<typeof deleteMetricReductionRuleByID>>,
|
||||
TError,
|
||||
{ pathParams: DeleteMetricReductionRuleByIDPathParameters },
|
||||
TContext
|
||||
> => {
|
||||
const mutationKey = ['deleteMetricReductionRuleByID'];
|
||||
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<ReturnType<typeof deleteMetricReductionRuleByID>>,
|
||||
{ pathParams: DeleteMetricReductionRuleByIDPathParameters }
|
||||
> = (props) => {
|
||||
const { pathParams } = props ?? {};
|
||||
|
||||
return deleteMetricReductionRuleByID(pathParams);
|
||||
};
|
||||
|
||||
return { mutationFn, ...mutationOptions };
|
||||
};
|
||||
|
||||
export type DeleteMetricReductionRuleByIDMutationResult = NonNullable<
|
||||
Awaited<ReturnType<typeof deleteMetricReductionRuleByID>>
|
||||
>;
|
||||
|
||||
export type DeleteMetricReductionRuleByIDMutationError =
|
||||
ErrorType<RenderErrorResponseDTO>;
|
||||
|
||||
/**
|
||||
* @summary Delete a metric reduction rule by id
|
||||
*/
|
||||
export const useDeleteMetricReductionRuleByID = <
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
TContext = unknown,
|
||||
>(options?: {
|
||||
mutation?: UseMutationOptions<
|
||||
Awaited<ReturnType<typeof deleteMetricReductionRuleByID>>,
|
||||
TError,
|
||||
{ pathParams: DeleteMetricReductionRuleByIDPathParameters },
|
||||
TContext
|
||||
>;
|
||||
}): UseMutationResult<
|
||||
Awaited<ReturnType<typeof deleteMetricReductionRuleByID>>,
|
||||
TError,
|
||||
{ pathParams: DeleteMetricReductionRuleByIDPathParameters },
|
||||
TContext
|
||||
> => {
|
||||
return useMutation(getDeleteMetricReductionRuleByIDMutationOptions(options));
|
||||
};
|
||||
/**
|
||||
* Returns a single volume-control rule by its id.
|
||||
* @summary Get a metric reduction rule by id
|
||||
*/
|
||||
export const getMetricReductionRuleByID = (
|
||||
{ id }: GetMetricReductionRuleByIDPathParameters,
|
||||
signal?: AbortSignal,
|
||||
) => {
|
||||
return GeneratedAPIInstance<GetMetricReductionRuleByID200>({
|
||||
url: `/api/v2/metric_reduction_rules/${id}`,
|
||||
method: 'GET',
|
||||
signal,
|
||||
});
|
||||
};
|
||||
|
||||
export const getGetMetricReductionRuleByIDQueryKey = ({
|
||||
id,
|
||||
}: GetMetricReductionRuleByIDPathParameters) => {
|
||||
return [`/api/v2/metric_reduction_rules/${id}`] as const;
|
||||
};
|
||||
|
||||
export const getGetMetricReductionRuleByIDQueryOptions = <
|
||||
TData = Awaited<ReturnType<typeof getMetricReductionRuleByID>>,
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
>(
|
||||
{ id }: GetMetricReductionRuleByIDPathParameters,
|
||||
options?: {
|
||||
query?: UseQueryOptions<
|
||||
Awaited<ReturnType<typeof getMetricReductionRuleByID>>,
|
||||
TError,
|
||||
TData
|
||||
>;
|
||||
},
|
||||
) => {
|
||||
const { query: queryOptions } = options ?? {};
|
||||
|
||||
const queryKey =
|
||||
queryOptions?.queryKey ?? getGetMetricReductionRuleByIDQueryKey({ id });
|
||||
|
||||
const queryFn: QueryFunction<
|
||||
Awaited<ReturnType<typeof getMetricReductionRuleByID>>
|
||||
> = ({ signal }) => getMetricReductionRuleByID({ id }, signal);
|
||||
|
||||
return {
|
||||
queryKey,
|
||||
queryFn,
|
||||
enabled: !!id,
|
||||
...queryOptions,
|
||||
} as UseQueryOptions<
|
||||
Awaited<ReturnType<typeof getMetricReductionRuleByID>>,
|
||||
TError,
|
||||
TData
|
||||
> & { queryKey: QueryKey };
|
||||
};
|
||||
|
||||
export type GetMetricReductionRuleByIDQueryResult = NonNullable<
|
||||
Awaited<ReturnType<typeof getMetricReductionRuleByID>>
|
||||
>;
|
||||
export type GetMetricReductionRuleByIDQueryError =
|
||||
ErrorType<RenderErrorResponseDTO>;
|
||||
|
||||
/**
|
||||
* @summary Get a metric reduction rule by id
|
||||
*/
|
||||
|
||||
export function useGetMetricReductionRuleByID<
|
||||
TData = Awaited<ReturnType<typeof getMetricReductionRuleByID>>,
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
>(
|
||||
{ id }: GetMetricReductionRuleByIDPathParameters,
|
||||
options?: {
|
||||
query?: UseQueryOptions<
|
||||
Awaited<ReturnType<typeof getMetricReductionRuleByID>>,
|
||||
TError,
|
||||
TData
|
||||
>;
|
||||
},
|
||||
): UseQueryResult<TData, TError> & { queryKey: QueryKey } {
|
||||
const queryOptions = getGetMetricReductionRuleByIDQueryOptions(
|
||||
{ id },
|
||||
options,
|
||||
);
|
||||
|
||||
const query = useQuery(queryOptions) as UseQueryResult<TData, TError> & {
|
||||
queryKey: QueryKey;
|
||||
};
|
||||
|
||||
return { ...query, queryKey: queryOptions.queryKey };
|
||||
}
|
||||
|
||||
/**
|
||||
* @summary Get a metric reduction rule by id
|
||||
*/
|
||||
export const invalidateGetMetricReductionRuleByID = async (
|
||||
queryClient: QueryClient,
|
||||
{ id }: GetMetricReductionRuleByIDPathParameters,
|
||||
options?: InvalidateOptions,
|
||||
): Promise<QueryClient> => {
|
||||
await queryClient.invalidateQueries(
|
||||
{ queryKey: getGetMetricReductionRuleByIDQueryKey({ id }) },
|
||||
options,
|
||||
);
|
||||
|
||||
return queryClient;
|
||||
};
|
||||
|
||||
/**
|
||||
* Updates the match type and labels of a volume-control rule by its id; the metric name is immutable.
|
||||
* @summary Update a metric reduction rule by id
|
||||
*/
|
||||
export const updateMetricReductionRuleByID = (
|
||||
{ id }: UpdateMetricReductionRuleByIDPathParameters,
|
||||
metricreductionruletypesUpdatableReductionRuleDTO?: BodyType<MetricreductionruletypesUpdatableReductionRuleDTO>,
|
||||
signal?: AbortSignal,
|
||||
) => {
|
||||
return GeneratedAPIInstance<UpdateMetricReductionRuleByID200>({
|
||||
url: `/api/v2/metric_reduction_rules/${id}`,
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
data: metricreductionruletypesUpdatableReductionRuleDTO,
|
||||
signal,
|
||||
});
|
||||
};
|
||||
|
||||
export const getUpdateMetricReductionRuleByIDMutationOptions = <
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
TContext = unknown,
|
||||
>(options?: {
|
||||
mutation?: UseMutationOptions<
|
||||
Awaited<ReturnType<typeof updateMetricReductionRuleByID>>,
|
||||
TError,
|
||||
{
|
||||
pathParams: UpdateMetricReductionRuleByIDPathParameters;
|
||||
data?: BodyType<MetricreductionruletypesUpdatableReductionRuleDTO>;
|
||||
},
|
||||
TContext
|
||||
>;
|
||||
}): UseMutationOptions<
|
||||
Awaited<ReturnType<typeof updateMetricReductionRuleByID>>,
|
||||
TError,
|
||||
{
|
||||
pathParams: UpdateMetricReductionRuleByIDPathParameters;
|
||||
data?: BodyType<MetricreductionruletypesUpdatableReductionRuleDTO>;
|
||||
},
|
||||
TContext
|
||||
> => {
|
||||
const mutationKey = ['updateMetricReductionRuleByID'];
|
||||
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<ReturnType<typeof updateMetricReductionRuleByID>>,
|
||||
{
|
||||
pathParams: UpdateMetricReductionRuleByIDPathParameters;
|
||||
data?: BodyType<MetricreductionruletypesUpdatableReductionRuleDTO>;
|
||||
}
|
||||
> = (props) => {
|
||||
const { pathParams, data } = props ?? {};
|
||||
|
||||
return updateMetricReductionRuleByID(pathParams, data);
|
||||
};
|
||||
|
||||
return { mutationFn, ...mutationOptions };
|
||||
};
|
||||
|
||||
export type UpdateMetricReductionRuleByIDMutationResult = NonNullable<
|
||||
Awaited<ReturnType<typeof updateMetricReductionRuleByID>>
|
||||
>;
|
||||
export type UpdateMetricReductionRuleByIDMutationBody =
|
||||
| BodyType<MetricreductionruletypesUpdatableReductionRuleDTO>
|
||||
| undefined;
|
||||
export type UpdateMetricReductionRuleByIDMutationError =
|
||||
ErrorType<RenderErrorResponseDTO>;
|
||||
|
||||
/**
|
||||
* @summary Update a metric reduction rule by id
|
||||
*/
|
||||
export const useUpdateMetricReductionRuleByID = <
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
TContext = unknown,
|
||||
>(options?: {
|
||||
mutation?: UseMutationOptions<
|
||||
Awaited<ReturnType<typeof updateMetricReductionRuleByID>>,
|
||||
TError,
|
||||
{
|
||||
pathParams: UpdateMetricReductionRuleByIDPathParameters;
|
||||
data?: BodyType<MetricreductionruletypesUpdatableReductionRuleDTO>;
|
||||
},
|
||||
TContext
|
||||
>;
|
||||
}): UseMutationResult<
|
||||
Awaited<ReturnType<typeof updateMetricReductionRuleByID>>,
|
||||
TError,
|
||||
{
|
||||
pathParams: UpdateMetricReductionRuleByIDPathParameters;
|
||||
data?: BodyType<MetricreductionruletypesUpdatableReductionRuleDTO>;
|
||||
},
|
||||
TContext
|
||||
> => {
|
||||
return useMutation(getUpdateMetricReductionRuleByIDMutationOptions(options));
|
||||
};
|
||||
/**
|
||||
* Estimates the series reduction and related-asset impact of a candidate volume-control rule without persisting it.
|
||||
* @summary Preview a metric reduction rule
|
||||
*/
|
||||
export const previewMetricReductionRule = (
|
||||
metricreductionruletypesPostableReductionRulePreviewDTO?: BodyType<MetricreductionruletypesPostableReductionRulePreviewDTO>,
|
||||
signal?: AbortSignal,
|
||||
) => {
|
||||
return GeneratedAPIInstance<PreviewMetricReductionRule200>({
|
||||
url: `/api/v2/metric_reduction_rules/preview`,
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
data: metricreductionruletypesPostableReductionRulePreviewDTO,
|
||||
signal,
|
||||
});
|
||||
};
|
||||
|
||||
export const getPreviewMetricReductionRuleMutationOptions = <
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
TContext = unknown,
|
||||
>(options?: {
|
||||
mutation?: UseMutationOptions<
|
||||
Awaited<ReturnType<typeof previewMetricReductionRule>>,
|
||||
TError,
|
||||
{ data?: BodyType<MetricreductionruletypesPostableReductionRulePreviewDTO> },
|
||||
TContext
|
||||
>;
|
||||
}): UseMutationOptions<
|
||||
Awaited<ReturnType<typeof previewMetricReductionRule>>,
|
||||
TError,
|
||||
{ data?: BodyType<MetricreductionruletypesPostableReductionRulePreviewDTO> },
|
||||
TContext
|
||||
> => {
|
||||
const mutationKey = ['previewMetricReductionRule'];
|
||||
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<ReturnType<typeof previewMetricReductionRule>>,
|
||||
{ data?: BodyType<MetricreductionruletypesPostableReductionRulePreviewDTO> }
|
||||
> = (props) => {
|
||||
const { data } = props ?? {};
|
||||
|
||||
return previewMetricReductionRule(data);
|
||||
};
|
||||
|
||||
return { mutationFn, ...mutationOptions };
|
||||
};
|
||||
|
||||
export type PreviewMetricReductionRuleMutationResult = NonNullable<
|
||||
Awaited<ReturnType<typeof previewMetricReductionRule>>
|
||||
>;
|
||||
export type PreviewMetricReductionRuleMutationBody =
|
||||
| BodyType<MetricreductionruletypesPostableReductionRulePreviewDTO>
|
||||
| undefined;
|
||||
export type PreviewMetricReductionRuleMutationError =
|
||||
ErrorType<RenderErrorResponseDTO>;
|
||||
|
||||
/**
|
||||
* @summary Preview a metric reduction rule
|
||||
*/
|
||||
export const usePreviewMetricReductionRule = <
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
TContext = unknown,
|
||||
>(options?: {
|
||||
mutation?: UseMutationOptions<
|
||||
Awaited<ReturnType<typeof previewMetricReductionRule>>,
|
||||
TError,
|
||||
{ data?: BodyType<MetricreductionruletypesPostableReductionRulePreviewDTO> },
|
||||
TContext
|
||||
>;
|
||||
}): UseMutationResult<
|
||||
Awaited<ReturnType<typeof previewMetricReductionRule>>,
|
||||
TError,
|
||||
{ data?: BodyType<MetricreductionruletypesPostableReductionRulePreviewDTO> },
|
||||
TContext
|
||||
> => {
|
||||
return useMutation(getPreviewMetricReductionRuleMutationOptions(options));
|
||||
};
|
||||
/**
|
||||
* Returns total ingested vs retained series and the estimated monthly savings across all volume-control rules.
|
||||
* @summary Metric reduction stats
|
||||
*/
|
||||
export const getMetricReductionRuleStats = (signal?: AbortSignal) => {
|
||||
return GeneratedAPIInstance<GetMetricReductionRuleStats200>({
|
||||
url: `/api/v2/metric_reduction_rules/stats`,
|
||||
method: 'GET',
|
||||
signal,
|
||||
});
|
||||
};
|
||||
|
||||
export const getGetMetricReductionRuleStatsQueryKey = () => {
|
||||
return [`/api/v2/metric_reduction_rules/stats`] as const;
|
||||
};
|
||||
|
||||
export const getGetMetricReductionRuleStatsQueryOptions = <
|
||||
TData = Awaited<ReturnType<typeof getMetricReductionRuleStats>>,
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
>(options?: {
|
||||
query?: UseQueryOptions<
|
||||
Awaited<ReturnType<typeof getMetricReductionRuleStats>>,
|
||||
TError,
|
||||
TData
|
||||
>;
|
||||
}) => {
|
||||
const { query: queryOptions } = options ?? {};
|
||||
|
||||
const queryKey =
|
||||
queryOptions?.queryKey ?? getGetMetricReductionRuleStatsQueryKey();
|
||||
|
||||
const queryFn: QueryFunction<
|
||||
Awaited<ReturnType<typeof getMetricReductionRuleStats>>
|
||||
> = ({ signal }) => getMetricReductionRuleStats(signal);
|
||||
|
||||
return { queryKey, queryFn, ...queryOptions } as UseQueryOptions<
|
||||
Awaited<ReturnType<typeof getMetricReductionRuleStats>>,
|
||||
TError,
|
||||
TData
|
||||
> & { queryKey: QueryKey };
|
||||
};
|
||||
|
||||
export type GetMetricReductionRuleStatsQueryResult = NonNullable<
|
||||
Awaited<ReturnType<typeof getMetricReductionRuleStats>>
|
||||
>;
|
||||
export type GetMetricReductionRuleStatsQueryError =
|
||||
ErrorType<RenderErrorResponseDTO>;
|
||||
|
||||
/**
|
||||
* @summary Metric reduction stats
|
||||
*/
|
||||
|
||||
export function useGetMetricReductionRuleStats<
|
||||
TData = Awaited<ReturnType<typeof getMetricReductionRuleStats>>,
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
>(options?: {
|
||||
query?: UseQueryOptions<
|
||||
Awaited<ReturnType<typeof getMetricReductionRuleStats>>,
|
||||
TError,
|
||||
TData
|
||||
>;
|
||||
}): UseQueryResult<TData, TError> & { queryKey: QueryKey } {
|
||||
const queryOptions = getGetMetricReductionRuleStatsQueryOptions(options);
|
||||
|
||||
const query = useQuery(queryOptions) as UseQueryResult<TData, TError> & {
|
||||
queryKey: QueryKey;
|
||||
};
|
||||
|
||||
return { ...query, queryKey: queryOptions.queryKey };
|
||||
}
|
||||
|
||||
/**
|
||||
* @summary Metric reduction stats
|
||||
*/
|
||||
export const invalidateGetMetricReductionRuleStats = async (
|
||||
queryClient: QueryClient,
|
||||
options?: InvalidateOptions,
|
||||
): Promise<QueryClient> => {
|
||||
await queryClient.invalidateQueries(
|
||||
{ queryKey: getGetMetricReductionRuleStatsQueryKey() },
|
||||
options,
|
||||
);
|
||||
|
||||
return queryClient;
|
||||
};
|
||||
|
||||
/**
|
||||
* Returns ingested vs retained series over time across all volume-control rules (hourly buckets), in the query-range time-series response shape.
|
||||
* @summary Metric reduction volume over time
|
||||
*/
|
||||
export const getMetricReductionRuleTimeseries = (signal?: AbortSignal) => {
|
||||
return GeneratedAPIInstance<GetMetricReductionRuleTimeseries200>({
|
||||
url: `/api/v2/metric_reduction_rules/timeseries`,
|
||||
method: 'GET',
|
||||
signal,
|
||||
});
|
||||
};
|
||||
|
||||
export const getGetMetricReductionRuleTimeseriesQueryKey = () => {
|
||||
return [`/api/v2/metric_reduction_rules/timeseries`] as const;
|
||||
};
|
||||
|
||||
export const getGetMetricReductionRuleTimeseriesQueryOptions = <
|
||||
TData = Awaited<ReturnType<typeof getMetricReductionRuleTimeseries>>,
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
>(options?: {
|
||||
query?: UseQueryOptions<
|
||||
Awaited<ReturnType<typeof getMetricReductionRuleTimeseries>>,
|
||||
TError,
|
||||
TData
|
||||
>;
|
||||
}) => {
|
||||
const { query: queryOptions } = options ?? {};
|
||||
|
||||
const queryKey =
|
||||
queryOptions?.queryKey ?? getGetMetricReductionRuleTimeseriesQueryKey();
|
||||
|
||||
const queryFn: QueryFunction<
|
||||
Awaited<ReturnType<typeof getMetricReductionRuleTimeseries>>
|
||||
> = ({ signal }) => getMetricReductionRuleTimeseries(signal);
|
||||
|
||||
return { queryKey, queryFn, ...queryOptions } as UseQueryOptions<
|
||||
Awaited<ReturnType<typeof getMetricReductionRuleTimeseries>>,
|
||||
TError,
|
||||
TData
|
||||
> & { queryKey: QueryKey };
|
||||
};
|
||||
|
||||
export type GetMetricReductionRuleTimeseriesQueryResult = NonNullable<
|
||||
Awaited<ReturnType<typeof getMetricReductionRuleTimeseries>>
|
||||
>;
|
||||
export type GetMetricReductionRuleTimeseriesQueryError =
|
||||
ErrorType<RenderErrorResponseDTO>;
|
||||
|
||||
/**
|
||||
* @summary Metric reduction volume over time
|
||||
*/
|
||||
|
||||
export function useGetMetricReductionRuleTimeseries<
|
||||
TData = Awaited<ReturnType<typeof getMetricReductionRuleTimeseries>>,
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
>(options?: {
|
||||
query?: UseQueryOptions<
|
||||
Awaited<ReturnType<typeof getMetricReductionRuleTimeseries>>,
|
||||
TError,
|
||||
TData
|
||||
>;
|
||||
}): UseQueryResult<TData, TError> & { queryKey: QueryKey } {
|
||||
const queryOptions = getGetMetricReductionRuleTimeseriesQueryOptions(options);
|
||||
|
||||
const query = useQuery(queryOptions) as UseQueryResult<TData, TError> & {
|
||||
queryKey: QueryKey;
|
||||
};
|
||||
|
||||
return { ...query, queryKey: queryOptions.queryKey };
|
||||
}
|
||||
|
||||
/**
|
||||
* @summary Metric reduction volume over time
|
||||
*/
|
||||
export const invalidateGetMetricReductionRuleTimeseries = async (
|
||||
queryClient: QueryClient,
|
||||
options?: InvalidateOptions,
|
||||
): Promise<QueryClient> => {
|
||||
await queryClient.invalidateQueries(
|
||||
{ queryKey: getGetMetricReductionRuleTimeseriesQueryKey() },
|
||||
options,
|
||||
);
|
||||
|
||||
return queryClient;
|
||||
};
|
||||
|
||||
/**
|
||||
* This endpoint returns a list of distinct metric names within the specified time range
|
||||
* @summary List metric names
|
||||
@@ -1045,3 +1789,100 @@ export const useGetMetricsTreemap = <
|
||||
> => {
|
||||
return useMutation(getGetMetricsTreemapMutationOptions(options));
|
||||
};
|
||||
/**
|
||||
* This endpoint returns associated v2 dashboards for a specified metric
|
||||
* @summary Get metric dashboards (v2)
|
||||
*/
|
||||
export const getMetricDashboardsV2 = (
|
||||
params: GetMetricDashboardsV2Params,
|
||||
signal?: AbortSignal,
|
||||
) => {
|
||||
return GeneratedAPIInstance<GetMetricDashboardsV2200>({
|
||||
url: `/api/v3/metrics/dashboards`,
|
||||
method: 'GET',
|
||||
params,
|
||||
signal,
|
||||
});
|
||||
};
|
||||
|
||||
export const getGetMetricDashboardsV2QueryKey = (
|
||||
params?: GetMetricDashboardsV2Params,
|
||||
) => {
|
||||
return [`/api/v3/metrics/dashboards`, ...(params ? [params] : [])] as const;
|
||||
};
|
||||
|
||||
export const getGetMetricDashboardsV2QueryOptions = <
|
||||
TData = Awaited<ReturnType<typeof getMetricDashboardsV2>>,
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
>(
|
||||
params: GetMetricDashboardsV2Params,
|
||||
options?: {
|
||||
query?: UseQueryOptions<
|
||||
Awaited<ReturnType<typeof getMetricDashboardsV2>>,
|
||||
TError,
|
||||
TData
|
||||
>;
|
||||
},
|
||||
) => {
|
||||
const { query: queryOptions } = options ?? {};
|
||||
|
||||
const queryKey =
|
||||
queryOptions?.queryKey ?? getGetMetricDashboardsV2QueryKey(params);
|
||||
|
||||
const queryFn: QueryFunction<
|
||||
Awaited<ReturnType<typeof getMetricDashboardsV2>>
|
||||
> = ({ signal }) => getMetricDashboardsV2(params, signal);
|
||||
|
||||
return { queryKey, queryFn, ...queryOptions } as UseQueryOptions<
|
||||
Awaited<ReturnType<typeof getMetricDashboardsV2>>,
|
||||
TError,
|
||||
TData
|
||||
> & { queryKey: QueryKey };
|
||||
};
|
||||
|
||||
export type GetMetricDashboardsV2QueryResult = NonNullable<
|
||||
Awaited<ReturnType<typeof getMetricDashboardsV2>>
|
||||
>;
|
||||
export type GetMetricDashboardsV2QueryError = ErrorType<RenderErrorResponseDTO>;
|
||||
|
||||
/**
|
||||
* @summary Get metric dashboards (v2)
|
||||
*/
|
||||
|
||||
export function useGetMetricDashboardsV2<
|
||||
TData = Awaited<ReturnType<typeof getMetricDashboardsV2>>,
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
>(
|
||||
params: GetMetricDashboardsV2Params,
|
||||
options?: {
|
||||
query?: UseQueryOptions<
|
||||
Awaited<ReturnType<typeof getMetricDashboardsV2>>,
|
||||
TError,
|
||||
TData
|
||||
>;
|
||||
},
|
||||
): UseQueryResult<TData, TError> & { queryKey: QueryKey } {
|
||||
const queryOptions = getGetMetricDashboardsV2QueryOptions(params, options);
|
||||
|
||||
const query = useQuery(queryOptions) as UseQueryResult<TData, TError> & {
|
||||
queryKey: QueryKey;
|
||||
};
|
||||
|
||||
return { ...query, queryKey: queryOptions.queryKey };
|
||||
}
|
||||
|
||||
/**
|
||||
* @summary Get metric dashboards (v2)
|
||||
*/
|
||||
export const invalidateGetMetricDashboardsV2 = async (
|
||||
queryClient: QueryClient,
|
||||
params: GetMetricDashboardsV2Params,
|
||||
options?: InvalidateOptions,
|
||||
): Promise<QueryClient> => {
|
||||
await queryClient.invalidateQueries(
|
||||
{ queryKey: getGetMetricDashboardsV2QueryKey(params) },
|
||||
options,
|
||||
);
|
||||
|
||||
return queryClient;
|
||||
};
|
||||
|
||||
@@ -12,6 +12,8 @@ import type {
|
||||
} from 'react-query';
|
||||
|
||||
import type {
|
||||
QueryRangePreviewV5200,
|
||||
QueryRangePreviewV5Params,
|
||||
QueryRangeV5200,
|
||||
Querybuildertypesv5QueryRangeRequestDTO,
|
||||
RenderErrorResponseDTO,
|
||||
@@ -104,6 +106,107 @@ export const useQueryRangeV5 = <
|
||||
> => {
|
||||
return useMutation(getQueryRangeV5MutationOptions(options));
|
||||
};
|
||||
/**
|
||||
* Validate a composite query without executing it. Accepts the same payload as the query range endpoint. By default (verbose=true) returns, for each query, the rendered underlying ClickHouse statement(s) with each statement's EXPLAIN ESTIMATE (per-table parts/rows/marks) and granule index analysis (candidate/surviving granules and the per-index pruning funnel). Pass ?verbose=false for the lightweight per-query verdict (valid/error/warnings) with no rendered SQL and no ClickHouse round trips. Intended for agentic/dry-run consumption: per-query errors are reported in the response rather than failing the whole request.
|
||||
* @summary Query range preview
|
||||
*/
|
||||
export const queryRangePreviewV5 = (
|
||||
querybuildertypesv5QueryRangeRequestDTO?: BodyType<Querybuildertypesv5QueryRangeRequestDTO>,
|
||||
params?: QueryRangePreviewV5Params,
|
||||
signal?: AbortSignal,
|
||||
) => {
|
||||
return GeneratedAPIInstance<QueryRangePreviewV5200>({
|
||||
url: `/api/v5/query_range/preview`,
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
data: querybuildertypesv5QueryRangeRequestDTO,
|
||||
params,
|
||||
signal,
|
||||
});
|
||||
};
|
||||
|
||||
export const getQueryRangePreviewV5MutationOptions = <
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
TContext = unknown,
|
||||
>(options?: {
|
||||
mutation?: UseMutationOptions<
|
||||
Awaited<ReturnType<typeof queryRangePreviewV5>>,
|
||||
TError,
|
||||
{
|
||||
data?: BodyType<Querybuildertypesv5QueryRangeRequestDTO>;
|
||||
params?: QueryRangePreviewV5Params;
|
||||
},
|
||||
TContext
|
||||
>;
|
||||
}): UseMutationOptions<
|
||||
Awaited<ReturnType<typeof queryRangePreviewV5>>,
|
||||
TError,
|
||||
{
|
||||
data?: BodyType<Querybuildertypesv5QueryRangeRequestDTO>;
|
||||
params?: QueryRangePreviewV5Params;
|
||||
},
|
||||
TContext
|
||||
> => {
|
||||
const mutationKey = ['queryRangePreviewV5'];
|
||||
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<ReturnType<typeof queryRangePreviewV5>>,
|
||||
{
|
||||
data?: BodyType<Querybuildertypesv5QueryRangeRequestDTO>;
|
||||
params?: QueryRangePreviewV5Params;
|
||||
}
|
||||
> = (props) => {
|
||||
const { data, params } = props ?? {};
|
||||
|
||||
return queryRangePreviewV5(data, params);
|
||||
};
|
||||
|
||||
return { mutationFn, ...mutationOptions };
|
||||
};
|
||||
|
||||
export type QueryRangePreviewV5MutationResult = NonNullable<
|
||||
Awaited<ReturnType<typeof queryRangePreviewV5>>
|
||||
>;
|
||||
export type QueryRangePreviewV5MutationBody =
|
||||
| BodyType<Querybuildertypesv5QueryRangeRequestDTO>
|
||||
| undefined;
|
||||
export type QueryRangePreviewV5MutationError =
|
||||
ErrorType<RenderErrorResponseDTO>;
|
||||
|
||||
/**
|
||||
* @summary Query range preview
|
||||
*/
|
||||
export const useQueryRangePreviewV5 = <
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
TContext = unknown,
|
||||
>(options?: {
|
||||
mutation?: UseMutationOptions<
|
||||
Awaited<ReturnType<typeof queryRangePreviewV5>>,
|
||||
TError,
|
||||
{
|
||||
data?: BodyType<Querybuildertypesv5QueryRangeRequestDTO>;
|
||||
params?: QueryRangePreviewV5Params;
|
||||
},
|
||||
TContext
|
||||
>;
|
||||
}): UseMutationResult<
|
||||
Awaited<ReturnType<typeof queryRangePreviewV5>>,
|
||||
TError,
|
||||
{
|
||||
data?: BodyType<Querybuildertypesv5QueryRangeRequestDTO>;
|
||||
params?: QueryRangePreviewV5Params;
|
||||
},
|
||||
TContext
|
||||
> => {
|
||||
return useMutation(getQueryRangePreviewV5MutationOptions(options));
|
||||
};
|
||||
/**
|
||||
* Replace variables in a query
|
||||
* @summary Replace variables
|
||||
|
||||
@@ -30,10 +30,8 @@ import type {
|
||||
RenderErrorResponseDTO,
|
||||
SpantypesPostableSpanMapperDTO,
|
||||
SpantypesPostableSpanMapperGroupDTO,
|
||||
SpantypesPostableSpanMapperTestDTO,
|
||||
SpantypesUpdatableSpanMapperDTO,
|
||||
SpantypesUpdatableSpanMapperGroupDTO,
|
||||
TestSpanMappers200,
|
||||
UpdateSpanMapperGroupPathParameters,
|
||||
UpdateSpanMapperPathParameters,
|
||||
} from '../sigNoz.schemas';
|
||||
@@ -782,86 +780,3 @@ export const useUpdateSpanMapper = <
|
||||
> => {
|
||||
return useMutation(getUpdateSpanMapperMutationOptions(options));
|
||||
};
|
||||
/**
|
||||
* Tests how span mappers would transform sample spans
|
||||
* @summary Test span mappers against sample spans
|
||||
*/
|
||||
export const testSpanMappers = (
|
||||
spantypesPostableSpanMapperTestDTO?: BodyType<SpantypesPostableSpanMapperTestDTO>,
|
||||
signal?: AbortSignal,
|
||||
) => {
|
||||
return GeneratedAPIInstance<TestSpanMappers200>({
|
||||
url: `/api/v1/span_mapper_groups/test`,
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
data: spantypesPostableSpanMapperTestDTO,
|
||||
signal,
|
||||
});
|
||||
};
|
||||
|
||||
export const getTestSpanMappersMutationOptions = <
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
TContext = unknown,
|
||||
>(options?: {
|
||||
mutation?: UseMutationOptions<
|
||||
Awaited<ReturnType<typeof testSpanMappers>>,
|
||||
TError,
|
||||
{ data?: BodyType<SpantypesPostableSpanMapperTestDTO> },
|
||||
TContext
|
||||
>;
|
||||
}): UseMutationOptions<
|
||||
Awaited<ReturnType<typeof testSpanMappers>>,
|
||||
TError,
|
||||
{ data?: BodyType<SpantypesPostableSpanMapperTestDTO> },
|
||||
TContext
|
||||
> => {
|
||||
const mutationKey = ['testSpanMappers'];
|
||||
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<ReturnType<typeof testSpanMappers>>,
|
||||
{ data?: BodyType<SpantypesPostableSpanMapperTestDTO> }
|
||||
> = (props) => {
|
||||
const { data } = props ?? {};
|
||||
|
||||
return testSpanMappers(data);
|
||||
};
|
||||
|
||||
return { mutationFn, ...mutationOptions };
|
||||
};
|
||||
|
||||
export type TestSpanMappersMutationResult = NonNullable<
|
||||
Awaited<ReturnType<typeof testSpanMappers>>
|
||||
>;
|
||||
export type TestSpanMappersMutationBody =
|
||||
| BodyType<SpantypesPostableSpanMapperTestDTO>
|
||||
| undefined;
|
||||
export type TestSpanMappersMutationError = ErrorType<RenderErrorResponseDTO>;
|
||||
|
||||
/**
|
||||
* @summary Test span mappers against sample spans
|
||||
*/
|
||||
export const useTestSpanMappers = <
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
TContext = unknown,
|
||||
>(options?: {
|
||||
mutation?: UseMutationOptions<
|
||||
Awaited<ReturnType<typeof testSpanMappers>>,
|
||||
TError,
|
||||
{ data?: BodyType<SpantypesPostableSpanMapperTestDTO> },
|
||||
TContext
|
||||
>;
|
||||
}): UseMutationResult<
|
||||
Awaited<ReturnType<typeof testSpanMappers>>,
|
||||
TError,
|
||||
{ data?: BodyType<SpantypesPostableSpanMapperTestDTO> },
|
||||
TContext
|
||||
> => {
|
||||
return useMutation(getTestSpanMappersMutationOptions(options));
|
||||
};
|
||||
|
||||
@@ -1,35 +0,0 @@
|
||||
import { ApiV2Instance as axios } from 'api';
|
||||
import { omit } from 'lodash-es';
|
||||
import { ErrorResponse, SuccessResponse } from 'types/api';
|
||||
import {
|
||||
GetTraceV2PayloadProps,
|
||||
GetTraceV2SuccessResponse,
|
||||
} from 'types/api/trace/getTraceV2';
|
||||
|
||||
const getTraceV2 = async (
|
||||
props: GetTraceV2PayloadProps,
|
||||
): Promise<SuccessResponse<GetTraceV2SuccessResponse> | ErrorResponse> => {
|
||||
let uncollapsedSpans = [...props.uncollapsedSpans];
|
||||
if (!props.isSelectedSpanIDUnCollapsed) {
|
||||
uncollapsedSpans = uncollapsedSpans.filter(
|
||||
(node) => node !== props.selectedSpanId,
|
||||
);
|
||||
}
|
||||
const postData: GetTraceV2PayloadProps = {
|
||||
...props,
|
||||
uncollapsedSpans,
|
||||
};
|
||||
const response = await axios.post<GetTraceV2SuccessResponse>(
|
||||
`/traces/waterfall/${props.traceId}`,
|
||||
omit(postData, 'traceId'),
|
||||
);
|
||||
|
||||
return {
|
||||
statusCode: 200,
|
||||
error: null,
|
||||
message: 'Success',
|
||||
payload: response.data,
|
||||
};
|
||||
};
|
||||
|
||||
export default getTraceV2;
|
||||
@@ -41,6 +41,7 @@ const getTraceV4 = async (
|
||||
> & { spans: WireSpan[] | null };
|
||||
|
||||
// Derive 'service.name' from resource for convenience — only derived field
|
||||
// todo(tech-debt): to remove use of this and to directly use service.name from resources.
|
||||
const spans: SpanV3[] = (rawPayload.spans || []).map((span) => ({
|
||||
...span,
|
||||
'service.name': span.resource?.['service.name'] || '',
|
||||
|
||||
@@ -1,25 +0,0 @@
|
||||
import { Color } from '@signozhq/design-tokens';
|
||||
import { useIsDarkMode } from 'hooks/useDarkMode';
|
||||
|
||||
function FlamegraphImg(): JSX.Element {
|
||||
const isDarkMode = useIsDarkMode();
|
||||
return (
|
||||
<svg
|
||||
width="14"
|
||||
height="14"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M8 3c1 3 2.5 3.5 3.5 4.5A5 5 0 0113 11a5 5 0 11-10 0c0-.3 0-.6.1-.9a2 2 0 103.3-2C4 5.5 7 3 8 3zM21 4h-8M20 14.5h-3M20 9.5h-3M21 20H4"
|
||||
stroke={isDarkMode ? Color.BG_VANILLA_100 : Color.BG_INK_500}
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export default FlamegraphImg;
|
||||
@@ -1,5 +1,5 @@
|
||||
import { ReactElement } from 'react';
|
||||
import { render, screen } from 'tests/test-utils';
|
||||
import { render, screen, userEvent, waitFor } from 'tests/test-utils';
|
||||
import { buildPermission } from 'hooks/useAuthZ/utils';
|
||||
import type { AuthZObject, BrandedPermission } from 'hooks/useAuthZ/types';
|
||||
import { useAuthZ } from 'hooks/useAuthZ/useAuthZ';
|
||||
@@ -66,6 +66,29 @@ describe('AuthZTooltip — single check', () => {
|
||||
expect(screen.getByRole('button', { name: 'Action' })).toBeDisabled();
|
||||
});
|
||||
|
||||
it('shows formatted permission message in tooltip when denied', async () => {
|
||||
mockUseAuthZ.mockReturnValue({
|
||||
...noPermissions,
|
||||
permissions: { [createPerm]: { isGranted: false } },
|
||||
});
|
||||
|
||||
render(
|
||||
<AuthZTooltip checks={[createPerm]}>
|
||||
<TestButton />
|
||||
</AuthZTooltip>,
|
||||
);
|
||||
|
||||
const user = userEvent.setup();
|
||||
await user.hover(screen.getByRole('button', { name: 'Action' }));
|
||||
|
||||
const expectedMessage =
|
||||
'user/some-user-id is not authorized to perform create:serviceaccount:*';
|
||||
await waitFor(() => {
|
||||
const matches = screen.queryAllByText(expectedMessage);
|
||||
expect(matches.length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
it('disables child while loading', () => {
|
||||
mockUseAuthZ.mockReturnValue({ ...noPermissions, isLoading: true });
|
||||
|
||||
@@ -142,4 +165,31 @@ describe('AuthZTooltip — multi-check (checks array)', () => {
|
||||
attachRolePerm,
|
||||
);
|
||||
});
|
||||
|
||||
it('shows multiple formatted permissions in tooltip when both denied', async () => {
|
||||
const sa = attachSAPerm('sa-1');
|
||||
mockUseAuthZ.mockReturnValue({
|
||||
...noPermissions,
|
||||
permissions: {
|
||||
[sa]: { isGranted: false },
|
||||
[attachRolePerm]: { isGranted: false },
|
||||
},
|
||||
});
|
||||
|
||||
render(
|
||||
<AuthZTooltip checks={[sa, attachRolePerm]}>
|
||||
<TestButton />
|
||||
</AuthZTooltip>,
|
||||
);
|
||||
|
||||
const user = userEvent.setup();
|
||||
await user.hover(screen.getByRole('button', { name: 'Action' }));
|
||||
|
||||
const expectedMessage =
|
||||
'user/some-user-id is not authorized to perform attach:serviceaccount:sa-1, attach:role:*';
|
||||
await waitFor(() => {
|
||||
const matches = screen.queryAllByText(expectedMessage);
|
||||
expect(matches.length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -7,7 +7,8 @@ import {
|
||||
} from '@signozhq/ui/tooltip';
|
||||
import type { BrandedPermission } from 'hooks/useAuthZ/types';
|
||||
import { useAuthZ } from 'hooks/useAuthZ/useAuthZ';
|
||||
import { parsePermission } from 'hooks/useAuthZ/utils';
|
||||
import { formatPermission } from 'hooks/useAuthZ/utils';
|
||||
import { useAppContext } from 'providers/App/App';
|
||||
import styles from './AuthZTooltip.module.scss';
|
||||
|
||||
interface AuthZTooltipProps {
|
||||
@@ -19,19 +20,14 @@ interface AuthZTooltipProps {
|
||||
|
||||
function formatDeniedMessage(
|
||||
denied: BrandedPermission[],
|
||||
userId: string,
|
||||
override?: string,
|
||||
): string {
|
||||
if (override) {
|
||||
return override;
|
||||
}
|
||||
const labels = denied.map((p) => {
|
||||
const { relation, object } = parsePermission(p);
|
||||
const resource = object.split(':')[0];
|
||||
return `${relation} ${resource}`;
|
||||
});
|
||||
return labels.length === 1
|
||||
? `You don't have ${labels[0]} permission`
|
||||
: `You don't have ${labels.join(', ')} permissions`;
|
||||
const permissions = denied.map(formatPermission).join(', ');
|
||||
return `user/${userId} is not authorized to perform ${permissions}`;
|
||||
}
|
||||
|
||||
function AuthZTooltip({
|
||||
@@ -40,6 +36,7 @@ function AuthZTooltip({
|
||||
enabled = true,
|
||||
tooltipMessage,
|
||||
}: AuthZTooltipProps): JSX.Element {
|
||||
const { user } = useAppContext();
|
||||
const shouldCheck = enabled && checks.length > 0;
|
||||
|
||||
const { permissions, isLoading } = useAuthZ(checks, { enabled: shouldCheck });
|
||||
@@ -75,7 +72,7 @@ function AuthZTooltip({
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent className={styles.errorContent}>
|
||||
{formatDeniedMessage(deniedPermissions, tooltipMessage)}
|
||||
{formatDeniedMessage(deniedPermissions, user.id, tooltipMessage)}
|
||||
</TooltipContent>
|
||||
</TooltipRoot>
|
||||
</TooltipProvider>
|
||||
|
||||
@@ -21,6 +21,8 @@ interface ErrorInPlaceProps {
|
||||
width?: string | number;
|
||||
/** Custom content instead of ErrorContent */
|
||||
children?: ReactNode;
|
||||
/** Test ID for testing */
|
||||
'data-testid'?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -44,6 +46,7 @@ function ErrorInPlace({
|
||||
height = '100%',
|
||||
width = '100%',
|
||||
children,
|
||||
'data-testid': dataTestId,
|
||||
}: ErrorInPlaceProps): JSX.Element {
|
||||
const containerStyle: React.CSSProperties = {
|
||||
display: 'flex',
|
||||
@@ -59,7 +62,11 @@ function ErrorInPlace({
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={`error-in-place ${className}`.trim()} style={containerStyle}>
|
||||
<div
|
||||
className={`error-in-place ${className}`.trim()}
|
||||
style={containerStyle}
|
||||
data-testid={dataTestId}
|
||||
>
|
||||
{children || <ErrorContent error={error} />}
|
||||
</div>
|
||||
);
|
||||
|
||||
163
frontend/src/components/InviteMembers/InviteMembers.module.scss
Normal file
@@ -0,0 +1,163 @@
|
||||
.inviteMembers {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-8);
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.table {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-4);
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.header {
|
||||
display: flex;
|
||||
gap: var(--spacing-8);
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.headerCellEmail {
|
||||
flex: 1 1 0;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.headerCellRole {
|
||||
flex: 0 0 160px;
|
||||
width: 160px;
|
||||
}
|
||||
|
||||
.headerCellAction {
|
||||
flex: 0 0 32px;
|
||||
width: 32px;
|
||||
}
|
||||
|
||||
.rows {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-6);
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.row {
|
||||
display: flex;
|
||||
gap: var(--spacing-8);
|
||||
align-items: flex-start;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.cellEmail {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-2);
|
||||
flex: 1 1 0;
|
||||
min-width: 0;
|
||||
|
||||
--input-background: var(
|
||||
--invite-members-field-background,
|
||||
var(--l2-background)
|
||||
);
|
||||
--input-hover-background: var(
|
||||
--invite-members-field-background,
|
||||
var(--l2-background)
|
||||
);
|
||||
--input-focus-background: var(
|
||||
--invite-members-field-background,
|
||||
var(--l2-background)
|
||||
);
|
||||
--input-disabled-background: var(
|
||||
--invite-members-field-background,
|
||||
var(--l2-background)
|
||||
);
|
||||
|
||||
input::placeholder {
|
||||
color: var(--l3-foreground);
|
||||
}
|
||||
}
|
||||
|
||||
.cellRole {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-2);
|
||||
flex: 0 0 160px;
|
||||
width: 160px;
|
||||
|
||||
:global(.roles-single-select) {
|
||||
width: 100%;
|
||||
|
||||
:global(.ant-select-selector) {
|
||||
background-color: var(
|
||||
--invite-members-field-background,
|
||||
var(--l2-background)
|
||||
) !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.cellAction {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-2);
|
||||
flex: 0 0 32px;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.errorText {
|
||||
color: var(--danger-background);
|
||||
}
|
||||
|
||||
.addRow {
|
||||
display: flex;
|
||||
justify-content: flex-start;
|
||||
margin-top: var(--spacing-2);
|
||||
}
|
||||
|
||||
.callout {
|
||||
animation: shake 300ms ease-out;
|
||||
|
||||
&[data-type='success'] {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
|
||||
.results {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-2);
|
||||
}
|
||||
|
||||
.resultsList {
|
||||
margin: var(--spacing-2) 0 0 var(--spacing-8);
|
||||
padding: 0;
|
||||
|
||||
li {
|
||||
margin-bottom: var(--spacing-1);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes shake {
|
||||
0% {
|
||||
transform: translateX(0);
|
||||
}
|
||||
|
||||
25% {
|
||||
transform: translateX(5px);
|
||||
}
|
||||
|
||||
50% {
|
||||
transform: translateX(-5px);
|
||||
}
|
||||
|
||||
75% {
|
||||
transform: translateX(5px);
|
||||
}
|
||||
|
||||
100% {
|
||||
transform: translateX(0);
|
||||
}
|
||||
}
|
||||
221
frontend/src/components/InviteMembers/InviteMembers.tsx
Normal file
@@ -0,0 +1,221 @@
|
||||
import { CircleAlert, Plus, Trash2 } from '@signozhq/icons';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import { Callout } from '@signozhq/ui/callout';
|
||||
import { Input } from '@signozhq/ui/input';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import cx from 'classnames';
|
||||
import RolesSelect from 'components/RolesSelect/RolesSelect';
|
||||
|
||||
import styles from './InviteMembers.module.scss';
|
||||
import { InviteMembersProps } from './types';
|
||||
import { useInviteMembers } from './useInviteMembers';
|
||||
|
||||
function InviteMembers({
|
||||
className,
|
||||
initialRowCount = 3,
|
||||
minRows = 1,
|
||||
emailPlaceholder = 'e.g. john@signoz.io',
|
||||
showHeader = true,
|
||||
showAddButton = true,
|
||||
onSuccess,
|
||||
onPartialSuccess,
|
||||
onAllFailed,
|
||||
renderFooter,
|
||||
}: InviteMembersProps): JSX.Element {
|
||||
const {
|
||||
rows,
|
||||
emailValidity,
|
||||
hasInvalidEmails,
|
||||
hasInvalidRoles,
|
||||
isSubmitting,
|
||||
inviteResults,
|
||||
addRow,
|
||||
removeRow,
|
||||
updateEmail,
|
||||
updateRole,
|
||||
reset,
|
||||
submit,
|
||||
touchedRows,
|
||||
failedResults,
|
||||
successResults,
|
||||
} = useInviteMembers({
|
||||
initialRowCount,
|
||||
onSuccess,
|
||||
onPartialSuccess,
|
||||
onAllFailed,
|
||||
});
|
||||
|
||||
const canSubmit = !isSubmitting && touchedRows.length > 0;
|
||||
const canRemoveRow = rows.length > minRows;
|
||||
|
||||
const getValidationErrorMessage = (): string => {
|
||||
if (hasInvalidEmails && hasInvalidRoles) {
|
||||
return 'Please enter valid emails and select roles for team members';
|
||||
}
|
||||
if (hasInvalidEmails) {
|
||||
return 'Please enter valid emails for team members';
|
||||
}
|
||||
return 'Please select roles for team members';
|
||||
};
|
||||
|
||||
const hasValidationErrors = hasInvalidEmails || hasInvalidRoles;
|
||||
const hasResults = inviteResults !== null;
|
||||
const hasFailures = failedResults.length > 0;
|
||||
const hasSuccesses = successResults.length > 0;
|
||||
|
||||
return (
|
||||
<div className={cx(styles.inviteMembers, className)}>
|
||||
<div className={styles.table}>
|
||||
{showHeader && (
|
||||
<div className={styles.header}>
|
||||
<Typography.Text
|
||||
size="base"
|
||||
weight="semibold"
|
||||
className={styles.headerCellEmail}
|
||||
>
|
||||
Email address
|
||||
</Typography.Text>
|
||||
<Typography.Text
|
||||
size="base"
|
||||
weight="semibold"
|
||||
className={styles.headerCellRole}
|
||||
>
|
||||
Role
|
||||
</Typography.Text>
|
||||
<div className={styles.headerCellAction} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className={styles.rows}>
|
||||
{rows.map((row) => (
|
||||
<div key={row.id} className={styles.row}>
|
||||
<div className={styles.cellEmail}>
|
||||
<Input
|
||||
type="email"
|
||||
placeholder={emailPlaceholder}
|
||||
value={row.email}
|
||||
onChange={(e): void => updateEmail(row.id, e.target.value)}
|
||||
name={`invite-email-${row.id}`}
|
||||
autoComplete="email"
|
||||
data-testid={`invite-email-${row.id}`}
|
||||
/>
|
||||
{emailValidity[row.id] === false && row.email.trim() !== '' && (
|
||||
<Typography.Text size="small" className={styles.errorText}>
|
||||
Invalid email address
|
||||
</Typography.Text>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className={styles.cellRole}>
|
||||
<RolesSelect
|
||||
mode="single"
|
||||
value={row.roleId || undefined}
|
||||
onChange={(roleId): void => updateRole(row.id, roleId)}
|
||||
placeholder="Select role"
|
||||
allowClear={false}
|
||||
id={`invite-role-${row.id}`}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className={styles.cellAction}>
|
||||
{canRemoveRow && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
color="destructive"
|
||||
onClick={(): void => removeRow(row.id)}
|
||||
aria-label="Remove row"
|
||||
data-testid={`invite-remove-${row.id}`}
|
||||
>
|
||||
<Trash2 size={12} />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{showAddButton && (
|
||||
<div className={styles.addRow}>
|
||||
<Button
|
||||
variant="dashed"
|
||||
color="secondary"
|
||||
prefix={<Plus size={12} />}
|
||||
onClick={addRow}
|
||||
data-testid="invite-add-row"
|
||||
>
|
||||
Add another
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{hasValidationErrors && (
|
||||
<Callout
|
||||
type="error"
|
||||
size="small"
|
||||
showIcon
|
||||
icon={<CircleAlert size={12} />}
|
||||
className={styles.callout}
|
||||
data-testid="invite-validation-error"
|
||||
>
|
||||
{getValidationErrorMessage()}
|
||||
</Callout>
|
||||
)}
|
||||
|
||||
{hasResults && hasFailures && (
|
||||
<Callout
|
||||
type="error"
|
||||
size="small"
|
||||
showIcon
|
||||
icon={<CircleAlert size={12} />}
|
||||
className={styles.callout}
|
||||
data-testid="invite-api-error"
|
||||
>
|
||||
<div className={styles.results}>
|
||||
{hasSuccesses && (
|
||||
<Typography.Text size="small">
|
||||
{successResults.length} invite(s) sent successfully.
|
||||
</Typography.Text>
|
||||
)}
|
||||
<Typography.Text size="small">
|
||||
{failedResults.length} invite(s) failed:
|
||||
</Typography.Text>
|
||||
<ul className={styles.resultsList}>
|
||||
{failedResults.map((result) => (
|
||||
<li key={result.email}>
|
||||
<Typography.Text size="small">
|
||||
{result.email}: {result.error}
|
||||
</Typography.Text>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</Callout>
|
||||
)}
|
||||
|
||||
{hasResults && !hasFailures && hasSuccesses && (
|
||||
<Callout
|
||||
type="success"
|
||||
size="small"
|
||||
showIcon
|
||||
className={styles.callout}
|
||||
data-testid="invite-success"
|
||||
>
|
||||
<Typography.Text size="small">
|
||||
{successResults.length} invite(s) sent successfully!
|
||||
</Typography.Text>
|
||||
</Callout>
|
||||
)}
|
||||
|
||||
{renderFooter?.({
|
||||
submit,
|
||||
reset,
|
||||
canSubmit,
|
||||
isSubmitting,
|
||||
touchedCount: touchedRows.length,
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default InviteMembers;
|
||||
@@ -0,0 +1,240 @@
|
||||
import { rest, server } from 'mocks-server/server';
|
||||
import { render, screen, userEvent, waitFor } from 'tests/test-utils';
|
||||
|
||||
import InviteMembers from '../InviteMembers';
|
||||
|
||||
import {
|
||||
CREATE_USER_ENDPOINT,
|
||||
createErrorHandler,
|
||||
createRolesHandler,
|
||||
createSuccessHandler,
|
||||
VALID_EMAIL,
|
||||
} from './testUtils';
|
||||
|
||||
describe('InviteMembers - Edge Cases', () => {
|
||||
beforeEach(() => {
|
||||
server.use(createRolesHandler(), createSuccessHandler());
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
server.resetHandlers();
|
||||
});
|
||||
|
||||
describe('reset behavior', () => {
|
||||
it('clears all rows when reset is called', async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
|
||||
render(
|
||||
<InviteMembers
|
||||
initialRowCount={2}
|
||||
renderFooter={({ reset }): JSX.Element => (
|
||||
<button data-testid="reset-btn" onClick={reset}>
|
||||
Reset
|
||||
</button>
|
||||
)}
|
||||
/>,
|
||||
);
|
||||
|
||||
const emailInputs = screen.getAllByPlaceholderText('e.g. john@signoz.io');
|
||||
await user.type(emailInputs[0], VALID_EMAIL);
|
||||
await user.type(emailInputs[1], 'bob@signoz.io');
|
||||
|
||||
await user.click(screen.getByTestId('reset-btn'));
|
||||
|
||||
const resetInputs = screen.getAllByPlaceholderText('e.g. john@signoz.io');
|
||||
expect(resetInputs).toHaveLength(2);
|
||||
resetInputs.forEach((input) => {
|
||||
expect(input).toHaveValue('');
|
||||
});
|
||||
});
|
||||
|
||||
it('clears results on reset', async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
|
||||
render(
|
||||
<InviteMembers
|
||||
renderFooter={({ submit, reset }): JSX.Element => (
|
||||
<>
|
||||
<button data-testid="submit-btn" onClick={submit}>
|
||||
Submit
|
||||
</button>
|
||||
<button data-testid="reset-btn" onClick={reset}>
|
||||
Reset
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
/>,
|
||||
);
|
||||
|
||||
const emailInputs = screen.getAllByPlaceholderText('e.g. john@signoz.io');
|
||||
await user.type(emailInputs[0], VALID_EMAIL);
|
||||
await user.click(screen.getAllByText('Select role')[0]);
|
||||
await user.click(await screen.findByText('Viewer'));
|
||||
|
||||
await user.click(screen.getByTestId('submit-btn'));
|
||||
await expect(
|
||||
screen.findByTestId('invite-success'),
|
||||
).resolves.toBeInTheDocument();
|
||||
|
||||
await user.click(screen.getByTestId('reset-btn'));
|
||||
|
||||
expect(screen.queryByTestId('invite-success')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('results cleared on edit', () => {
|
||||
it('clears API error when email is edited', async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
|
||||
server.use(createErrorHandler('already_exists', 'User already exists'));
|
||||
|
||||
render(
|
||||
<InviteMembers
|
||||
renderFooter={({ submit }): JSX.Element => (
|
||||
<button data-testid="submit-btn" onClick={submit}>
|
||||
Submit
|
||||
</button>
|
||||
)}
|
||||
/>,
|
||||
);
|
||||
|
||||
const emailInputs = screen.getAllByPlaceholderText('e.g. john@signoz.io');
|
||||
await user.type(emailInputs[0], VALID_EMAIL);
|
||||
await user.click(screen.getAllByText('Select role')[0]);
|
||||
await user.click(await screen.findByText('Viewer'));
|
||||
|
||||
await user.click(screen.getByTestId('submit-btn'));
|
||||
await expect(
|
||||
screen.findByTestId('invite-api-error'),
|
||||
).resolves.toBeInTheDocument();
|
||||
|
||||
await user.type(emailInputs[0], 'x');
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByTestId('invite-api-error')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('clears API error when role is changed', async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
|
||||
server.use(createErrorHandler('already_exists', 'User already exists'));
|
||||
|
||||
render(
|
||||
<InviteMembers
|
||||
renderFooter={({ submit }): JSX.Element => (
|
||||
<button data-testid="submit-btn" onClick={submit}>
|
||||
Submit
|
||||
</button>
|
||||
)}
|
||||
/>,
|
||||
);
|
||||
|
||||
const emailInputs = screen.getAllByPlaceholderText('e.g. john@signoz.io');
|
||||
await user.type(emailInputs[0], VALID_EMAIL);
|
||||
await user.click(screen.getAllByText('Select role')[0]);
|
||||
await user.click(await screen.findByText('Viewer'));
|
||||
|
||||
await user.click(screen.getByTestId('submit-btn'));
|
||||
await expect(
|
||||
screen.findByTestId('invite-api-error'),
|
||||
).resolves.toBeInTheDocument();
|
||||
|
||||
const viewerElements = screen.getAllByText('Viewer');
|
||||
await user.click(viewerElements[0]);
|
||||
const editorOptions = await screen.findAllByText('Editor');
|
||||
await user.click(editorOptions[editorOptions.length - 1]);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByTestId('invite-api-error')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('empty submission', () => {
|
||||
it('does not submit when no rows are touched', async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
const onSuccess = jest.fn();
|
||||
|
||||
render(
|
||||
<InviteMembers
|
||||
onSuccess={onSuccess}
|
||||
renderFooter={({ submit }): JSX.Element => (
|
||||
<button data-testid="submit-btn" onClick={submit}>
|
||||
Submit
|
||||
</button>
|
||||
)}
|
||||
/>,
|
||||
);
|
||||
|
||||
await user.click(screen.getByTestId('submit-btn'));
|
||||
|
||||
expect(onSuccess).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('submitting state', () => {
|
||||
it('disables submit while submitting', async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
|
||||
render(
|
||||
<InviteMembers
|
||||
renderFooter={({ submit, isSubmitting }): JSX.Element => (
|
||||
<button data-testid="submit-btn" onClick={submit} disabled={isSubmitting}>
|
||||
{isSubmitting ? 'Submitting...' : 'Submit'}
|
||||
</button>
|
||||
)}
|
||||
/>,
|
||||
);
|
||||
|
||||
const emailInputs = screen.getAllByPlaceholderText('e.g. john@signoz.io');
|
||||
await user.type(emailInputs[0], VALID_EMAIL);
|
||||
await user.click(screen.getAllByText('Select role')[0]);
|
||||
await user.click(await screen.findByText('Viewer'));
|
||||
|
||||
const submitBtn = screen.getByTestId('submit-btn');
|
||||
await user.click(submitBtn);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByText('Submitting...')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('whitespace handling', () => {
|
||||
it('trims email whitespace before submission', async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
const calls: { email: string }[] = [];
|
||||
|
||||
server.use(
|
||||
rest.post(CREATE_USER_ENDPOINT, async (req, res, ctx) => {
|
||||
const body = await req.json();
|
||||
calls.push(body);
|
||||
return res(ctx.status(201), ctx.json({ data: { id: 'user-123' } }));
|
||||
}),
|
||||
);
|
||||
|
||||
render(
|
||||
<InviteMembers
|
||||
renderFooter={({ submit }): JSX.Element => (
|
||||
<button data-testid="submit-btn" onClick={submit}>
|
||||
Submit
|
||||
</button>
|
||||
)}
|
||||
/>,
|
||||
);
|
||||
|
||||
const emailInputs = screen.getAllByPlaceholderText('e.g. john@signoz.io');
|
||||
await user.type(emailInputs[0], ' alice@signoz.io ');
|
||||
await user.click(screen.getAllByText('Select role')[0]);
|
||||
await user.click(await screen.findByText('Viewer'));
|
||||
|
||||
await user.click(screen.getByTestId('submit-btn'));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(calls).toHaveLength(1);
|
||||
expect(calls[0].email).toBe('alice@signoz.io');
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,95 @@
|
||||
import { server } from 'mocks-server/server';
|
||||
import { render, screen } from 'tests/test-utils';
|
||||
|
||||
import InviteMembers from '../InviteMembers';
|
||||
|
||||
import { createRolesHandler, createSuccessHandler } from './testUtils';
|
||||
|
||||
describe('InviteMembers - Rendering', () => {
|
||||
beforeEach(() => {
|
||||
server.use(createRolesHandler(), createSuccessHandler());
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
server.resetHandlers();
|
||||
});
|
||||
|
||||
it('renders default initial row count of 3', () => {
|
||||
render(<InviteMembers />);
|
||||
|
||||
const emailInputs = screen.getAllByPlaceholderText('e.g. john@signoz.io');
|
||||
expect(emailInputs).toHaveLength(3);
|
||||
});
|
||||
|
||||
it('renders custom initial row count', () => {
|
||||
render(<InviteMembers initialRowCount={5} />);
|
||||
|
||||
const emailInputs = screen.getAllByPlaceholderText('e.g. john@signoz.io');
|
||||
expect(emailInputs).toHaveLength(5);
|
||||
});
|
||||
|
||||
it('renders header by default', () => {
|
||||
render(<InviteMembers />);
|
||||
|
||||
expect(screen.getByText('Email address')).toBeInTheDocument();
|
||||
expect(screen.getByText('Role')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('hides header when showHeader is false', () => {
|
||||
render(<InviteMembers showHeader={false} />);
|
||||
|
||||
expect(screen.queryByText('Email address')).not.toBeInTheDocument();
|
||||
expect(screen.queryByText('Role')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders add button by default', () => {
|
||||
render(<InviteMembers />);
|
||||
|
||||
expect(
|
||||
screen.getByRole('button', { name: /add another/i }),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('hides add button when showAddButton is false', () => {
|
||||
render(<InviteMembers showAddButton={false} />);
|
||||
|
||||
expect(
|
||||
screen.queryByRole('button', { name: /add another/i }),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders custom email placeholder', () => {
|
||||
render(<InviteMembers emailPlaceholder="custom@placeholder.com" />);
|
||||
|
||||
const emailInputs = screen.getAllByPlaceholderText('custom@placeholder.com');
|
||||
expect(emailInputs).toHaveLength(3);
|
||||
});
|
||||
|
||||
it('applies custom className', () => {
|
||||
const { container } = render(<InviteMembers className="custom-class" />);
|
||||
|
||||
expect(container.querySelector('.custom-class')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders footer via renderFooter prop', () => {
|
||||
render(
|
||||
<InviteMembers
|
||||
renderFooter={({ canSubmit }): JSX.Element => (
|
||||
<button data-testid="custom-footer" disabled={!canSubmit}>
|
||||
Custom Submit
|
||||
</button>
|
||||
)}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByTestId('custom-footer')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('custom-footer')).toBeDisabled();
|
||||
});
|
||||
|
||||
it('renders role select for each row', () => {
|
||||
render(<InviteMembers initialRowCount={2} />);
|
||||
|
||||
const roleSelects = screen.getAllByText('Select role');
|
||||
expect(roleSelects).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,95 @@
|
||||
import { server } from 'mocks-server/server';
|
||||
import { render, screen, userEvent } from 'tests/test-utils';
|
||||
|
||||
import InviteMembers from '../InviteMembers';
|
||||
|
||||
import { createRolesHandler, createSuccessHandler } from './testUtils';
|
||||
|
||||
describe('InviteMembers - Row Management', () => {
|
||||
beforeEach(() => {
|
||||
server.use(createRolesHandler(), createSuccessHandler());
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
server.resetHandlers();
|
||||
});
|
||||
|
||||
it('adds a row when "Add another" is clicked', async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
|
||||
render(<InviteMembers initialRowCount={2} />);
|
||||
|
||||
expect(screen.getAllByPlaceholderText('e.g. john@signoz.io')).toHaveLength(2);
|
||||
|
||||
await user.click(screen.getByRole('button', { name: /add another/i }));
|
||||
|
||||
expect(screen.getAllByPlaceholderText('e.g. john@signoz.io')).toHaveLength(3);
|
||||
});
|
||||
|
||||
it('removes a row when trash button is clicked', async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
|
||||
render(<InviteMembers initialRowCount={3} />);
|
||||
|
||||
const removeButtons = screen.getAllByRole('button', { name: /remove row/i });
|
||||
expect(removeButtons).toHaveLength(3);
|
||||
|
||||
await user.click(removeButtons[0]);
|
||||
|
||||
expect(screen.getAllByPlaceholderText('e.g. john@signoz.io')).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('respects minRows constraint when removing rows', async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
|
||||
render(<InviteMembers initialRowCount={2} minRows={2} />);
|
||||
|
||||
expect(screen.queryAllByRole('button', { name: /remove row/i })).toHaveLength(
|
||||
0,
|
||||
);
|
||||
|
||||
await user.click(screen.getByRole('button', { name: /add another/i }));
|
||||
|
||||
const removeButtons = screen.getAllByRole('button', { name: /remove row/i });
|
||||
expect(removeButtons).toHaveLength(3);
|
||||
|
||||
await user.click(removeButtons[0]);
|
||||
|
||||
expect(screen.getAllByPlaceholderText('e.g. john@signoz.io')).toHaveLength(2);
|
||||
expect(screen.queryAllByRole('button', { name: /remove row/i })).toHaveLength(
|
||||
0,
|
||||
);
|
||||
});
|
||||
|
||||
it('cannot remove rows below minRows=1 default', async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
|
||||
render(<InviteMembers initialRowCount={2} />);
|
||||
|
||||
const removeButtons = screen.getAllByRole('button', { name: /remove row/i });
|
||||
await user.click(removeButtons[0]);
|
||||
|
||||
expect(screen.getAllByPlaceholderText('e.g. john@signoz.io')).toHaveLength(1);
|
||||
expect(screen.queryAllByRole('button', { name: /remove row/i })).toHaveLength(
|
||||
0,
|
||||
);
|
||||
});
|
||||
|
||||
it('preserves data in other rows when removing one', async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
|
||||
render(<InviteMembers initialRowCount={3} />);
|
||||
|
||||
const emailInputs = screen.getAllByPlaceholderText('e.g. john@signoz.io');
|
||||
await user.type(emailInputs[0], 'first@signoz.io');
|
||||
await user.type(emailInputs[2], 'third@signoz.io');
|
||||
|
||||
const removeButtons = screen.getAllByRole('button', { name: /remove row/i });
|
||||
await user.click(removeButtons[1]);
|
||||
|
||||
const remainingInputs = screen.getAllByPlaceholderText('e.g. john@signoz.io');
|
||||
expect(remainingInputs).toHaveLength(2);
|
||||
expect(remainingInputs[0]).toHaveValue('first@signoz.io');
|
||||
expect(remainingInputs[1]).toHaveValue('third@signoz.io');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,364 @@
|
||||
import { rest, server } from 'mocks-server/server';
|
||||
import { render, screen, userEvent, waitFor } from 'tests/test-utils';
|
||||
|
||||
import InviteMembers from '../InviteMembers';
|
||||
|
||||
import {
|
||||
CREATE_USER_ENDPOINT,
|
||||
createErrorHandler,
|
||||
createRolesHandler,
|
||||
createSuccessHandler,
|
||||
createTrackingHandler,
|
||||
VALID_EMAIL,
|
||||
} from './testUtils';
|
||||
|
||||
describe('InviteMembers - Submission', () => {
|
||||
beforeEach(() => {
|
||||
server.use(createRolesHandler(), createSuccessHandler());
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
server.resetHandlers();
|
||||
});
|
||||
|
||||
describe('API calls', () => {
|
||||
it('calls createUser API for each touched row', async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
const { handler, calls } = createTrackingHandler();
|
||||
server.use(handler);
|
||||
|
||||
render(
|
||||
<InviteMembers
|
||||
initialRowCount={3}
|
||||
renderFooter={({ submit }): JSX.Element => (
|
||||
<button data-testid="submit-btn" onClick={submit}>
|
||||
Submit
|
||||
</button>
|
||||
)}
|
||||
/>,
|
||||
);
|
||||
|
||||
const emailInputs = screen.getAllByPlaceholderText('e.g. john@signoz.io');
|
||||
await user.type(emailInputs[0], 'alice@signoz.io');
|
||||
await user.click(screen.getAllByText('Select role')[0]);
|
||||
await user.click(await screen.findByText('Viewer'));
|
||||
|
||||
await user.click(screen.getByTestId('submit-btn'));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(calls).toHaveLength(1);
|
||||
expect(calls[0]).toMatchObject({
|
||||
email: 'alice@signoz.io',
|
||||
userRoles: [{ id: 'role-viewer' }],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it('calls createUser API for multiple touched rows', async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
const { handler, calls } = createTrackingHandler();
|
||||
server.use(handler);
|
||||
|
||||
render(
|
||||
<InviteMembers
|
||||
initialRowCount={3}
|
||||
renderFooter={({ submit }): JSX.Element => (
|
||||
<button data-testid="submit-btn" onClick={submit}>
|
||||
Submit
|
||||
</button>
|
||||
)}
|
||||
/>,
|
||||
);
|
||||
|
||||
const emailInputs = screen.getAllByPlaceholderText('e.g. john@signoz.io');
|
||||
|
||||
await user.type(emailInputs[0], 'alice@signoz.io');
|
||||
await user.click(screen.getAllByText('Select role')[0]);
|
||||
await user.click(await screen.findByText('Viewer'));
|
||||
|
||||
await user.type(emailInputs[1], 'bob@signoz.io');
|
||||
await user.click(screen.getAllByText('Select role')[0]);
|
||||
const editorOptions = await screen.findAllByText('Editor');
|
||||
await user.click(editorOptions[editorOptions.length - 1]);
|
||||
|
||||
await user.type(emailInputs[2], 'charlie@signoz.io');
|
||||
await user.click(screen.getAllByText('Select role')[0]);
|
||||
const adminOptions = await screen.findAllByText('Admin');
|
||||
await user.click(adminOptions[adminOptions.length - 1]);
|
||||
|
||||
await user.click(screen.getByTestId('submit-btn'));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(calls).toHaveLength(3);
|
||||
});
|
||||
|
||||
expect(calls[0]).toMatchObject({
|
||||
email: 'alice@signoz.io',
|
||||
userRoles: [{ id: 'role-viewer' }],
|
||||
});
|
||||
expect(calls[1]).toMatchObject({
|
||||
email: 'bob@signoz.io',
|
||||
userRoles: [{ id: 'role-editor' }],
|
||||
});
|
||||
expect(calls[2]).toMatchObject({
|
||||
email: 'charlie@signoz.io',
|
||||
userRoles: [{ id: 'role-admin' }],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('callbacks', () => {
|
||||
it('calls onSuccess when all invites succeed', async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
const onSuccess = jest.fn();
|
||||
|
||||
render(
|
||||
<InviteMembers
|
||||
onSuccess={onSuccess}
|
||||
renderFooter={({ submit }): JSX.Element => (
|
||||
<button data-testid="submit-btn" onClick={submit}>
|
||||
Submit
|
||||
</button>
|
||||
)}
|
||||
/>,
|
||||
);
|
||||
|
||||
const emailInputs = screen.getAllByPlaceholderText('e.g. john@signoz.io');
|
||||
await user.type(emailInputs[0], VALID_EMAIL);
|
||||
await user.click(screen.getAllByText('Select role')[0]);
|
||||
await user.click(await screen.findByText('Viewer'));
|
||||
|
||||
await user.click(screen.getByTestId('submit-btn'));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(onSuccess).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
it('calls onAllFailed when all invites fail', async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
const onAllFailed = jest.fn();
|
||||
|
||||
server.use(createErrorHandler('already_exists', 'User already exists'));
|
||||
|
||||
render(
|
||||
<InviteMembers
|
||||
onAllFailed={onAllFailed}
|
||||
renderFooter={({ submit }): JSX.Element => (
|
||||
<button data-testid="submit-btn" onClick={submit}>
|
||||
Submit
|
||||
</button>
|
||||
)}
|
||||
/>,
|
||||
);
|
||||
|
||||
const emailInputs = screen.getAllByPlaceholderText('e.g. john@signoz.io');
|
||||
await user.type(emailInputs[0], VALID_EMAIL);
|
||||
await user.click(screen.getAllByText('Select role')[0]);
|
||||
await user.click(await screen.findByText('Viewer'));
|
||||
|
||||
await user.click(screen.getByTestId('submit-btn'));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(onAllFailed).toHaveBeenCalledWith(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
email: VALID_EMAIL,
|
||||
success: false,
|
||||
}),
|
||||
]),
|
||||
expect.any(Array),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it('calls onPartialSuccess when some invites succeed and some fail', async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
const onPartialSuccess = jest.fn();
|
||||
const onSuccess = jest.fn();
|
||||
const onAllFailed = jest.fn();
|
||||
const apiCalls: string[] = [];
|
||||
let callCount = 0;
|
||||
|
||||
server.use(
|
||||
createRolesHandler(),
|
||||
rest.post(CREATE_USER_ENDPOINT, async (req, res, ctx) => {
|
||||
const body = await req.json();
|
||||
apiCalls.push(body.email);
|
||||
callCount++;
|
||||
if (callCount === 1) {
|
||||
return res(ctx.status(201), ctx.json({ data: { id: 'user-123' } }));
|
||||
}
|
||||
return res(
|
||||
ctx.status(409),
|
||||
ctx.json({
|
||||
error: {
|
||||
code: 'already_exists',
|
||||
message: 'User already exists',
|
||||
},
|
||||
}),
|
||||
);
|
||||
}),
|
||||
);
|
||||
|
||||
render(
|
||||
<InviteMembers
|
||||
initialRowCount={2}
|
||||
onSuccess={onSuccess}
|
||||
onPartialSuccess={onPartialSuccess}
|
||||
onAllFailed={onAllFailed}
|
||||
renderFooter={({ submit }): JSX.Element => (
|
||||
<button data-testid="submit-btn" onClick={submit}>
|
||||
Submit
|
||||
</button>
|
||||
)}
|
||||
/>,
|
||||
);
|
||||
|
||||
const emailInputs = screen.getAllByPlaceholderText('e.g. john@signoz.io');
|
||||
|
||||
await user.type(emailInputs[0], 'alice@signoz.io');
|
||||
await user.click(screen.getAllByText('Select role')[0]);
|
||||
await user.click(await screen.findByText('Viewer'));
|
||||
|
||||
await user.type(emailInputs[1], 'bob@signoz.io');
|
||||
await user.click(screen.getAllByText('Select role')[0]);
|
||||
const editorOptions = await screen.findAllByText('Editor');
|
||||
await user.click(editorOptions[editorOptions.length - 1]);
|
||||
|
||||
await user.click(screen.getByTestId('submit-btn'));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(apiCalls).toHaveLength(2);
|
||||
});
|
||||
|
||||
expect(apiCalls).toStrictEqual(['alice@signoz.io', 'bob@signoz.io']);
|
||||
expect(onSuccess).not.toHaveBeenCalled();
|
||||
expect(onAllFailed).not.toHaveBeenCalled();
|
||||
expect(onPartialSuccess).toHaveBeenCalledWith(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({ email: 'alice@signoz.io', success: true }),
|
||||
expect.objectContaining({
|
||||
email: 'bob@signoz.io',
|
||||
success: false,
|
||||
error: 'User already exists',
|
||||
}),
|
||||
]),
|
||||
expect.any(Array),
|
||||
);
|
||||
|
||||
await expect(
|
||||
screen.findByTestId('invite-api-error'),
|
||||
).resolves.toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText('1 invite(s) sent successfully.'),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByText('1 invite(s) failed:')).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText('bob@signoz.io: User already exists'),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('result display', () => {
|
||||
it('shows success callout when all invites succeed', async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
|
||||
render(
|
||||
<InviteMembers
|
||||
renderFooter={({ submit }): JSX.Element => (
|
||||
<button data-testid="submit-btn" onClick={submit}>
|
||||
Submit
|
||||
</button>
|
||||
)}
|
||||
/>,
|
||||
);
|
||||
|
||||
const emailInputs = screen.getAllByPlaceholderText('e.g. john@signoz.io');
|
||||
await user.type(emailInputs[0], VALID_EMAIL);
|
||||
await user.click(screen.getAllByText('Select role')[0]);
|
||||
await user.click(await screen.findByText('Viewer'));
|
||||
|
||||
await user.click(screen.getByTestId('submit-btn'));
|
||||
|
||||
await expect(
|
||||
screen.findByTestId('invite-success'),
|
||||
).resolves.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows error callout with failed emails when API fails', async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
|
||||
server.use(createErrorHandler('already_exists', 'User already exists'));
|
||||
|
||||
render(
|
||||
<InviteMembers
|
||||
renderFooter={({ submit }): JSX.Element => (
|
||||
<button data-testid="submit-btn" onClick={submit}>
|
||||
Submit
|
||||
</button>
|
||||
)}
|
||||
/>,
|
||||
);
|
||||
|
||||
const emailInputs = screen.getAllByPlaceholderText('e.g. john@signoz.io');
|
||||
await user.type(emailInputs[0], VALID_EMAIL);
|
||||
await user.click(screen.getAllByText('Select role')[0]);
|
||||
await user.click(await screen.findByText('Viewer'));
|
||||
|
||||
await user.click(screen.getByTestId('submit-btn'));
|
||||
|
||||
await expect(
|
||||
screen.findByTestId('invite-api-error'),
|
||||
).resolves.toBeInTheDocument();
|
||||
expect(screen.getByText(/1 invite\(s\) failed/)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('footer props', () => {
|
||||
it('provides correct canSubmit state', async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
|
||||
render(
|
||||
<InviteMembers
|
||||
renderFooter={({ canSubmit }): JSX.Element => (
|
||||
<button data-testid="submit-btn" disabled={!canSubmit}>
|
||||
Submit
|
||||
</button>
|
||||
)}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByTestId('submit-btn')).toBeDisabled();
|
||||
|
||||
const emailInputs = screen.getAllByPlaceholderText('e.g. john@signoz.io');
|
||||
await user.type(emailInputs[0], VALID_EMAIL);
|
||||
|
||||
expect(screen.getByTestId('submit-btn')).not.toBeDisabled();
|
||||
});
|
||||
|
||||
it('provides touchedCount', async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
|
||||
render(
|
||||
<InviteMembers
|
||||
initialRowCount={3}
|
||||
renderFooter={({ touchedCount }): JSX.Element => (
|
||||
<span data-testid="touched-count">{touchedCount}</span>
|
||||
)}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByTestId('touched-count')).toHaveTextContent('0');
|
||||
|
||||
const emailInputs = screen.getAllByPlaceholderText('e.g. john@signoz.io');
|
||||
await user.type(emailInputs[0], 'a@b.com');
|
||||
|
||||
expect(screen.getByTestId('touched-count')).toHaveTextContent('1');
|
||||
|
||||
await user.type(emailInputs[1], 'c@d.com');
|
||||
|
||||
expect(screen.getByTestId('touched-count')).toHaveTextContent('2');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,217 @@
|
||||
import { server } from 'mocks-server/server';
|
||||
import { render, screen, userEvent, waitFor } from 'tests/test-utils';
|
||||
|
||||
import InviteMembers from '../InviteMembers';
|
||||
|
||||
import {
|
||||
createRolesHandler,
|
||||
createSuccessHandler,
|
||||
INVALID_EMAIL,
|
||||
VALID_EMAIL,
|
||||
} from './testUtils';
|
||||
|
||||
describe('InviteMembers - Validation', () => {
|
||||
beforeEach(() => {
|
||||
server.use(createRolesHandler(), createSuccessHandler());
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
server.resetHandlers();
|
||||
});
|
||||
|
||||
describe('email validation', () => {
|
||||
it('shows email validation error when email is invalid and role is selected', async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
|
||||
render(
|
||||
<InviteMembers
|
||||
renderFooter={({ submit }): JSX.Element => (
|
||||
<button data-testid="submit-btn" onClick={submit}>
|
||||
Submit
|
||||
</button>
|
||||
)}
|
||||
/>,
|
||||
);
|
||||
|
||||
const emailInputs = screen.getAllByPlaceholderText('e.g. john@signoz.io');
|
||||
await user.type(emailInputs[0], INVALID_EMAIL);
|
||||
await user.click(screen.getAllByText('Select role')[0]);
|
||||
await user.click(await screen.findByText('Viewer'));
|
||||
|
||||
await user.click(screen.getByTestId('submit-btn'));
|
||||
|
||||
await expect(
|
||||
screen.findByText('Please enter valid emails for team members'),
|
||||
).resolves.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows inline error for invalid email field', async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
|
||||
render(
|
||||
<InviteMembers
|
||||
renderFooter={({ submit }): JSX.Element => (
|
||||
<button data-testid="submit-btn" onClick={submit}>
|
||||
Submit
|
||||
</button>
|
||||
)}
|
||||
/>,
|
||||
);
|
||||
|
||||
const emailInputs = screen.getAllByPlaceholderText('e.g. john@signoz.io');
|
||||
await user.type(emailInputs[0], INVALID_EMAIL);
|
||||
await user.click(screen.getAllByText('Select role')[0]);
|
||||
await user.click(await screen.findByText('Viewer'));
|
||||
|
||||
await user.click(screen.getByTestId('submit-btn'));
|
||||
|
||||
await expect(
|
||||
screen.findByText('Invalid email address'),
|
||||
).resolves.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('clears validation error when email is corrected', async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
|
||||
render(
|
||||
<InviteMembers
|
||||
renderFooter={({ submit }): JSX.Element => (
|
||||
<button data-testid="submit-btn" onClick={submit}>
|
||||
Submit
|
||||
</button>
|
||||
)}
|
||||
/>,
|
||||
);
|
||||
|
||||
const emailInputs = screen.getAllByPlaceholderText('e.g. john@signoz.io');
|
||||
await user.type(emailInputs[0], INVALID_EMAIL);
|
||||
await user.click(screen.getAllByText('Select role')[0]);
|
||||
await user.click(await screen.findByText('Viewer'));
|
||||
await user.click(screen.getByTestId('submit-btn'));
|
||||
|
||||
await expect(
|
||||
screen.findByText('Please enter valid emails for team members'),
|
||||
).resolves.toBeInTheDocument();
|
||||
|
||||
await user.clear(emailInputs[0]);
|
||||
await user.type(emailInputs[0], VALID_EMAIL);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen.queryByText('Please enter valid emails for team members'),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('role validation', () => {
|
||||
it('shows role validation error when role is missing', async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
|
||||
render(
|
||||
<InviteMembers
|
||||
renderFooter={({ submit }): JSX.Element => (
|
||||
<button data-testid="submit-btn" onClick={submit}>
|
||||
Submit
|
||||
</button>
|
||||
)}
|
||||
/>,
|
||||
);
|
||||
|
||||
const emailInputs = screen.getAllByPlaceholderText('e.g. john@signoz.io');
|
||||
await user.type(emailInputs[0], VALID_EMAIL);
|
||||
|
||||
await user.click(screen.getByTestId('submit-btn'));
|
||||
|
||||
await expect(
|
||||
screen.findByText('Please select roles for team members'),
|
||||
).resolves.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('clears role validation error when role is selected', async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
|
||||
render(
|
||||
<InviteMembers
|
||||
renderFooter={({ submit }): JSX.Element => (
|
||||
<button data-testid="submit-btn" onClick={submit}>
|
||||
Submit
|
||||
</button>
|
||||
)}
|
||||
/>,
|
||||
);
|
||||
|
||||
const emailInputs = screen.getAllByPlaceholderText('e.g. john@signoz.io');
|
||||
await user.type(emailInputs[0], VALID_EMAIL);
|
||||
await user.click(screen.getByTestId('submit-btn'));
|
||||
|
||||
await expect(
|
||||
screen.findByText('Please select roles for team members'),
|
||||
).resolves.toBeInTheDocument();
|
||||
|
||||
await user.click(screen.getAllByText('Select role')[0]);
|
||||
await user.click(await screen.findByText('Viewer'));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen.queryByText('Please select roles for team members'),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('combined validation', () => {
|
||||
it('shows combined error when both email and role are invalid', async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
|
||||
render(
|
||||
<InviteMembers
|
||||
renderFooter={({ submit }): JSX.Element => (
|
||||
<button data-testid="submit-btn" onClick={submit}>
|
||||
Submit
|
||||
</button>
|
||||
)}
|
||||
/>,
|
||||
);
|
||||
|
||||
const emailInputs = screen.getAllByPlaceholderText('e.g. john@signoz.io');
|
||||
await user.type(emailInputs[0], INVALID_EMAIL);
|
||||
|
||||
await user.click(screen.getByTestId('submit-btn'));
|
||||
|
||||
await expect(
|
||||
screen.findByText(
|
||||
'Please enter valid emails and select roles for team members',
|
||||
),
|
||||
).resolves.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('touched rows', () => {
|
||||
it('only validates touched rows (rows with email or role)', async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
|
||||
render(
|
||||
<InviteMembers
|
||||
initialRowCount={3}
|
||||
renderFooter={({ submit }): JSX.Element => (
|
||||
<button data-testid="submit-btn" onClick={submit}>
|
||||
Submit
|
||||
</button>
|
||||
)}
|
||||
/>,
|
||||
);
|
||||
|
||||
const emailInputs = screen.getAllByPlaceholderText('e.g. john@signoz.io');
|
||||
await user.type(emailInputs[0], VALID_EMAIL);
|
||||
await user.click(screen.getAllByText('Select role')[0]);
|
||||
await user.click(await screen.findByText('Viewer'));
|
||||
|
||||
await user.click(screen.getByTestId('submit-btn'));
|
||||
|
||||
expect(
|
||||
screen.queryByTestId('invite-validation-error'),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
54
frontend/src/components/InviteMembers/__tests__/testUtils.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
import { RestHandler } from 'msw';
|
||||
import { rest } from 'mocks-server/server';
|
||||
|
||||
export const CREATE_USER_ENDPOINT = '*/api/v2/users';
|
||||
export const LIST_ROLES_ENDPOINT = '*/api/v1/roles';
|
||||
|
||||
export const MOCK_ROLES = [
|
||||
{ id: 'role-admin', name: 'Admin', description: 'Admin role' },
|
||||
{ id: 'role-editor', name: 'Editor', description: 'Editor role' },
|
||||
{ id: 'role-viewer', name: 'Viewer', description: 'Viewer role' },
|
||||
];
|
||||
|
||||
export const VALID_EMAIL = 'alice@signoz.io';
|
||||
export const INVALID_EMAIL = 'not-an-email';
|
||||
|
||||
export function createSuccessHandler(): RestHandler {
|
||||
return rest.post(CREATE_USER_ENDPOINT, (_, res, ctx) =>
|
||||
res(ctx.status(201), ctx.json({ data: { id: 'user-123' } })),
|
||||
);
|
||||
}
|
||||
|
||||
export function createErrorHandler(
|
||||
code: string,
|
||||
message: string,
|
||||
status = 409,
|
||||
): RestHandler {
|
||||
return rest.post(CREATE_USER_ENDPOINT, (_, res, ctx) =>
|
||||
res(
|
||||
ctx.status(status),
|
||||
ctx.json({
|
||||
errors: [{ code, msg: message }],
|
||||
}),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
export function createRolesHandler(): RestHandler {
|
||||
return rest.get(LIST_ROLES_ENDPOINT, (_, res, ctx) =>
|
||||
res(ctx.status(200), ctx.json({ data: MOCK_ROLES })),
|
||||
);
|
||||
}
|
||||
|
||||
export function createTrackingHandler(): {
|
||||
handler: RestHandler;
|
||||
calls: unknown[];
|
||||
} {
|
||||
const calls: unknown[] = [];
|
||||
const handler = rest.post(CREATE_USER_ENDPOINT, async (req, res, ctx) => {
|
||||
const body = await req.json();
|
||||
calls.push(body);
|
||||
return res(ctx.status(201), ctx.json({ data: { id: 'user-123' } }));
|
||||
});
|
||||
return { handler, calls };
|
||||
}
|
||||
64
frontend/src/components/InviteMembers/types.ts
Normal file
@@ -0,0 +1,64 @@
|
||||
import { ReactNode } from 'react';
|
||||
|
||||
export interface InviteMemberRow {
|
||||
id: string;
|
||||
email: string;
|
||||
roleId: string;
|
||||
}
|
||||
|
||||
export interface InviteResult {
|
||||
email: string;
|
||||
success: boolean;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export interface FooterRenderProps {
|
||||
submit: () => Promise<InviteResult[]>;
|
||||
reset: () => void;
|
||||
canSubmit: boolean;
|
||||
isSubmitting: boolean;
|
||||
touchedCount: number;
|
||||
}
|
||||
|
||||
export interface UseInviteMembersOptions {
|
||||
initialRowCount?: number;
|
||||
onSuccess?: (results: InviteResult[], rows: InviteMemberRow[]) => void;
|
||||
onPartialSuccess?: (results: InviteResult[], rows: InviteMemberRow[]) => void;
|
||||
onAllFailed?: (results: InviteResult[], rows: InviteMemberRow[]) => void;
|
||||
}
|
||||
|
||||
export interface UseInviteMembersReturn {
|
||||
rows: InviteMemberRow[];
|
||||
emailValidity: Record<string, boolean>;
|
||||
hasInvalidEmails: boolean;
|
||||
hasInvalidRoles: boolean;
|
||||
isSubmitting: boolean;
|
||||
inviteResults: InviteResult[] | null;
|
||||
|
||||
addRow: () => void;
|
||||
removeRow: (id: string) => void;
|
||||
updateEmail: (id: string, email: string) => void;
|
||||
updateRole: (id: string, roleId: string | undefined) => void;
|
||||
reset: () => void;
|
||||
submit: () => Promise<InviteResult[]>;
|
||||
|
||||
touchedRows: InviteMemberRow[];
|
||||
failedResults: InviteResult[];
|
||||
successResults: InviteResult[];
|
||||
canSubmit: boolean;
|
||||
}
|
||||
|
||||
export interface InviteMembersProps {
|
||||
className?: string;
|
||||
initialRowCount?: number;
|
||||
minRows?: number;
|
||||
emailPlaceholder?: string;
|
||||
showHeader?: boolean;
|
||||
showAddButton?: boolean;
|
||||
|
||||
onSuccess?: (results: InviteResult[], rows: InviteMemberRow[]) => void;
|
||||
onPartialSuccess?: (results: InviteResult[], rows: InviteMemberRow[]) => void;
|
||||
onAllFailed?: (results: InviteResult[], rows: InviteMemberRow[]) => void;
|
||||
|
||||
renderFooter?: (props: FooterRenderProps) => ReactNode;
|
||||
}
|
||||
245
frontend/src/components/InviteMembers/useInviteMembers.ts
Normal file
@@ -0,0 +1,245 @@
|
||||
import { useCallback, useMemo, useState } from 'react';
|
||||
import { AxiosError } from 'axios';
|
||||
import { convertToApiError } from 'api/ErrorResponseHandlerForGeneratedAPIs';
|
||||
import { RenderErrorResponseDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { createUser } from 'api/generated/services/users';
|
||||
import { cloneDeep, debounce } from 'lodash-es';
|
||||
import { EMAIL_REGEX } from 'utils/app';
|
||||
import { getBaseUrl } from 'utils/basePath';
|
||||
import { v4 as uuid } from 'uuid';
|
||||
|
||||
import {
|
||||
InviteMemberRow,
|
||||
InviteResult,
|
||||
UseInviteMembersOptions,
|
||||
UseInviteMembersReturn,
|
||||
} from './types';
|
||||
|
||||
const createEmptyRow = (): InviteMemberRow => ({
|
||||
id: uuid(),
|
||||
email: '',
|
||||
roleId: '',
|
||||
});
|
||||
|
||||
const isRowTouched = (row: InviteMemberRow): boolean =>
|
||||
row.email.trim() !== '' || row.roleId !== '';
|
||||
|
||||
export function useInviteMembers(
|
||||
options: UseInviteMembersOptions = {},
|
||||
): UseInviteMembersReturn {
|
||||
const {
|
||||
initialRowCount = 3,
|
||||
onSuccess,
|
||||
onPartialSuccess,
|
||||
onAllFailed,
|
||||
} = options;
|
||||
|
||||
const [rows, setRows] = useState<InviteMemberRow[]>(() =>
|
||||
Array.from({ length: initialRowCount }, () => createEmptyRow()),
|
||||
);
|
||||
const [emailValidity, setEmailValidity] = useState<Record<string, boolean>>(
|
||||
{},
|
||||
);
|
||||
const [hasInvalidEmails, setHasInvalidEmails] = useState(false);
|
||||
const [hasInvalidRoles, setHasInvalidRoles] = useState(false);
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [inviteResults, setInviteResults] = useState<InviteResult[] | null>(
|
||||
null,
|
||||
);
|
||||
|
||||
const touchedRows = useMemo(() => rows.filter(isRowTouched), [rows]);
|
||||
|
||||
const failedResults = useMemo(
|
||||
() => inviteResults?.filter((r) => !r.success) ?? [],
|
||||
[inviteResults],
|
||||
);
|
||||
|
||||
const successResults = useMemo(
|
||||
() => inviteResults?.filter((r) => r.success) ?? [],
|
||||
[inviteResults],
|
||||
);
|
||||
|
||||
const debouncedValidateEmail = useMemo(
|
||||
() =>
|
||||
debounce((email: string, rowId: string) => {
|
||||
const isValid = EMAIL_REGEX.test(email);
|
||||
setEmailValidity((prev) => ({ ...prev, [rowId]: isValid }));
|
||||
}, 500),
|
||||
[],
|
||||
);
|
||||
|
||||
const validateAllRows = useCallback((): boolean => {
|
||||
let isValid = true;
|
||||
let hasEmailErrors = false;
|
||||
let hasRoleErrors = false;
|
||||
const updatedEmailValidity: Record<string, boolean> = {};
|
||||
|
||||
const touched = rows.filter(isRowTouched);
|
||||
|
||||
touched.forEach((row) => {
|
||||
const emailValid = EMAIL_REGEX.test(row.email);
|
||||
const roleValid = row.roleId !== '';
|
||||
|
||||
if (!emailValid || !row.email) {
|
||||
isValid = false;
|
||||
hasEmailErrors = true;
|
||||
}
|
||||
if (!roleValid) {
|
||||
isValid = false;
|
||||
hasRoleErrors = true;
|
||||
}
|
||||
|
||||
updatedEmailValidity[row.id] = emailValid;
|
||||
});
|
||||
|
||||
setEmailValidity(updatedEmailValidity);
|
||||
setHasInvalidEmails(hasEmailErrors);
|
||||
setHasInvalidRoles(hasRoleErrors);
|
||||
|
||||
return isValid;
|
||||
}, [rows]);
|
||||
|
||||
const addRow = useCallback((): void => {
|
||||
setRows((prev) => [...prev, createEmptyRow()]);
|
||||
}, []);
|
||||
|
||||
const removeRow = useCallback((id: string): void => {
|
||||
setRows((prev) => prev.filter((r) => r.id !== id));
|
||||
setEmailValidity((prev) => {
|
||||
const updated = { ...prev };
|
||||
delete updated[id];
|
||||
return updated;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const updateEmail = useCallback(
|
||||
(id: string, email: string): void => {
|
||||
setRows((prev) => {
|
||||
const updated = cloneDeep(prev);
|
||||
const row = updated.find((r) => r.id === id);
|
||||
if (row) {
|
||||
row.email = email;
|
||||
}
|
||||
return updated;
|
||||
});
|
||||
|
||||
if (hasInvalidEmails) {
|
||||
setHasInvalidEmails(false);
|
||||
}
|
||||
if (emailValidity[id] === false) {
|
||||
setEmailValidity((prev) => ({ ...prev, [id]: true }));
|
||||
}
|
||||
if (inviteResults) {
|
||||
setInviteResults(null);
|
||||
}
|
||||
|
||||
debouncedValidateEmail(email, id);
|
||||
},
|
||||
[hasInvalidEmails, emailValidity, inviteResults, debouncedValidateEmail],
|
||||
);
|
||||
|
||||
const updateRole = useCallback(
|
||||
(id: string, roleId: string | undefined): void => {
|
||||
setRows((prev) => {
|
||||
const updated = cloneDeep(prev);
|
||||
const row = updated.find((r) => r.id === id);
|
||||
if (row) {
|
||||
row.roleId = roleId ?? '';
|
||||
}
|
||||
return updated;
|
||||
});
|
||||
|
||||
if (hasInvalidRoles) {
|
||||
setHasInvalidRoles(false);
|
||||
}
|
||||
if (inviteResults) {
|
||||
setInviteResults(null);
|
||||
}
|
||||
},
|
||||
[hasInvalidRoles, inviteResults],
|
||||
);
|
||||
|
||||
const reset = useCallback((): void => {
|
||||
setRows(Array.from({ length: initialRowCount }, () => createEmptyRow()));
|
||||
setEmailValidity({});
|
||||
setHasInvalidEmails(false);
|
||||
setHasInvalidRoles(false);
|
||||
setInviteResults(null);
|
||||
}, [initialRowCount]);
|
||||
|
||||
const submit = useCallback(async (): Promise<InviteResult[]> => {
|
||||
if (!validateAllRows()) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const touched = rows.filter(isRowTouched);
|
||||
if (touched.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
setIsSubmitting(true);
|
||||
setInviteResults(null);
|
||||
|
||||
const results: InviteResult[] = [];
|
||||
|
||||
for (const row of touched) {
|
||||
try {
|
||||
await createUser({
|
||||
email: row.email.trim(),
|
||||
frontendBaseUrl: getBaseUrl(),
|
||||
userRoles: [{ id: row.roleId }],
|
||||
});
|
||||
results.push({ email: row.email, success: true });
|
||||
} catch (err) {
|
||||
const apiErr = convertToApiError(err as AxiosError<RenderErrorResponseDTO>);
|
||||
results.push({
|
||||
email: row.email,
|
||||
success: false,
|
||||
error: apiErr?.getErrorMessage() ?? 'Unknown error',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
setInviteResults(results);
|
||||
setIsSubmitting(false);
|
||||
|
||||
const failures = results.filter((r) => !r.success);
|
||||
const successes = results.filter((r) => r.success);
|
||||
|
||||
if (failures.length === 0) {
|
||||
onSuccess?.(results, touched);
|
||||
} else if (successes.length > 0) {
|
||||
onPartialSuccess?.(results, touched);
|
||||
} else {
|
||||
onAllFailed?.(results, touched);
|
||||
}
|
||||
|
||||
return results;
|
||||
}, [validateAllRows, rows, onSuccess, onPartialSuccess, onAllFailed]);
|
||||
|
||||
const canSubmit = useMemo(
|
||||
() => !isSubmitting && touchedRows.length > 0,
|
||||
[isSubmitting, touchedRows.length],
|
||||
);
|
||||
|
||||
return {
|
||||
rows,
|
||||
emailValidity,
|
||||
hasInvalidEmails,
|
||||
hasInvalidRoles,
|
||||
isSubmitting,
|
||||
inviteResults,
|
||||
|
||||
addRow,
|
||||
removeRow,
|
||||
updateEmail,
|
||||
updateRole,
|
||||
reset,
|
||||
submit,
|
||||
|
||||
touchedRows,
|
||||
failedResults,
|
||||
successResults,
|
||||
canSubmit,
|
||||
};
|
||||
}
|
||||
@@ -1,254 +0,0 @@
|
||||
.invite-members-modal {
|
||||
max-width: 700px;
|
||||
background: var(--popover);
|
||||
border: 1px solid var(--secondary);
|
||||
border-radius: 4px;
|
||||
box-shadow: 0 4px 9px 0 rgba(0, 0, 0, 0.04);
|
||||
|
||||
[data-slot='dialog-header'] {
|
||||
padding: var(--padding-4);
|
||||
border-bottom: 1px solid var(--secondary);
|
||||
flex-shrink: 0;
|
||||
background: transparent;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
[data-slot='dialog-title'] {
|
||||
font-family: Inter, sans-serif;
|
||||
font-size: var(--label-base-400-font-size);
|
||||
font-weight: var(--label-base-400-font-weight);
|
||||
line-height: var(--label-base-400-line-height);
|
||||
letter-spacing: -0.065px;
|
||||
color: var(--l1-foreground);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
[data-slot='dialog-description'] {
|
||||
padding: 0;
|
||||
|
||||
.invite-members-modal__content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-8);
|
||||
padding: var(--padding-4);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.invite-members-modal__table {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-4);
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.invite-members-modal__table-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-8);
|
||||
width: 100%;
|
||||
|
||||
.email-header {
|
||||
flex: 0 0 240px;
|
||||
}
|
||||
|
||||
.role-header {
|
||||
flex: 1 0 0;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.action-header {
|
||||
flex: 0 0 32px;
|
||||
}
|
||||
|
||||
.table-header-cell {
|
||||
font-family: Inter, sans-serif;
|
||||
font-size: var(--paragraph-base-400-font-size);
|
||||
font-weight: var(--paragraph-base-400-font-weight);
|
||||
line-height: var(--paragraph-base-400-line-height);
|
||||
letter-spacing: -0.07px;
|
||||
color: var(--foreground);
|
||||
}
|
||||
}
|
||||
|
||||
.invite-members-modal__container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-8);
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.team-member-row {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: var(--spacing-8);
|
||||
width: 100%;
|
||||
|
||||
> .email-cell {
|
||||
flex: 0 0 240px;
|
||||
}
|
||||
|
||||
> .role-cell {
|
||||
flex: 1 0 0;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
> .action-cell {
|
||||
flex: 0 0 32px;
|
||||
}
|
||||
}
|
||||
|
||||
.team-member-cell {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-2);
|
||||
|
||||
&.action-cell {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 32px;
|
||||
}
|
||||
}
|
||||
|
||||
.team-member-email-input {
|
||||
width: 100%;
|
||||
height: 32px;
|
||||
color: var(--l1-foreground);
|
||||
background-color: var(--l2-background);
|
||||
border-color: var(--l1-border);
|
||||
font-size: var(--paragraph-base-400-font-size);
|
||||
|
||||
&::placeholder {
|
||||
color: var(--l3-foreground);
|
||||
}
|
||||
|
||||
&:focus {
|
||||
border-color: var(--primary);
|
||||
box-shadow: none;
|
||||
}
|
||||
}
|
||||
.team-member-role-select {
|
||||
width: 100%;
|
||||
|
||||
.ant-select-selector {
|
||||
height: 32px;
|
||||
border-radius: 2px;
|
||||
background-color: var(--l2-background) !important;
|
||||
border: 1px solid var(--border) !important;
|
||||
padding: 0 var(--padding-2) !important;
|
||||
|
||||
.ant-select-selection-placeholder {
|
||||
color: var(--l3-foreground);
|
||||
opacity: 0.4;
|
||||
font-size: var(--paragraph-base-400-font-size);
|
||||
letter-spacing: -0.07px;
|
||||
line-height: 32px;
|
||||
}
|
||||
|
||||
.ant-select-selection-item {
|
||||
font-size: var(--paragraph-base-400-font-size);
|
||||
letter-spacing: -0.07px;
|
||||
color: var(--l1-foreground);
|
||||
line-height: 32px;
|
||||
}
|
||||
}
|
||||
|
||||
.ant-select-arrow {
|
||||
color: var(--foreground);
|
||||
}
|
||||
|
||||
&.ant-select-focused .ant-select-selector,
|
||||
&:not(.ant-select-disabled):hover .ant-select-selector {
|
||||
border-color: var(--primary);
|
||||
}
|
||||
}
|
||||
|
||||
.remove-team-member-button {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
min-width: 32px;
|
||||
border: none;
|
||||
border-radius: 2px;
|
||||
background: transparent;
|
||||
color: var(--destructive);
|
||||
opacity: 0.6;
|
||||
padding: 0;
|
||||
transition:
|
||||
background-color 0.2s,
|
||||
opacity 0.2s;
|
||||
box-shadow: none;
|
||||
|
||||
&:hover {
|
||||
background: color-mix(in srgb, var(--danger-background) 10%, transparent);
|
||||
opacity: 0.9;
|
||||
}
|
||||
}
|
||||
|
||||
.email-error-message {
|
||||
display: block;
|
||||
font-family: Inter, sans-serif;
|
||||
font-size: var(--font-size-xs);
|
||||
font-weight: var(--font-weight-normal);
|
||||
line-height: var(--line-height-18);
|
||||
color: var(--destructive);
|
||||
}
|
||||
|
||||
.invite-team-members-error-callout {
|
||||
background: color-mix(in srgb, var(--danger-background) 10%, transparent);
|
||||
border: 1px solid color-mix(in srgb, var(--danger-background) 20%, transparent);
|
||||
border-radius: 4px;
|
||||
animation: horizontal-shaking 300ms ease-out;
|
||||
}
|
||||
|
||||
.invite-members-modal__error-callout {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
@keyframes horizontal-shaking {
|
||||
0% {
|
||||
transform: translateX(0);
|
||||
}
|
||||
25% {
|
||||
transform: translateX(5px);
|
||||
}
|
||||
50% {
|
||||
transform: translateX(-5px);
|
||||
}
|
||||
75% {
|
||||
transform: translateX(5px);
|
||||
}
|
||||
100% {
|
||||
transform: translateX(0);
|
||||
}
|
||||
}
|
||||
|
||||
.invite-members-modal__footer {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0 var(--padding-4);
|
||||
height: 56px;
|
||||
min-height: 56px;
|
||||
border-top: 1px solid var(--secondary);
|
||||
gap: 0;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.invite-members-modal__footer-right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-6);
|
||||
}
|
||||
|
||||
.add-another-member-button {
|
||||
&:hover {
|
||||
border-color: var(--primary);
|
||||
border-style: dashed;
|
||||
color: var(--l1-foreground);
|
||||
}
|
||||
}
|
||||
@@ -1,337 +0,0 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { Style } from '@signozhq/design-tokens';
|
||||
import { ChevronDown, Plus, Trash2, X } from '@signozhq/icons';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import { Callout } from '@signozhq/ui/callout';
|
||||
import { DialogFooter, DialogWrapper } from '@signozhq/ui/dialog';
|
||||
import { Input } from '@signozhq/ui/input';
|
||||
import { toast } from '@signozhq/ui/sonner';
|
||||
import { Select } from 'antd';
|
||||
import inviteUsers from 'api/v1/invite/bulk/create';
|
||||
import sendInvite from 'api/v1/invite/create';
|
||||
import { cloneDeep, debounce } from 'lodash-es';
|
||||
import { useErrorModal } from 'providers/ErrorModalProvider';
|
||||
import APIError from 'types/api/error';
|
||||
import { ROLES } from 'types/roles';
|
||||
import { EMAIL_REGEX } from 'utils/app';
|
||||
import { getBaseUrl } from 'utils/basePath';
|
||||
import { popupContainer } from 'utils/selectPopupContainer';
|
||||
import { v4 as uuid } from 'uuid';
|
||||
|
||||
import './InviteMembersModal.styles.scss';
|
||||
|
||||
interface InviteRow {
|
||||
id: string;
|
||||
email: string;
|
||||
role: ROLES | '';
|
||||
}
|
||||
|
||||
export interface InviteMembersModalProps {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
onComplete?: () => void;
|
||||
}
|
||||
|
||||
const EMPTY_ROW = (): InviteRow => ({ id: uuid(), email: '', role: '' });
|
||||
|
||||
const isRowTouched = (row: InviteRow): boolean =>
|
||||
row.email.trim() !== '' || Boolean(row.role && row.role.trim() !== '');
|
||||
|
||||
function InviteMembersModal({
|
||||
open,
|
||||
onClose,
|
||||
onComplete,
|
||||
}: InviteMembersModalProps): JSX.Element {
|
||||
const { showErrorModal, isErrorModalVisible } = useErrorModal();
|
||||
|
||||
const [rows, setRows] = useState<InviteRow[]>(() => [
|
||||
EMPTY_ROW(),
|
||||
EMPTY_ROW(),
|
||||
EMPTY_ROW(),
|
||||
]);
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [emailValidity, setEmailValidity] = useState<Record<string, boolean>>(
|
||||
{},
|
||||
);
|
||||
const [hasInvalidEmails, setHasInvalidEmails] = useState<boolean>(false);
|
||||
const [hasInvalidRoles, setHasInvalidRoles] = useState<boolean>(false);
|
||||
|
||||
const resetAndClose = useCallback((): void => {
|
||||
setRows([EMPTY_ROW(), EMPTY_ROW(), EMPTY_ROW()]);
|
||||
setEmailValidity({});
|
||||
setHasInvalidEmails(false);
|
||||
setHasInvalidRoles(false);
|
||||
onClose();
|
||||
}, [onClose]);
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setRows([EMPTY_ROW(), EMPTY_ROW(), EMPTY_ROW()]);
|
||||
}
|
||||
}, [open]);
|
||||
|
||||
const getValidationErrorMessage = (): string => {
|
||||
if (hasInvalidEmails && hasInvalidRoles) {
|
||||
return 'Please enter valid emails and select roles for team members';
|
||||
}
|
||||
if (hasInvalidEmails) {
|
||||
return 'Please enter valid emails for team members';
|
||||
}
|
||||
return 'Please select roles for team members';
|
||||
};
|
||||
|
||||
const validateAllUsers = useCallback((): boolean => {
|
||||
let isValid = true;
|
||||
let hasEmailErrors = false;
|
||||
let hasRoleErrors = false;
|
||||
|
||||
const updatedEmailValidity: Record<string, boolean> = {};
|
||||
|
||||
const touchedRows = rows.filter(isRowTouched);
|
||||
|
||||
touchedRows.forEach((row) => {
|
||||
const emailValid = EMAIL_REGEX.test(row.email);
|
||||
const roleValid = Boolean(row.role && row.role.trim() !== '');
|
||||
|
||||
if (!emailValid || !row.email) {
|
||||
isValid = false;
|
||||
hasEmailErrors = true;
|
||||
}
|
||||
if (!roleValid) {
|
||||
isValid = false;
|
||||
hasRoleErrors = true;
|
||||
}
|
||||
|
||||
if (row.id) {
|
||||
updatedEmailValidity[row.id] = emailValid;
|
||||
}
|
||||
});
|
||||
|
||||
setEmailValidity(updatedEmailValidity);
|
||||
setHasInvalidEmails(hasEmailErrors);
|
||||
setHasInvalidRoles(hasRoleErrors);
|
||||
|
||||
return isValid;
|
||||
}, [rows]);
|
||||
|
||||
const debouncedValidateEmail = useMemo(
|
||||
() =>
|
||||
debounce((email: string, rowId: string) => {
|
||||
const isValid = EMAIL_REGEX.test(email);
|
||||
setEmailValidity((prev) => ({ ...prev, [rowId]: isValid }));
|
||||
}, 500),
|
||||
[],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
debouncedValidateEmail.cancel();
|
||||
}
|
||||
return (): void => {
|
||||
debouncedValidateEmail.cancel();
|
||||
};
|
||||
}, [open, debouncedValidateEmail]);
|
||||
|
||||
const updateEmail = (id: string, email: string): void => {
|
||||
const updatedRows = cloneDeep(rows);
|
||||
const rowToUpdate = updatedRows.find((r) => r.id === id);
|
||||
if (rowToUpdate) {
|
||||
rowToUpdate.email = email;
|
||||
setRows(updatedRows);
|
||||
|
||||
if (hasInvalidEmails) {
|
||||
setHasInvalidEmails(false);
|
||||
}
|
||||
if (emailValidity[id] === false) {
|
||||
setEmailValidity((prev) => ({ ...prev, [id]: true }));
|
||||
}
|
||||
|
||||
debouncedValidateEmail(email, id);
|
||||
}
|
||||
};
|
||||
|
||||
const updateRole = (id: string, role: ROLES): void => {
|
||||
const updatedRows = cloneDeep(rows);
|
||||
const rowToUpdate = updatedRows.find((r) => r.id === id);
|
||||
if (rowToUpdate) {
|
||||
rowToUpdate.role = role;
|
||||
setRows(updatedRows);
|
||||
|
||||
if (hasInvalidRoles) {
|
||||
setHasInvalidRoles(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const addRow = (): void => {
|
||||
setRows((prev) => [...prev, EMPTY_ROW()]);
|
||||
};
|
||||
|
||||
const removeRow = (id: string): void => {
|
||||
setRows((prev) => prev.filter((r) => r.id !== id));
|
||||
};
|
||||
|
||||
const handleSubmit = useCallback(async (): Promise<void> => {
|
||||
if (!validateAllUsers()) {
|
||||
return;
|
||||
}
|
||||
|
||||
const touchedRows = rows.filter(isRowTouched);
|
||||
if (touchedRows.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
setIsSubmitting(true);
|
||||
try {
|
||||
if (touchedRows.length === 1) {
|
||||
const row = touchedRows[0];
|
||||
await sendInvite({
|
||||
email: row.email.trim(),
|
||||
name: '',
|
||||
role: row.role as ROLES,
|
||||
frontendBaseUrl: getBaseUrl(),
|
||||
});
|
||||
} else {
|
||||
await inviteUsers({
|
||||
invites: touchedRows.map((row) => ({
|
||||
email: row.email.trim(),
|
||||
name: '',
|
||||
role: row.role,
|
||||
frontendBaseUrl: getBaseUrl(),
|
||||
})),
|
||||
});
|
||||
}
|
||||
toast.success('Invites sent successfully', { position: 'top-right' });
|
||||
resetAndClose();
|
||||
onComplete?.();
|
||||
} catch (err) {
|
||||
showErrorModal(err as APIError);
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
}, [validateAllUsers, rows, resetAndClose, onComplete, showErrorModal]);
|
||||
|
||||
const touchedRows = rows.filter(isRowTouched);
|
||||
const isSubmitDisabled = isSubmitting || touchedRows.length === 0;
|
||||
|
||||
return (
|
||||
<DialogWrapper
|
||||
title="Invite Team Members"
|
||||
open={open}
|
||||
onOpenChange={(isOpen): void => {
|
||||
if (!isOpen) {
|
||||
resetAndClose();
|
||||
}
|
||||
}}
|
||||
showCloseButton
|
||||
width="wide"
|
||||
className="invite-members-modal"
|
||||
disableOutsideClick={isErrorModalVisible}
|
||||
>
|
||||
<div className="invite-members-modal__content">
|
||||
<div className="invite-members-modal__table">
|
||||
<div className="invite-members-modal__table-header">
|
||||
<div className="table-header-cell email-header">Email address</div>
|
||||
<div className="table-header-cell role-header">Roles</div>
|
||||
<div className="table-header-cell action-header" />
|
||||
</div>
|
||||
<div className="invite-members-modal__container">
|
||||
{rows.map(
|
||||
(row): JSX.Element => (
|
||||
<div key={row.id} className="team-member-row">
|
||||
<div className="team-member-cell email-cell">
|
||||
<Input
|
||||
type="email"
|
||||
placeholder="john@signoz.io"
|
||||
value={row.email}
|
||||
onChange={(e): void => updateEmail(row.id, e.target.value)}
|
||||
className="team-member-email-input"
|
||||
name={`invite-email-${row.id}`}
|
||||
autoComplete="email"
|
||||
/>
|
||||
{emailValidity[row.id] === false && row.email.trim() !== '' && (
|
||||
<span className="email-error-message">Invalid email address</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="team-member-cell role-cell">
|
||||
<Select
|
||||
value={row.role || undefined}
|
||||
onChange={(role): void => updateRole(row.id, role as ROLES)}
|
||||
className="team-member-role-select"
|
||||
placeholder="Select roles"
|
||||
suffixIcon={<ChevronDown size={14} />}
|
||||
getPopupContainer={popupContainer}
|
||||
>
|
||||
<Select.Option value="VIEWER">Viewer</Select.Option>
|
||||
<Select.Option value="EDITOR">Editor</Select.Option>
|
||||
<Select.Option value="ADMIN">Admin</Select.Option>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="team-member-cell action-cell">
|
||||
{rows.length > 1 && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
color="destructive"
|
||||
onClick={(): void => removeRow(row.id)}
|
||||
aria-label="Remove row"
|
||||
>
|
||||
<Trash2 size={12} />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{(hasInvalidEmails || hasInvalidRoles) && (
|
||||
<div className="invite-members-modal__error-callout">
|
||||
<Callout
|
||||
type="error"
|
||||
size="small"
|
||||
showIcon
|
||||
title={getValidationErrorMessage()}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<DialogFooter className="invite-members-modal__footer">
|
||||
<Button
|
||||
variant="dashed"
|
||||
color="secondary"
|
||||
className="add-another-member-button"
|
||||
prefix={<Plus size={12} color={Style.L1_FOREGROUND} />}
|
||||
onClick={addRow}
|
||||
>
|
||||
Add another
|
||||
</Button>
|
||||
|
||||
<div className="invite-members-modal__footer-right">
|
||||
<Button
|
||||
type="button"
|
||||
variant="solid"
|
||||
color="secondary"
|
||||
onClick={resetAndClose}
|
||||
>
|
||||
<X size={12} />
|
||||
Cancel
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant="solid"
|
||||
color="primary"
|
||||
onClick={handleSubmit}
|
||||
disabled={isSubmitDisabled}
|
||||
loading={isSubmitting}
|
||||
>
|
||||
{isSubmitting ? 'Inviting...' : 'Invite Team Members'}
|
||||
</Button>
|
||||
</div>
|
||||
</DialogFooter>
|
||||
</DialogWrapper>
|
||||
);
|
||||
}
|
||||
|
||||
export default InviteMembersModal;
|
||||
@@ -1,276 +0,0 @@
|
||||
import inviteUsers from 'api/v1/invite/bulk/create';
|
||||
import sendInvite from 'api/v1/invite/create';
|
||||
import { StatusCodes } from 'http-status-codes';
|
||||
import { render, screen, userEvent, waitFor } from 'tests/test-utils';
|
||||
import APIError from 'types/api/error';
|
||||
|
||||
import InviteMembersModal from '../InviteMembersModal';
|
||||
|
||||
const makeApiError = (message: string, code = StatusCodes.CONFLICT): APIError =>
|
||||
new APIError({
|
||||
httpStatusCode: code,
|
||||
error: { code: 'already_exists', message, url: '', errors: [] },
|
||||
});
|
||||
|
||||
jest.mock('api/v1/invite/create');
|
||||
jest.mock('api/v1/invite/bulk/create');
|
||||
jest.mock('@signozhq/ui/sonner', () => ({
|
||||
...jest.requireActual('@signozhq/ui/sonner'),
|
||||
toast: {
|
||||
success: jest.fn(),
|
||||
error: jest.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
const showErrorModal = jest.fn();
|
||||
jest.mock('providers/ErrorModalProvider', () => ({
|
||||
__esModule: true,
|
||||
...jest.requireActual('providers/ErrorModalProvider'),
|
||||
useErrorModal: jest.fn(() => ({
|
||||
showErrorModal,
|
||||
isErrorModalVisible: false,
|
||||
})),
|
||||
}));
|
||||
|
||||
const mockSendInvite = jest.mocked(sendInvite);
|
||||
const mockInviteUsers = jest.mocked(inviteUsers);
|
||||
|
||||
const defaultProps = {
|
||||
open: true,
|
||||
onClose: jest.fn(),
|
||||
onComplete: jest.fn(),
|
||||
};
|
||||
|
||||
describe('InviteMembersModal', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
showErrorModal.mockClear();
|
||||
mockSendInvite.mockResolvedValue({
|
||||
httpStatusCode: 200,
|
||||
data: { data: 'test', status: 'success' },
|
||||
});
|
||||
mockInviteUsers.mockResolvedValue({ httpStatusCode: 200, data: null });
|
||||
});
|
||||
|
||||
it('renders 3 initial empty rows and disables the submit button', () => {
|
||||
render(<InviteMembersModal {...defaultProps} />);
|
||||
|
||||
const emailInputs = screen.getAllByPlaceholderText('john@signoz.io');
|
||||
expect(emailInputs).toHaveLength(3);
|
||||
|
||||
expect(
|
||||
screen.getByRole('button', { name: /invite team members/i }),
|
||||
).toBeDisabled();
|
||||
});
|
||||
|
||||
it('adds a row when "Add another" is clicked and removes a row via trash button', async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
|
||||
render(<InviteMembersModal {...defaultProps} />);
|
||||
|
||||
await user.click(screen.getByRole('button', { name: /add another/i }));
|
||||
expect(screen.getAllByPlaceholderText('john@signoz.io')).toHaveLength(4);
|
||||
|
||||
const removeButtons = screen.getAllByRole('button', { name: /remove row/i });
|
||||
await user.click(removeButtons[0]);
|
||||
expect(screen.getAllByPlaceholderText('john@signoz.io')).toHaveLength(3);
|
||||
});
|
||||
|
||||
describe('validation callout messages', () => {
|
||||
it('shows combined message when email is invalid and role is missing', async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
|
||||
render(<InviteMembersModal {...defaultProps} />);
|
||||
|
||||
await user.type(
|
||||
screen.getAllByPlaceholderText('john@signoz.io')[0],
|
||||
'not-an-email',
|
||||
);
|
||||
await user.click(
|
||||
screen.getByRole('button', { name: /invite team members/i }),
|
||||
);
|
||||
|
||||
await expect(
|
||||
screen.findByText(
|
||||
'Please enter valid emails and select roles for team members',
|
||||
),
|
||||
).resolves.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows email-only message when email is invalid but role is selected', async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
|
||||
render(<InviteMembersModal {...defaultProps} />);
|
||||
|
||||
const emailInputs = screen.getAllByPlaceholderText('john@signoz.io');
|
||||
await user.type(emailInputs[0], 'not-an-email');
|
||||
|
||||
await user.click(screen.getAllByText('Select roles')[0]);
|
||||
await user.click(await screen.findByText('Viewer'));
|
||||
|
||||
await user.click(
|
||||
screen.getByRole('button', { name: /invite team members/i }),
|
||||
);
|
||||
|
||||
await expect(
|
||||
screen.findByText('Please enter valid emails for team members'),
|
||||
).resolves.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows role-only message when email is valid but role is missing', async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
|
||||
render(<InviteMembersModal {...defaultProps} />);
|
||||
|
||||
await user.type(
|
||||
screen.getAllByPlaceholderText('john@signoz.io')[0],
|
||||
'valid@signoz.io',
|
||||
);
|
||||
await user.click(
|
||||
screen.getByRole('button', { name: /invite team members/i }),
|
||||
);
|
||||
|
||||
await expect(
|
||||
screen.findByText('Please select roles for team members'),
|
||||
).resolves.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('uses sendInvite (single) when only one row is filled', async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
const onComplete = jest.fn();
|
||||
|
||||
render(<InviteMembersModal {...defaultProps} onComplete={onComplete} />);
|
||||
|
||||
const emailInputs = screen.getAllByPlaceholderText('john@signoz.io');
|
||||
await user.type(emailInputs[0], 'single@signoz.io');
|
||||
|
||||
const roleSelects = screen.getAllByText('Select roles');
|
||||
await user.click(roleSelects[0]);
|
||||
await user.click(await screen.findByText('Viewer'));
|
||||
|
||||
await user.click(
|
||||
screen.getByRole('button', { name: /invite team members/i }),
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockSendInvite).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ email: 'single@signoz.io', role: 'VIEWER' }),
|
||||
);
|
||||
expect(mockInviteUsers).not.toHaveBeenCalled();
|
||||
expect(onComplete).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('error handling', () => {
|
||||
it('shows BE message on single invite 409', async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
const error = makeApiError(
|
||||
'An invite already exists for this email: single@signoz.io',
|
||||
);
|
||||
mockSendInvite.mockRejectedValue(error);
|
||||
|
||||
render(<InviteMembersModal {...defaultProps} />);
|
||||
|
||||
await user.type(
|
||||
screen.getAllByPlaceholderText('john@signoz.io')[0],
|
||||
'single@signoz.io',
|
||||
);
|
||||
await user.click(screen.getAllByText('Select roles')[0]);
|
||||
await user.click(await screen.findByText('Viewer'));
|
||||
await user.click(
|
||||
screen.getByRole('button', { name: /invite team members/i }),
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(showErrorModal).toHaveBeenCalledWith(error);
|
||||
});
|
||||
});
|
||||
|
||||
it('shows BE message on bulk invite 409', async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
const error = makeApiError(
|
||||
'An invite already exists for this email: alice@signoz.io',
|
||||
);
|
||||
mockInviteUsers.mockRejectedValue(error);
|
||||
|
||||
render(<InviteMembersModal {...defaultProps} />);
|
||||
|
||||
const emailInputs = screen.getAllByPlaceholderText('john@signoz.io');
|
||||
await user.type(emailInputs[0], 'alice@signoz.io');
|
||||
await user.click(screen.getAllByText('Select roles')[0]);
|
||||
await user.click(await screen.findByText('Viewer'));
|
||||
|
||||
await user.type(emailInputs[1], 'bob@signoz.io');
|
||||
await user.click(screen.getAllByText('Select roles')[0]);
|
||||
const editorOptions = await screen.findAllByText('Editor');
|
||||
await user.click(editorOptions[editorOptions.length - 1]);
|
||||
|
||||
await user.click(
|
||||
screen.getByRole('button', { name: /invite team members/i }),
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(showErrorModal).toHaveBeenCalledWith(error);
|
||||
});
|
||||
});
|
||||
|
||||
it('shows BE message on generic error', async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
const error = makeApiError(
|
||||
'Internal server error',
|
||||
StatusCodes.INTERNAL_SERVER_ERROR,
|
||||
);
|
||||
mockSendInvite.mockRejectedValue(error);
|
||||
|
||||
render(<InviteMembersModal {...defaultProps} />);
|
||||
|
||||
await user.type(
|
||||
screen.getAllByPlaceholderText('john@signoz.io')[0],
|
||||
'single@signoz.io',
|
||||
);
|
||||
await user.click(screen.getAllByText('Select roles')[0]);
|
||||
await user.click(await screen.findByText('Viewer'));
|
||||
await user.click(
|
||||
screen.getByRole('button', { name: /invite team members/i }),
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(showErrorModal).toHaveBeenCalledWith(error);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it('uses inviteUsers (bulk) when multiple rows are filled', async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
const onComplete = jest.fn();
|
||||
|
||||
render(<InviteMembersModal {...defaultProps} onComplete={onComplete} />);
|
||||
|
||||
const emailInputs = screen.getAllByPlaceholderText('john@signoz.io');
|
||||
|
||||
await user.type(emailInputs[0], 'alice@signoz.io');
|
||||
await user.click(screen.getAllByText('Select roles')[0]);
|
||||
await user.click(await screen.findByText('Viewer'));
|
||||
|
||||
await user.type(emailInputs[1], 'bob@signoz.io');
|
||||
await user.click(screen.getAllByText('Select roles')[0]);
|
||||
const editorOptions = await screen.findAllByText('Editor');
|
||||
await user.click(editorOptions[editorOptions.length - 1]);
|
||||
|
||||
await user.click(
|
||||
screen.getByRole('button', { name: /invite team members/i }),
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockInviteUsers).toHaveBeenCalledWith({
|
||||
invites: expect.arrayContaining([
|
||||
expect.objectContaining({ email: 'alice@signoz.io', role: 'VIEWER' }),
|
||||
expect.objectContaining({ email: 'bob@signoz.io', role: 'EDITOR' }),
|
||||
]),
|
||||
});
|
||||
expect(mockSendInvite).not.toHaveBeenCalled();
|
||||
expect(onComplete).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -62,13 +62,13 @@ function ErrorTitleAndKey({
|
||||
|
||||
switch (parentTitle) {
|
||||
case 'Consumers':
|
||||
link = `${ROUTES.GET_STARTED_APPLICATION_MONITORING}?${QueryParams.getStartedSource}=kafka&${QueryParams.getStartedSourceService}=${MessagingQueueHealthCheckService.Consumers}`;
|
||||
link = `${ROUTES.GET_STARTED_WITH_CLOUD}?${QueryParams.getStartedSource}=self-hosted-kafka&${QueryParams.getStartedSourceService}=${MessagingQueueHealthCheckService.Consumers}`;
|
||||
break;
|
||||
case 'Producers':
|
||||
link = `${ROUTES.GET_STARTED_APPLICATION_MONITORING}?${QueryParams.getStartedSource}=kafka&${QueryParams.getStartedSourceService}=${MessagingQueueHealthCheckService.Producers}`;
|
||||
link = `${ROUTES.GET_STARTED_WITH_CLOUD}?${QueryParams.getStartedSource}=self-hosted-kafka&${QueryParams.getStartedSourceService}=${MessagingQueueHealthCheckService.Producers}`;
|
||||
break;
|
||||
case 'Kafka':
|
||||
link = `${ROUTES.GET_STARTED_INFRASTRUCTURE_MONITORING}?${QueryParams.getStartedSource}=kafka&${QueryParams.getStartedSourceService}=${MessagingQueueHealthCheckService.Kafka}`;
|
||||
link = `${ROUTES.GET_STARTED_WITH_CLOUD}?${QueryParams.getStartedSource}=self-hosted-kafka&${QueryParams.getStartedSourceService}=${MessagingQueueHealthCheckService.Kafka}`;
|
||||
break;
|
||||
default:
|
||||
link = '';
|
||||
|
||||
@@ -2,3 +2,11 @@
|
||||
box-sizing: border-box;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.permission {
|
||||
color: var(--l2-foreground);
|
||||
}
|
||||
|
||||
.permissionCode {
|
||||
font-family: monospace;
|
||||
}
|
||||
|
||||
@@ -5,9 +5,8 @@ describe('PermissionDeniedCallout', () => {
|
||||
it('renders the permission name in the callout message', () => {
|
||||
render(<PermissionDeniedCallout permissionName="serviceaccount:attach" />);
|
||||
|
||||
expect(screen.getByText(/You don't have/)).toBeInTheDocument();
|
||||
expect(screen.getByText(/is not authorized/)).toBeInTheDocument();
|
||||
expect(screen.getByText(/serviceaccount:attach/)).toBeInTheDocument();
|
||||
expect(screen.getByText(/permission/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('accepts an optional className', () => {
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { Callout } from '@signozhq/ui/callout';
|
||||
import cx from 'classnames';
|
||||
import styles from './PermissionDeniedCallout.module.scss';
|
||||
import { useAppContext } from 'providers/App/App';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
|
||||
interface PermissionDeniedCalloutProps {
|
||||
permissionName: string;
|
||||
@@ -11,6 +13,8 @@ function PermissionDeniedCallout({
|
||||
permissionName,
|
||||
className,
|
||||
}: PermissionDeniedCalloutProps): JSX.Element {
|
||||
const { user } = useAppContext();
|
||||
|
||||
return (
|
||||
<Callout
|
||||
type="error"
|
||||
@@ -18,7 +22,11 @@ function PermissionDeniedCallout({
|
||||
size="small"
|
||||
className={cx(styles.callout, className)}
|
||||
>
|
||||
{`You don't have ${permissionName} permission`}
|
||||
<Typography.Text className={styles.permission}>
|
||||
<code className={styles.permissionCode}>user/{user.id}</code> is not
|
||||
authorized to perform{' '}
|
||||
<code className={styles.permissionCode}>{permissionName}</code>
|
||||
</Typography.Text>
|
||||
</Callout>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -5,13 +5,9 @@ describe('PermissionDeniedFullPage', () => {
|
||||
it('renders the title and subtitle with the permissionName interpolated', () => {
|
||||
render(<PermissionDeniedFullPage permissionName="serviceaccount:list" />);
|
||||
|
||||
expect(
|
||||
screen.getByText("Uh-oh! You don't have permission to view this page."),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByText('Uh-oh! You are not authorized')).toBeInTheDocument();
|
||||
expect(screen.getByText(/serviceaccount:list/)).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText(/Please ask your SigNoz administrator to grant access/),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByText(/is not authorized to perform/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders with a different permissionName', () => {
|
||||
|
||||
@@ -2,6 +2,7 @@ import { CircleSlash2 } from '@signozhq/icons';
|
||||
|
||||
import styles from './PermissionDeniedFullPage.module.scss';
|
||||
import { Style } from '@signozhq/design-tokens';
|
||||
import { useAppContext } from 'providers/App/App';
|
||||
|
||||
interface PermissionDeniedFullPageProps {
|
||||
permissionName: string;
|
||||
@@ -10,18 +11,18 @@ interface PermissionDeniedFullPageProps {
|
||||
function PermissionDeniedFullPage({
|
||||
permissionName,
|
||||
}: PermissionDeniedFullPageProps): JSX.Element {
|
||||
const { user } = useAppContext();
|
||||
|
||||
return (
|
||||
<div className={styles.container}>
|
||||
<div className={styles.content}>
|
||||
<span className={styles.icon}>
|
||||
<CircleSlash2 color={Style.CALLOUT_WARNING_TITLE} size={14} />
|
||||
</span>
|
||||
<p className={styles.title}>
|
||||
Uh-oh! You don't have permission to view this page.
|
||||
</p>
|
||||
<p className={styles.title}>Uh-oh! You are not authorized</p>
|
||||
<p className={styles.subtitle}>
|
||||
You need <code className={styles.permission}>{permissionName}</code> to
|
||||
view this page. Please ask your SigNoz administrator to grant access.
|
||||
<code className={styles.permission}>user/{user.id}</code> is not authorized
|
||||
to perform <code className={styles.permission}>{permissionName}</code>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -32,10 +32,13 @@ export function useRoles(): {
|
||||
};
|
||||
}
|
||||
|
||||
export function getRoleOptions(roles: AuthtypesRoleDTO[]): RoleOption[] {
|
||||
export function getRoleOptions(
|
||||
roles: AuthtypesRoleDTO[],
|
||||
valueField: 'id' | 'name',
|
||||
): RoleOption[] {
|
||||
return roles.map((role) => ({
|
||||
label: role.name ?? '',
|
||||
value: role.id ?? '',
|
||||
value: role[valueField] ?? '',
|
||||
}));
|
||||
}
|
||||
|
||||
@@ -82,6 +85,7 @@ interface BaseProps {
|
||||
error?: APIError;
|
||||
onRefetch?: () => void;
|
||||
disabled?: boolean;
|
||||
valueField?: 'id' | 'name';
|
||||
}
|
||||
|
||||
interface SingleProps extends BaseProps {
|
||||
@@ -113,7 +117,7 @@ function RolesSelect(props: RolesSelectProps): JSX.Element {
|
||||
});
|
||||
|
||||
const roles = externalRoles ?? data?.data ?? [];
|
||||
const options = getRoleOptions(roles);
|
||||
const options = getRoleOptions(roles, props.valueField || 'id');
|
||||
|
||||
const {
|
||||
mode,
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import { useCallback } from 'react';
|
||||
import { LockKeyhole } from '@signozhq/icons';
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { Check, Copy, LockKeyhole } from '@signozhq/icons';
|
||||
import { Badge } from '@signozhq/ui/badge';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import { Input } from '@signozhq/ui/input';
|
||||
import { useCopyToClipboard } from 'react-use';
|
||||
import type { AuthtypesRoleDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import AuthZTooltip from 'components/AuthZTooltip/AuthZTooltip';
|
||||
import RolesSelect from 'components/RolesSelect';
|
||||
@@ -46,6 +48,23 @@ function OverviewTab({
|
||||
saveErrors = [],
|
||||
}: OverviewTabProps): JSX.Element {
|
||||
const { formatTimezoneAdjustedTimestamp } = useTimezone();
|
||||
const [, copyToClipboard] = useCopyToClipboard();
|
||||
const [hasCopiedId, setHasCopiedId] = useState(false);
|
||||
|
||||
const handleCopyId = useCallback((): void => {
|
||||
if (account.id) {
|
||||
copyToClipboard(account.id);
|
||||
setHasCopiedId(true);
|
||||
}
|
||||
}, [account.id, copyToClipboard]);
|
||||
|
||||
useEffect(() => {
|
||||
if (hasCopiedId) {
|
||||
const timer = setTimeout(() => setHasCopiedId(false), 2000);
|
||||
return (): void => clearTimeout(timer);
|
||||
}
|
||||
return undefined;
|
||||
}, [hasCopiedId]);
|
||||
|
||||
const formatTimestamp = useCallback(
|
||||
(ts: string | null | undefined): string => {
|
||||
@@ -93,6 +112,17 @@ function OverviewTab({
|
||||
</label>
|
||||
<div className="sa-drawer__input-wrapper sa-drawer__input-wrapper--disabled">
|
||||
<span className="sa-drawer__input-text">{account.id || '—'}</span>
|
||||
{account.id && (
|
||||
<Button
|
||||
variant="link"
|
||||
color="secondary"
|
||||
onClick={handleCopyId}
|
||||
className="sa-drawer__copy-btn"
|
||||
data-testid="copy-id-btn"
|
||||
>
|
||||
{hasCopiedId ? <Check size={14} /> : <Copy size={14} />}
|
||||
</Button>
|
||||
)}
|
||||
<LockKeyhole size={14} className="sa-drawer__lock-icon" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -203,6 +203,19 @@
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
&__copy-btn {
|
||||
flex-shrink: 0;
|
||||
padding: 0;
|
||||
height: auto;
|
||||
min-height: auto;
|
||||
color: var(--foreground);
|
||||
opacity: 0.6;
|
||||
|
||||
&:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
&__disabled-roles {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
|
||||
@@ -16,7 +16,6 @@ import {
|
||||
import type { RenderErrorResponseDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { AxiosError } from 'axios';
|
||||
import ErrorInPlace from 'components/ErrorInPlace/ErrorInPlace';
|
||||
import { GuardAuthZ } from 'components/GuardAuthZ/GuardAuthZ';
|
||||
import PermissionDeniedCallout from 'components/PermissionDeniedCallout/PermissionDeniedCallout';
|
||||
import { useRoles } from 'components/RolesSelect';
|
||||
import { SA_QUERY_PARAMS } from 'container/ServiceAccountsSettings/constants';
|
||||
@@ -477,15 +476,9 @@ function ServiceAccountDrawer({
|
||||
!isAccountLoading &&
|
||||
!isAccountError &&
|
||||
selectedAccountId && (
|
||||
<GuardAuthZ
|
||||
relation="read"
|
||||
object={`serviceaccount:${selectedAccountId}`}
|
||||
fallbackOnNoPermissions={(): JSX.Element => (
|
||||
<PermissionDeniedCallout permissionName="serviceaccount:read" />
|
||||
)}
|
||||
>
|
||||
<>
|
||||
{activeTab === ServiceAccountDrawerTab.Overview && account && (
|
||||
<>
|
||||
{activeTab === ServiceAccountDrawerTab.Overview &&
|
||||
(canRead && account ? (
|
||||
<OverviewTab
|
||||
account={account}
|
||||
localName={localName}
|
||||
@@ -504,23 +497,24 @@ function ServiceAccountDrawer({
|
||||
onRefetchRoles={refetchRoles}
|
||||
saveErrors={saveErrors}
|
||||
/>
|
||||
)}
|
||||
{activeTab === ServiceAccountDrawerTab.Keys &&
|
||||
(canListKeys ? (
|
||||
<KeysTab
|
||||
keys={keys}
|
||||
isLoading={keysLoading}
|
||||
isDisabled={isDeleted}
|
||||
canUpdate={canUpdate}
|
||||
accountId={selectedAccountId}
|
||||
currentPage={keysPage}
|
||||
pageSize={PAGE_SIZE}
|
||||
/>
|
||||
) : (
|
||||
<PermissionDeniedCallout permissionName="factor-api-key:list" />
|
||||
))}
|
||||
</>
|
||||
</GuardAuthZ>
|
||||
) : (
|
||||
<PermissionDeniedCallout permissionName="serviceaccount:read" />
|
||||
))}
|
||||
{activeTab === ServiceAccountDrawerTab.Keys &&
|
||||
(canListKeys ? (
|
||||
<KeysTab
|
||||
keys={keys}
|
||||
isLoading={keysLoading}
|
||||
isDisabled={isDeleted}
|
||||
canUpdate={canUpdate}
|
||||
accountId={selectedAccountId}
|
||||
currentPage={keysPage}
|
||||
pageSize={PAGE_SIZE}
|
||||
/>
|
||||
) : (
|
||||
<PermissionDeniedCallout permissionName="factor-api-key:list" />
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,106 +0,0 @@
|
||||
.span-hover-card {
|
||||
.ant-popover-inner {
|
||||
background: linear-gradient(
|
||||
139deg,
|
||||
color-mix(in srgb, var(--card) 32%, transparent) 0%,
|
||||
color-mix(in srgb, var(--card) 36%, transparent) 98.68%
|
||||
);
|
||||
padding: 12px 16px;
|
||||
border: 1px solid var(--l1-border);
|
||||
|
||||
&::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: linear-gradient(
|
||||
139deg,
|
||||
color-mix(in srgb, var(--card) 32%, transparent) 0%,
|
||||
color-mix(in srgb, var(--card) 36%, transparent) 98.68%
|
||||
);
|
||||
backdrop-filter: blur(20px);
|
||||
border-radius: 4px;
|
||||
z-index: -1;
|
||||
will-change: background-color, backdrop-filter;
|
||||
}
|
||||
}
|
||||
|
||||
&__title {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
&__operation {
|
||||
color: var(--l1-foreground);
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
line-height: 20px;
|
||||
letter-spacing: 0.48px;
|
||||
}
|
||||
|
||||
&__service {
|
||||
font-size: 0.875rem;
|
||||
color: var(--muted-foreground);
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
&__error {
|
||||
font-size: 0.75rem;
|
||||
color: var(--danger-background);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
&__row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-top: 8px;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
&__label {
|
||||
color: var(--muted-foreground);
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
line-height: 20px;
|
||||
}
|
||||
|
||||
&__value {
|
||||
color: var(--l1-foreground);
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
line-height: 20px;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
&__relative-time {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin-top: 4px;
|
||||
gap: 8px;
|
||||
border-radius: 1px 0 0 1px;
|
||||
background: linear-gradient(
|
||||
90deg,
|
||||
hsla(358, 75%, 59%, 0.2) 0%,
|
||||
transparent 100%
|
||||
);
|
||||
|
||||
&-icon {
|
||||
width: 2px;
|
||||
height: 20px;
|
||||
flex-shrink: 0;
|
||||
border-radius: 2px;
|
||||
background: var(--danger-background);
|
||||
}
|
||||
}
|
||||
|
||||
&__relative-text {
|
||||
color: var(--bg-cherry-300);
|
||||
font-size: 12px;
|
||||
line-height: 20px;
|
||||
}
|
||||
}
|
||||
@@ -1,103 +0,0 @@
|
||||
import { ReactNode } from 'react';
|
||||
import { Popover } from 'antd';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import { DATE_TIME_FORMATS } from 'constants/dateTimeFormats';
|
||||
import { convertTimeToRelevantUnit } from 'container/TraceDetail/utils';
|
||||
import dayjs from 'dayjs';
|
||||
import { useTimezone } from 'providers/Timezone';
|
||||
import { Span } from 'types/api/trace/getTraceV2';
|
||||
import { toFixed } from 'utils/toFixed';
|
||||
|
||||
import './SpanHoverCard.styles.scss';
|
||||
|
||||
interface ITraceMetadata {
|
||||
startTime: number;
|
||||
endTime: number;
|
||||
}
|
||||
|
||||
interface SpanHoverCardProps {
|
||||
span: Span;
|
||||
traceMetadata: ITraceMetadata;
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
function SpanHoverCard({
|
||||
span,
|
||||
traceMetadata,
|
||||
children,
|
||||
}: SpanHoverCardProps): JSX.Element {
|
||||
const duration = span.durationNano / 1e6; // Convert nanoseconds to milliseconds
|
||||
const { time: formattedDuration, timeUnitName } =
|
||||
convertTimeToRelevantUnit(duration);
|
||||
|
||||
const { timezone } = useTimezone();
|
||||
|
||||
// Calculate relative start time from trace start
|
||||
const relativeStartTime = span.timestamp - traceMetadata.startTime;
|
||||
const { time: relativeTime, timeUnitName: relativeTimeUnit } =
|
||||
convertTimeToRelevantUnit(relativeStartTime);
|
||||
|
||||
// Format absolute start time
|
||||
const startTimeFormatted = dayjs(span.timestamp)
|
||||
.tz(timezone.value)
|
||||
.format(DATE_TIME_FORMATS.DD_MMM_YYYY_HH_MM_SS);
|
||||
|
||||
const getContent = (): JSX.Element => (
|
||||
<div className="span-hover-card">
|
||||
<div className="span-hover-card__row">
|
||||
<Typography.Text className="span-hover-card__label">
|
||||
Duration:
|
||||
</Typography.Text>
|
||||
<Typography.Text className="span-hover-card__value">
|
||||
{toFixed(formattedDuration, 2)}
|
||||
{timeUnitName}
|
||||
</Typography.Text>
|
||||
</div>
|
||||
<div className="span-hover-card__row">
|
||||
<Typography.Text className="span-hover-card__label">
|
||||
Events:
|
||||
</Typography.Text>
|
||||
<Typography.Text className="span-hover-card__value">
|
||||
{span.event?.length || 0}
|
||||
</Typography.Text>
|
||||
</div>
|
||||
<div className="span-hover-card__row">
|
||||
<Typography.Text className="span-hover-card__label">
|
||||
Start time:
|
||||
</Typography.Text>
|
||||
<Typography.Text className="span-hover-card__value">
|
||||
{startTimeFormatted}
|
||||
</Typography.Text>
|
||||
</div>
|
||||
<div className="span-hover-card__relative-time">
|
||||
<div className="span-hover-card__relative-time-icon" />
|
||||
<Typography.Text className="span-hover-card__relative-text">
|
||||
{toFixed(relativeTime, 2)}
|
||||
{relativeTimeUnit} after trace start
|
||||
</Typography.Text>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<Popover
|
||||
title={
|
||||
<div className="span-hover-card__title">
|
||||
<Typography.Text className="span-hover-card__operation">
|
||||
{span.name}
|
||||
</Typography.Text>
|
||||
</div>
|
||||
}
|
||||
mouseEnterDelay={0.2}
|
||||
content={getContent()}
|
||||
trigger="hover"
|
||||
rootClassName="span-hover-card"
|
||||
autoAdjustOverflow
|
||||
arrow={false}
|
||||
>
|
||||
{children}
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
|
||||
export default SpanHoverCard;
|
||||
@@ -1,291 +0,0 @@
|
||||
import { act, fireEvent, render, screen } from '@testing-library/react';
|
||||
import { TimezoneContextType } from 'providers/Timezone';
|
||||
import { Span } from 'types/api/trace/getTraceV2';
|
||||
|
||||
import SpanHoverCard from '../SpanHoverCard';
|
||||
|
||||
// Mock timezone provider so SpanHoverCard can use useTimezone without a real context
|
||||
jest.mock('providers/Timezone', () => ({
|
||||
__esModule: true,
|
||||
useTimezone: (): TimezoneContextType => ({
|
||||
timezone: {
|
||||
name: 'Coordinated Universal Time — UTC, GMT',
|
||||
value: 'UTC',
|
||||
offset: 'UTC',
|
||||
searchIndex: 'UTC',
|
||||
},
|
||||
browserTimezone: {
|
||||
name: 'Coordinated Universal Time — UTC, GMT',
|
||||
value: 'UTC',
|
||||
offset: 'UTC',
|
||||
searchIndex: 'UTC',
|
||||
},
|
||||
updateTimezone: jest.fn(),
|
||||
formatTimezoneAdjustedTimestamp: jest.fn(() => 'mock-date'),
|
||||
isAdaptationEnabled: true,
|
||||
setIsAdaptationEnabled: jest.fn(),
|
||||
}),
|
||||
}));
|
||||
|
||||
// Mock dayjs for testing, including timezone helpers used in timezoneUtils
|
||||
jest.mock('dayjs', () => {
|
||||
const mockDayjsInstance: any = {};
|
||||
|
||||
mockDayjsInstance.format = jest.fn((formatString: string) =>
|
||||
// Match the DD_MMM_YYYY_HH_MM_SS format: 'DD MMM YYYY, HH:mm:ss'
|
||||
formatString === 'DD MMM YYYY, HH:mm:ss'
|
||||
? '15 Mar 2024, 14:23:45'
|
||||
: 'mock-date',
|
||||
);
|
||||
|
||||
// Support chaining: dayjs().tz(timezone).format(...) and dayjs().tz(timezone).utcOffset()
|
||||
mockDayjsInstance.tz = jest.fn(() => mockDayjsInstance);
|
||||
mockDayjsInstance.utcOffset = jest.fn(() => 0);
|
||||
|
||||
const mockDayjs = jest.fn(() => mockDayjsInstance);
|
||||
|
||||
Object.assign(mockDayjs, {
|
||||
extend: jest.fn(),
|
||||
// Support dayjs.tz.guess()
|
||||
tz: { guess: jest.fn(() => 'UTC') },
|
||||
});
|
||||
|
||||
return mockDayjs;
|
||||
});
|
||||
|
||||
const HOVER_ELEMENT_ID = 'hover-element';
|
||||
|
||||
const mockSpan: Span = {
|
||||
spanId: 'test-span-id',
|
||||
traceId: 'test-trace-id',
|
||||
rootSpanId: 'root-span-id',
|
||||
parentSpanId: 'parent-span-id',
|
||||
name: 'GET /api/users',
|
||||
timestamp: 1679748225000000,
|
||||
durationNano: 150000000,
|
||||
serviceName: 'user-service',
|
||||
kind: 1,
|
||||
hasError: false,
|
||||
level: 1,
|
||||
references: [],
|
||||
tagMap: {},
|
||||
event: [
|
||||
{
|
||||
name: 'event1',
|
||||
timeUnixNano: 1679748225100000,
|
||||
attributeMap: {},
|
||||
isError: false,
|
||||
},
|
||||
{
|
||||
name: 'event2',
|
||||
timeUnixNano: 1679748225200000,
|
||||
attributeMap: {},
|
||||
isError: false,
|
||||
},
|
||||
],
|
||||
rootName: 'root-span',
|
||||
statusMessage: '',
|
||||
statusCodeString: 'OK',
|
||||
spanKind: 'server',
|
||||
hasChildren: false,
|
||||
hasSibling: false,
|
||||
subTreeNodeCount: 1,
|
||||
};
|
||||
|
||||
const mockTraceMetadata = {
|
||||
startTime: 1679748225000000,
|
||||
endTime: 1679748226000000,
|
||||
};
|
||||
|
||||
describe('SpanHoverCard', () => {
|
||||
beforeEach(() => {
|
||||
jest.useFakeTimers();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.useRealTimers();
|
||||
});
|
||||
|
||||
it('renders child element correctly', () => {
|
||||
render(
|
||||
<SpanHoverCard span={mockSpan} traceMetadata={mockTraceMetadata}>
|
||||
<div data-testid="child-element">Hover me</div>
|
||||
</SpanHoverCard>,
|
||||
);
|
||||
|
||||
expect(screen.getByTestId('child-element')).toBeInTheDocument();
|
||||
expect(screen.getByText('Hover me')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows popover after 0.2 second delay on hover', async () => {
|
||||
render(
|
||||
<SpanHoverCard span={mockSpan} traceMetadata={mockTraceMetadata}>
|
||||
<div data-testid={HOVER_ELEMENT_ID}>Hover for details</div>
|
||||
</SpanHoverCard>,
|
||||
);
|
||||
|
||||
const hoverElement = screen.getByTestId(HOVER_ELEMENT_ID);
|
||||
|
||||
// Hover over the element
|
||||
fireEvent.mouseEnter(hoverElement);
|
||||
|
||||
// Popover should NOT appear immediately
|
||||
expect(screen.queryByText('Duration:')).not.toBeInTheDocument();
|
||||
|
||||
// Advance time by 0.5 seconds
|
||||
act(() => {
|
||||
jest.advanceTimersByTime(200);
|
||||
});
|
||||
|
||||
// Now popover should appear
|
||||
expect(screen.getByText('Duration:')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('does not show popover if hover is too brief', async () => {
|
||||
render(
|
||||
<SpanHoverCard span={mockSpan} traceMetadata={mockTraceMetadata}>
|
||||
<div data-testid={HOVER_ELEMENT_ID}>Quick hover test</div>
|
||||
</SpanHoverCard>,
|
||||
);
|
||||
|
||||
const hoverElement = screen.getByTestId(HOVER_ELEMENT_ID);
|
||||
|
||||
// Quick hover and unhover (less than the 0.2s delay)
|
||||
fireEvent.mouseEnter(hoverElement);
|
||||
act(() => {
|
||||
jest.advanceTimersByTime(100); // Only 0.1 seconds
|
||||
});
|
||||
fireEvent.mouseLeave(hoverElement);
|
||||
|
||||
// Advance past the full delay
|
||||
act(() => {
|
||||
jest.advanceTimersByTime(400);
|
||||
});
|
||||
|
||||
// Popover should not appear
|
||||
expect(screen.queryByText('Duration:')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('displays span information in popover content after delay', async () => {
|
||||
render(
|
||||
<SpanHoverCard span={mockSpan} traceMetadata={mockTraceMetadata}>
|
||||
<div data-testid={HOVER_ELEMENT_ID}>Test span</div>
|
||||
</SpanHoverCard>,
|
||||
);
|
||||
|
||||
const hoverElement = screen.getByTestId(HOVER_ELEMENT_ID);
|
||||
|
||||
// Hover and wait for popover
|
||||
fireEvent.mouseEnter(hoverElement);
|
||||
act(() => {
|
||||
jest.advanceTimersByTime(500);
|
||||
});
|
||||
|
||||
// Check that popover shows span operation name in title
|
||||
expect(screen.getByText('GET /api/users')).toBeInTheDocument();
|
||||
|
||||
// Check duration information
|
||||
expect(screen.getByText('Duration:')).toBeInTheDocument();
|
||||
expect(screen.getByText('150ms')).toBeInTheDocument();
|
||||
|
||||
// Check events count
|
||||
expect(screen.getByText('Events:')).toBeInTheDocument();
|
||||
expect(screen.getByText('2')).toBeInTheDocument();
|
||||
|
||||
// Check start time label
|
||||
expect(screen.getByText('Start time:')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('displays date in DD MMM YYYY, HH:mm:ss format with seconds', async () => {
|
||||
render(
|
||||
<SpanHoverCard span={mockSpan} traceMetadata={mockTraceMetadata}>
|
||||
<div data-testid={HOVER_ELEMENT_ID}>Date format test</div>
|
||||
</SpanHoverCard>,
|
||||
);
|
||||
|
||||
const hoverElement = screen.getByTestId(HOVER_ELEMENT_ID);
|
||||
|
||||
// Hover and wait for popover
|
||||
fireEvent.mouseEnter(hoverElement);
|
||||
act(() => {
|
||||
jest.advanceTimersByTime(500);
|
||||
});
|
||||
|
||||
// Verify the DD MMM YYYY, HH:mm:ss format is displayed
|
||||
expect(screen.getByText('15 Mar 2024, 14:23:45')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('displays relative time information', async () => {
|
||||
const spanWithRelativeTime: Span = {
|
||||
...mockSpan,
|
||||
timestamp: mockTraceMetadata.startTime + 1000000, // 1 second later
|
||||
};
|
||||
|
||||
render(
|
||||
<SpanHoverCard span={spanWithRelativeTime} traceMetadata={mockTraceMetadata}>
|
||||
<div data-testid={HOVER_ELEMENT_ID}>Relative time test</div>
|
||||
</SpanHoverCard>,
|
||||
);
|
||||
|
||||
const hoverElement = screen.getByTestId(HOVER_ELEMENT_ID);
|
||||
|
||||
// Hover and wait for popover
|
||||
fireEvent.mouseEnter(hoverElement);
|
||||
act(() => {
|
||||
jest.advanceTimersByTime(500);
|
||||
});
|
||||
|
||||
// Check relative time display
|
||||
expect(screen.getByText(/after trace start/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('handles spans with no events correctly', async () => {
|
||||
const spanWithoutEvents: Span = {
|
||||
...mockSpan,
|
||||
event: [],
|
||||
};
|
||||
|
||||
render(
|
||||
<SpanHoverCard span={spanWithoutEvents} traceMetadata={mockTraceMetadata}>
|
||||
<div data-testid={HOVER_ELEMENT_ID}>No events test</div>
|
||||
</SpanHoverCard>,
|
||||
);
|
||||
|
||||
const hoverElement = screen.getByTestId(HOVER_ELEMENT_ID);
|
||||
|
||||
// Hover and wait for popover
|
||||
fireEvent.mouseEnter(hoverElement);
|
||||
act(() => {
|
||||
jest.advanceTimersByTime(500);
|
||||
});
|
||||
|
||||
expect(screen.getByText('Events:')).toBeInTheDocument();
|
||||
expect(screen.getByText('0')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('verifies mouseEnterDelay prop is set to 0.5', () => {
|
||||
const { container } = render(
|
||||
<SpanHoverCard span={mockSpan} traceMetadata={mockTraceMetadata}>
|
||||
<div data-testid={HOVER_ELEMENT_ID}>Delay test</div>
|
||||
</SpanHoverCard>,
|
||||
);
|
||||
|
||||
// The mouseEnterDelay prop should be set on the Popover component
|
||||
// This test verifies the implementation includes the delay
|
||||
const popover = container.querySelector('.ant-popover');
|
||||
expect(popover).not.toBeInTheDocument(); // Initially not visible
|
||||
|
||||
// Hover to trigger delay mechanism
|
||||
const hoverElement = screen.getByTestId(HOVER_ELEMENT_ID);
|
||||
fireEvent.mouseEnter(hoverElement);
|
||||
|
||||
// Should not appear before delay
|
||||
expect(screen.queryByText('Duration:')).not.toBeInTheDocument();
|
||||
|
||||
// Should appear after delay
|
||||
act(() => {
|
||||
jest.advanceTimersByTime(500);
|
||||
});
|
||||
expect(screen.getByText('Duration:')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,90 @@
|
||||
.container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.field {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 6px 8px;
|
||||
border-radius: 2px;
|
||||
border: 1px solid var(--l2-border);
|
||||
}
|
||||
|
||||
// Sienna chip — matches the dashboard list-row tag badge.
|
||||
.tag {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
max-width: 240px;
|
||||
height: 24px;
|
||||
padding: 2px 4px 2px 8px;
|
||||
border-radius: 50px;
|
||||
border: 1px solid color-mix(in srgb, var(--bg-sienna-500) 20%, transparent);
|
||||
background: color-mix(in srgb, var(--bg-sienna-500) 10%, transparent);
|
||||
color: var(--bg-sienna-400);
|
||||
font-size: 13px;
|
||||
font-weight: var(--font-weight-normal);
|
||||
line-height: 20px;
|
||||
cursor: text;
|
||||
}
|
||||
|
||||
.tagLabel {
|
||||
--button-height: auto;
|
||||
--button-padding: 0;
|
||||
--button-gap: 0;
|
||||
--button-variant-ghost-background-color: transparent;
|
||||
--button-variant-ghost-hover-background-color: transparent;
|
||||
--button-variant-ghost-color: inherit;
|
||||
--button-variant-ghost-hover-color: inherit;
|
||||
overflow: hidden;
|
||||
max-width: 200px;
|
||||
font-size: 13px;
|
||||
font-weight: var(--font-weight-normal);
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
cursor: text;
|
||||
}
|
||||
|
||||
.remove {
|
||||
// Size overrides to fit the chip, plus a sienna-tinted hover — the Button's
|
||||
// default ghost hover is a grey that clashes with the chip. Resting color is
|
||||
// left at the Button default.
|
||||
--button-height: 16px;
|
||||
--button-padding: 0;
|
||||
--button-border-radius: 50%;
|
||||
--button-variant-ghost-hover-background-color: color-mix(
|
||||
in srgb,
|
||||
var(--bg-sienna-500) 22%,
|
||||
transparent
|
||||
);
|
||||
--button-variant-ghost-hover-color: var(--bg-sienna-400);
|
||||
width: 16px;
|
||||
min-width: 16px;
|
||||
flex: none;
|
||||
}
|
||||
|
||||
.input {
|
||||
flex: 1;
|
||||
min-width: 120px;
|
||||
border: none;
|
||||
background: transparent;
|
||||
|
||||
&::placeholder {
|
||||
color: var(--l3-foreground);
|
||||
}
|
||||
}
|
||||
|
||||
.editInput {
|
||||
width: 160px;
|
||||
height: 24px;
|
||||
}
|
||||
|
||||
.error {
|
||||
color: var(--bg-cherry-500);
|
||||
font-size: 12px;
|
||||
}
|
||||
164
frontend/src/components/TagKeyValueInput/TagKeyValueInput.tsx
Normal file
@@ -0,0 +1,164 @@
|
||||
import { type ChangeEvent, type KeyboardEvent, useState } from 'react';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import { Input } from '@signozhq/ui/input';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import { X } from '@signozhq/icons';
|
||||
import cx from 'classnames';
|
||||
|
||||
import { parseKeyValueTag } from './utils';
|
||||
|
||||
import styles from './TagKeyValueInput.module.scss';
|
||||
|
||||
interface TagKeyValueInputProps {
|
||||
// Tags as `key:value` strings.
|
||||
tags: string[];
|
||||
onTagsChange: (tags: string[]) => void;
|
||||
placeholder?: string;
|
||||
// Override the outer container styling per host (e.g. the create modal).
|
||||
className?: string;
|
||||
testId?: string;
|
||||
}
|
||||
|
||||
// Strict key:value tag editor. A tag is committed only on Enter and only when
|
||||
// it parses to a valid `key:value` pair — bare values are rejected with an
|
||||
// inline error. Existing chips can be edited inline (double-click), and removed.
|
||||
function TagKeyValueInput({
|
||||
tags,
|
||||
onTagsChange,
|
||||
placeholder = 'key:value',
|
||||
className,
|
||||
testId = 'tag-key-value-input',
|
||||
}: TagKeyValueInputProps): JSX.Element {
|
||||
const [inputValue, setInputValue] = useState('');
|
||||
const [error, setError] = useState('');
|
||||
const [editIndex, setEditIndex] = useState(-1);
|
||||
const [editValue, setEditValue] = useState('');
|
||||
|
||||
const removeTag = (tag: string): void => {
|
||||
onTagsChange(tags.filter((t) => t !== tag));
|
||||
};
|
||||
|
||||
const commit = (): void => {
|
||||
const raw = inputValue.trim();
|
||||
if (!raw) {
|
||||
return;
|
||||
}
|
||||
const normalized = parseKeyValueTag(raw);
|
||||
if (!normalized) {
|
||||
setError('Tags must be in key:value format (both sides required).');
|
||||
return;
|
||||
}
|
||||
if (tags.includes(normalized)) {
|
||||
setError('This tag already exists.');
|
||||
return;
|
||||
}
|
||||
onTagsChange([...tags, normalized]);
|
||||
setInputValue('');
|
||||
setError('');
|
||||
};
|
||||
|
||||
const handleChange = (e: ChangeEvent<HTMLInputElement>): void => {
|
||||
setInputValue(e.target.value);
|
||||
if (error) {
|
||||
setError('');
|
||||
}
|
||||
};
|
||||
|
||||
const handleKeyDown = (e: KeyboardEvent<HTMLInputElement>): void => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
commit();
|
||||
}
|
||||
};
|
||||
|
||||
const startEdit = (index: number): void => {
|
||||
setEditIndex(index);
|
||||
setEditValue(tags[index]);
|
||||
setError('');
|
||||
};
|
||||
|
||||
const cancelEdit = (): void => {
|
||||
setEditIndex(-1);
|
||||
setEditValue('');
|
||||
};
|
||||
|
||||
const commitEdit = (): void => {
|
||||
const normalized = parseKeyValueTag(editValue);
|
||||
// Drop into a no-op (revert) on invalid or duplicate edits rather than
|
||||
// stranding the user in an un-exitable edit box.
|
||||
if (normalized && !tags.some((t, i) => t === normalized && i !== editIndex)) {
|
||||
onTagsChange(tags.map((t, i) => (i === editIndex ? normalized : t)));
|
||||
}
|
||||
cancelEdit();
|
||||
};
|
||||
|
||||
const handleEditKeyDown = (e: KeyboardEvent<HTMLInputElement>): void => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
commitEdit();
|
||||
} else if (e.key === 'Escape') {
|
||||
e.preventDefault();
|
||||
cancelEdit();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={cx(styles.container, className)}>
|
||||
<div className={styles.field}>
|
||||
{tags.map((tag, index) =>
|
||||
index === editIndex ? (
|
||||
<Input
|
||||
key={tag}
|
||||
className={styles.editInput}
|
||||
value={editValue}
|
||||
autoFocus
|
||||
testId={`${testId}-edit`}
|
||||
onChange={(e: ChangeEvent<HTMLInputElement>): void =>
|
||||
setEditValue(e.target.value)
|
||||
}
|
||||
onKeyDown={handleEditKeyDown}
|
||||
onBlur={commitEdit}
|
||||
/>
|
||||
) : (
|
||||
<div key={tag} className={styles.tag} data-testid={`${testId}-chip`}>
|
||||
<Button
|
||||
variant="ghost"
|
||||
color="secondary"
|
||||
className={styles.tagLabel}
|
||||
title="Double-click to edit"
|
||||
onDoubleClick={(): void => startEdit(index)}
|
||||
>
|
||||
{tag}
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
color="secondary"
|
||||
size="icon"
|
||||
className={styles.remove}
|
||||
aria-label={`Remove ${tag}`}
|
||||
onClick={(): void => removeTag(tag)}
|
||||
>
|
||||
<X size={12} />
|
||||
</Button>
|
||||
</div>
|
||||
),
|
||||
)}
|
||||
<Input
|
||||
className={styles.input}
|
||||
value={inputValue}
|
||||
placeholder={placeholder}
|
||||
testId={testId}
|
||||
onChange={handleChange}
|
||||
onKeyDown={handleKeyDown}
|
||||
/>
|
||||
</div>
|
||||
{error && (
|
||||
<Typography className={styles.error} data-testid={`${testId}-error`}>
|
||||
{error}
|
||||
</Typography>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default TagKeyValueInput;
|
||||
31
frontend/src/components/TagKeyValueInput/utils.test.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
import { parseKeyValueTag } from './utils';
|
||||
|
||||
describe('parseKeyValueTag', () => {
|
||||
it('normalizes a valid key:value pair', () => {
|
||||
expect(parseKeyValueTag('env:prod')).toBe('env:prod');
|
||||
});
|
||||
|
||||
it('trims whitespace around key and value', () => {
|
||||
expect(parseKeyValueTag(' env : prod ')).toBe('env:prod');
|
||||
});
|
||||
|
||||
it('keeps colons inside the value', () => {
|
||||
expect(parseKeyValueTag('url:http://x')).toBe('url:http://x');
|
||||
});
|
||||
|
||||
it('rejects a bare value with no colon', () => {
|
||||
expect(parseKeyValueTag('prod')).toBeNull();
|
||||
});
|
||||
|
||||
it('rejects an empty key', () => {
|
||||
expect(parseKeyValueTag(':prod')).toBeNull();
|
||||
});
|
||||
|
||||
it('rejects an empty value', () => {
|
||||
expect(parseKeyValueTag('env:')).toBeNull();
|
||||
});
|
||||
|
||||
it('rejects blank input', () => {
|
||||
expect(parseKeyValueTag(' ')).toBeNull();
|
||||
});
|
||||
});
|
||||
17
frontend/src/components/TagKeyValueInput/utils.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
// Tags are strictly key:value. Parse a raw input into a normalized `key:value`
|
||||
// string, or null if it isn't a valid pair (both sides non-empty). The first
|
||||
// colon separates key from value, so values may themselves contain colons
|
||||
// (e.g. `url:http://x`).
|
||||
export function parseKeyValueTag(raw: string): string | null {
|
||||
const trimmed = raw.trim();
|
||||
const idx = trimmed.indexOf(':');
|
||||
if (idx <= 0) {
|
||||
return null;
|
||||
}
|
||||
const key = trimmed.slice(0, idx).trim();
|
||||
const value = trimmed.slice(idx + 1).trim();
|
||||
if (!key || !value) {
|
||||
return null;
|
||||
}
|
||||
return `${key}:${value}`;
|
||||
}
|
||||
@@ -267,11 +267,10 @@ describe('createGuardedRoute', () => {
|
||||
await waitFor(() => {
|
||||
const heading = document.querySelector('h3');
|
||||
expect(heading).toBeInTheDocument();
|
||||
expect(heading?.textContent).toMatch(/permission to view/i);
|
||||
expect(heading?.textContent).toMatch(/not authorized/i);
|
||||
});
|
||||
|
||||
expect(screen.getByText('update')).toBeInTheDocument();
|
||||
expect(screen.getByText('role:123')).toBeInTheDocument();
|
||||
expect(screen.getByText(/update:role:123/)).toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByText('Test Component: test-value'),
|
||||
).not.toBeInTheDocument();
|
||||
|
||||
@@ -5,7 +5,8 @@ import {
|
||||
AuthZRelation,
|
||||
BrandedPermission,
|
||||
} from 'hooks/useAuthZ/types';
|
||||
import { parsePermission } from 'hooks/useAuthZ/utils';
|
||||
import { formatPermission } from 'hooks/useAuthZ/utils';
|
||||
import { useAppContext } from 'providers/App/App';
|
||||
|
||||
import noDataUrl from '@/assets/Icons/no-data.svg';
|
||||
|
||||
@@ -17,21 +18,16 @@ import './createGuardedRoute.styles.scss';
|
||||
function OnNoPermissionsFallback(response: {
|
||||
requiredPermissionName: BrandedPermission;
|
||||
}): ReactElement {
|
||||
const { relation, object } = parsePermission(response.requiredPermissionName);
|
||||
const { user } = useAppContext();
|
||||
|
||||
return (
|
||||
<div className="guard-authz-error-no-authz">
|
||||
<div className="guard-authz-error-no-authz-content">
|
||||
<img src={noDataUrl} alt="No permission" />
|
||||
<h3>Uh-oh! You don’t have permission to view this page.</h3>
|
||||
<h3>Uh-oh! You are not authorized</h3>
|
||||
<p>
|
||||
You need the following permission to view this page:
|
||||
<br />
|
||||
Relation: <span>{relation}</span>
|
||||
<br />
|
||||
Object: <span>{object}</span>
|
||||
<br />
|
||||
Please ask your SigNoz administrator to grant access.
|
||||
<code>user/{user.id}</code> is not authorized to perform{' '}
|
||||
<code>{formatPermission(response.requiredPermissionName)}</code>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -7,10 +7,10 @@ export enum FeatureKeys {
|
||||
GATEWAY = 'gateway',
|
||||
PREMIUM_SUPPORT = 'premium_support',
|
||||
ANOMALY_DETECTION = 'anomaly_detection',
|
||||
ONBOARDING_V3 = 'onboarding_v3',
|
||||
DOT_METRICS_ENABLED = 'dot_metrics_enabled',
|
||||
USE_JSON_BODY = 'use_json_body',
|
||||
USE_FINE_GRAINED_AUTHZ = 'use_fine_grained_authz',
|
||||
USE_DASHBOARD_V2 = 'use_dashboard_v2',
|
||||
EMABLE_AI_OBSERVABILITY = 'enable_ai_observability',
|
||||
ENABLE_AI_OBSERVABILITY = 'enable_ai_observability',
|
||||
ENABLE_METRICS_REDUCTION = 'enable_metrics_reduction',
|
||||
}
|
||||
|
||||
@@ -11,14 +11,7 @@ const ROUTES = {
|
||||
TRACE_DETAIL_OLD: '/trace-old/:id',
|
||||
TRACES_EXPLORER: '/traces-explorer',
|
||||
ONBOARDING: '/onboarding',
|
||||
GET_STARTED: '/get-started',
|
||||
GET_STARTED_WITH_CLOUD: '/get-started-with-signoz-cloud',
|
||||
GET_STARTED_APPLICATION_MONITORING: '/get-started/application-monitoring',
|
||||
GET_STARTED_LOGS_MANAGEMENT: '/get-started/logs-management',
|
||||
GET_STARTED_INFRASTRUCTURE_MONITORING:
|
||||
'/get-started/infrastructure-monitoring',
|
||||
GET_STARTED_AWS_MONITORING: '/get-started/aws-monitoring',
|
||||
GET_STARTED_AZURE_MONITORING: '/get-started/azure-monitoring',
|
||||
USAGE_EXPLORER: '/usage-explorer',
|
||||
APPLICATION: '/services',
|
||||
ALL_DASHBOARD: '/dashboard',
|
||||
@@ -56,7 +49,9 @@ const ROUTES = {
|
||||
TRACE_EXPLORER: '/trace-explorer',
|
||||
BILLING: '/settings/billing',
|
||||
ROLES_SETTINGS: '/settings/roles',
|
||||
ROLE_CREATE: '/settings/roles/new',
|
||||
ROLE_DETAILS: '/settings/roles/:roleId',
|
||||
ROLE_EDIT: '/settings/roles/:roleId/edit',
|
||||
MEMBERS_SETTINGS: '/settings/members',
|
||||
SUPPORT: '/support',
|
||||
LOGS_SAVE_VIEWS: '/logs/saved-views',
|
||||
@@ -79,6 +74,7 @@ const ROUTES = {
|
||||
METRICS_EXPLORER: '/metrics-explorer/summary',
|
||||
METRICS_EXPLORER_EXPLORER: '/metrics-explorer/explorer',
|
||||
METRICS_EXPLORER_VIEWS: '/metrics-explorer/views',
|
||||
METRICS_EXPLORER_VOLUME_CONTROL: '/metrics-explorer/volume-control',
|
||||
API_MONITORING_BASE: '/api-monitoring',
|
||||
API_MONITORING: '/api-monitoring/explorer',
|
||||
METRICS_EXPLORER_BASE: '/metrics-explorer',
|
||||
@@ -93,6 +89,8 @@ const ROUTES = {
|
||||
AI_ASSISTANT_BASE: '/ai-assistant',
|
||||
AI_ASSISTANT_ICON_PREVIEW: '/ai-assistant-icon-preview',
|
||||
MCP_SERVER: '/settings/mcp-server',
|
||||
LLM_OBSERVABILITY_BASE: '/llm-observability',
|
||||
LLM_OBSERVABILITY_MODEL_PRICING: '/llm-observability/settings/model-pricing',
|
||||
} as const;
|
||||
|
||||
export default ROUTES;
|
||||
|
||||
@@ -48,6 +48,48 @@ describe('getAutoContexts', () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it('includes the query in alert edit context', () => {
|
||||
const ruleId = 'rule-edit';
|
||||
const query = { queryType: 'builder', builder: { queryData: [] } };
|
||||
const compositeQuery = encodeURIComponent(JSON.stringify(query));
|
||||
const search = `?${QueryParams.ruleId}=${ruleId}&${QueryParams.compositeQuery}=${compositeQuery}`;
|
||||
|
||||
const contexts = getAutoContexts(ROUTES.EDIT_ALERTS, search);
|
||||
|
||||
expect(contexts).toStrictEqual([
|
||||
{
|
||||
source: 'auto',
|
||||
type: 'alert',
|
||||
resourceId: ruleId,
|
||||
metadata: {
|
||||
page: 'alert_edit',
|
||||
ruleId,
|
||||
query,
|
||||
},
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('includes the query in alert new context (no ruleId)', () => {
|
||||
const query = { queryType: 'builder', builder: { queryData: [] } };
|
||||
const compositeQuery = encodeURIComponent(JSON.stringify(query));
|
||||
const search = `?${QueryParams.compositeQuery}=${compositeQuery}`;
|
||||
|
||||
const contexts = getAutoContexts(ROUTES.ALERTS_NEW, search);
|
||||
|
||||
expect(contexts).toStrictEqual([
|
||||
{
|
||||
source: 'auto',
|
||||
type: 'alert',
|
||||
resourceId: null,
|
||||
metadata: {
|
||||
page: 'alert_new',
|
||||
query,
|
||||
},
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('returns triggered alerts context on alert history without ruleId', () => {
|
||||
const contexts = getAutoContexts(ROUTES.ALERT_HISTORY, '');
|
||||
|
||||
|
||||
@@ -377,9 +377,63 @@
|
||||
}
|
||||
|
||||
.contextPopoverEmpty {
|
||||
// Fill the entity panel so the state sits centred in the dead space rather
|
||||
// than clinging to the top-left corner.
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 10px;
|
||||
padding: 24px 20px;
|
||||
text-align: center;
|
||||
font-size: 12px;
|
||||
color: var(--l3-foreground);
|
||||
padding: 10px 8px;
|
||||
}
|
||||
|
||||
.contextPopoverEmptyIcon {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
// `--empty-accent` is set per category on the root (robin/cherry/forest).
|
||||
color: var(--empty-accent);
|
||||
background: color-mix(in srgb, var(--empty-accent), transparent 88%);
|
||||
border-radius: var(--radius-2);
|
||||
}
|
||||
|
||||
.contextPopoverEmptyTitle {
|
||||
margin: 0;
|
||||
max-width: 280px;
|
||||
color: var(--l2-foreground);
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
line-height: 1.4;
|
||||
// Clamp to 2 lines with an ellipsis so a long query can't blow out the
|
||||
// popover height. The CTA below is a stock DS link button, so the query is
|
||||
// kept readable here rather than forcing the button to wrap.
|
||||
display: -webkit-box;
|
||||
-webkit-box-orient: vertical;
|
||||
-webkit-line-clamp: 2;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.contextPopoverEmptyCta {
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.contextPopoverEmptyCtaLabel {
|
||||
display: -webkit-box;
|
||||
-webkit-box-orient: vertical;
|
||||
-webkit-line-clamp: 1;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
overflow-wrap: anywhere;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.micBtn {
|
||||
|
||||
@@ -42,19 +42,22 @@ import { useSpeechRecognition } from '../../hooks/useSpeechRecognition';
|
||||
import { MessageAttachment } from '../../types';
|
||||
import { MessageContext } from '../../../../api/ai-assistant/chat';
|
||||
import {
|
||||
Bell,
|
||||
LayoutDashboard,
|
||||
Mic,
|
||||
Plus,
|
||||
Search,
|
||||
Send,
|
||||
ShieldCheck,
|
||||
Square,
|
||||
TriangleAlert,
|
||||
X,
|
||||
} from '@signozhq/icons';
|
||||
|
||||
import styles from './ChatInput.module.scss';
|
||||
import ContextPickerEmptyState from './ContextPickerEmptyState';
|
||||
import {
|
||||
CONTEXT_CATEGORIES,
|
||||
CONTEXT_CATEGORY_ICONS,
|
||||
ContextCategory,
|
||||
} from './contextPicker';
|
||||
|
||||
interface ChatInputProps {
|
||||
onSend: (
|
||||
@@ -162,10 +165,6 @@ const HOME_SERVICES_INTERVAL = 30 * 60 * 1000;
|
||||
/** sessionStorage key for the "voice input failed this tab" flag. */
|
||||
const VOICE_UNAVAILABLE_KEY = 'ai-assistant-voice-unavailable';
|
||||
|
||||
const CONTEXT_CATEGORIES = ['Dashboards', 'Alerts', 'Services'] as const;
|
||||
|
||||
type ContextCategory = (typeof CONTEXT_CATEGORIES)[number];
|
||||
|
||||
interface SelectedContextItem {
|
||||
category: ContextCategory;
|
||||
entityId: string;
|
||||
@@ -205,12 +204,6 @@ interface ContextEntityItem {
|
||||
value: string;
|
||||
}
|
||||
|
||||
const CONTEXT_CATEGORY_ICONS = {
|
||||
Dashboards: LayoutDashboard,
|
||||
Alerts: Bell,
|
||||
Services: ShieldCheck,
|
||||
} satisfies Record<ContextCategory, unknown>;
|
||||
|
||||
function fileToDataUrl(file: File): Promise<string> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const reader = new FileReader();
|
||||
@@ -331,6 +324,30 @@ export default function ChatInput({
|
||||
[mentionRange, selectedContexts, text],
|
||||
);
|
||||
|
||||
// Empty-state CTA: drop a starter prompt into the composer (never auto-sent)
|
||||
// and hand the user the caret at the end so they can finish the sentence.
|
||||
const handleContextPrefill = useCallback(
|
||||
(prompt: string) => {
|
||||
const next = capText(prompt);
|
||||
setText(next);
|
||||
committedTextRef.current = next;
|
||||
setMentionRange(null);
|
||||
setPickerSearchQuery('');
|
||||
setIsContextPickerOpen(false);
|
||||
// Defer so React commits the new value before we place the caret.
|
||||
requestAnimationFrame(() => {
|
||||
const el = textareaRef.current;
|
||||
if (!el) {
|
||||
return;
|
||||
}
|
||||
el.focus();
|
||||
const end = el.value.length;
|
||||
el.setSelectionRange(end, end);
|
||||
});
|
||||
},
|
||||
[capText],
|
||||
);
|
||||
|
||||
const focusCategory = useCallback((category: ContextCategory) => {
|
||||
setActiveContextCategory(category);
|
||||
setPickerSearchQuery('');
|
||||
@@ -824,10 +841,14 @@ export default function ChatInput({
|
||||
// Type-ahead filter against the `@<query>` typed in the textarea. When
|
||||
// the picker was opened from the "Add Context" button there's no
|
||||
// mention query, so fall back to the in-popover search input.
|
||||
const mentionQuery = mentionRange
|
||||
? text.slice(mentionRange.start + 1, mentionRange.end).toLowerCase()
|
||||
const rawMentionQuery = mentionRange
|
||||
? text.slice(mentionRange.start + 1, mentionRange.end)
|
||||
: '';
|
||||
const mentionQuery = rawMentionQuery.toLowerCase();
|
||||
const activeQuery = mentionQuery || pickerSearchQuery.trim().toLowerCase();
|
||||
// Original-case query for empty-state copy + prefill ("checkout", not the
|
||||
// lowercased filter key). Mirrors `activeQuery`'s mention-then-search order.
|
||||
const displayQuery = rawMentionQuery || pickerSearchQuery.trim();
|
||||
const filteredContextOptions = activeQuery
|
||||
? contextEntitiesByCategory[activeContextCategory].filter((entity) =>
|
||||
entity.value.toLowerCase().includes(activeQuery),
|
||||
@@ -1071,9 +1092,11 @@ export default function ChatInput({
|
||||
Failed to load {activeContextCategory.toLowerCase()}.
|
||||
</div>
|
||||
) : filteredContextOptions.length === 0 ? (
|
||||
<div className={styles.contextPopoverEmpty}>
|
||||
No matching entities
|
||||
</div>
|
||||
<ContextPickerEmptyState
|
||||
category={activeContextCategory}
|
||||
query={displayQuery}
|
||||
onPrefill={handleContextPrefill}
|
||||
/>
|
||||
) : (
|
||||
filteredContextOptions.map((option, index) => {
|
||||
const isSelected = selectedContexts.some(
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
import { Sparkles } from '@signozhq/icons';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import type { CSSProperties } from 'react';
|
||||
|
||||
import styles from './ChatInput.module.scss';
|
||||
import {
|
||||
CONTEXT_CATEGORY_ICONS,
|
||||
ContextCategory,
|
||||
getContextPickerEmptyContent,
|
||||
} from './contextPicker';
|
||||
|
||||
// Per-category accent, mapped to semantic accent tokens (robin is the brand
|
||||
// primary). Exposed to the SCSS as the `--empty-accent` custom property so the
|
||||
// icon and CTA share one colour per category.
|
||||
const CATEGORY_ACCENT: Record<ContextCategory, string> = {
|
||||
Dashboards: 'var(--accent-primary)',
|
||||
Alerts: 'var(--accent-cherry)',
|
||||
Services: 'var(--accent-forest)',
|
||||
};
|
||||
|
||||
interface ContextPickerEmptyStateProps {
|
||||
category: ContextCategory;
|
||||
/** The active search query (mention or in-popover search), original case. */
|
||||
query: string;
|
||||
/** Drops the starter prompt into the composer (never auto-sends). */
|
||||
onPrefill: (prompt: string) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Empty state for the @-mention context picker. Distinguishes a brand-new user
|
||||
* with nothing to pick (onboarding) from a search that matched nothing, and in
|
||||
* both cases offers a clickable CTA that seeds the composer.
|
||||
*/
|
||||
export default function ContextPickerEmptyState({
|
||||
category,
|
||||
query,
|
||||
onPrefill,
|
||||
}: ContextPickerEmptyStateProps): JSX.Element {
|
||||
const { title, ctaLabel, prefill } = getContextPickerEmptyContent(
|
||||
category,
|
||||
query,
|
||||
);
|
||||
const CategoryIcon = CONTEXT_CATEGORY_ICONS[category];
|
||||
|
||||
return (
|
||||
<div
|
||||
className={styles.contextPopoverEmpty}
|
||||
style={{ '--empty-accent': CATEGORY_ACCENT[category] } as CSSProperties}
|
||||
>
|
||||
<span className={styles.contextPopoverEmptyIcon} aria-hidden="true">
|
||||
<CategoryIcon size={16} />
|
||||
</span>
|
||||
<p className={styles.contextPopoverEmptyTitle}>{title}</p>
|
||||
<Button
|
||||
type="button"
|
||||
variant="link"
|
||||
size="sm"
|
||||
color="primary"
|
||||
className={styles.contextPopoverEmptyCta}
|
||||
onClick={(): void => onPrefill(prefill)}
|
||||
data-testid={`ai-context-empty-cta-${category}`}
|
||||
prefix={<Sparkles size={14} />}
|
||||
>
|
||||
<span className={styles.contextPopoverEmptyCtaLabel}>{ctaLabel}</span>
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
import { render, screen, userEvent, waitFor } from 'tests/test-utils';
|
||||
|
||||
// The prefill flow only depends on the context-picker data hooks resolving to
|
||||
// empty lists (so the empty state renders) — mock them to skip real fetches.
|
||||
jest.mock('hooks/dashboard/useGetAllDashboard', () => ({
|
||||
useGetAllDashboard: (): unknown => ({
|
||||
data: [],
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
}),
|
||||
}));
|
||||
|
||||
jest.mock('api/generated/services/rules', () => ({
|
||||
useListRules: (): unknown => ({ data: [], isLoading: false, isError: false }),
|
||||
getListRulesQueryKey: (): string[] => ['rules'],
|
||||
}));
|
||||
|
||||
jest.mock('hooks/useQueryService', () => ({
|
||||
useQueryService: (): unknown => ({
|
||||
data: [],
|
||||
isLoading: false,
|
||||
isFetching: false,
|
||||
isError: false,
|
||||
}),
|
||||
}));
|
||||
|
||||
// Irrelevant to the prefill flow and otherwise require browser APIs / extra
|
||||
// context providers, so stub them out.
|
||||
jest.mock('../../../hooks/useSpeechRecognition', () => ({
|
||||
useSpeechRecognition: (): unknown => ({
|
||||
isListening: false,
|
||||
isSupported: false,
|
||||
permission: 'prompt',
|
||||
start: jest.fn(),
|
||||
discard: jest.fn(),
|
||||
}),
|
||||
}));
|
||||
|
||||
jest.mock('../../../hooks/useAIAssistantAnalyticsContext', () => ({
|
||||
useAIAssistantAnalyticsContext: (): unknown => ({
|
||||
threadId: undefined,
|
||||
page: '/',
|
||||
mode: 'sidepane',
|
||||
}),
|
||||
}));
|
||||
|
||||
// eslint-disable-next-line import/first
|
||||
import { TooltipProvider } from '@signozhq/ui/tooltip';
|
||||
// eslint-disable-next-line import/first
|
||||
import ChatInput from '../ChatInput';
|
||||
|
||||
function renderChatInput(): void {
|
||||
render(
|
||||
<TooltipProvider>
|
||||
<ChatInput onSend={jest.fn()} />
|
||||
</TooltipProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
function getComposer(): HTMLTextAreaElement {
|
||||
return screen.getByPlaceholderText(/Ask anything/i) as HTMLTextAreaElement;
|
||||
}
|
||||
|
||||
describe('ChatInput — empty-state CTA prefill flow', () => {
|
||||
it('full-replaces existing prose with the query-seeded prompt and closes the picker', async () => {
|
||||
renderChatInput();
|
||||
|
||||
// Pre-existing prose in the composer.
|
||||
await userEvent.type(getComposer(), 'show me something');
|
||||
|
||||
// Open the picker and search for an entity that does not exist.
|
||||
await userEvent.click(screen.getByRole('button', { name: /add context/i }));
|
||||
await userEvent.type(
|
||||
await screen.findByPlaceholderText(/search dashboards/i),
|
||||
'chk',
|
||||
);
|
||||
|
||||
const cta = await screen.findByTestId('ai-context-empty-cta-Dashboards');
|
||||
expect(cta).toHaveTextContent('Create a dashboard for "chk"');
|
||||
|
||||
await userEvent.click(cta);
|
||||
|
||||
// Full-replace is intentional: the prefill is a complete sentence, so the
|
||||
// prior "show me something" prose is discarded rather than producing
|
||||
// broken grammar (see handleContextPrefill). The query is seeded in.
|
||||
expect(getComposer().value).toBe('Create a dashboard for chk');
|
||||
|
||||
// Picker closed → the empty-state CTA is gone.
|
||||
await waitFor(() =>
|
||||
expect(
|
||||
screen.queryByTestId('ai-context-empty-cta-Dashboards'),
|
||||
).not.toBeInTheDocument(),
|
||||
);
|
||||
});
|
||||
|
||||
it('seeds only the prefix (with trailing space) in the onboarding case', async () => {
|
||||
renderChatInput();
|
||||
|
||||
await userEvent.click(screen.getByRole('button', { name: /add context/i }));
|
||||
|
||||
const cta = await screen.findByTestId('ai-context-empty-cta-Dashboards');
|
||||
expect(cta).toHaveTextContent('Ask me to create one');
|
||||
|
||||
await userEvent.click(cta);
|
||||
|
||||
expect(getComposer().value).toBe('Create a dashboard for ');
|
||||
await waitFor(() =>
|
||||
expect(
|
||||
screen.queryByTestId('ai-context-empty-cta-Dashboards'),
|
||||
).not.toBeInTheDocument(),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,135 @@
|
||||
import { render, screen, userEvent } from 'tests/test-utils';
|
||||
|
||||
import { ContextCategory } from '../contextPicker';
|
||||
import ContextPickerEmptyState from '../ContextPickerEmptyState';
|
||||
|
||||
function renderEmptyState(
|
||||
category: ContextCategory,
|
||||
query: string,
|
||||
onPrefill = jest.fn(),
|
||||
): { onPrefill: jest.Mock; container: HTMLElement } {
|
||||
const { container } = render(
|
||||
<ContextPickerEmptyState
|
||||
category={category}
|
||||
query={query}
|
||||
onPrefill={onPrefill}
|
||||
/>,
|
||||
);
|
||||
return { onPrefill, container };
|
||||
}
|
||||
|
||||
function ctaFor(category: ContextCategory): HTMLElement {
|
||||
return screen.getByTestId(`ai-context-empty-cta-${category}`);
|
||||
}
|
||||
|
||||
describe('ContextPickerEmptyState', () => {
|
||||
describe('onboarding (no query)', () => {
|
||||
it('renders dashboards copy and prefills the prefix only', async () => {
|
||||
const { onPrefill } = renderEmptyState('Dashboards', '');
|
||||
|
||||
expect(screen.getByText('No dashboards yet.')).toBeInTheDocument();
|
||||
const cta = ctaFor('Dashboards');
|
||||
expect(cta).toHaveTextContent('Ask me to create one');
|
||||
|
||||
await userEvent.click(cta);
|
||||
expect(onPrefill).toHaveBeenCalledTimes(1);
|
||||
expect(onPrefill).toHaveBeenCalledWith('Create a dashboard for ');
|
||||
});
|
||||
|
||||
it('renders alerts copy and prefills the prefix only', async () => {
|
||||
const { onPrefill } = renderEmptyState('Alerts', '');
|
||||
|
||||
expect(screen.getByText('No alerts yet.')).toBeInTheDocument();
|
||||
expect(ctaFor('Alerts')).toHaveTextContent('Ask me to create one');
|
||||
|
||||
await userEvent.click(ctaFor('Alerts'));
|
||||
expect(onPrefill).toHaveBeenCalledWith('Create an alert for ');
|
||||
});
|
||||
|
||||
it('renders instrumentation-flavoured services copy and prefill', async () => {
|
||||
const { onPrefill } = renderEmptyState('Services', '');
|
||||
|
||||
expect(
|
||||
screen.getByText('No services reporting data yet.'),
|
||||
).toBeInTheDocument();
|
||||
expect(ctaFor('Services')).toHaveTextContent(
|
||||
'Ask me to help set up instrumentation',
|
||||
);
|
||||
|
||||
await userEvent.click(ctaFor('Services'));
|
||||
expect(onPrefill).toHaveBeenCalledWith(
|
||||
'Help me set up instrumentation for ',
|
||||
);
|
||||
});
|
||||
|
||||
it('treats a whitespace-only query as onboarding', () => {
|
||||
renderEmptyState('Dashboards', ' ');
|
||||
expect(screen.getByText('No dashboards yet.')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('search miss (query, no match)', () => {
|
||||
it('seeds the query into dashboards copy and prefill', async () => {
|
||||
const { onPrefill } = renderEmptyState('Dashboards', 'checkout');
|
||||
|
||||
expect(
|
||||
screen.getByText('No dashboards match "checkout".'),
|
||||
).toBeInTheDocument();
|
||||
expect(ctaFor('Dashboards')).toHaveTextContent(
|
||||
'Create a dashboard for "checkout"',
|
||||
);
|
||||
|
||||
await userEvent.click(ctaFor('Dashboards'));
|
||||
expect(onPrefill).toHaveBeenCalledWith('Create a dashboard for checkout');
|
||||
});
|
||||
|
||||
it('seeds the query into alerts copy and prefill', async () => {
|
||||
const { onPrefill } = renderEmptyState('Alerts', 'checkout');
|
||||
|
||||
expect(screen.getByText('No alerts match "checkout".')).toBeInTheDocument();
|
||||
await userEvent.click(ctaFor('Alerts'));
|
||||
expect(onPrefill).toHaveBeenCalledWith('Create an alert for checkout');
|
||||
});
|
||||
|
||||
it('uses instrumentation wording for services search misses', async () => {
|
||||
const { onPrefill } = renderEmptyState('Services', 'checkout');
|
||||
|
||||
expect(
|
||||
screen.getByText('No services match "checkout".'),
|
||||
).toBeInTheDocument();
|
||||
await userEvent.click(ctaFor('Services'));
|
||||
expect(onPrefill).toHaveBeenCalledWith(
|
||||
'Help me set up instrumentation for checkout',
|
||||
);
|
||||
});
|
||||
|
||||
it('preserves the original casing of the query in copy and prefill', async () => {
|
||||
const { onPrefill } = renderEmptyState('Dashboards', 'Checkout API');
|
||||
|
||||
expect(
|
||||
screen.getByText('No dashboards match "Checkout API".'),
|
||||
).toBeInTheDocument();
|
||||
await userEvent.click(ctaFor('Dashboards'));
|
||||
expect(onPrefill).toHaveBeenCalledWith(
|
||||
'Create a dashboard for Checkout API',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('per-category accent token', () => {
|
||||
it.each<[ContextCategory, string]>([
|
||||
['Dashboards', 'var(--accent-primary)'],
|
||||
['Alerts', 'var(--accent-cherry)'],
|
||||
['Services', 'var(--accent-forest)'],
|
||||
])('maps %s to the semantic accent %s', (category, accent) => {
|
||||
const { container } = renderEmptyState(category, '');
|
||||
const root = container.firstChild as HTMLElement;
|
||||
expect(root.style.getPropertyValue('--empty-accent')).toBe(accent);
|
||||
});
|
||||
});
|
||||
|
||||
it('does not auto-send: nothing fires until the CTA is clicked', () => {
|
||||
const { onPrefill } = renderEmptyState('Dashboards', 'checkout');
|
||||
expect(onPrefill).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,100 @@
|
||||
import {
|
||||
CONTEXT_CATEGORIES,
|
||||
getContextPickerEmptyContent,
|
||||
} from '../contextPicker';
|
||||
|
||||
describe('getContextPickerEmptyContent', () => {
|
||||
describe('onboarding (no query)', () => {
|
||||
it('returns per-category copy and prefix-only prefill for dashboards', () => {
|
||||
expect(getContextPickerEmptyContent('Dashboards', '')).toStrictEqual({
|
||||
title: 'No dashboards yet.',
|
||||
ctaLabel: 'Ask me to create one',
|
||||
prefill: 'Create a dashboard for ',
|
||||
});
|
||||
});
|
||||
|
||||
it('returns per-category copy and prefix-only prefill for alerts', () => {
|
||||
expect(getContextPickerEmptyContent('Alerts', '')).toStrictEqual({
|
||||
title: 'No alerts yet.',
|
||||
ctaLabel: 'Ask me to create one',
|
||||
prefill: 'Create an alert for ',
|
||||
});
|
||||
});
|
||||
|
||||
it('returns instrumentation-flavoured copy for services', () => {
|
||||
expect(getContextPickerEmptyContent('Services', '')).toStrictEqual({
|
||||
title: 'No services reporting data yet.',
|
||||
ctaLabel: 'Ask me to help set up instrumentation',
|
||||
prefill: 'Help me set up instrumentation for ',
|
||||
});
|
||||
});
|
||||
|
||||
it('treats a whitespace-only query as no query', () => {
|
||||
expect(getContextPickerEmptyContent('Dashboards', ' ')).toStrictEqual({
|
||||
title: 'No dashboards yet.',
|
||||
ctaLabel: 'Ask me to create one',
|
||||
prefill: 'Create a dashboard for ',
|
||||
});
|
||||
});
|
||||
|
||||
it('leaves the prefill ending in a space so the caret sits after it', () => {
|
||||
CONTEXT_CATEGORIES.forEach((category) => {
|
||||
expect(getContextPickerEmptyContent(category, '').prefill).toMatch(/ $/);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('search miss (query, no match)', () => {
|
||||
it('seeds the query into dashboards copy and prefill', () => {
|
||||
expect(getContextPickerEmptyContent('Dashboards', 'checkout')).toStrictEqual(
|
||||
{
|
||||
title: 'No dashboards match "checkout".',
|
||||
ctaLabel: 'Create a dashboard for "checkout"',
|
||||
prefill: 'Create a dashboard for checkout',
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it('seeds the query into alerts copy and prefill', () => {
|
||||
expect(getContextPickerEmptyContent('Alerts', 'checkout')).toStrictEqual({
|
||||
title: 'No alerts match "checkout".',
|
||||
ctaLabel: 'Create an alert for "checkout"',
|
||||
prefill: 'Create an alert for checkout',
|
||||
});
|
||||
});
|
||||
|
||||
it('uses instrumentation wording for services search misses', () => {
|
||||
expect(getContextPickerEmptyContent('Services', 'checkout')).toStrictEqual({
|
||||
title: 'No services match "checkout".',
|
||||
ctaLabel: 'Set up instrumentation for "checkout"',
|
||||
prefill: 'Help me set up instrumentation for checkout',
|
||||
});
|
||||
});
|
||||
|
||||
it('preserves the original casing of the query', () => {
|
||||
const { title, ctaLabel, prefill } = getContextPickerEmptyContent(
|
||||
'Dashboards',
|
||||
'Checkout API',
|
||||
);
|
||||
expect(title).toBe('No dashboards match "Checkout API".');
|
||||
expect(ctaLabel).toBe('Create a dashboard for "Checkout API"');
|
||||
expect(prefill).toBe('Create a dashboard for Checkout API');
|
||||
});
|
||||
|
||||
it('trims surrounding whitespace from the query', () => {
|
||||
expect(
|
||||
getContextPickerEmptyContent('Dashboards', ' checkout ').prefill,
|
||||
).toBe('Create a dashboard for checkout');
|
||||
});
|
||||
});
|
||||
|
||||
it('never emits an em-dash (house style)', () => {
|
||||
CONTEXT_CATEGORIES.forEach((category) => {
|
||||
const empty = getContextPickerEmptyContent(category, '');
|
||||
const miss = getContextPickerEmptyContent(category, 'q');
|
||||
[empty, miss].forEach(({ title, ctaLabel, prefill }) => {
|
||||
expect(`${title}${ctaLabel}${prefill}`).not.toContain('—');
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||