Compare commits
2 Commits
main
...
feat/noz-e
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f41dc64d65 | ||
|
|
934cf08774 |
233
README.de-de.md
@@ -1,190 +1,197 @@
|
||||
<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 nach deinen Bedingungen, basierend auf offenen Standards." src="docs/readme-assets/signoz-hero-light.png" width="900">
|
||||
</picture>
|
||||
<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>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="README.md">English</a> ·
|
||||
<a href="README.zh-cn.md">中文</a> ·
|
||||
<a href="README.pt-br.md">Português</a>
|
||||
<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>
|
||||
</p>
|
||||
|
||||
<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>
|
||||
<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>
|
||||
|
||||
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.
|
||||
##
|
||||
|
||||
### Wähle, wie du SigNoz betreibst
|
||||
SigNoz hilft Entwicklern, Anwendungen zu überwachen und Probleme in ihren bereitgestellten Anwendungen zu beheben. Mit SigNoz können Sie Folgendes tun:
|
||||
|
||||
#### SigNoz Cloud (empfohlen)
|
||||
👉 Visualisieren Sie Metriken, Traces und Logs in einer einzigen Oberfläche.
|
||||
|
||||
Vollständig verwaltetes SigNoz mit 30 Tagen kostenloser Testphase, ohne Kreditkarte, nutzungsbasierter Preisgestaltung ab 49 USD und regionalem Datenhosting.
|
||||
👉 Sie können Metriken wie die p99-Latenz, Fehlerquoten für Ihre Dienste, externe API-Aufrufe und individuelle Endpunkte anzeigen.
|
||||
|
||||
[**Kostenlos starten →**](https://signoz.io/teams/)
|
||||
👉 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.
|
||||
|
||||
#### Enterprise
|
||||
👉 Führen Sie Aggregationen auf Trace-Daten durch, um geschäftsrelevante Metriken zu erhalten.
|
||||
|
||||
Enterprise Cloud, BYOC oder Enterprise Self-Hosted mit Compliance, Support, benutzerdefinierter Aufbewahrung, RBAC, Ingestion Controls, Datenresidenz und Regionsauswahl.
|
||||
👉 Filtern und Abfragen von Logs, Erstellen von Dashboards und Benachrichtigungen basierend auf Attributen in den Logs.
|
||||
|
||||
[**Enterprise entdecken →**](https://signoz.io/enterprise/)
|
||||
👉 Automatische Aufzeichnung von Ausnahmen in Python, Java, Ruby und Javascript.
|
||||
|
||||
#### Community
|
||||
👉 Einfache Einrichtung von Benachrichtigungen mit dem selbst erstellbaren Abfrage-Builder.
|
||||
|
||||
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.
|
||||
##
|
||||
|
||||
[**SigNoz installieren →**](https://signoz.io/docs/install/self-host/)
|
||||
### Anwendung Metriken
|
||||
|
||||
### Was kannst du überwachen?
|
||||

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

|
||||
|
||||
#### Metriken und Dashboards
|
||||
### Alarme
|
||||
|
||||
Erstelle Dashboards für Anwendungs-, Infrastruktur- und benutzerdefinierte Metriken mit Query Builder, PromQL oder ClickHouse SQL.
|
||||
<img width="2068" alt="alerts_management" src="https://user-images.githubusercontent.com/83692067/226536548-2c81e2e8-c12d-47e8-bad7-c6be79055def.png">
|
||||
|
||||
<p align="center">
|
||||
<img alt="SigNoz Host-Metrics-Dashboard mit Systemlast- und Netzwerkdiagrammen" src="docs/readme-assets/monitor/metrics.png" width="900">
|
||||
</p>
|
||||
<br /><br />
|
||||
|
||||
Mehr erfahren: [Metriken-Dokumentation](https://signoz.io/docs/metrics-management/overview/)
|
||||
## Werde Teil unserer Slack Community
|
||||
|
||||
#### Infrastruktur-Monitoring
|
||||
Sag Hi zu uns auf [Slack](https://signoz.io/slack) 👋
|
||||
|
||||
Überwache Kubernetes-Cluster, Pods, Nodes, Workloads sowie Host-CPU, Arbeitsspeicher, Festplatten, Netzwerk, Logs und Traces.
|
||||
<br /><br />
|
||||
|
||||
<p align="center">
|
||||
<img alt="SigNoz Kubernetes-Infrastruktur-Dashboard mit Pod- und Node-Metriken" src="docs/readme-assets/monitor/infrastructure.png" width="900">
|
||||
</p>
|
||||
## Funktionen:
|
||||
|
||||
Mehr erfahren: [Infrastruktur-Monitoring-Dokumentation](https://signoz.io/docs/infrastructure-monitoring/overview/)
|
||||
- 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.
|
||||
|
||||
#### LLM- und AI-Observability
|
||||
<br /><br />
|
||||
|
||||
Verfolge LLM-Apps, RAG-Pipelines, Prompts, Tool Calls, Tokens, Latenz und Kosten zusammen mit Anwendungs- und Infrastruktur-Telemetrie.
|
||||
## Wieso SigNoz?
|
||||
|
||||
<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>
|
||||
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.
|
||||
|
||||
Mehr erfahren: [LLM-Observability-Dokumentation](https://signoz.io/docs/llm-observability/)
|
||||
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.
|
||||
|
||||
#### Agent-Native Observability und MCP
|
||||
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.
|
||||
|
||||
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.
|
||||
### Languages supported:
|
||||
|
||||
<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>
|
||||
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:
|
||||
|
||||
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)
|
||||
- Java
|
||||
- Python
|
||||
- NodeJS
|
||||
- Go
|
||||
- PHP
|
||||
- .NET
|
||||
- Ruby
|
||||
- Elixir
|
||||
- Rust
|
||||
|
||||
#### Distributed Tracing
|
||||
Hier findest du die vollständige Liste von unterstützten Programmiersprachen - https://opentelemetry.io/docs/
|
||||
|
||||
Verfolge Requests über Services hinweg mit Flamegraphs, Waterfalls, Span Events, Filtern und Trace Analytics.
|
||||
<br /><br />
|
||||
|
||||
<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>
|
||||
## Erste Schritte mit SigNoz
|
||||
|
||||
Mehr erfahren: [Distributed-Tracing-Dokumentation](https://signoz.io/docs/instrumentation/)
|
||||
### Bereitstellung mit Docker
|
||||
|
||||
#### Trace Funnels
|
||||
Bitte folge den [hier](https://signoz.io/docs/install/docker/) aufgelisteten Schritten um deine Anwendung mit Docker bereitzustellen.
|
||||
|
||||
Erstelle Funnels aus Traces, um Drop-offs im Request-Flow, fehlgeschlagene Übergänge und systemische Workflow-Probleme zu verstehen.
|
||||
Die [Anleitungen zur Fehlerbehebung](https://signoz.io/docs/install/troubleshooting/) könnten hilfreich sein, falls du auf irgendwelche Schwierigkeiten stößt.
|
||||
|
||||
<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>
|
||||
<p>  </p>
|
||||
|
||||
Mehr erfahren: [Trace-Funnels-Dokumentation](https://signoz.io/docs/trace-funnels/overview/)
|
||||
### Deploy in Kubernetes using Helm
|
||||
|
||||
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.
|
||||
Bitte folge den [hier](https://signoz.io/docs/deployment/helm_chart) aufgelisteten Schritten, um deine Anwendung mit Helm Charts bereitzustellen.
|
||||
|
||||
### Warum Teams SigNoz nutzen
|
||||
<br /><br />
|
||||
|
||||
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.
|
||||
## Vergleiche mit bekannten Tools
|
||||
|
||||
### Erste Schritte
|
||||
### SigNoz vs Prometheus
|
||||
|
||||
#### Mit Cloud starten
|
||||
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.
|
||||
|
||||
Erstelle einen verwalteten SigNoz-Workspace und erhalte dein erstes Dashboard, ohne Observability-Infrastruktur betreiben zu müssen.
|
||||
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.
|
||||
|
||||
[**Kostenlos mit SigNoz Cloud starten**](https://signoz.io/teams/)
|
||||
<p>  </p>
|
||||
|
||||
#### SigNoz selbst hosten
|
||||
### SigNoz vs Jaeger
|
||||
|
||||
Betreibe SigNoz in deiner eigenen Infrastruktur mit Foundry, Docker, Kubernetes oder Linux.
|
||||
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.
|
||||
|
||||
[**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/)
|
||||
Außerdem hat SigNoz noch mehr spezielle Funktionen im Vergleich zu Jaeger:
|
||||
|
||||
#### Daten senden
|
||||
- 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.
|
||||
|
||||
Instrumentiere Anwendungen und Infrastruktur mit OpenTelemetry, Prometheus, Sprach-SDKs und Integrationen.
|
||||
<p>  </p>
|
||||
|
||||
[**Instrumentation**](https://signoz.io/docs/instrumentation/) · [**Integrationen**](https://signoz.io/docs/integrations/integrations-list/)
|
||||
### SigNoz vs Elastic
|
||||
|
||||
### Vergleich mit bekannten Tools
|
||||
- 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.
|
||||
|
||||
SigNoz wird häufig von Teams eingeführt, die von einzelnen Spezialtools oder kommerziellen Plattformen mit unvorhersehbarer Preisgestaltung wechseln.
|
||||
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)
|
||||
|
||||
**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.
|
||||
<p>  </p>
|
||||
|
||||
**Jaeger**<br>
|
||||
Jaeger macht ausschließlich Distributed Tracing. SigNoz ergänzt Metriken, Logs, Trace Analytics, Dashboards, Alerts, Exceptions und Trace-to-Log-Workflows.
|
||||
### SigNoz vs Loki
|
||||
|
||||
**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).
|
||||
- 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.
|
||||
|
||||
**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).
|
||||
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)
|
||||
|
||||
## Mitwirken
|
||||
<br /><br />
|
||||
|
||||
Wir freuen uns über große und kleine Beiträge. Lies bitte [CONTRIBUTING.md](CONTRIBUTING.md), um mit Beiträgen zu SigNoz loszulegen.
|
||||
## Zum Projekt beitragen
|
||||
|
||||
Nicht sicher, wie du anfangen sollst? **Schreib uns einfach im Channel `#contributing` in unserer [Slack Community](https://signoz.io/slack).**
|
||||
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)
|
||||
|
||||
Wie immer: Danke an unsere großartigen Contributors!
|
||||
<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!
|
||||
|
||||
<a href="https://github.com/signoz/signoz/graphs/contributors">
|
||||
<img alt="SigNoz Contributors" src="https://contrib.rocks/image?repo=signoz/signoz" />
|
||||
<img src="https://contrib.rocks/image?repo=signoz/signoz" />
|
||||
</a>
|
||||
|
||||
278
README.md
@@ -1,190 +1,244 @@
|
||||
<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>
|
||||
<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">
|
||||
<a href="README.zh-cn.md">中文</a> ·
|
||||
<a href="README.de-de.md">Deutsch</a> ·
|
||||
<a href="README.pt-br.md">Português</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>
|
||||
</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>
|
||||
|
||||
<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>
|
||||
## Features
|
||||
|
||||
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.
|
||||
|
||||
### Choose how to run SigNoz
|
||||
### Application Performance Monitoring
|
||||
|
||||
#### SigNoz Cloud (Recommended)
|
||||
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/).
|
||||
|
||||
Fully managed SigNoz with a 30-day free trial, no credit card required, usage-based pricing that starts at $49, and regional data hosting.
|
||||
You can [instrument](https://signoz.io/docs/instrumentation/) your application with OpenTelemetry to get started.
|
||||
|
||||
[**Start free →**](https://signoz.io/teams/)
|
||||

|
||||
|
||||
#### Enterprise
|
||||
|
||||
Enterprise Cloud, BYOC, or Enterprise Self-Hosted with compliance, support, custom retention, RBAC, ingestion controls, data residency, and region selection.
|
||||
### Logs Management
|
||||
|
||||
[**Explore Enterprise →**](https://signoz.io/enterprise/)
|
||||
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.
|
||||
|
||||
#### Community
|
||||
You can also create charts on your logs and monitor them with customized dashboards. Read [more](https://signoz.io/log-management/).
|
||||
|
||||
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/)
|
||||
|
||||
### What can you monitor?
|
||||
### Distributed Tracing
|
||||
|
||||
SigNoz helps teams debug production issues faster by connecting logs, metrics, traces, alerts, dashboards, exceptions, and agent-native workflows in one place.
|
||||
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.
|
||||
|
||||
#### APM Overview
|
||||
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.
|
||||
|
||||
Monitor service latency, error rate, throughput, Apdex, top endpoints, database calls, and external calls.
|
||||
Read [more](https://signoz.io/distributed-tracing/).
|
||||
|
||||
<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
|
||||
|
||||
Ingest, search, aggregate, and correlate logs with traces and metrics using a visual query builder.
|
||||
### Metrics and Dashboards
|
||||
|
||||
<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>
|
||||
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.
|
||||
|
||||
Learn more: [Log management documentation](https://signoz.io/docs/logs-management/overview/)
|
||||
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.
|
||||
|
||||
#### Metrics and Dashboards
|
||||
Read [more](https://signoz.io/metrics-and-dashboards/).
|
||||
|
||||
Build dashboards for application, infrastructure, and custom metrics using Query Builder, PromQL, or ClickHouse SQL.
|
||||

|
||||
|
||||
<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>
|
||||
### LLM Observability
|
||||
|
||||
Learn more: [Metrics documentation](https://signoz.io/docs/metrics-management/overview/)
|
||||
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.
|
||||
|
||||
#### Infrastructure Monitoring
|
||||
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.
|
||||
|
||||
Monitor Kubernetes clusters, pods, nodes, workloads, and host-level CPU, memory, disk, network, logs, and traces.
|
||||
[Get started with LLM Observability →](https://signoz.io/docs/llm-observability/)
|
||||
|
||||
<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/)
|
||||
|
||||
#### LLM and AI Observability
|
||||
### Alerts
|
||||
|
||||
Trace LLM apps, RAG pipelines, prompts, tool calls, tokens, latency, and costs alongside application and infrastructure telemetry.
|
||||
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.
|
||||
|
||||
<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>
|
||||
Alerts in SigNoz help you identify issues proactively so that you can address them before they reach your customers.
|
||||
|
||||
Learn more: [LLM observability documentation](https://signoz.io/docs/llm-observability/)
|
||||
Read [more](https://signoz.io/alerts-management/).
|
||||
|
||||
#### Agent-Native Observability and MCP
|
||||

|
||||
|
||||
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.
|
||||
### Exceptions Monitoring
|
||||
|
||||
<p align="center">
|
||||
<img alt="SigNoz Noz interface alongside MCP-powered agent workflow" src="docs/readme-assets/monitor/agent-native.png" width="900">
|
||||
</p>
|
||||
Monitor exceptions automatically in Python, Java, Ruby, and Javascript. For other languages, just drop in a few lines of code and start monitoring exceptions.
|
||||
|
||||
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)
|
||||
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.
|
||||
|
||||
#### Distributed Tracing
|
||||
Read [more](https://signoz.io/exceptions-monitoring/).
|
||||
|
||||
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/)
|
||||
|
||||
#### Trace Funnels
|
||||
<br /><br />
|
||||
|
||||
Create funnels from traces to understand request-flow drop-offs, failed transitions, and systemic workflow issues.
|
||||
## Why 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>
|
||||
SigNoz is a single tool for all your monitoring and observability needs. Here are a few reasons why you should choose SigNoz:
|
||||
|
||||
Learn more: [Trace funnels documentation](https://signoz.io/docs/trace-funnels/overview/)
|
||||
- Single tool for observability(logs, metrics, and traces)
|
||||
|
||||
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.
|
||||
- Built on top of [OpenTelemetry](https://opentelemetry.io/), the open-source standard which frees you from any type of vendor lock-in
|
||||
|
||||
### Why teams use SigNoz
|
||||
- Correlated logs, metrics and traces for much richer context while debugging
|
||||
|
||||
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.
|
||||
- Uses ClickHouse (used by likes of Uber & Cloudflare) as datastore - an extremely fast and highly optimized storage for observability data
|
||||
|
||||
### Getting started
|
||||
- DIY Query builder, PromQL, and ClickHouse queries to fulfill all your use-cases around querying observability data
|
||||
|
||||
#### Start on Cloud
|
||||
- Open-Source - you can use open-source, our [cloud service](https://signoz.io/teams/) or a mix of both based on your use case
|
||||
|
||||
Create a managed SigNoz workspace and get your first dashboard without running observability infrastructure.
|
||||
|
||||
[**Start free on SigNoz Cloud**](https://signoz.io/teams/)
|
||||
## Getting Started
|
||||
|
||||
#### Self-host SigNoz
|
||||
### Create a SigNoz Cloud Account
|
||||
|
||||
Run SigNoz in your own infrastructure with Foundry, Docker, Kubernetes, or Linux.
|
||||
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.
|
||||
|
||||
[**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/)
|
||||
[Get started for free](https://signoz.io/teams/)
|
||||
|
||||
#### Send data
|
||||
### Deploy using Docker(self-hosted)
|
||||
|
||||
Instrument applications and infrastructure with OpenTelemetry, Prometheus, language SDKs, and integrations.
|
||||
Please follow the steps listed [here](https://signoz.io/docs/install/docker/) to install using docker
|
||||
|
||||
[**Instrumentation**](https://signoz.io/docs/instrumentation/) · [**Integrations**](https://signoz.io/docs/integrations/integrations-list/)
|
||||
The [troubleshooting instructions](https://signoz.io/docs/install/troubleshooting/) may be helpful if you face any issues.
|
||||
|
||||
### Comparisons to familiar tools
|
||||
<p>  </p>
|
||||
|
||||
|
||||
### Deploy in Kubernetes using Helm(self-hosted)
|
||||
|
||||
SigNoz is often adopted by teams moving from a stack of single-purpose tools or commercial platforms with unpredictable pricing.
|
||||
Please follow the steps listed [here](https://signoz.io/docs/deployment/helm_chart) to install using helm charts
|
||||
|
||||
**Prometheus**<br>
|
||||
Good if you just need metrics. SigNoz keeps metrics, logs, traces, dashboards, and alerts together so teams can debug with correlated context.
|
||||
<br /><br />
|
||||
|
||||
**Jaeger**<br>
|
||||
Jaeger only does distributed tracing. SigNoz adds metrics, logs, trace analytics, dashboards, alerts, exceptions, and trace-to-log workflows.
|
||||
We also offer managed services in your infra. Check our [pricing plans](https://signoz.io/pricing/) for all details.
|
||||
|
||||
**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).
|
||||
|
||||
**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).
|
||||
## 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 />
|
||||
|
||||
|
||||
## 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).**
|
||||
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)
|
||||
|
||||
As always, thanks to our amazing contributors!
|
||||
|
||||
<a href="https://github.com/signoz/signoz/graphs/contributors">
|
||||
<img alt="SigNoz contributors" src="https://contrib.rocks/image?repo=signoz/signoz" />
|
||||
<img src="https://contrib.rocks/image?repo=signoz/signoz" />
|
||||
</a>
|
||||
|
||||
232
README.pt-br.md
@@ -1,190 +1,158 @@
|
||||
<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 - Observabilidade nos seus termos, baseada em padrões abertos." src="docs/readme-assets/signoz-hero-light.png" width="900">
|
||||
</picture>
|
||||
<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>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="README.md">English</a> ·
|
||||
<a href="README.zh-cn.md">中文</a> ·
|
||||
<a href="README.de-de.md">Deutsch</a>
|
||||
<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>
|
||||
</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 é 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.
|
||||
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.
|
||||
|
||||
### Escolha como executar o SigNoz
|
||||
👉 Você pode verificar métricas como latência p99, taxas de erro em seus serviços, requisições às APIs externas e endpoints individuais.
|
||||
|
||||
#### SigNoz Cloud (recomendado)
|
||||
👉 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 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.
|
||||
👉 Execute agregações em dados de rastreamento para obter métricas de negócios relevantes.
|
||||
|
||||
[**Comece gratuitamente →**](https://signoz.io/teams/)
|
||||
|
||||
#### Enterprise
|
||||

|
||||
|
||||
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.
|
||||
<br /><br />
|
||||
|
||||
[**Conheça o Enterprise →**](https://signoz.io/enterprise/)
|
||||
<img align="left" src="https://signoz-public.s3.us-east-2.amazonaws.com/Contributing.svg" width="50px" />
|
||||
|
||||
#### Community
|
||||
## Junte-se à nossa comunidade no 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.
|
||||
Venha dizer oi para nós no [Slack](https://signoz.io/slack) 👋
|
||||
|
||||
[**Instale o SigNoz →**](https://signoz.io/docs/install/self-host/)
|
||||
<br /><br />
|
||||
|
||||
### O que você pode monitorar?
|
||||
<img align="left" src="https://signoz-public.s3.us-east-2.amazonaws.com/Features.svg" width="50px" />
|
||||
|
||||
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.
|
||||
## Funções:
|
||||
|
||||
#### Visão geral de APM
|
||||
- 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.
|
||||
|
||||
Monitore latência de serviço, taxa de erro, throughput, Apdex, principais endpoints, chamadas ao banco de dados e chamadas externas.
|
||||
<br /><br />
|
||||
|
||||
<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>
|
||||
<img align="left" src="https://signoz-public.s3.us-east-2.amazonaws.com/WhatsCool.svg" width="50px" />
|
||||
|
||||
Saiba mais: [documentação de APM](https://signoz.io/docs/instrumentation/overview/)
|
||||
## Por que escolher SigNoz?
|
||||
|
||||
#### Gerenciamento de logs
|
||||
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 .
|
||||
|
||||
Ingira, pesquise, agregue e correlacione logs com traces e métricas usando um construtor visual de consultas.
|
||||
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.
|
||||
|
||||
<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>
|
||||
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.
|
||||
|
||||
Saiba mais: [documentação de gerenciamento de logs](https://signoz.io/docs/logs-management/overview/)
|
||||
### Linguagens Suportadas:
|
||||
|
||||
#### Métricas e dashboards
|
||||
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:
|
||||
|
||||
Crie dashboards para métricas de aplicação, infraestrutura e métricas personalizadas usando Query Builder, PromQL ou ClickHouse SQL.
|
||||
- Java
|
||||
- Python
|
||||
- NodeJS
|
||||
- Go
|
||||
|
||||
<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>
|
||||
Você pode encontrar a lista completa de linguagens aqui - https://opentelemetry.io/docs/
|
||||
|
||||
Saiba mais: [documentação de métricas](https://signoz.io/docs/metrics-management/overview/)
|
||||
<br /><br />
|
||||
|
||||
#### Monitoramento de infraestrutura
|
||||
<img align="left" src="https://signoz-public.s3.us-east-2.amazonaws.com/Philosophy.svg" width="50px" />
|
||||
|
||||
Monitore clusters Kubernetes, pods, nodes, workloads e CPU, memória, disco, rede, logs e traces em nível de host.
|
||||
## Iniciando
|
||||
|
||||
|
||||
### Implantar usando 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>
|
||||
Siga as etapas listadas [aqui](https://signoz.io/docs/install/docker/) para instalar usando o Docker.
|
||||
|
||||
Saiba mais: [documentação de monitoramento de infraestrutura](https://signoz.io/docs/infrastructure-monitoring/overview/)
|
||||
Esse [guia para solução de problemas](https://signoz.io/docs/install/troubleshooting/) pode ser útil se você enfrentar quaisquer problemas.
|
||||
|
||||
#### Observabilidade de LLM e AI
|
||||
<p>  </p>
|
||||
|
||||
|
||||
### Implentar no Kubernetes usando Helm
|
||||
|
||||
Rastreie apps LLM, pipelines RAG, prompts, chamadas de ferramentas, tokens, latência e custos junto com telemetria de aplicação e infraestrutura.
|
||||
Siga as etapas listadas [aqui](https://signoz.io/docs/deployment/helm_chart) para instalar usando helm charts.
|
||||
|
||||
|
||||
<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>
|
||||
<br /><br />
|
||||
|
||||
Saiba mais: [documentação de observabilidade de LLM](https://signoz.io/docs/llm-observability/)
|
||||
<img align="left" src="https://signoz-public.s3.us-east-2.amazonaws.com/UseSigNoz.svg" width="50px" />
|
||||
|
||||
#### Observabilidade agent-native e MCP
|
||||
## Comparações com ferramentas similares
|
||||
|
||||
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.
|
||||
### SigNoz ou Prometheus
|
||||
|
||||
<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>
|
||||
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.
|
||||
|
||||
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)
|
||||
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.
|
||||
|
||||
#### Tracing distribuído
|
||||
<p>  </p>
|
||||
|
||||
Acompanhe requisições entre serviços com flamegraphs, waterfalls, eventos de span, filtros e análise de traces.
|
||||
### SigNoz ou Jaeger
|
||||
|
||||
<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>
|
||||
Jaeger só faz rastreamento distribuído. SigNoz faz métricas e rastreia, e também temos gerenciamento de log em nossos planos.
|
||||
|
||||
Saiba mais: [documentação de tracing distribuído](https://signoz.io/docs/instrumentation/)
|
||||
Além disso, SigNoz tem alguns recursos mais avançados do que Jaeger:
|
||||
|
||||
#### Trace Funnels
|
||||
- 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.
|
||||
|
||||
Crie funis a partir de traces para entender quedas no fluxo de requisições, transições com falha e problemas sistêmicos de workflow.
|
||||
<br /><br />
|
||||
|
||||
<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).
|
||||
<img align="left" src="https://signoz-public.s3.us-east-2.amazonaws.com/Contributors.svg" width="50px" />
|
||||
|
||||
## Contribuindo
|
||||
|
||||
Adoramos contribuições grandes ou pequenas. Leia [CONTRIBUTING.md](CONTRIBUTING.md) para começar a contribuir com o SigNoz.
|
||||
|
||||
Não sabe como começar? **Fale conosco no `#contributing` na nossa [comunidade Slack](https://signoz.io/slack).**
|
||||
Nós ❤️ contribuições grandes ou pequenas. Leia [CONTRIBUTING.md](CONTRIBUTING.md) para começar a fazer contribuições para o SigNoz.
|
||||
|
||||
Como sempre, obrigado aos nossos incríveis contribuidores!
|
||||
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!
|
||||
|
||||
<a href="https://github.com/signoz/signoz/graphs/contributors">
|
||||
<img alt="Contribuidores do SigNoz" src="https://contrib.rocks/image?repo=signoz/signoz" />
|
||||
<img src="https://contrib.rocks/image?repo=signoz/signoz" />
|
||||
</a>
|
||||
|
||||
|
||||
|
||||
|
||||
244
README.zh-cn.md
@@ -1,190 +1,208 @@
|
||||
<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>
|
||||
<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>
|
||||
|
||||
<p align="center">
|
||||
<a href="README.md">English</a> ·
|
||||
<a href="README.de-de.md">Deutsch</a> ·
|
||||
<a href="README.pt-br.md">Português</a>
|
||||
<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>
|
||||
</p>
|
||||
|
||||
<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>
|
||||
<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>
|
||||
|
||||
SigNoz 是一个基于 OpenTelemetry 构建的开源可观测性平台。我们正在构建一个企业级替代方案,用来替代分散的监控工具栈,把日志、指标、链路追踪、告警和仪表盘放在同一个地方。
|
||||
##
|
||||
|
||||
### 选择 SigNoz 的运行方式
|
||||
SigNoz 帮助开发人员监控应用并排查已部署应用的问题。你可以使用 SigNoz 实现如下能力:
|
||||
|
||||
#### SigNoz Cloud(推荐)
|
||||
👉 在同一块面板上,可视化 Metrics, Traces 和 Logs 内容。
|
||||
|
||||
完全托管的 SigNoz,提供 30 天免费试用,无需信用卡,按用量计费,起价为 49 美元,并支持区域化数据托管。
|
||||
👉 你可以关注服务的 p99 延迟和错误率, 包括外部 API 调用和个别的端点。
|
||||
|
||||
[**免费开始 →**](https://signoz.io/teams/)
|
||||
👉 你可以找到问题的根因,通过提取相关问题的 traces 日志、单独查看请求 traces 的火焰图详情。
|
||||
|
||||
#### 企业版
|
||||
👉 执行 trace 数据聚合,以获取业务相关的 metrics
|
||||
|
||||
Enterprise Cloud、BYOC 或 Enterprise Self-Hosted,提供合规、支持、自定义保留期、RBAC、摄取控制、数据驻留和区域选择。
|
||||
👉 对日志过滤和查询,通过日志的属性建立看板和告警
|
||||
|
||||
[**了解企业版 →**](https://signoz.io/enterprise/)
|
||||
👉 通过 Python,java,Ruby 和 Javascript 自动记录异常
|
||||
|
||||
#### 社区版
|
||||
👉 轻松的自定义查询和设置告警
|
||||
|
||||
免费的开源 SigNoz,可运行在你自己的基础设施中。使用 Docker、Kubernetes 或 Linux 部署,并完全掌控你的数据平面。
|
||||
### 应用 Metrics 展示
|
||||
|
||||
[**安装 SigNoz →**](https://signoz.io/docs/install/self-host/)
|
||||

|
||||
|
||||
### 你可以监控什么?
|
||||
### 分布式追踪
|
||||
|
||||
SigNoz 将日志、指标、链路追踪、告警、仪表盘、异常和面向 Agent 的工作流连接在一起,帮助团队更快地调试生产问题。
|
||||
<img width="2068" alt="distributed_tracing_2 2" src="https://user-images.githubusercontent.com/83692067/226536447-bae58321-6a22-4ed3-af80-e3e964cb3489.png">
|
||||
|
||||
#### APM 概览
|
||||
<img width="2068" alt="distributed_tracing_1" src="https://user-images.githubusercontent.com/83692067/226536462-939745b6-4f9d-45a6-8016-814837e7f7b4.png">
|
||||
|
||||
监控服务延迟、错误率、吞吐量、Apdex、核心端点、数据库调用和外部调用。
|
||||
### 日志管理
|
||||
|
||||
<p align="center">
|
||||
<img alt="SigNoz APM 仪表盘,展示延迟、吞吐量、Apdex 和关键操作" 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">
|
||||
|
||||
了解更多:[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">
|
||||
|
||||
使用 Query Builder、PromQL 或 ClickHouse SQL 为应用、基础设施和自定义指标构建仪表盘。
|
||||
<br /><br />
|
||||
|
||||
<p align="center">
|
||||
<img alt="SigNoz 主机指标仪表盘,展示系统负载和网络图表" src="docs/readme-assets/monitor/metrics.png" width="900">
|
||||
</p>
|
||||
## 加入我们 Slack 社区
|
||||
|
||||
了解更多:[指标文档](https://signoz.io/docs/metrics-management/overview/)
|
||||
来 [Slack](https://signoz.io/slack) 和我们打招呼吧 👋
|
||||
|
||||
#### 基础设施监控
|
||||
<br /><br />
|
||||
|
||||
监控 Kubernetes 集群、Pod、节点、工作负载,以及主机级 CPU、内存、磁盘、网络、日志和链路追踪。
|
||||
## 特性:
|
||||
|
||||
<p align="center">
|
||||
<img alt="SigNoz Kubernetes 基础设施仪表盘,展示 Pod 和节点指标" src="docs/readme-assets/monitor/infrastructure.png" width="900">
|
||||
</p>
|
||||
- 为 metrics, traces and logs 制定统一的 UI。 无需切换 Prometheus 到 Jaeger 去查找问题,也无需使用想 Elastic 这样的日志工具分开你的 metrics 和 traces
|
||||
|
||||
了解更多:[基础设施监控文档](https://signoz.io/docs/infrastructure-monitoring/overview/)
|
||||
- 默认统计应用的 metrics 数据,像 RPS (每秒请求数), 50th/90th/99th 的分位数延迟数据,还有相关的错误率
|
||||
|
||||
#### LLM 和 AI 可观测性
|
||||
- 找到应用中最慢的端点
|
||||
|
||||
追踪 LLM 应用、RAG 流水线、Prompt、工具调用、Token、延迟和成本,并与应用和基础设施遥测数据放在一起分析。
|
||||
- 查看准确的请求跟踪数据,找到下游服务的问题了,比如 DB 慢查询,或者调用第三方的支付网关等
|
||||
|
||||
<p align="center">
|
||||
<img alt="SigNoz LLM 可观测性仪表盘,展示链路追踪、Token 使用、延迟和成本" src="docs/readme-assets/monitor/llm.png" width="900">
|
||||
</p>
|
||||
- 通过 服务名、操作方式、延迟、错误、标签/注释 过滤 traces 数据
|
||||
|
||||
了解更多:[LLM 可观测性文档](https://signoz.io/docs/llm-observability/)
|
||||
- 通过聚合 trace 数据而获得业务相关的 metrics。 比如你可以通过 `customer_type: gold` 或者 `deployment_version: v2` 或者 `external_call: paypal` 获取错误率和 P99 延迟数据
|
||||
|
||||
#### Agent 原生可观测性和 MCP
|
||||
- 原生支持 OpenTelemetry 日志,高级日志查询,自动收集 k8s 相关日志
|
||||
|
||||
使用 SigNoz MCP server 将遥测数据带入编程 Agent,或在 SigNoz 中使用 Noz,基于生产上下文调查事故、优化告警并构建仪表盘。[Noz](https://signoz.io/docs/ai/noz/) 仅适用于 SigNoz Cloud。
|
||||
- 快如闪电的日志分析 ([Logs Perf. Benchmark](https://signoz.io/blog/logs-performance-benchmark/))
|
||||
|
||||
<p align="center">
|
||||
<img alt="SigNoz Noz 界面与基于 MCP 的 Agent 工作流" src="docs/readme-assets/monitor/agent-native.png" width="900">
|
||||
</p>
|
||||
- 可视化点到点的基础设施性能,提取有所有类型机器的 metrics 数据
|
||||
|
||||
了解更多:[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 />
|
||||
|
||||
通过火焰图、瀑布图、Span 事件、过滤器和 Trace 分析,跟踪请求在各个服务之间的流转。
|
||||
## 为什么使用 SigNoz?
|
||||
|
||||
<p align="center">
|
||||
<img alt="SigNoz 分布式链路追踪视图,包含火焰图和瀑布图 Span" src="docs/readme-assets/monitor/distributed-tracing.png" width="900">
|
||||
</p>
|
||||
作为开发者, 我们发现 SaaS 厂商对一些大家想要的小功能都是闭源的,这种行为真的让人有点恼火。 闭源厂商还会在月底给你一张没有明细的巨额账单。
|
||||
|
||||
了解更多:[分布式链路追踪文档](https://signoz.io/docs/instrumentation/)
|
||||
我们想做一个自托管并且可开源的工具,像 DataDog 和 NewRelic 那样, 为那些担心数据隐私和安全的公司提供第三方服务。
|
||||
|
||||
#### Trace Funnels
|
||||
作为开源的项目,你完全可以自己掌控你的配置、样本和更新。你同样可以基于 SigNoz 拓展特定的业务模块。
|
||||
|
||||
基于链路追踪创建漏斗,用于理解请求流中的掉点、失败转换和系统性工作流问题。
|
||||
### 支持的编程语言:
|
||||
|
||||
<p align="center">
|
||||
<img alt="SigNoz Trace Funnels,展示请求流掉点和失败转换" src="docs/readme-assets/monitor/trace-funnels.png" width="900">
|
||||
</p>
|
||||
我们支持 [OpenTelemetry](https://opentelemetry.io)。作为一个观测你应用的库文件。所以任何 OpenTelemetry 支持的框架和语言,对于 SigNoz 也同样支持。 一些主要支持的语言如下:
|
||||
|
||||
了解更多:[Trace Funnels 文档](https://signoz.io/docs/trace-funnels/overview/)
|
||||
- Java
|
||||
- Python
|
||||
- NodeJS
|
||||
- Go
|
||||
- PHP
|
||||
- .NET
|
||||
- Ruby
|
||||
- Elixir
|
||||
- Rust
|
||||
|
||||
你还可以监控:[**异常**](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/)。
|
||||
你可以在这里找到全部支持的语言列表 - https://opentelemetry.io/docs/
|
||||
|
||||
### 为什么团队选择 SigNoz
|
||||
<br /><br />
|
||||
|
||||
1. **OpenTelemetry 原生**<br>
|
||||
用开放标准完成一次接入,并保持对遥测数据的所有权。
|
||||
2. **信号关联**<br>
|
||||
在服务图表、链路追踪、日志、基础设施指标和异常之间切换时,不需要更换工具。
|
||||
3. **单一列式数据库**<br>
|
||||
为高基数、高吞吐量的可观测性工作负载而构建。
|
||||
4. **可预测的定价**<br>
|
||||
不按主机收费,不按用户席位收费,也不对自定义指标设置特殊价格。
|
||||
5. **企业就绪**<br>
|
||||
SOC 2 Type II 和 HIPAA 合规、RBAC、摄取控制、自定义保留期、支持、BYOC 和自托管。
|
||||
## 让我们开始吧
|
||||
|
||||
### 快速开始
|
||||
### 使用 Docker 部署
|
||||
|
||||
#### 从 Cloud 开始
|
||||
请一步步跟随 [这里](https://signoz.io/docs/install/docker/) 通过 docker 来安装。
|
||||
|
||||
创建一个托管的 SigNoz 工作区,无需运行可观测性基础设施,即可获得第一个仪表盘。
|
||||
这个 [排障说明书](https://signoz.io/docs/install/troubleshooting/) 可以帮助你解决碰到的问题。
|
||||
|
||||
[**免费开始使用 SigNoz Cloud**](https://signoz.io/teams/)
|
||||
<p>  </p>
|
||||
|
||||
#### 自托管 SigNoz
|
||||
### 使用 Helm 在 Kubernetes 部署
|
||||
|
||||
在你自己的基础设施中通过 Foundry、Docker、Kubernetes 或 Linux 运行 SigNoz。
|
||||
请一步步跟随 [这里](https://signoz.io/docs/deployment/helm_chart) 通过 helm 来安装
|
||||
|
||||
[**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/)
|
||||
<br /><br />
|
||||
|
||||
#### 发送数据
|
||||
## 比较相似的工具
|
||||
|
||||
使用 OpenTelemetry、Prometheus、语言 SDK 和集成来接入应用与基础设施。
|
||||
### SigNoz vs Prometheus
|
||||
|
||||
[**接入文档**](https://signoz.io/docs/instrumentation/) · [**集成列表**](https://signoz.io/docs/integrations/integrations-list/)
|
||||
Prometheus 是一个针对 metrics 监控的强大工具。但是如果你想无缝的切换 metrics 和 traces 查询,你当前大概率需要在 Prometheus 和 Jaeger 之间切换。
|
||||
|
||||
### 与常见工具的对比
|
||||
我们的目标是提供一个客户观测 metrics 和 traces 整合的 UI。就像 SaaS 供应商 DataDog,它提供很多 jaeger 缺失的功能,比如针对 traces 过滤功能和聚合功能。
|
||||
|
||||
许多团队在从单一用途工具或价格不可预测的商业平台迁移时,会选择 SigNoz。
|
||||
<p>  </p>
|
||||
|
||||
**Prometheus**<br>
|
||||
如果你只需要指标,Prometheus 很合适。SigNoz 将指标、日志、链路追踪、仪表盘和告警放在一起,让团队可以通过关联上下文进行调试。
|
||||
### SigNoz vs Jaeger
|
||||
|
||||
**Jaeger**<br>
|
||||
Jaeger 只做分布式链路追踪。SigNoz 增加了指标、日志、Trace 分析、仪表盘、告警、异常和 Trace 到日志的工作流。
|
||||
Jaeger 仅仅是一个分布式追踪系统。 但是 SigNoz 可以提供 metrics, traces 和 logs 所有的观测。
|
||||
|
||||
**Elastic**<br>
|
||||
SigNoz 使用列式数据库来高效处理可观测性分析和高基数日志工作负载。在摄取阶段,相比 Elastic 可降低 50% 的资源需求。查看[详细评测](https://signoz.io/blog/logs-performance-benchmark/?utm_source=github-readme&utm_medium=logs-benchmark)。
|
||||
而且, SigNoz 相较于 Jaeger 拥有更对的高级功能:
|
||||
|
||||
**Loki**<br>
|
||||
在链接的评测中,SigNoz 在测试设置中索引了所有键,而 Loki 在增加更多标签时遇到了 max stream 错误。查看[详细评测](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 />
|
||||
|
||||
## 贡献
|
||||
|
||||
无论贡献大小,我们都非常欢迎。请阅读 [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 alt="SigNoz 贡献者" src="https://contrib.rocks/image?repo=signoz/signoz" />
|
||||
<img src="https://contrib.rocks/image?repo=signoz/signoz" />
|
||||
</a>
|
||||
|
||||
@@ -177,11 +177,9 @@ func runServer(ctx context.Context, config signoz.Config, logger *slog.Logger) e
|
||||
return nil, err
|
||||
}
|
||||
azureCloudProviderModule := implcloudprovider.NewAzureCloudProvider(defStore)
|
||||
gcpCloudProviderModule := implcloudprovider.NewGCPCloudProvider(defStore)
|
||||
cloudProvidersMap := map[cloudintegrationtypes.CloudProviderType]cloudintegration.CloudProviderModule{
|
||||
cloudintegrationtypes.CloudProviderTypeAWS: awsCloudProviderModule,
|
||||
cloudintegrationtypes.CloudProviderTypeAzure: azureCloudProviderModule,
|
||||
cloudintegrationtypes.CloudProviderTypeGCP: gcpCloudProviderModule,
|
||||
}
|
||||
|
||||
return implcloudintegration.NewModule(pkgcloudintegration.NewStore(sqlStore), dashboardModule, global, zeus, gateway, licensing, serviceAccount, cloudProvidersMap, config)
|
||||
|
||||
@@ -65,31 +65,15 @@ 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:
|
||||
|
||||
@@ -1024,8 +1024,6 @@ components:
|
||||
$ref: '#/components/schemas/CloudintegrationtypesAWSAccountConfig'
|
||||
azure:
|
||||
$ref: '#/components/schemas/CloudintegrationtypesAzureAccountConfig'
|
||||
gcp:
|
||||
$ref: '#/components/schemas/CloudintegrationtypesGCPAccountConfig'
|
||||
type: object
|
||||
CloudintegrationtypesAgentReport:
|
||||
nullable: true
|
||||
@@ -1171,8 +1169,6 @@ components:
|
||||
$ref: '#/components/schemas/CloudintegrationtypesAWSConnectionArtifact'
|
||||
azure:
|
||||
$ref: '#/components/schemas/CloudintegrationtypesAzureConnectionArtifact'
|
||||
gcp:
|
||||
$ref: '#/components/schemas/CloudintegrationtypesGCPConnectionArtifact'
|
||||
type: object
|
||||
CloudintegrationtypesCredentials:
|
||||
properties:
|
||||
@@ -1203,46 +1199,6 @@ components:
|
||||
nullable: true
|
||||
type: array
|
||||
type: object
|
||||
CloudintegrationtypesGCPAccountConfig:
|
||||
properties:
|
||||
deploymentProjectId:
|
||||
type: string
|
||||
deploymentRegion:
|
||||
type: string
|
||||
projectIds:
|
||||
items:
|
||||
type: string
|
||||
type: array
|
||||
required:
|
||||
- deploymentProjectId
|
||||
- deploymentRegion
|
||||
- projectIds
|
||||
type: object
|
||||
CloudintegrationtypesGCPConnectionArtifact:
|
||||
type: object
|
||||
CloudintegrationtypesGCPIntegrationConfig:
|
||||
type: object
|
||||
CloudintegrationtypesGCPServiceConfig:
|
||||
properties:
|
||||
logs:
|
||||
$ref: '#/components/schemas/CloudintegrationtypesGCPServiceLogsConfig'
|
||||
metrics:
|
||||
$ref: '#/components/schemas/CloudintegrationtypesGCPServiceMetricsConfig'
|
||||
type: object
|
||||
CloudintegrationtypesGCPServiceLogsConfig:
|
||||
properties:
|
||||
enabled:
|
||||
type: boolean
|
||||
required:
|
||||
- enabled
|
||||
type: object
|
||||
CloudintegrationtypesGCPServiceMetricsConfig:
|
||||
properties:
|
||||
enabled:
|
||||
type: boolean
|
||||
required:
|
||||
- enabled
|
||||
type: object
|
||||
CloudintegrationtypesGettableAccountWithConnectionArtifact:
|
||||
properties:
|
||||
connectionArtifact:
|
||||
@@ -1375,8 +1331,6 @@ components:
|
||||
$ref: '#/components/schemas/CloudintegrationtypesAWSPostableAccountConfig'
|
||||
azure:
|
||||
$ref: '#/components/schemas/CloudintegrationtypesAzureAccountConfig'
|
||||
gcp:
|
||||
$ref: '#/components/schemas/CloudintegrationtypesGCPAccountConfig'
|
||||
type: object
|
||||
CloudintegrationtypesPostableAgentCheckIn:
|
||||
properties:
|
||||
@@ -1401,8 +1355,6 @@ components:
|
||||
$ref: '#/components/schemas/CloudintegrationtypesAWSIntegrationConfig'
|
||||
azure:
|
||||
$ref: '#/components/schemas/CloudintegrationtypesAzureIntegrationConfig'
|
||||
gcp:
|
||||
$ref: '#/components/schemas/CloudintegrationtypesGCPIntegrationConfig'
|
||||
type: object
|
||||
CloudintegrationtypesService:
|
||||
properties:
|
||||
@@ -1447,8 +1399,6 @@ components:
|
||||
$ref: '#/components/schemas/CloudintegrationtypesAWSServiceConfig'
|
||||
azure:
|
||||
$ref: '#/components/schemas/CloudintegrationtypesAzureServiceConfig'
|
||||
gcp:
|
||||
$ref: '#/components/schemas/CloudintegrationtypesGCPServiceConfig'
|
||||
type: object
|
||||
CloudintegrationtypesServiceDashboard:
|
||||
properties:
|
||||
@@ -1491,7 +1441,6 @@ components:
|
||||
- cosmosdb
|
||||
- cassandradb
|
||||
- redis
|
||||
- cloudsql
|
||||
type: string
|
||||
CloudintegrationtypesServiceMetadata:
|
||||
properties:
|
||||
@@ -1553,8 +1502,6 @@ components:
|
||||
$ref: '#/components/schemas/CloudintegrationtypesAWSAccountConfig'
|
||||
azure:
|
||||
$ref: '#/components/schemas/CloudintegrationtypesUpdatableAzureAccountConfig'
|
||||
gcp:
|
||||
$ref: '#/components/schemas/CloudintegrationtypesUpdatableGCPAccountConfig'
|
||||
type: object
|
||||
CloudintegrationtypesUpdatableAzureAccountConfig:
|
||||
properties:
|
||||
@@ -1565,22 +1512,6 @@ components:
|
||||
required:
|
||||
- resourceGroups
|
||||
type: object
|
||||
CloudintegrationtypesUpdatableGCPAccountConfig:
|
||||
properties:
|
||||
deploymentProjectId:
|
||||
type: string
|
||||
deploymentRegion:
|
||||
type: string
|
||||
projectIds:
|
||||
items:
|
||||
type: string
|
||||
nullable: true
|
||||
type: array
|
||||
required:
|
||||
- deploymentProjectId
|
||||
- deploymentRegion
|
||||
- projectIds
|
||||
type: object
|
||||
CloudintegrationtypesUpdatableService:
|
||||
properties:
|
||||
config:
|
||||
@@ -6281,25 +6212,6 @@ components:
|
||||
- asc
|
||||
- desc
|
||||
type: string
|
||||
Querybuildertypesv5PreviewStatement:
|
||||
properties:
|
||||
db.statement.args:
|
||||
items: {}
|
||||
type: array
|
||||
db.statement.query:
|
||||
type: string
|
||||
estimate:
|
||||
items:
|
||||
$ref: '#/components/schemas/TelemetrystoretypesEstimateEntry'
|
||||
type: array
|
||||
granules:
|
||||
$ref: '#/components/schemas/TelemetrystoretypesGranules'
|
||||
required:
|
||||
- db.statement.query
|
||||
- db.statement.args
|
||||
- estimate
|
||||
- granules
|
||||
type: object
|
||||
Querybuildertypesv5PromQuery:
|
||||
properties:
|
||||
disabled:
|
||||
@@ -6620,40 +6532,6 @@ components:
|
||||
required:
|
||||
- type
|
||||
type: object
|
||||
Querybuildertypesv5QueryPreview:
|
||||
properties:
|
||||
error: {}
|
||||
statements:
|
||||
items:
|
||||
$ref: '#/components/schemas/Querybuildertypesv5PreviewStatement'
|
||||
type: array
|
||||
valid:
|
||||
type: boolean
|
||||
warnings:
|
||||
items:
|
||||
type: string
|
||||
type: array
|
||||
required:
|
||||
- valid
|
||||
- error
|
||||
- warnings
|
||||
- statements
|
||||
type: object
|
||||
Querybuildertypesv5QueryRangePreviewResponse:
|
||||
description: Response from the v5 query range preview (dry-run) endpoint. For
|
||||
each query in the composite query, returns the underlying ClickHouse statement(s)
|
||||
it renders to without executing them (one per PromQL metric selector; exactly
|
||||
one for builder/ClickHouse/trace-operator queries), with the optional EXPLAIN
|
||||
ESTIMATE and granule analysis attached per statement when requested.
|
||||
properties:
|
||||
compositeQuery:
|
||||
additionalProperties:
|
||||
$ref: '#/components/schemas/Querybuildertypesv5QueryPreview'
|
||||
nullable: true
|
||||
type: object
|
||||
required:
|
||||
- compositeQuery
|
||||
type: object
|
||||
Querybuildertypesv5QueryRangeRequest:
|
||||
description: Request body for the v5 query range endpoint. Supports builder
|
||||
queries (traces, logs, metrics), formulas, joins, trace operators, PromQL,
|
||||
@@ -8004,96 +7882,6 @@ components:
|
||||
- key
|
||||
- value
|
||||
type: object
|
||||
TelemetrystoretypesEstimateEntry:
|
||||
properties:
|
||||
database:
|
||||
type: string
|
||||
marks:
|
||||
format: int64
|
||||
type: integer
|
||||
parts:
|
||||
format: int64
|
||||
type: integer
|
||||
rows:
|
||||
format: int64
|
||||
type: integer
|
||||
table:
|
||||
type: string
|
||||
required:
|
||||
- database
|
||||
- table
|
||||
- parts
|
||||
- rows
|
||||
- marks
|
||||
type: object
|
||||
TelemetrystoretypesGranules:
|
||||
nullable: true
|
||||
properties:
|
||||
initial:
|
||||
format: int64
|
||||
type: integer
|
||||
reads:
|
||||
items:
|
||||
$ref: '#/components/schemas/TelemetrystoretypesMergeTreeRead'
|
||||
type: array
|
||||
selected:
|
||||
format: int64
|
||||
type: integer
|
||||
skipped:
|
||||
format: int64
|
||||
type: integer
|
||||
required:
|
||||
- initial
|
||||
- selected
|
||||
- skipped
|
||||
- reads
|
||||
type: object
|
||||
TelemetrystoretypesIndexStep:
|
||||
properties:
|
||||
condition:
|
||||
type: string
|
||||
initialGranules:
|
||||
format: int64
|
||||
type: integer
|
||||
initialParts:
|
||||
format: int64
|
||||
type: integer
|
||||
keys:
|
||||
items:
|
||||
type: string
|
||||
type: array
|
||||
name:
|
||||
type: string
|
||||
selectedGranules:
|
||||
format: int64
|
||||
type: integer
|
||||
selectedParts:
|
||||
format: int64
|
||||
type: integer
|
||||
type:
|
||||
type: string
|
||||
required:
|
||||
- type
|
||||
- name
|
||||
- keys
|
||||
- condition
|
||||
- initialParts
|
||||
- selectedParts
|
||||
- initialGranules
|
||||
- selectedGranules
|
||||
type: object
|
||||
TelemetrystoretypesMergeTreeRead:
|
||||
properties:
|
||||
steps:
|
||||
items:
|
||||
$ref: '#/components/schemas/TelemetrystoretypesIndexStep'
|
||||
type: array
|
||||
table:
|
||||
type: string
|
||||
required:
|
||||
- table
|
||||
- steps
|
||||
type: object
|
||||
TelemetrytypesFieldContext:
|
||||
enum:
|
||||
- metric
|
||||
@@ -23625,75 +23413,6 @@ paths:
|
||||
summary: Query range
|
||||
tags:
|
||||
- querier
|
||||
/api/v5/query_range/preview:
|
||||
post:
|
||||
deprecated: false
|
||||
description: '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.'
|
||||
operationId: QueryRangePreviewV5
|
||||
parameters:
|
||||
- in: query
|
||||
name: verbose
|
||||
schema:
|
||||
type: string
|
||||
requestBody:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/Querybuildertypesv5QueryRangeRequest'
|
||||
responses:
|
||||
"200":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
properties:
|
||||
data:
|
||||
$ref: '#/components/schemas/Querybuildertypesv5QueryRangePreviewResponse'
|
||||
status:
|
||||
type: string
|
||||
required:
|
||||
- status
|
||||
- data
|
||||
type: object
|
||||
description: OK
|
||||
"400":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Bad Request
|
||||
"401":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Unauthorized
|
||||
"403":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Forbidden
|
||||
"500":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Internal Server Error
|
||||
security:
|
||||
- api_key:
|
||||
- VIEWER
|
||||
- tokenizer:
|
||||
- VIEWER
|
||||
summary: Query range preview
|
||||
tags:
|
||||
- querier
|
||||
/api/v5/substitute_vars:
|
||||
post:
|
||||
deprecated: false
|
||||
|
||||
|
Before Width: | Height: | Size: 629 KiB |
|
Before Width: | Height: | Size: 274 KiB |
|
Before Width: | Height: | Size: 250 KiB |
|
Before Width: | Height: | Size: 563 KiB |
|
Before Width: | Height: | Size: 434 KiB |
|
Before Width: | Height: | Size: 783 KiB |
|
Before Width: | Height: | Size: 482 KiB |
|
Before Width: | Height: | Size: 254 KiB |
|
Before Width: | Height: | Size: 171 KiB |
|
Before Width: | Height: | Size: 142 KiB |
@@ -1,36 +0,0 @@
|
||||
package implcloudprovider
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/modules/cloudintegration"
|
||||
"github.com/SigNoz/signoz/pkg/types/cloudintegrationtypes"
|
||||
)
|
||||
|
||||
type gcpcloudprovider struct {
|
||||
serviceDefinitions cloudintegrationtypes.ServiceDefinitionStore
|
||||
}
|
||||
|
||||
func NewGCPCloudProvider(defStore cloudintegrationtypes.ServiceDefinitionStore) cloudintegration.CloudProviderModule {
|
||||
return &gcpcloudprovider{
|
||||
serviceDefinitions: defStore,
|
||||
}
|
||||
}
|
||||
|
||||
func (g *gcpcloudprovider) BuildIntegrationConfig(ctx context.Context, account *cloudintegrationtypes.Account, services []*cloudintegrationtypes.StorableCloudIntegrationService) (*cloudintegrationtypes.ProviderIntegrationConfig, error) {
|
||||
// for manual flow we don't have any integration config to return, so returning empty config for now.
|
||||
return &cloudintegrationtypes.ProviderIntegrationConfig{}, nil
|
||||
}
|
||||
|
||||
func (g *gcpcloudprovider) GetConnectionArtifact(ctx context.Context, account *cloudintegrationtypes.Account, req *cloudintegrationtypes.GetConnectionArtifactRequest) (*cloudintegrationtypes.ConnectionArtifact, error) {
|
||||
// for manual flow we don't have any connection artifact to return, so returning empty artifact for now.
|
||||
return &cloudintegrationtypes.ConnectionArtifact{}, nil
|
||||
}
|
||||
|
||||
func (g *gcpcloudprovider) GetServiceDefinition(ctx context.Context, serviceID cloudintegrationtypes.ServiceID) (*cloudintegrationtypes.ServiceDefinition, error) {
|
||||
return g.serviceDefinitions.Get(ctx, cloudintegrationtypes.CloudProviderTypeGCP, serviceID)
|
||||
}
|
||||
|
||||
func (g *gcpcloudprovider) ListServiceDefinitions(ctx context.Context) ([]*cloudintegrationtypes.ServiceDefinition, error) {
|
||||
return g.serviceDefinitions.List(ctx, cloudintegrationtypes.CloudProviderTypeGCP)
|
||||
}
|
||||
@@ -101,10 +101,6 @@ 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)
|
||||
}
|
||||
|
||||
@@ -12,8 +12,6 @@ import type {
|
||||
} from 'react-query';
|
||||
|
||||
import type {
|
||||
QueryRangePreviewV5200,
|
||||
QueryRangePreviewV5Params,
|
||||
QueryRangeV5200,
|
||||
Querybuildertypesv5QueryRangeRequestDTO,
|
||||
RenderErrorResponseDTO,
|
||||
@@ -106,107 +104,6 @@ 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
|
||||
|
||||
@@ -2630,25 +2630,9 @@ export interface CloudintegrationtypesAzureAccountConfigDTO {
|
||||
resourceGroups: string[];
|
||||
}
|
||||
|
||||
export interface CloudintegrationtypesGCPAccountConfigDTO {
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
deploymentProjectId: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
deploymentRegion: string;
|
||||
/**
|
||||
* @type array
|
||||
*/
|
||||
projectIds: string[];
|
||||
}
|
||||
|
||||
export interface CloudintegrationtypesAccountConfigDTO {
|
||||
aws?: CloudintegrationtypesAWSAccountConfigDTO;
|
||||
azure?: CloudintegrationtypesAzureAccountConfigDTO;
|
||||
gcp?: CloudintegrationtypesGCPAccountConfigDTO;
|
||||
}
|
||||
|
||||
export interface CloudintegrationtypesAccountDTO {
|
||||
@@ -2756,29 +2740,9 @@ export interface CloudintegrationtypesAzureServiceConfigDTO {
|
||||
metrics: CloudintegrationtypesAzureServiceMetricsConfigDTO;
|
||||
}
|
||||
|
||||
export interface CloudintegrationtypesGCPServiceLogsConfigDTO {
|
||||
/**
|
||||
* @type boolean
|
||||
*/
|
||||
enabled: boolean;
|
||||
}
|
||||
|
||||
export interface CloudintegrationtypesGCPServiceMetricsConfigDTO {
|
||||
/**
|
||||
* @type boolean
|
||||
*/
|
||||
enabled: boolean;
|
||||
}
|
||||
|
||||
export interface CloudintegrationtypesGCPServiceConfigDTO {
|
||||
logs?: CloudintegrationtypesGCPServiceLogsConfigDTO;
|
||||
metrics?: CloudintegrationtypesGCPServiceMetricsConfigDTO;
|
||||
}
|
||||
|
||||
export interface CloudintegrationtypesServiceConfigDTO {
|
||||
aws?: CloudintegrationtypesAWSServiceConfigDTO;
|
||||
azure?: CloudintegrationtypesAzureServiceConfigDTO;
|
||||
gcp?: CloudintegrationtypesGCPServiceConfigDTO;
|
||||
}
|
||||
|
||||
export enum CloudintegrationtypesServiceIDDTO {
|
||||
@@ -2809,7 +2773,6 @@ export enum CloudintegrationtypesServiceIDDTO {
|
||||
cosmosdb = 'cosmosdb',
|
||||
cassandradb = 'cassandradb',
|
||||
redis = 'redis',
|
||||
cloudsql = 'cloudsql',
|
||||
}
|
||||
export type CloudintegrationtypesCloudIntegrationServiceDTOAnyOf = {
|
||||
/**
|
||||
@@ -2874,14 +2837,9 @@ export interface CloudintegrationtypesCollectedMetricDTO {
|
||||
unit?: string;
|
||||
}
|
||||
|
||||
export interface CloudintegrationtypesGCPConnectionArtifactDTO {
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export interface CloudintegrationtypesConnectionArtifactDTO {
|
||||
aws?: CloudintegrationtypesAWSConnectionArtifactDTO;
|
||||
azure?: CloudintegrationtypesAzureConnectionArtifactDTO;
|
||||
gcp?: CloudintegrationtypesGCPConnectionArtifactDTO;
|
||||
}
|
||||
|
||||
export interface CloudintegrationtypesCredentialsDTO {
|
||||
@@ -2914,10 +2872,6 @@ export interface CloudintegrationtypesDataCollectedDTO {
|
||||
metrics?: CloudintegrationtypesCollectedMetricDTO[] | null;
|
||||
}
|
||||
|
||||
export interface CloudintegrationtypesGCPIntegrationConfigDTO {
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export interface CloudintegrationtypesGettableAccountWithConnectionArtifactDTO {
|
||||
connectionArtifact: CloudintegrationtypesConnectionArtifactDTO;
|
||||
/**
|
||||
@@ -3009,7 +2963,6 @@ export type CloudintegrationtypesIntegrationConfigDTO =
|
||||
export interface CloudintegrationtypesProviderIntegrationConfigDTO {
|
||||
aws?: CloudintegrationtypesAWSIntegrationConfigDTO;
|
||||
azure?: CloudintegrationtypesAzureIntegrationConfigDTO;
|
||||
gcp?: CloudintegrationtypesGCPIntegrationConfigDTO;
|
||||
}
|
||||
|
||||
export interface CloudintegrationtypesGettableAgentCheckInDTO {
|
||||
@@ -3072,7 +3025,6 @@ export interface CloudintegrationtypesGettableServicesMetadataDTO {
|
||||
export interface CloudintegrationtypesPostableAccountConfigDTO {
|
||||
aws?: CloudintegrationtypesAWSPostableAccountConfigDTO;
|
||||
azure?: CloudintegrationtypesAzureAccountConfigDTO;
|
||||
gcp?: CloudintegrationtypesGCPAccountConfigDTO;
|
||||
}
|
||||
|
||||
export interface CloudintegrationtypesPostableAccountDTO {
|
||||
@@ -3202,25 +3154,9 @@ export interface CloudintegrationtypesUpdatableAzureAccountConfigDTO {
|
||||
resourceGroups: string[];
|
||||
}
|
||||
|
||||
export interface CloudintegrationtypesUpdatableGCPAccountConfigDTO {
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
deploymentProjectId: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
deploymentRegion: string;
|
||||
/**
|
||||
* @type array,null
|
||||
*/
|
||||
projectIds: string[] | null;
|
||||
}
|
||||
|
||||
export interface CloudintegrationtypesUpdatableAccountConfigDTO {
|
||||
aws?: CloudintegrationtypesAWSAccountConfigDTO;
|
||||
azure?: CloudintegrationtypesUpdatableAzureAccountConfigDTO;
|
||||
gcp?: CloudintegrationtypesUpdatableGCPAccountConfigDTO;
|
||||
}
|
||||
|
||||
export interface CloudintegrationtypesUpdatableAccountDTO {
|
||||
@@ -7619,126 +7555,6 @@ export interface Querybuildertypesv5FormatOptionsDTO {
|
||||
formatTableResultForUI?: boolean;
|
||||
}
|
||||
|
||||
export interface TelemetrystoretypesEstimateEntryDTO {
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
database: string;
|
||||
/**
|
||||
* @type integer
|
||||
* @format int64
|
||||
*/
|
||||
marks: number;
|
||||
/**
|
||||
* @type integer
|
||||
* @format int64
|
||||
*/
|
||||
parts: number;
|
||||
/**
|
||||
* @type integer
|
||||
* @format int64
|
||||
*/
|
||||
rows: number;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
table: string;
|
||||
}
|
||||
|
||||
export interface TelemetrystoretypesIndexStepDTO {
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
condition: string;
|
||||
/**
|
||||
* @type integer
|
||||
* @format int64
|
||||
*/
|
||||
initialGranules: number;
|
||||
/**
|
||||
* @type integer
|
||||
* @format int64
|
||||
*/
|
||||
initialParts: number;
|
||||
/**
|
||||
* @type array
|
||||
*/
|
||||
keys: string[];
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
name: string;
|
||||
/**
|
||||
* @type integer
|
||||
* @format int64
|
||||
*/
|
||||
selectedGranules: number;
|
||||
/**
|
||||
* @type integer
|
||||
* @format int64
|
||||
*/
|
||||
selectedParts: number;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
type: string;
|
||||
}
|
||||
|
||||
export interface TelemetrystoretypesMergeTreeReadDTO {
|
||||
/**
|
||||
* @type array
|
||||
*/
|
||||
steps: TelemetrystoretypesIndexStepDTO[];
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
table: string;
|
||||
}
|
||||
|
||||
export type TelemetrystoretypesGranulesDTOAnyOf = {
|
||||
/**
|
||||
* @type integer
|
||||
* @format int64
|
||||
*/
|
||||
initial: number;
|
||||
/**
|
||||
* @type array
|
||||
*/
|
||||
reads: TelemetrystoretypesMergeTreeReadDTO[];
|
||||
/**
|
||||
* @type integer
|
||||
* @format int64
|
||||
*/
|
||||
selected: number;
|
||||
/**
|
||||
* @type integer
|
||||
* @format int64
|
||||
*/
|
||||
skipped: number;
|
||||
};
|
||||
|
||||
/**
|
||||
* @nullable
|
||||
*/
|
||||
export type TelemetrystoretypesGranulesDTO =
|
||||
TelemetrystoretypesGranulesDTOAnyOf | null;
|
||||
|
||||
export interface Querybuildertypesv5PreviewStatementDTO {
|
||||
/**
|
||||
* @type array
|
||||
*/
|
||||
'db.statement.args': unknown[];
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
'db.statement.query': string;
|
||||
/**
|
||||
* @type array
|
||||
*/
|
||||
estimate: TelemetrystoretypesEstimateEntryDTO[];
|
||||
granules: TelemetrystoretypesGranulesDTO | null;
|
||||
}
|
||||
|
||||
export interface Querybuildertypesv5TimeSeriesDataDTO {
|
||||
/**
|
||||
* @type array,null
|
||||
@@ -7820,41 +7636,6 @@ export type Querybuildertypesv5QueryDataDTO =
|
||||
results?: unknown[] | null;
|
||||
});
|
||||
|
||||
export interface Querybuildertypesv5QueryPreviewDTO {
|
||||
error: unknown;
|
||||
/**
|
||||
* @type array
|
||||
*/
|
||||
statements: Querybuildertypesv5PreviewStatementDTO[];
|
||||
/**
|
||||
* @type boolean
|
||||
*/
|
||||
valid: boolean;
|
||||
/**
|
||||
* @type array
|
||||
*/
|
||||
warnings: string[];
|
||||
}
|
||||
|
||||
export type Querybuildertypesv5QueryRangePreviewResponseDTOCompositeQueryAnyOf =
|
||||
{ [key: string]: Querybuildertypesv5QueryPreviewDTO };
|
||||
|
||||
/**
|
||||
* @nullable
|
||||
*/
|
||||
export type Querybuildertypesv5QueryRangePreviewResponseDTOCompositeQuery =
|
||||
Querybuildertypesv5QueryRangePreviewResponseDTOCompositeQueryAnyOf | null;
|
||||
|
||||
/**
|
||||
* Response from the v5 query range preview (dry-run) endpoint. For each query in the composite query, returns the underlying ClickHouse statement(s) it renders to without executing them (one per PromQL metric selector; exactly one for builder/ClickHouse/trace-operator queries), with the optional EXPLAIN ESTIMATE and granule analysis attached per statement when requested.
|
||||
*/
|
||||
export interface Querybuildertypesv5QueryRangePreviewResponseDTO {
|
||||
/**
|
||||
* @type object,null
|
||||
*/
|
||||
compositeQuery: Querybuildertypesv5QueryRangePreviewResponseDTOCompositeQuery;
|
||||
}
|
||||
|
||||
export enum Querybuildertypesv5VariableTypeDTO {
|
||||
query = 'query',
|
||||
dynamic = 'dynamic',
|
||||
@@ -11729,22 +11510,6 @@ export type QueryRangeV5200 = {
|
||||
status: string;
|
||||
};
|
||||
|
||||
export type QueryRangePreviewV5Params = {
|
||||
/**
|
||||
* @type string
|
||||
* @description undefined
|
||||
*/
|
||||
verbose?: string;
|
||||
};
|
||||
|
||||
export type QueryRangePreviewV5200 = {
|
||||
data: Querybuildertypesv5QueryRangePreviewResponseDTO;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
status: string;
|
||||
};
|
||||
|
||||
export type ReplaceVariables200 = {
|
||||
data: Querybuildertypesv5QueryRangeRequestDTO;
|
||||
/**
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { ReactElement } from 'react';
|
||||
import { render, screen, userEvent, waitFor } from 'tests/test-utils';
|
||||
import { render, screen } 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,29 +66,6 @@ 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 });
|
||||
|
||||
@@ -165,31 +142,4 @@ 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,8 +7,7 @@ import {
|
||||
} from '@signozhq/ui/tooltip';
|
||||
import type { BrandedPermission } from 'hooks/useAuthZ/types';
|
||||
import { useAuthZ } from 'hooks/useAuthZ/useAuthZ';
|
||||
import { formatPermission } from 'hooks/useAuthZ/utils';
|
||||
import { useAppContext } from 'providers/App/App';
|
||||
import { parsePermission } from 'hooks/useAuthZ/utils';
|
||||
import styles from './AuthZTooltip.module.scss';
|
||||
|
||||
interface AuthZTooltipProps {
|
||||
@@ -20,14 +19,19 @@ interface AuthZTooltipProps {
|
||||
|
||||
function formatDeniedMessage(
|
||||
denied: BrandedPermission[],
|
||||
userId: string,
|
||||
override?: string,
|
||||
): string {
|
||||
if (override) {
|
||||
return override;
|
||||
}
|
||||
const permissions = denied.map(formatPermission).join(', ');
|
||||
return `user/${userId} is not authorized to perform ${permissions}`;
|
||||
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`;
|
||||
}
|
||||
|
||||
function AuthZTooltip({
|
||||
@@ -36,7 +40,6 @@ function AuthZTooltip({
|
||||
enabled = true,
|
||||
tooltipMessage,
|
||||
}: AuthZTooltipProps): JSX.Element {
|
||||
const { user } = useAppContext();
|
||||
const shouldCheck = enabled && checks.length > 0;
|
||||
|
||||
const { permissions, isLoading } = useAuthZ(checks, { enabled: shouldCheck });
|
||||
@@ -72,7 +75,7 @@ function AuthZTooltip({
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent className={styles.errorContent}>
|
||||
{formatDeniedMessage(deniedPermissions, user.id, tooltipMessage)}
|
||||
{formatDeniedMessage(deniedPermissions, tooltipMessage)}
|
||||
</TooltipContent>
|
||||
</TooltipRoot>
|
||||
</TooltipProvider>
|
||||
|
||||
@@ -167,7 +167,6 @@ describe('InviteMembers - Submission', () => {
|
||||
success: false,
|
||||
}),
|
||||
]),
|
||||
expect.any(Array),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -244,7 +243,6 @@ describe('InviteMembers - Submission', () => {
|
||||
error: 'User already exists',
|
||||
}),
|
||||
]),
|
||||
expect.any(Array),
|
||||
);
|
||||
|
||||
await expect(
|
||||
|
||||
@@ -22,9 +22,9 @@ export interface FooterRenderProps {
|
||||
|
||||
export interface UseInviteMembersOptions {
|
||||
initialRowCount?: number;
|
||||
onSuccess?: (results: InviteResult[], rows: InviteMemberRow[]) => void;
|
||||
onPartialSuccess?: (results: InviteResult[], rows: InviteMemberRow[]) => void;
|
||||
onAllFailed?: (results: InviteResult[], rows: InviteMemberRow[]) => void;
|
||||
onSuccess?: () => void;
|
||||
onPartialSuccess?: (results: InviteResult[]) => void;
|
||||
onAllFailed?: (results: InviteResult[]) => void;
|
||||
}
|
||||
|
||||
export interface UseInviteMembersReturn {
|
||||
@@ -56,9 +56,9 @@ export interface InviteMembersProps {
|
||||
showHeader?: boolean;
|
||||
showAddButton?: boolean;
|
||||
|
||||
onSuccess?: (results: InviteResult[], rows: InviteMemberRow[]) => void;
|
||||
onPartialSuccess?: (results: InviteResult[], rows: InviteMemberRow[]) => void;
|
||||
onAllFailed?: (results: InviteResult[], rows: InviteMemberRow[]) => void;
|
||||
onSuccess?: () => void;
|
||||
onPartialSuccess?: (results: InviteResult[]) => void;
|
||||
onAllFailed?: (results: InviteResult[]) => void;
|
||||
|
||||
renderFooter?: (props: FooterRenderProps) => ReactNode;
|
||||
}
|
||||
|
||||
@@ -207,11 +207,11 @@ export function useInviteMembers(
|
||||
const successes = results.filter((r) => r.success);
|
||||
|
||||
if (failures.length === 0) {
|
||||
onSuccess?.(results, touched);
|
||||
onSuccess?.();
|
||||
} else if (successes.length > 0) {
|
||||
onPartialSuccess?.(results, touched);
|
||||
onPartialSuccess?.(results);
|
||||
} else {
|
||||
onAllFailed?.(results, touched);
|
||||
onAllFailed?.(results);
|
||||
}
|
||||
|
||||
return results;
|
||||
|
||||
@@ -0,0 +1,254 @@
|
||||
.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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,337 @@
|
||||
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;
|
||||
@@ -0,0 +1,276 @@
|
||||
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();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -3,6 +3,7 @@
|
||||
flex-direction: column;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
|
||||
@@ -2,11 +2,3 @@
|
||||
box-sizing: border-box;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.permission {
|
||||
color: var(--l2-foreground);
|
||||
}
|
||||
|
||||
.permissionCode {
|
||||
font-family: monospace;
|
||||
}
|
||||
|
||||
@@ -5,8 +5,9 @@ describe('PermissionDeniedCallout', () => {
|
||||
it('renders the permission name in the callout message', () => {
|
||||
render(<PermissionDeniedCallout permissionName="serviceaccount:attach" />);
|
||||
|
||||
expect(screen.getByText(/is not authorized/)).toBeInTheDocument();
|
||||
expect(screen.getByText(/You don't have/)).toBeInTheDocument();
|
||||
expect(screen.getByText(/serviceaccount:attach/)).toBeInTheDocument();
|
||||
expect(screen.getByText(/permission/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('accepts an optional className', () => {
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
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;
|
||||
@@ -13,8 +11,6 @@ function PermissionDeniedCallout({
|
||||
permissionName,
|
||||
className,
|
||||
}: PermissionDeniedCalloutProps): JSX.Element {
|
||||
const { user } = useAppContext();
|
||||
|
||||
return (
|
||||
<Callout
|
||||
type="error"
|
||||
@@ -22,11 +18,7 @@ function PermissionDeniedCallout({
|
||||
size="small"
|
||||
className={cx(styles.callout, className)}
|
||||
>
|
||||
<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>
|
||||
{`You don't have ${permissionName} permission`}
|
||||
</Callout>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -32,13 +32,10 @@ export function useRoles(): {
|
||||
};
|
||||
}
|
||||
|
||||
export function getRoleOptions(
|
||||
roles: AuthtypesRoleDTO[],
|
||||
valueField: 'id' | 'name',
|
||||
): RoleOption[] {
|
||||
export function getRoleOptions(roles: AuthtypesRoleDTO[]): RoleOption[] {
|
||||
return roles.map((role) => ({
|
||||
label: role.name ?? '',
|
||||
value: role[valueField] ?? '',
|
||||
value: role.id ?? '',
|
||||
}));
|
||||
}
|
||||
|
||||
@@ -85,7 +82,6 @@ interface BaseProps {
|
||||
error?: APIError;
|
||||
onRefetch?: () => void;
|
||||
disabled?: boolean;
|
||||
valueField?: 'id' | 'name';
|
||||
}
|
||||
|
||||
interface SingleProps extends BaseProps {
|
||||
@@ -117,7 +113,7 @@ function RolesSelect(props: RolesSelectProps): JSX.Element {
|
||||
});
|
||||
|
||||
const roles = externalRoles ?? data?.data ?? [];
|
||||
const options = getRoleOptions(roles, props.valueField || 'id');
|
||||
const options = getRoleOptions(roles);
|
||||
|
||||
const {
|
||||
mode,
|
||||
|
||||
@@ -1,90 +0,0 @@
|
||||
.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;
|
||||
}
|
||||
@@ -1,164 +0,0 @@
|
||||
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;
|
||||
@@ -1,31 +0,0 @@
|
||||
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();
|
||||
});
|
||||
});
|
||||
@@ -1,17 +0,0 @@
|
||||
// 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,10 +267,11 @@ describe('createGuardedRoute', () => {
|
||||
await waitFor(() => {
|
||||
const heading = document.querySelector('h3');
|
||||
expect(heading).toBeInTheDocument();
|
||||
expect(heading?.textContent).toMatch(/not authorized/i);
|
||||
expect(heading?.textContent).toMatch(/permission to view/i);
|
||||
});
|
||||
|
||||
expect(screen.getByText(/update:role:123/)).toBeInTheDocument();
|
||||
expect(screen.getByText('update')).toBeInTheDocument();
|
||||
expect(screen.getByText('role:123')).toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByText('Test Component: test-value'),
|
||||
).not.toBeInTheDocument();
|
||||
|
||||
@@ -5,8 +5,7 @@ import {
|
||||
AuthZRelation,
|
||||
BrandedPermission,
|
||||
} from 'hooks/useAuthZ/types';
|
||||
import { formatPermission } from 'hooks/useAuthZ/utils';
|
||||
import { useAppContext } from 'providers/App/App';
|
||||
import { parsePermission } from 'hooks/useAuthZ/utils';
|
||||
|
||||
import noDataUrl from '@/assets/Icons/no-data.svg';
|
||||
|
||||
@@ -18,16 +17,21 @@ import './createGuardedRoute.styles.scss';
|
||||
function OnNoPermissionsFallback(response: {
|
||||
requiredPermissionName: BrandedPermission;
|
||||
}): ReactElement {
|
||||
const { user } = useAppContext();
|
||||
const { relation, object } = parsePermission(response.requiredPermissionName);
|
||||
|
||||
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 are not authorized</h3>
|
||||
<h3>Uh-oh! You don’t have permission to view this page.</h3>
|
||||
<p>
|
||||
<code>user/{user.id}</code> is not authorized to perform{' '}
|
||||
<code>{formatPermission(response.requiredPermissionName)}</code>
|
||||
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.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -75,6 +75,38 @@
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
// Error bubble: a subtle error-tinted callout replacing the default
|
||||
// assistant background, rendered when a turn fails.
|
||||
.bubble.error {
|
||||
.assistant & {
|
||||
background: var(--callout-error-background);
|
||||
border: 1px solid var(--callout-error-border);
|
||||
}
|
||||
}
|
||||
|
||||
.errorContent {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.errorIcon {
|
||||
flex-shrink: 0;
|
||||
margin-top: 2px;
|
||||
color: var(--destructive);
|
||||
}
|
||||
|
||||
.errorText {
|
||||
color: var(--callout-error-title);
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.retryButton {
|
||||
margin-top: 6px;
|
||||
align-self: flex-start;
|
||||
}
|
||||
|
||||
// User-bubble row: pencil button sits to the LEFT of the bubble within
|
||||
// the right-aligned message line, so it visually "ends" at the bubble's
|
||||
// right edge while keeping the bubble in its original position.
|
||||
|
||||
@@ -2,6 +2,10 @@ import React, { useMemo } from 'react';
|
||||
import cx from 'classnames';
|
||||
import ReactMarkdown from 'react-markdown';
|
||||
import remarkGfm from 'remark-gfm';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import { RotateCw, TriangleAlert } from '@signozhq/icons';
|
||||
|
||||
import { RetryActionDTO } from 'api/ai-assistant/sigNozAIAssistantAPI.schemas';
|
||||
|
||||
// Side-effect: registers all built-in block types into the BlockRegistry
|
||||
import '../blocks';
|
||||
@@ -104,18 +108,23 @@ function renderGroup(group: RenderGroup): JSX.Element {
|
||||
interface MessageBubbleProps {
|
||||
message: Message;
|
||||
onRegenerate?: () => void;
|
||||
onRetry?: () => void;
|
||||
isLastAssistant?: boolean;
|
||||
}
|
||||
|
||||
export default function MessageBubble({
|
||||
message,
|
||||
onRegenerate,
|
||||
onRetry,
|
||||
isLastAssistant = false,
|
||||
}: MessageBubbleProps): JSX.Element {
|
||||
const variant = useVariant();
|
||||
const isCompact = variant === 'panel';
|
||||
const isUser = message.role === 'user';
|
||||
const isError = !isUser && Boolean(message.isError);
|
||||
const hasBlocks = !isUser && message.blocks && message.blocks.length > 0;
|
||||
const showRetry =
|
||||
isError && message.retryAction === RetryActionDTO.manual && Boolean(onRetry);
|
||||
|
||||
// Recompute groups only when the blocks array identity changes — store
|
||||
// updates that don't touch this message's blocks should not re-render the
|
||||
@@ -138,7 +147,7 @@ export default function MessageBubble({
|
||||
<div className={messageClass} data-testid={`ai-message-${message.id}`}>
|
||||
<div className={bodyClass}>
|
||||
<div className={styles.bubbleRow}>
|
||||
<div className={styles.bubble}>
|
||||
<div className={cx(styles.bubble, { [styles.error]: isError })}>
|
||||
{message.attachments && message.attachments.length > 0 && (
|
||||
<div className={styles.attachments}>
|
||||
{message.attachments.map((att) => {
|
||||
@@ -161,6 +170,11 @@ export default function MessageBubble({
|
||||
|
||||
{isUser ? (
|
||||
<p className={styles.text}>{message.content}</p>
|
||||
) : isError ? (
|
||||
<div className={styles.errorContent}>
|
||||
<TriangleAlert size={14} className={styles.errorIcon} />
|
||||
<span className={styles.errorText}>{message.content}</span>
|
||||
</div>
|
||||
) : hasBlocks ? (
|
||||
<MessageContext.Provider value={{ messageId: message.id }}>
|
||||
{groups.map((g) => renderGroup(g))}
|
||||
@@ -183,7 +197,21 @@ export default function MessageBubble({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{!isUser && !message.isRateLimitError && (
|
||||
{showRetry && (
|
||||
<Button
|
||||
className={styles.retryButton}
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
color="secondary"
|
||||
onClick={onRetry}
|
||||
testId={`ai-message-retry-${message.id}`}
|
||||
>
|
||||
<RotateCw size={12} />
|
||||
Retry
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{!isUser && !isError && !message.isRateLimitError && (
|
||||
<MessageFeedback
|
||||
message={message}
|
||||
onRegenerate={onRegenerate}
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
import React from 'react';
|
||||
import { render, screen, userEvent } from 'tests/test-utils';
|
||||
import {
|
||||
ErrorCodeDTO,
|
||||
RetryActionDTO,
|
||||
} from 'api/ai-assistant/sigNozAIAssistantAPI.schemas';
|
||||
|
||||
import { Message } from '../../../types';
|
||||
|
||||
// react-markdown + remark-gfm are ESM-only and pull a large untransformed
|
||||
// dependency chain into jest. The error-rendering path under test renders
|
||||
// plain text (no markdown), so stub them to keep the import graph loadable.
|
||||
jest.mock('react-markdown', () => ({
|
||||
__esModule: true,
|
||||
default: ({ children }: { children?: React.ReactNode }): React.ReactNode =>
|
||||
children,
|
||||
}));
|
||||
jest.mock('remark-gfm', () => ({
|
||||
__esModule: true,
|
||||
default: (): void => undefined,
|
||||
}));
|
||||
|
||||
// eslint-disable-next-line import/first
|
||||
import MessageBubble from '../MessageBubble';
|
||||
|
||||
function errorMessage(overrides: Partial<Message> = {}): Message {
|
||||
return {
|
||||
id: 'err-1',
|
||||
role: 'assistant',
|
||||
content: 'This conversation is still finishing a previous response.',
|
||||
isError: true,
|
||||
errorCode: ErrorCodeDTO.thread_busy,
|
||||
retryAction: RetryActionDTO.manual,
|
||||
createdAt: 0,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
const retryButton = (): HTMLElement | null =>
|
||||
screen.queryByRole('button', { name: /retry/i });
|
||||
|
||||
describe('MessageBubble — error rendering', () => {
|
||||
it('shows a Retry button for a manual error and invokes onRetry on click', async () => {
|
||||
const onRetry = jest.fn();
|
||||
render(<MessageBubble message={errorMessage()} onRetry={onRetry} />);
|
||||
|
||||
// Error copy is rendered, and the feedback bar is suppressed on errors.
|
||||
expect(
|
||||
screen.getByText(/still finishing a previous response/i),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByRole('button', { name: /copy message/i }),
|
||||
).not.toBeInTheDocument();
|
||||
|
||||
const button = retryButton();
|
||||
expect(button).toBeInTheDocument();
|
||||
await userEvent.click(button as HTMLElement);
|
||||
expect(onRetry).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('hides the Retry button when retryAction is none', () => {
|
||||
render(
|
||||
<MessageBubble
|
||||
message={errorMessage({ retryAction: RetryActionDTO.none })}
|
||||
onRetry={jest.fn()}
|
||||
/>,
|
||||
);
|
||||
expect(retryButton()).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('hides the Retry button when retryAction is auto', () => {
|
||||
render(
|
||||
<MessageBubble
|
||||
message={errorMessage({ retryAction: RetryActionDTO.auto })}
|
||||
onRetry={jest.fn()}
|
||||
/>,
|
||||
);
|
||||
expect(retryButton()).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('hides the Retry button when no onRetry handler is provided', () => {
|
||||
render(<MessageBubble message={errorMessage()} />);
|
||||
expect(retryButton()).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -37,6 +37,9 @@ export default function VirtualizedMessages({
|
||||
const regenerateAssistantMessage = useAIAssistantStore(
|
||||
(s) => s.regenerateAssistantMessage,
|
||||
);
|
||||
const retryAssistantMessage = useAIAssistantStore(
|
||||
(s) => s.retryAssistantMessage,
|
||||
);
|
||||
const { threadId } = useAIAssistantAnalyticsContext(conversationId);
|
||||
const streamingStatus = useAIAssistantStore(
|
||||
(s) => s.streams[conversationId]?.streamingStatus ?? '',
|
||||
@@ -85,6 +88,14 @@ export default function VirtualizedMessages({
|
||||
[conversationId, isStreaming, regenerateAssistantMessage, threadId],
|
||||
);
|
||||
|
||||
const handleRetry = useCallback((): void => {
|
||||
if (isStreaming) {
|
||||
return;
|
||||
}
|
||||
void logEvent(AIAssistantEvents.RetryClicked, { threadId });
|
||||
void retryAssistantMessage(conversationId);
|
||||
}, [conversationId, isStreaming, retryAssistantMessage, threadId]);
|
||||
|
||||
// Scroll all the way to the actual bottom — including the 64px of bottom
|
||||
// padding on the scroller — so the last bubble has visible breathing room
|
||||
// above the disclaimer / input bar. Virtuoso's `scrollToIndex(LAST,
|
||||
@@ -206,6 +217,11 @@ export default function VirtualizedMessages({
|
||||
? (): void => handleRegenerate(msg.id)
|
||||
: undefined
|
||||
}
|
||||
onRetry={
|
||||
msg.isError && isLastAssistant && !showStreamingSlot
|
||||
? handleRetry
|
||||
: undefined
|
||||
}
|
||||
isLastAssistant={isLastAssistant}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -90,6 +90,7 @@ export enum AIAssistantEvents {
|
||||
SuggestedPromptClicked = 'AI Assistant: Suggested prompt clicked',
|
||||
CancelClicked = 'AI Assistant: Cancel clicked',
|
||||
RegenerateClicked = 'AI Assistant: Regenerate clicked',
|
||||
RetryClicked = 'AI Assistant: Retry clicked',
|
||||
MessageCopied = 'AI Assistant: Message copied',
|
||||
FeedbackSubmitted = 'AI Assistant: Feedback submitted',
|
||||
ResourceOpened = 'AI Assistant: Resource opened',
|
||||
|
||||
@@ -0,0 +1,263 @@
|
||||
import {
|
||||
ErrorCodeDTO,
|
||||
RetryActionDTO,
|
||||
} from 'api/ai-assistant/sigNozAIAssistantAPI.schemas';
|
||||
import type { SSEEvent } from 'api/ai-assistant/chat';
|
||||
|
||||
import { useAIAssistantStore } from '../useAIAssistantStore';
|
||||
import type { Message } from '../../types';
|
||||
|
||||
// The store talks to the chat API only through these named exports. Mock the
|
||||
// whole module so we can drive the SSE stream + REST calls deterministically.
|
||||
jest.mock('api/ai-assistant/chat', () => ({
|
||||
__esModule: true,
|
||||
createThread: jest.fn(),
|
||||
sendMessage: jest.fn(),
|
||||
streamEvents: jest.fn(),
|
||||
approveExecution: jest.fn(),
|
||||
clarifyExecution: jest.fn(),
|
||||
regenerateMessage: jest.fn(),
|
||||
rejectExecution: jest.fn(),
|
||||
cancelExecution: jest.fn(),
|
||||
listThreads: jest.fn(),
|
||||
getThreadDetail: jest.fn(),
|
||||
updateThread: jest.fn(),
|
||||
submitFeedback: jest.fn(),
|
||||
}));
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-var-requires, global-require
|
||||
const chat = jest.requireMock('api/ai-assistant/chat') as Record<
|
||||
string,
|
||||
jest.Mock
|
||||
>;
|
||||
|
||||
// Builds a single-use async stream from a fixed list of SSE events.
|
||||
async function* eventStream(events: SSEEvent[]): AsyncGenerator<SSEEvent> {
|
||||
for (const event of events) {
|
||||
yield event;
|
||||
}
|
||||
}
|
||||
|
||||
function errorEvent(
|
||||
executionId: string,
|
||||
code: ErrorCodeDTO,
|
||||
retryAction: RetryActionDTO,
|
||||
): SSEEvent {
|
||||
return {
|
||||
type: 'error',
|
||||
executionId,
|
||||
error: { code, message: 'backend message' },
|
||||
retryAction,
|
||||
};
|
||||
}
|
||||
|
||||
function lastMessage(conversationId: string): Message {
|
||||
const conv = useAIAssistantStore.getState().conversations[conversationId];
|
||||
return conv.messages[conv.messages.length - 1];
|
||||
}
|
||||
|
||||
describe('useAIAssistantStore — streaming error handling', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
useAIAssistantStore.setState((s) => {
|
||||
s.conversations = {};
|
||||
s.streams = {};
|
||||
s.activeConversationId = null;
|
||||
});
|
||||
});
|
||||
|
||||
it('commits a manually-retryable error bubble with friendly copy and metadata', async () => {
|
||||
chat.createThread.mockResolvedValue('thread-1');
|
||||
chat.sendMessage.mockResolvedValue('exec-1');
|
||||
chat.streamEvents.mockReturnValueOnce(
|
||||
eventStream([
|
||||
errorEvent('exec-1', ErrorCodeDTO.thread_busy, RetryActionDTO.manual),
|
||||
]),
|
||||
);
|
||||
|
||||
useAIAssistantStore.getState().startNewConversation();
|
||||
await useAIAssistantStore.getState().sendMessage('hello');
|
||||
|
||||
const conv = useAIAssistantStore.getState().conversations['thread-1'];
|
||||
expect(conv.messages).toHaveLength(2);
|
||||
expect(conv.messages[0]).toMatchObject({ role: 'user', content: 'hello' });
|
||||
expect(conv.messages[1]).toMatchObject({
|
||||
role: 'assistant',
|
||||
isError: true,
|
||||
errorCode: ErrorCodeDTO.thread_busy,
|
||||
retryAction: RetryActionDTO.manual,
|
||||
});
|
||||
// Code-specific FE copy, not the raw backend message.
|
||||
expect(conv.messages[1].content).toContain(
|
||||
'still finishing a previous response',
|
||||
);
|
||||
});
|
||||
|
||||
it('replays the send on retry without re-pushing the user message', async () => {
|
||||
chat.createThread.mockResolvedValue('thread-1');
|
||||
chat.sendMessage.mockResolvedValue('exec-1');
|
||||
chat.streamEvents.mockReturnValueOnce(
|
||||
eventStream([
|
||||
errorEvent('exec-1', ErrorCodeDTO.thread_busy, RetryActionDTO.manual),
|
||||
]),
|
||||
);
|
||||
|
||||
useAIAssistantStore.getState().startNewConversation();
|
||||
await useAIAssistantStore.getState().sendMessage('hello');
|
||||
|
||||
// The retry succeeds this time.
|
||||
chat.streamEvents.mockReturnValueOnce(
|
||||
eventStream([
|
||||
{
|
||||
type: 'message',
|
||||
executionId: 'exec-1',
|
||||
messageId: 'm1',
|
||||
delta: 'Hi there',
|
||||
done: true,
|
||||
},
|
||||
]),
|
||||
);
|
||||
|
||||
await useAIAssistantStore.getState().retryAssistantMessage('thread-1');
|
||||
|
||||
const conv = useAIAssistantStore.getState().conversations['thread-1'];
|
||||
// Error bubble replaced by the assistant reply; the user message stays.
|
||||
expect(conv.messages).toHaveLength(2);
|
||||
expect(conv.messages[0]).toMatchObject({ role: 'user', content: 'hello' });
|
||||
expect(conv.messages[1]).toMatchObject({
|
||||
role: 'assistant',
|
||||
content: 'Hi there',
|
||||
});
|
||||
expect(conv.messages[1].isError).toBeUndefined();
|
||||
// Thread already existed on retry; the user message was never re-sent as new.
|
||||
expect(chat.createThread).toHaveBeenCalledTimes(1);
|
||||
expect(chat.sendMessage).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('silently retries auto-flagged errors, then downgrades to manual once spent', async () => {
|
||||
chat.createThread.mockResolvedValue('thread-2');
|
||||
chat.sendMessage.mockResolvedValue('exec');
|
||||
// Always auto-retryable: 1 initial attempt + MAX_AUTO_RETRIES (2) = 3 sends.
|
||||
chat.streamEvents.mockImplementation(() =>
|
||||
eventStream([
|
||||
errorEvent('exec', ErrorCodeDTO.internal_error, RetryActionDTO.auto),
|
||||
]),
|
||||
);
|
||||
|
||||
useAIAssistantStore.getState().startNewConversation();
|
||||
await useAIAssistantStore.getState().sendMessage('hi');
|
||||
|
||||
expect(chat.sendMessage).toHaveBeenCalledTimes(3);
|
||||
expect(lastMessage('thread-2')).toMatchObject({
|
||||
isError: true,
|
||||
errorCode: ErrorCodeDTO.internal_error,
|
||||
// Auto budget exhausted → presented as manual so a Retry button shows.
|
||||
retryAction: RetryActionDTO.manual,
|
||||
});
|
||||
}, 10000);
|
||||
|
||||
it('marks rate-limit errors and offers no retry', async () => {
|
||||
chat.createThread.mockResolvedValue('thread-3');
|
||||
chat.sendMessage.mockResolvedValue('exec');
|
||||
chat.streamEvents.mockReturnValueOnce(
|
||||
eventStream([
|
||||
errorEvent('exec', ErrorCodeDTO.hourly_message_limit, RetryActionDTO.none),
|
||||
]),
|
||||
);
|
||||
|
||||
useAIAssistantStore.getState().startNewConversation();
|
||||
await useAIAssistantStore.getState().sendMessage('hi');
|
||||
|
||||
expect(lastMessage('thread-3')).toMatchObject({
|
||||
isError: true,
|
||||
isRateLimitError: true,
|
||||
retryAction: RetryActionDTO.none,
|
||||
});
|
||||
|
||||
// No retry thunk registered for a non-retryable error — retry is a no-op.
|
||||
const before =
|
||||
useAIAssistantStore.getState().conversations['thread-3'].messages.length;
|
||||
await useAIAssistantStore.getState().retryAssistantMessage('thread-3');
|
||||
expect(
|
||||
useAIAssistantStore.getState().conversations['thread-3'].messages,
|
||||
).toHaveLength(before);
|
||||
});
|
||||
|
||||
it('recovers silently when an auto-flagged error succeeds on retry', async () => {
|
||||
chat.createThread.mockResolvedValue('thread-4');
|
||||
chat.sendMessage.mockResolvedValue('exec');
|
||||
chat.streamEvents
|
||||
.mockReturnValueOnce(
|
||||
eventStream([
|
||||
errorEvent('exec', ErrorCodeDTO.internal_error, RetryActionDTO.auto),
|
||||
]),
|
||||
)
|
||||
.mockReturnValueOnce(
|
||||
eventStream([
|
||||
{
|
||||
type: 'message',
|
||||
executionId: 'exec',
|
||||
messageId: 'm1',
|
||||
delta: 'Recovered',
|
||||
done: true,
|
||||
},
|
||||
]),
|
||||
);
|
||||
|
||||
useAIAssistantStore.getState().startNewConversation();
|
||||
await useAIAssistantStore.getState().sendMessage('hi');
|
||||
|
||||
// 1 initial attempt + 1 silent auto retry, then success — no error bubble.
|
||||
expect(chat.sendMessage).toHaveBeenCalledTimes(2);
|
||||
const conv = useAIAssistantStore.getState().conversations['thread-4'];
|
||||
expect(conv.messages).toHaveLength(2);
|
||||
expect(conv.messages[0]).toMatchObject({ role: 'user', content: 'hi' });
|
||||
expect(conv.messages[1]).toMatchObject({
|
||||
role: 'assistant',
|
||||
content: 'Recovered',
|
||||
});
|
||||
expect(conv.messages.some((m) => m.isError)).toBe(false);
|
||||
}, 10000);
|
||||
|
||||
it('replays the originating action on retry for a non-send error (approve)', async () => {
|
||||
chat.approveExecution.mockResolvedValue('exec-a');
|
||||
chat.streamEvents.mockReturnValueOnce(
|
||||
eventStream([
|
||||
errorEvent('exec-a', ErrorCodeDTO.thread_busy, RetryActionDTO.manual),
|
||||
]),
|
||||
);
|
||||
|
||||
const convId = useAIAssistantStore.getState().startNewConversation();
|
||||
await useAIAssistantStore.getState().approveAction(convId, 'approval-1');
|
||||
|
||||
expect(lastMessage(convId)).toMatchObject({
|
||||
isError: true,
|
||||
retryAction: RetryActionDTO.manual,
|
||||
});
|
||||
expect(chat.approveExecution).toHaveBeenCalledTimes(1);
|
||||
|
||||
// Retry replays the approval (not a send) and succeeds this time.
|
||||
chat.streamEvents.mockReturnValueOnce(
|
||||
eventStream([
|
||||
{
|
||||
type: 'message',
|
||||
executionId: 'exec-a',
|
||||
messageId: 'm1',
|
||||
delta: 'Approved',
|
||||
done: true,
|
||||
},
|
||||
]),
|
||||
);
|
||||
await useAIAssistantStore.getState().retryAssistantMessage(convId);
|
||||
|
||||
const conv = useAIAssistantStore.getState().conversations[convId];
|
||||
expect(conv.messages).toHaveLength(1);
|
||||
expect(conv.messages[0]).toMatchObject({
|
||||
role: 'assistant',
|
||||
content: 'Approved',
|
||||
});
|
||||
expect(conv.messages[0].isError).toBeUndefined();
|
||||
expect(chat.approveExecution).toHaveBeenCalledTimes(2);
|
||||
expect(chat.sendMessage).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -8,6 +8,7 @@ import type {
|
||||
MessageActionDTO,
|
||||
MessageSummaryDTOBlocksAnyOfItem,
|
||||
} from 'api/ai-assistant/sigNozAIAssistantAPI.schemas';
|
||||
import { RetryActionDTO } from 'api/ai-assistant/sigNozAIAssistantAPI.schemas';
|
||||
|
||||
import {
|
||||
approveExecution,
|
||||
@@ -35,7 +36,10 @@ import {
|
||||
MessageBlock,
|
||||
MessageRole,
|
||||
} from '../types';
|
||||
import { resolveAssistantErrorMessage } from '../utils/resolveAssistantErrorMessage';
|
||||
import {
|
||||
resolveAssistantError,
|
||||
type AssistantErrorResolution,
|
||||
} from '../utils/resolveAssistantError';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types used by module-level helpers
|
||||
@@ -56,6 +60,15 @@ interface SSEStreamCtx {
|
||||
|
||||
const streamControllers = new Map<string, AbortController>();
|
||||
|
||||
/**
|
||||
* Per-conversation retry thunks for the most recent failed turn. Populated by
|
||||
* `finalizeStreamingError` when the error is manually retryable; consumed by
|
||||
* the `retryAssistantMessage` action when the user clicks Retry. Transient
|
||||
* (not persisted) — it shares the in-memory lifetime of the error bubble it
|
||||
* backs, so a page reload drops both together.
|
||||
*/
|
||||
const retryRegistry = new Map<string, () => Promise<void>>();
|
||||
|
||||
function abortStream(conversationId: string): void {
|
||||
const ctrl = streamControllers.get(conversationId);
|
||||
if (ctrl) {
|
||||
@@ -197,7 +210,7 @@ function resetStreamingState(
|
||||
* Marker thrown by `runStreamingLoop` when an SSE event reports
|
||||
* `invalid_token`. Callers that own an originating action (sendMessage /
|
||||
* approve / clarify / regenerate) catch this and re-issue that action via
|
||||
* `streamWithAuthRetry`; the retry's first REST call will 401, at which point
|
||||
* `streamWithRetry`; the retry's first REST call will 401, at which point
|
||||
* the shared axios `interceptorRejected` rotates the access token and replays.
|
||||
*/
|
||||
class AuthExpiredError extends Error {
|
||||
@@ -207,27 +220,50 @@ class AuthExpiredError extends Error {
|
||||
}
|
||||
}
|
||||
|
||||
/** Capped silent re-attempts for backend-flagged transient (`auto`) errors. */
|
||||
const MAX_AUTO_RETRIES = 2;
|
||||
/** Backoff before each auto re-attempt, indexed by prior auto-retry count. */
|
||||
const AUTO_RETRY_BACKOFF_MS = [500, 1500];
|
||||
|
||||
function delay(ms: number): Promise<void> {
|
||||
return new Promise((resolve) => {
|
||||
setTimeout(resolve, ms);
|
||||
});
|
||||
}
|
||||
|
||||
/** True when the SSE error carries the backend's `retryAction: 'auto'` flag. */
|
||||
function isAutoRetryableError(err: unknown): boolean {
|
||||
return (
|
||||
(err as { retryAction?: unknown } | undefined)?.retryAction ===
|
||||
RetryActionDTO.auto
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Runs the originating action (e.g. sendMessage POST) and streams the
|
||||
* resulting execution. On `AuthExpiredError`, re-issues `start` once — the
|
||||
* retry's REST call hits 401, the shared axios interceptor rotates the
|
||||
* access token and replays, and the new SSE picks up the rotated token from
|
||||
* localStorage. Backend signals `retryAction: 'manual'` for `invalid_token`,
|
||||
* so the dead execution can't be resumed — only a fresh one helps.
|
||||
* resulting execution, with two independent retry budgets:
|
||||
*
|
||||
* • Auth — on `AuthExpiredError` (SSE `invalid_token`), re-issues `start`
|
||||
* once. The retry's REST call 401s, the shared axios interceptor rotates
|
||||
* the access token + replays, and the new SSE picks up the rotated token.
|
||||
* Backend flags `invalid_token` as `manual`, so only a fresh execution helps.
|
||||
* • Auto — on an SSE error the backend flagged `retryAction: 'auto'`
|
||||
* (transient), silently re-issues `start` up to `MAX_AUTO_RETRIES` times
|
||||
* with backoff. Once exhausted the error propagates so the caller can
|
||||
* surface a manual Retry affordance.
|
||||
*
|
||||
* Both reset the stream state before re-attempting so a dead execution's
|
||||
* partial output isn't concatenated onto the retry.
|
||||
*/
|
||||
async function streamWithAuthRetry(
|
||||
async function streamWithRetry(
|
||||
conversationId: string,
|
||||
start: () => Promise<string>,
|
||||
set: StoreSetter,
|
||||
): Promise<void> {
|
||||
for (let attempt = 0; attempt <= 1; attempt += 1) {
|
||||
if (attempt > 0) {
|
||||
// Drop any partial content/events from the previous attempt so the
|
||||
// retried execution's stream isn't concatenated with the dead one.
|
||||
set((s) => {
|
||||
resetStreamingState(s, conversationId);
|
||||
});
|
||||
}
|
||||
let authRetried = false;
|
||||
let autoRetries = 0;
|
||||
|
||||
for (;;) {
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
const executionId = await start();
|
||||
const ctrl = newStreamController(conversationId);
|
||||
@@ -242,10 +278,28 @@ async function streamWithAuthRetry(
|
||||
return;
|
||||
} catch (err) {
|
||||
streamControllers.delete(conversationId);
|
||||
if (err instanceof AuthExpiredError && attempt < 1) {
|
||||
continue;
|
||||
|
||||
if (err instanceof AuthExpiredError && !authRetried) {
|
||||
authRetried = true;
|
||||
} else if (isAutoRetryableError(err) && autoRetries < MAX_AUTO_RETRIES) {
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
await delay(AUTO_RETRY_BACKOFF_MS[autoRetries] ?? 1500);
|
||||
autoRetries += 1;
|
||||
} else {
|
||||
if (isAutoRetryableError(err)) {
|
||||
// Auto-retry budget spent — present the failure as manually
|
||||
// retryable so the caller surfaces a Retry button rather than
|
||||
// silently giving up.
|
||||
(err as { retryAction?: RetryActionDTO }).retryAction =
|
||||
RetryActionDTO.manual;
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
throw err;
|
||||
|
||||
// Drop partial content/events from the failed attempt before retrying.
|
||||
set((s) => {
|
||||
resetStreamingState(s, conversationId);
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -258,7 +312,7 @@ async function streamWithAuthRetry(
|
||||
*
|
||||
* On an `invalid_token` error event (e.g. MCP auth expired mid-execution),
|
||||
* throws `AuthExpiredError` so the caller can re-issue the originating
|
||||
* action via `streamWithAuthRetry`. We don't refresh here ourselves — the
|
||||
* action via `streamWithRetry`. We don't refresh here ourselves — the
|
||||
* retry's REST call will 401 and the shared axios `interceptorRejected`
|
||||
* handles rotation + replay. Throws on any other `error` event — the
|
||||
* caller's catch block handles UI feedback.
|
||||
@@ -484,20 +538,37 @@ function hasPendingInput(conversationId: string, get: StoreGetter): boolean {
|
||||
return Boolean(stream?.pendingApproval || stream?.pendingClarification);
|
||||
}
|
||||
|
||||
/**
|
||||
* Commits a failed turn as an error message and removes the stream entry.
|
||||
* When the failure is manually retryable and a `retry` thunk is supplied, the
|
||||
* thunk is stashed in `retryRegistry` so the bubble's Retry button can replay
|
||||
* the originating action.
|
||||
*/
|
||||
function finalizeStreamingError(
|
||||
conversationId: string,
|
||||
errorContent: string,
|
||||
resolution: AssistantErrorResolution,
|
||||
set: StoreSetter,
|
||||
isRateLimit = false,
|
||||
retry?: () => Promise<void>,
|
||||
): void {
|
||||
const { message, code, retryAction, isRateLimit } = resolution;
|
||||
|
||||
if (retryAction === RetryActionDTO.manual && retry) {
|
||||
retryRegistry.set(conversationId, retry);
|
||||
} else {
|
||||
retryRegistry.delete(conversationId);
|
||||
}
|
||||
|
||||
set((s) => {
|
||||
const conv = s.conversations[conversationId];
|
||||
if (conv) {
|
||||
conv.messages.push({
|
||||
id: uuidv4(),
|
||||
role: 'assistant',
|
||||
content: errorContent,
|
||||
content: message,
|
||||
createdAt: Date.now(),
|
||||
isError: true,
|
||||
retryAction,
|
||||
...(code ? { errorCode: code } : {}),
|
||||
...(isRateLimit ? { isRateLimitError: true } : {}),
|
||||
});
|
||||
conv.updatedAt = Date.now();
|
||||
@@ -506,6 +577,40 @@ function finalizeStreamingError(
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Shared streaming wrapper for actions that have no pre-stream setup beyond
|
||||
* resetting state (approve / clarify / regenerate). Streams the execution,
|
||||
* finalizes the message on success, and on failure resolves the error +
|
||||
* registers `retry` (the caller's own re-invocation) so the bubble can replay
|
||||
* it. `sendMessage` does not use this — it owns thread-creation/re-keying and
|
||||
* runs its own equivalent loop.
|
||||
*/
|
||||
async function streamAndFinalize(
|
||||
conversationId: string,
|
||||
start: () => Promise<string>,
|
||||
fallback: string,
|
||||
logLabel: string,
|
||||
set: StoreSetter,
|
||||
get: StoreGetter,
|
||||
retry: () => Promise<void>,
|
||||
): Promise<void> {
|
||||
try {
|
||||
await streamWithRetry(conversationId, start, set);
|
||||
if (!hasPendingInput(conversationId, get)) {
|
||||
finalizeStreamingMessage(conversationId, set, get);
|
||||
}
|
||||
} catch (err) {
|
||||
// Abort errors are expected when the user cancels — not a failure.
|
||||
if (err instanceof DOMException && err.name === 'AbortError') {
|
||||
return;
|
||||
}
|
||||
// eslint-disable-next-line no-console
|
||||
console.error(logLabel, err);
|
||||
const resolution = resolveAssistantError(err, fallback);
|
||||
finalizeStreamingError(conversationId, resolution, set, retry);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Store interface
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -564,6 +669,8 @@ export interface AIAssistantStore {
|
||||
conversationId: string,
|
||||
messageId: string,
|
||||
) => Promise<void>;
|
||||
/** Replays the originating action for a manually-retryable error bubble. */
|
||||
retryAssistantMessage: (conversationId: string) => Promise<void>;
|
||||
submitMessageFeedback: (
|
||||
messageId: string,
|
||||
rating: FeedbackRating,
|
||||
@@ -877,7 +984,7 @@ export const useAIAssistantStore = create<AIAssistantStore>()(
|
||||
// there's no "originating action" to redo — reopening the
|
||||
// same dead executionId would just re-emit the failure.
|
||||
// Let the error bubble; the user can send a new message,
|
||||
// which will go through `streamWithAuthRetry`.
|
||||
// which will go through `streamWithRetry`.
|
||||
if (
|
||||
detail.activeExecutionId &&
|
||||
!streamControllers.has(threadId) &&
|
||||
@@ -1060,7 +1167,7 @@ export const useAIAssistantStore = create<AIAssistantStore>()(
|
||||
attachments?: MessageAttachment[],
|
||||
contexts?: MessageContext[],
|
||||
): Promise<void> => {
|
||||
let convId = get().activeConversationId;
|
||||
const convId = get().activeConversationId;
|
||||
if (!convId || !get().conversations[convId]) {
|
||||
return;
|
||||
}
|
||||
@@ -1093,63 +1200,75 @@ export const useAIAssistantStore = create<AIAssistantStore>()(
|
||||
};
|
||||
|
||||
set((state) => {
|
||||
const conv = state.conversations[convId!];
|
||||
const conv = state.conversations[convId];
|
||||
conv.messages.push(userMessage);
|
||||
conv.updatedAt = Date.now();
|
||||
if (!conv.title && text.trim()) {
|
||||
conv.title = deriveTitle(text);
|
||||
}
|
||||
resetStreamingState(state, convId!);
|
||||
resetStreamingState(state, convId);
|
||||
});
|
||||
|
||||
try {
|
||||
let { threadId } = get().conversations[convId];
|
||||
if (!threadId) {
|
||||
threadId = await createThread();
|
||||
// Re-key the conversation from client UUID to backend threadId
|
||||
// so fetchThreads won't create a duplicate entry later.
|
||||
const oldId = convId;
|
||||
convId = threadId;
|
||||
set((s) => {
|
||||
const conv = s.conversations[oldId];
|
||||
if (conv) {
|
||||
conv.id = convId!;
|
||||
conv.threadId = convId!;
|
||||
s.conversations[convId!] = conv;
|
||||
delete s.conversations[oldId];
|
||||
if (s.activeConversationId === oldId) {
|
||||
s.activeConversationId = convId!;
|
||||
// The full send — ensure a backend thread exists (re-keying the
|
||||
// optimistic client UUID on first send), POST the message, and
|
||||
// stream the reply. Defined as a closure so the error bubble's
|
||||
// Retry button can replay it without re-pushing the user message.
|
||||
const runSend = async (cid: string): Promise<void> => {
|
||||
let targetConvId = cid;
|
||||
try {
|
||||
let { threadId } = get().conversations[targetConvId];
|
||||
if (!threadId) {
|
||||
threadId = await createThread();
|
||||
// Re-key the conversation from client UUID to backend threadId
|
||||
// so fetchThreads won't create a duplicate entry later.
|
||||
const oldId = targetConvId;
|
||||
const newId = threadId;
|
||||
set((s) => {
|
||||
const conv = s.conversations[oldId];
|
||||
if (conv) {
|
||||
conv.id = newId;
|
||||
conv.threadId = newId;
|
||||
s.conversations[newId] = conv;
|
||||
delete s.conversations[oldId];
|
||||
if (s.activeConversationId === oldId) {
|
||||
s.activeConversationId = newId;
|
||||
}
|
||||
const stream = s.streams[oldId];
|
||||
if (stream) {
|
||||
s.streams[newId] = stream;
|
||||
delete s.streams[oldId];
|
||||
}
|
||||
}
|
||||
const stream = s.streams[oldId];
|
||||
if (stream) {
|
||||
s.streams[convId!] = stream;
|
||||
delete s.streams[oldId];
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
const tid = threadId;
|
||||
await streamWithAuthRetry(
|
||||
convId,
|
||||
() => sendMessageToThread(tid, text, contexts),
|
||||
set,
|
||||
);
|
||||
});
|
||||
targetConvId = newId;
|
||||
}
|
||||
const tid = threadId;
|
||||
await streamWithRetry(
|
||||
targetConvId,
|
||||
() => sendMessageToThread(tid, text, contexts),
|
||||
set,
|
||||
);
|
||||
|
||||
if (!hasPendingInput(convId, get)) {
|
||||
finalizeStreamingMessage(convId, set, get);
|
||||
if (!hasPendingInput(targetConvId, get)) {
|
||||
finalizeStreamingMessage(targetConvId, set, get);
|
||||
}
|
||||
} catch (err) {
|
||||
// Abort errors are expected when the user cancels — not a failure.
|
||||
if (err instanceof DOMException && err.name === 'AbortError') {
|
||||
return;
|
||||
}
|
||||
console.error('[AIAssistant] sendMessage failed:', err);
|
||||
const resolution = resolveAssistantError(
|
||||
err,
|
||||
'Something went wrong while fetching the response. Please try again.',
|
||||
);
|
||||
finalizeStreamingError(targetConvId, resolution, set, () =>
|
||||
runSend(targetConvId),
|
||||
);
|
||||
}
|
||||
} catch (err) {
|
||||
// Abort errors are expected when the user cancels — not a failure
|
||||
if (err instanceof DOMException && err.name === 'AbortError') {
|
||||
return;
|
||||
}
|
||||
console.error('[AIAssistant] sendMessage failed:', err);
|
||||
const { message, isRateLimit } = resolveAssistantErrorMessage(
|
||||
err,
|
||||
'Something went wrong while fetching the response. Please try again.',
|
||||
);
|
||||
finalizeStreamingError(convId, message, set, isRateLimit);
|
||||
}
|
||||
};
|
||||
|
||||
await runSend(convId);
|
||||
},
|
||||
|
||||
approveAction: async (
|
||||
@@ -1167,26 +1286,17 @@ export const useAIAssistantStore = create<AIAssistantStore>()(
|
||||
}
|
||||
});
|
||||
|
||||
try {
|
||||
await streamWithAuthRetry(
|
||||
const run = (): Promise<void> =>
|
||||
streamAndFinalize(
|
||||
conversationId,
|
||||
() => approveExecution(approvalId),
|
||||
set,
|
||||
);
|
||||
if (!hasPendingInput(conversationId, get)) {
|
||||
finalizeStreamingMessage(conversationId, set, get);
|
||||
}
|
||||
} catch (err) {
|
||||
if (err instanceof DOMException && err.name === 'AbortError') {
|
||||
return;
|
||||
}
|
||||
console.error('[AIAssistant] approveAction failed:', err);
|
||||
const { message, isRateLimit } = resolveAssistantErrorMessage(
|
||||
err,
|
||||
'Something went wrong while processing the approval. Please try again.',
|
||||
'[AIAssistant] approveAction failed:',
|
||||
set,
|
||||
get,
|
||||
run,
|
||||
);
|
||||
finalizeStreamingError(conversationId, message, set, isRateLimit);
|
||||
}
|
||||
await run();
|
||||
},
|
||||
|
||||
rejectAction: async (
|
||||
@@ -1246,26 +1356,17 @@ export const useAIAssistantStore = create<AIAssistantStore>()(
|
||||
resetStreamingState(s, conversationId);
|
||||
});
|
||||
|
||||
try {
|
||||
await streamWithAuthRetry(
|
||||
const run = (): Promise<void> =>
|
||||
streamAndFinalize(
|
||||
conversationId,
|
||||
() => regenerateMessage(messageId),
|
||||
set,
|
||||
);
|
||||
if (!hasPendingInput(conversationId, get)) {
|
||||
finalizeStreamingMessage(conversationId, set, get);
|
||||
}
|
||||
} catch (err) {
|
||||
if (err instanceof DOMException && err.name === 'AbortError') {
|
||||
return;
|
||||
}
|
||||
console.error('[AIAssistant] regenerateAssistantMessage failed:', err);
|
||||
const { message, isRateLimit } = resolveAssistantErrorMessage(
|
||||
err,
|
||||
'Something went wrong while regenerating the response. Please try again.',
|
||||
'[AIAssistant] regenerateAssistantMessage failed:',
|
||||
set,
|
||||
get,
|
||||
run,
|
||||
);
|
||||
finalizeStreamingError(conversationId, message, set, isRateLimit);
|
||||
}
|
||||
await run();
|
||||
},
|
||||
|
||||
submitMessageFeedback: async (
|
||||
@@ -1312,26 +1413,42 @@ export const useAIAssistantStore = create<AIAssistantStore>()(
|
||||
}
|
||||
});
|
||||
|
||||
try {
|
||||
await streamWithAuthRetry(
|
||||
const run = (): Promise<void> =>
|
||||
streamAndFinalize(
|
||||
conversationId,
|
||||
() => clarifyExecution(clarificationId, answers),
|
||||
set,
|
||||
);
|
||||
if (!hasPendingInput(conversationId, get)) {
|
||||
finalizeStreamingMessage(conversationId, set, get);
|
||||
}
|
||||
} catch (err) {
|
||||
if (err instanceof DOMException && err.name === 'AbortError') {
|
||||
return;
|
||||
}
|
||||
console.error('[AIAssistant] submitClarification failed:', err);
|
||||
const { message, isRateLimit } = resolveAssistantErrorMessage(
|
||||
err,
|
||||
'Something went wrong while processing your answers. Please try again.',
|
||||
'[AIAssistant] submitClarification failed:',
|
||||
set,
|
||||
get,
|
||||
run,
|
||||
);
|
||||
finalizeStreamingError(conversationId, message, set, isRateLimit);
|
||||
await run();
|
||||
},
|
||||
|
||||
retryAssistantMessage: async (conversationId: string): Promise<void> => {
|
||||
const retry = retryRegistry.get(conversationId);
|
||||
if (!retry) {
|
||||
return;
|
||||
}
|
||||
retryRegistry.delete(conversationId);
|
||||
|
||||
// Drop the trailing error bubble we're retrying from and reset the
|
||||
// stream so the in-progress retry renders immediately. The retry
|
||||
// thunk replays the originating action without re-pushing the
|
||||
// user's message.
|
||||
set((s) => {
|
||||
const conv = s.conversations[conversationId];
|
||||
if (conv) {
|
||||
const last = conv.messages[conv.messages.length - 1];
|
||||
if (last?.isError) {
|
||||
conv.messages.pop();
|
||||
}
|
||||
}
|
||||
resetStreamingState(s, conversationId);
|
||||
});
|
||||
|
||||
await retry();
|
||||
},
|
||||
})),
|
||||
{
|
||||
|
||||
@@ -15,9 +15,11 @@
|
||||
import type {
|
||||
ApprovalEventDTO,
|
||||
ClarificationEventDTO,
|
||||
ErrorCodeDTO,
|
||||
FeedbackRatingDTO,
|
||||
MessageActionDTO,
|
||||
MessageActionKindDTO,
|
||||
RetryActionDTO,
|
||||
} from 'api/ai-assistant/sigNozAIAssistantAPI.schemas';
|
||||
|
||||
/** Client-only file attachment — no API equivalent (uploads happen via data URLs). */
|
||||
@@ -91,6 +93,18 @@ export interface Message {
|
||||
* bar (copy/vote/regenerate) is hidden — retrying would just 429 again.
|
||||
*/
|
||||
isRateLimitError?: boolean;
|
||||
/**
|
||||
* Marks an assistant message that represents a failed turn. Drives the
|
||||
* error styling and replaces the feedback bar with a retry affordance.
|
||||
*/
|
||||
isError?: boolean;
|
||||
/** Known backend error code for the failure, when recognised. */
|
||||
errorCode?: ErrorCodeDTO;
|
||||
/**
|
||||
* Retry semantics for a failed turn — `manual` renders an inline Retry
|
||||
* button on the error bubble; `none`/`auto` render no button.
|
||||
*/
|
||||
retryAction?: RetryActionDTO;
|
||||
createdAt: number;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,154 @@
|
||||
import { AxiosError } from 'axios';
|
||||
import {
|
||||
ErrorCodeDTO,
|
||||
RetryActionDTO,
|
||||
} from 'api/ai-assistant/sigNozAIAssistantAPI.schemas';
|
||||
|
||||
import { resolveAssistantError } from '../resolveAssistantError';
|
||||
|
||||
const FALLBACK = 'Something went wrong. Please try again.';
|
||||
|
||||
function restError(status: number, code: string, message: string): AxiosError {
|
||||
const err = new AxiosError('Request failed');
|
||||
err.response = {
|
||||
status,
|
||||
data: { error: { code, message } },
|
||||
} as AxiosError['response'];
|
||||
return err;
|
||||
}
|
||||
|
||||
describe('resolveAssistantError', () => {
|
||||
describe('message resolution', () => {
|
||||
it('prefers code-specific FE copy over the backend message', () => {
|
||||
const err = restError(409, ErrorCodeDTO.thread_busy, 'raw backend phrasing');
|
||||
|
||||
const { message } = resolveAssistantError(err, FALLBACK);
|
||||
expect(message).toBe(
|
||||
'This conversation is still finishing a previous response. Give it a moment and try again.',
|
||||
);
|
||||
});
|
||||
|
||||
it('falls through to the backend message for a known code without FE copy', () => {
|
||||
const err = restError(
|
||||
400,
|
||||
ErrorCodeDTO.message_not_found,
|
||||
'No such message exists.',
|
||||
);
|
||||
|
||||
expect(resolveAssistantError(err, FALLBACK)).toStrictEqual({
|
||||
message: 'No such message exists.',
|
||||
code: ErrorCodeDTO.message_not_found,
|
||||
retryAction: RetryActionDTO.none,
|
||||
isRateLimit: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('falls back when the error code is not in ErrorCodeDTO', () => {
|
||||
const err = restError(400, 'future_unknown_code', 'Backend-only message');
|
||||
|
||||
expect(resolveAssistantError(err, FALLBACK)).toStrictEqual({
|
||||
message: FALLBACK,
|
||||
code: undefined,
|
||||
retryAction: RetryActionDTO.none,
|
||||
isRateLimit: false,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('rate limiting', () => {
|
||||
it('marks HTTP 429 responses as rate limited and non-retryable', () => {
|
||||
const err = restError(
|
||||
429,
|
||||
ErrorCodeDTO.hourly_message_limit,
|
||||
'Hourly limit reached.',
|
||||
);
|
||||
|
||||
expect(resolveAssistantError(err, FALLBACK)).toStrictEqual({
|
||||
message: "You've reached the hourly message limit. Please try again later.",
|
||||
code: ErrorCodeDTO.hourly_message_limit,
|
||||
retryAction: RetryActionDTO.none,
|
||||
isRateLimit: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('treats known SSE rate-limit codes as rate limited', () => {
|
||||
const err = Object.assign(new Error('Daily token limit exceeded.'), {
|
||||
code: ErrorCodeDTO.daily_token_limit,
|
||||
});
|
||||
|
||||
const res = resolveAssistantError(err, FALLBACK);
|
||||
expect(res.isRateLimit).toBe(true);
|
||||
expect(res.retryAction).toBe(RetryActionDTO.none);
|
||||
});
|
||||
|
||||
it('marks 429 as rate limited even when the code is unknown', () => {
|
||||
const err = restError(429, 'future_unknown_code', 'Too many requests');
|
||||
|
||||
expect(resolveAssistantError(err, FALLBACK)).toStrictEqual({
|
||||
message: FALLBACK,
|
||||
code: undefined,
|
||||
retryAction: RetryActionDTO.none,
|
||||
isRateLimit: true,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('retryAction resolution', () => {
|
||||
it('honours an explicit retryAction from an SSE error event', () => {
|
||||
const err = Object.assign(new Error('Transient hiccup'), {
|
||||
code: ErrorCodeDTO.internal_error,
|
||||
retryAction: RetryActionDTO.auto,
|
||||
});
|
||||
|
||||
expect(resolveAssistantError(err, FALLBACK).retryAction).toBe(
|
||||
RetryActionDTO.auto,
|
||||
);
|
||||
});
|
||||
|
||||
it('forces none for non-retryable permission errors', () => {
|
||||
const err = restError(403, ErrorCodeDTO.permission_denied, 'forbidden');
|
||||
|
||||
expect(resolveAssistantError(err, FALLBACK).retryAction).toBe(
|
||||
RetryActionDTO.none,
|
||||
);
|
||||
});
|
||||
|
||||
it('derives manual for 409 conflicts', () => {
|
||||
const err = restError(409, ErrorCodeDTO.thread_has_active_execution, 'busy');
|
||||
|
||||
expect(resolveAssistantError(err, FALLBACK).retryAction).toBe(
|
||||
RetryActionDTO.manual,
|
||||
);
|
||||
});
|
||||
|
||||
it('derives manual for 5xx responses', () => {
|
||||
const err = restError(503, 'future_unknown_code', 'unavailable');
|
||||
|
||||
expect(resolveAssistantError(err, FALLBACK).retryAction).toBe(
|
||||
RetryActionDTO.manual,
|
||||
);
|
||||
});
|
||||
|
||||
it('derives manual for network failures with no response', () => {
|
||||
const err = new AxiosError('Network Error');
|
||||
|
||||
expect(resolveAssistantError(err, FALLBACK).retryAction).toBe(
|
||||
RetryActionDTO.manual,
|
||||
);
|
||||
});
|
||||
|
||||
it('derives none for other 4xx responses', () => {
|
||||
const err = restError(400, 'future_unknown_code', 'bad request');
|
||||
|
||||
expect(resolveAssistantError(err, FALLBACK).retryAction).toBe(
|
||||
RetryActionDTO.none,
|
||||
);
|
||||
});
|
||||
|
||||
it('defaults to manual for non-Axios errors with no code', () => {
|
||||
expect(resolveAssistantError(new Error('boom'), FALLBACK).retryAction).toBe(
|
||||
RetryActionDTO.manual,
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,91 +0,0 @@
|
||||
import { AxiosError } from 'axios';
|
||||
import { ErrorCodeDTO } from 'api/ai-assistant/sigNozAIAssistantAPI.schemas';
|
||||
|
||||
import { resolveAssistantErrorMessage } from '../resolveAssistantErrorMessage';
|
||||
|
||||
const FALLBACK = 'Something went wrong. Please try again.';
|
||||
|
||||
describe('resolveAssistantErrorMessage', () => {
|
||||
it('returns backend message for a known error code', () => {
|
||||
const err = new AxiosError('Request failed');
|
||||
err.response = {
|
||||
status: 400,
|
||||
data: {
|
||||
error: {
|
||||
code: ErrorCodeDTO.thread_busy,
|
||||
message: 'This thread is busy. Try again shortly.',
|
||||
},
|
||||
},
|
||||
} as AxiosError['response'];
|
||||
|
||||
expect(resolveAssistantErrorMessage(err, FALLBACK)).toStrictEqual({
|
||||
message: 'This thread is busy. Try again shortly.',
|
||||
isRateLimit: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('falls back when error code is not in ErrorCodeDTO', () => {
|
||||
const err = new AxiosError('Request failed');
|
||||
err.response = {
|
||||
status: 400,
|
||||
data: {
|
||||
error: {
|
||||
code: 'future_unknown_code',
|
||||
message: 'Backend-only message',
|
||||
},
|
||||
},
|
||||
} as AxiosError['response'];
|
||||
|
||||
expect(resolveAssistantErrorMessage(err, FALLBACK)).toStrictEqual({
|
||||
message: FALLBACK,
|
||||
isRateLimit: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('marks HTTP 429 responses as rate limited', () => {
|
||||
const err = new AxiosError('Too many requests');
|
||||
err.response = {
|
||||
status: 429,
|
||||
data: {
|
||||
error: {
|
||||
code: ErrorCodeDTO.hourly_message_limit,
|
||||
message: 'Hourly limit reached.',
|
||||
},
|
||||
},
|
||||
} as AxiosError['response'];
|
||||
|
||||
expect(resolveAssistantErrorMessage(err, FALLBACK)).toStrictEqual({
|
||||
message: 'Hourly limit reached.',
|
||||
isRateLimit: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('uses backend message for known SSE rate-limit error codes', () => {
|
||||
const err = Object.assign(new Error('Daily token limit exceeded.'), {
|
||||
code: ErrorCodeDTO.daily_token_limit,
|
||||
});
|
||||
|
||||
expect(resolveAssistantErrorMessage(err, FALLBACK)).toStrictEqual({
|
||||
message: 'Daily token limit exceeded.',
|
||||
isRateLimit: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('marks 429 as rate limited even when error code is unknown', () => {
|
||||
const err = new AxiosError('Too many requests');
|
||||
err.response = {
|
||||
status: 429,
|
||||
data: {
|
||||
error: {
|
||||
code: 'future_unknown_code',
|
||||
message: 'Too many requests',
|
||||
},
|
||||
},
|
||||
} as AxiosError['response'];
|
||||
|
||||
expect(resolveAssistantErrorMessage(err, FALLBACK)).toStrictEqual({
|
||||
message: FALLBACK,
|
||||
isRateLimit: true,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,209 @@
|
||||
import { isAxiosError } from 'axios';
|
||||
import {
|
||||
ErrorCodeDTO,
|
||||
RetryActionDTO,
|
||||
type ErrorBodyDTO,
|
||||
type ErrorResponseDTO,
|
||||
} from 'api/ai-assistant/sigNozAIAssistantAPI.schemas';
|
||||
|
||||
export interface AssistantErrorResolution {
|
||||
/** User-facing copy: code-specific FE copy → backend message → caller fallback. */
|
||||
message: string;
|
||||
/** Known backend error code, when one we recognise was supplied. */
|
||||
code?: ErrorCodeDTO;
|
||||
/**
|
||||
* Whether/how the failed action may be retried:
|
||||
* • `auto` — transient; the caller may silently re-attempt (capped).
|
||||
* • `manual` — surface a Retry affordance to the user.
|
||||
* • `none` — retrying would re-fail deterministically; offer nothing.
|
||||
*/
|
||||
retryAction: RetryActionDTO;
|
||||
/** Quota/limit error — callers hide the retry + feedback bar (retrying just re-limits). */
|
||||
isRateLimit: boolean;
|
||||
}
|
||||
|
||||
/** Quota/limit codes — surfaced as rate-limit errors (no retry, feedback bar hidden). */
|
||||
const RATE_LIMIT_ERROR_CODES = new Set<ErrorCodeDTO>([
|
||||
ErrorCodeDTO.rate_limit_override_exceeds_ceiling,
|
||||
ErrorCodeDTO.thread_message_limit,
|
||||
ErrorCodeDTO.connection_limit_exceeded,
|
||||
ErrorCodeDTO.hourly_message_limit,
|
||||
ErrorCodeDTO.daily_message_limit,
|
||||
ErrorCodeDTO.daily_token_limit,
|
||||
ErrorCodeDTO.daily_cost_limit,
|
||||
ErrorCodeDTO.budget_exceeded,
|
||||
]);
|
||||
|
||||
/**
|
||||
* Codes whose retry would re-fail deterministically — permission/config/validation
|
||||
* failures. These force `retryAction: none` regardless of HTTP status.
|
||||
*/
|
||||
const NON_RETRYABLE_CODES = new Set<ErrorCodeDTO>([
|
||||
ErrorCodeDTO.permission_denied,
|
||||
ErrorCodeDTO.user_disabled,
|
||||
ErrorCodeDTO.org_disabled,
|
||||
ErrorCodeDTO.validation_error,
|
||||
ErrorCodeDTO.invalid_content_length,
|
||||
ErrorCodeDTO.invalid_fork_target,
|
||||
ErrorCodeDTO.missing_signoz_url,
|
||||
ErrorCodeDTO.invalid_signoz_url,
|
||||
ErrorCodeDTO.region_not_configured,
|
||||
]);
|
||||
|
||||
/**
|
||||
* Code-specific, user-friendly copy. Takes precedence over the backend's raw
|
||||
* `error.message` so the user sees an actionable, consistent sentence rather
|
||||
* than internal phrasing. Codes absent here fall through to the backend message.
|
||||
*/
|
||||
const ERROR_CODE_COPY: Partial<Record<ErrorCodeDTO, string>> = {
|
||||
[ErrorCodeDTO.permission_denied]:
|
||||
"You don't have permission to do that. Contact your workspace admin if you think this is a mistake.",
|
||||
[ErrorCodeDTO.user_disabled]:
|
||||
'Your access to the AI assistant has been disabled. Contact your workspace admin to re-enable it.',
|
||||
[ErrorCodeDTO.org_disabled]:
|
||||
'The AI assistant is disabled for your organisation. An admin can enable it in settings.',
|
||||
[ErrorCodeDTO.thread_busy]:
|
||||
'This conversation is still finishing a previous response. Give it a moment and try again.',
|
||||
[ErrorCodeDTO.thread_has_active_execution]:
|
||||
'This conversation is still finishing a previous response. Give it a moment and try again.',
|
||||
[ErrorCodeDTO.hourly_message_limit]:
|
||||
"You've reached the hourly message limit. Please try again later.",
|
||||
[ErrorCodeDTO.daily_message_limit]:
|
||||
"You've reached the daily message limit. Please try again tomorrow.",
|
||||
[ErrorCodeDTO.daily_token_limit]:
|
||||
"You've reached today's usage limit. Please try again tomorrow.",
|
||||
[ErrorCodeDTO.daily_cost_limit]:
|
||||
"You've reached today's usage limit. Please try again tomorrow.",
|
||||
[ErrorCodeDTO.budget_exceeded]:
|
||||
"You've reached your usage budget. Contact your workspace admin to raise it.",
|
||||
[ErrorCodeDTO.thread_message_limit]:
|
||||
'This conversation has reached its length limit. Start a new conversation to continue.',
|
||||
[ErrorCodeDTO.connection_limit_exceeded]:
|
||||
'Too many active conversations right now. Close one and try again.',
|
||||
[ErrorCodeDTO.max_turns_exceeded]:
|
||||
'The assistant reached the maximum number of steps for this request. Try rephrasing or breaking it into smaller asks.',
|
||||
[ErrorCodeDTO.region_unreachable]:
|
||||
"Couldn't reach your region's services. Please try again in a moment.",
|
||||
[ErrorCodeDTO.region_not_configured]:
|
||||
'No region is configured for the AI assistant yet. An admin can set this up in settings.',
|
||||
[ErrorCodeDTO.mcp_unavailable]:
|
||||
'A required service is temporarily unavailable. Please try again shortly.',
|
||||
[ErrorCodeDTO.sandbox_unavailable]:
|
||||
'The execution environment is temporarily unavailable. Please try again shortly.',
|
||||
[ErrorCodeDTO.internal_error]:
|
||||
'Something went wrong on our end. Please try again.',
|
||||
};
|
||||
|
||||
function isErrorCodeDTO(code: string | undefined): code is ErrorCodeDTO {
|
||||
return (
|
||||
code !== undefined && (Object.values(ErrorCodeDTO) as string[]).includes(code)
|
||||
);
|
||||
}
|
||||
|
||||
function isRetryActionDTO(value: unknown): value is RetryActionDTO {
|
||||
return (
|
||||
typeof value === 'string' &&
|
||||
(Object.values(RetryActionDTO) as string[]).includes(value)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Pulls the structured error body out of either an Axios REST error or the
|
||||
* SSE error the streaming loop throws (a plain `Error` augmented with `code`).
|
||||
*/
|
||||
function getErrorBody(err: unknown): ErrorBodyDTO | null {
|
||||
if (isAxiosError(err)) {
|
||||
return (err.response?.data as ErrorResponseDTO | undefined)?.error ?? null;
|
||||
}
|
||||
|
||||
const code = (err as { code?: string } | undefined)?.code;
|
||||
const message = err instanceof Error ? err.message : undefined;
|
||||
if (!code || !message) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return { code: code as ErrorCodeDTO, message };
|
||||
}
|
||||
|
||||
function isRateLimit(code: ErrorCodeDTO | undefined, err: unknown): boolean {
|
||||
if (isAxiosError(err) && err.response?.status === 429) {
|
||||
return true;
|
||||
}
|
||||
return code !== undefined && RATE_LIMIT_ERROR_CODES.has(code);
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves how the failed action may be retried. The backend's explicit signal
|
||||
* (SSE `ErrorEventDTO.retryAction`) is authoritative; otherwise we derive it
|
||||
* from the rate-limit/non-retryable code sets and the HTTP status.
|
||||
*/
|
||||
function resolveRetryAction(
|
||||
err: unknown,
|
||||
code: ErrorCodeDTO | undefined,
|
||||
rateLimited: boolean,
|
||||
): RetryActionDTO {
|
||||
const explicit = (err as { retryAction?: unknown } | undefined)?.retryAction;
|
||||
if (isRetryActionDTO(explicit)) {
|
||||
return explicit;
|
||||
}
|
||||
|
||||
if (rateLimited || (code !== undefined && NON_RETRYABLE_CODES.has(code))) {
|
||||
return RetryActionDTO.none;
|
||||
}
|
||||
|
||||
if (isAxiosError(err)) {
|
||||
const status = err.response?.status;
|
||||
// No response → network/timeout failure; retrying may well succeed.
|
||||
if (status === undefined || status === 408) {
|
||||
return RetryActionDTO.manual;
|
||||
}
|
||||
if (status === 401 || status === 403) {
|
||||
return RetryActionDTO.none;
|
||||
}
|
||||
if (status === 409 || status >= 500) {
|
||||
return RetryActionDTO.manual;
|
||||
}
|
||||
// Other 4xx (validation, bad request) re-fail deterministically.
|
||||
return RetryActionDTO.none;
|
||||
}
|
||||
|
||||
// Non-Axios transport/parse error with no code — let the user retry.
|
||||
return RetryActionDTO.manual;
|
||||
}
|
||||
|
||||
function resolveMessage(
|
||||
code: ErrorCodeDTO | undefined,
|
||||
body: ErrorBodyDTO | null,
|
||||
fallback: string,
|
||||
): string {
|
||||
if (code !== undefined && ERROR_CODE_COPY[code]) {
|
||||
return ERROR_CODE_COPY[code] as string;
|
||||
}
|
||||
// Trust the backend's message only for codes we recognise — never surface
|
||||
// raw text for unknown codes (could be an internal stack trace).
|
||||
if (code !== undefined && body?.message.trim()) {
|
||||
return body.message.trim();
|
||||
}
|
||||
return fallback;
|
||||
}
|
||||
|
||||
/**
|
||||
* Single resolution point for both SSE and REST assistant errors. Maps the
|
||||
* error onto user-facing copy plus retry semantics, degrading gracefully for
|
||||
* unknown codes (falls back to `fallback` + a `manual` retry where sensible).
|
||||
*/
|
||||
export function resolveAssistantError(
|
||||
err: unknown,
|
||||
fallback: string,
|
||||
): AssistantErrorResolution {
|
||||
const body = getErrorBody(err);
|
||||
const code = isErrorCodeDTO(body?.code) ? body?.code : undefined;
|
||||
const rateLimited = isRateLimit(code, err);
|
||||
|
||||
return {
|
||||
message: resolveMessage(code, body, fallback),
|
||||
code,
|
||||
retryAction: resolveRetryAction(err, code, rateLimited),
|
||||
isRateLimit: rateLimited,
|
||||
};
|
||||
}
|
||||
@@ -1,71 +0,0 @@
|
||||
import { isAxiosError } from 'axios';
|
||||
import {
|
||||
ErrorCodeDTO,
|
||||
type ErrorBodyDTO,
|
||||
type ErrorResponseDTO,
|
||||
} from 'api/ai-assistant/sigNozAIAssistantAPI.schemas';
|
||||
|
||||
export interface AssistantErrorResolution {
|
||||
message: string;
|
||||
isRateLimit: boolean;
|
||||
}
|
||||
|
||||
function isErrorCodeDTO(code: string | undefined): code is ErrorCodeDTO {
|
||||
return (
|
||||
code !== undefined && (Object.values(ErrorCodeDTO) as string[]).includes(code)
|
||||
);
|
||||
}
|
||||
|
||||
const RATE_LIMIT_ERROR_CODES = new Set<ErrorCodeDTO>([
|
||||
ErrorCodeDTO.rate_limit_override_exceeds_ceiling,
|
||||
ErrorCodeDTO.thread_message_limit,
|
||||
ErrorCodeDTO.connection_limit_exceeded,
|
||||
ErrorCodeDTO.hourly_message_limit,
|
||||
ErrorCodeDTO.daily_message_limit,
|
||||
ErrorCodeDTO.daily_token_limit,
|
||||
ErrorCodeDTO.daily_cost_limit,
|
||||
ErrorCodeDTO.budget_exceeded,
|
||||
]);
|
||||
|
||||
function isRateLimitError(code: string | undefined, err: unknown): boolean {
|
||||
if (isAxiosError(err) && err.response?.status === 429) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return isErrorCodeDTO(code) && RATE_LIMIT_ERROR_CODES.has(code);
|
||||
}
|
||||
|
||||
function getErrorBody(err: unknown): ErrorBodyDTO | null {
|
||||
if (isAxiosError(err)) {
|
||||
return (err.response?.data as ErrorResponseDTO | undefined)?.error ?? null;
|
||||
}
|
||||
|
||||
const code = (err as { code?: string } | undefined)?.code;
|
||||
const message = err instanceof Error ? err.message : undefined;
|
||||
if (!code || !message) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return { code: code as ErrorCodeDTO, message };
|
||||
}
|
||||
|
||||
/**
|
||||
* Uses `error.message` when `error.code` is a known `ErrorCodeDTO`;
|
||||
* otherwise returns `fallback`.
|
||||
*/
|
||||
export function resolveAssistantErrorMessage(
|
||||
err: unknown,
|
||||
fallback: string,
|
||||
): AssistantErrorResolution {
|
||||
const body = getErrorBody(err);
|
||||
const isRateLimit = isRateLimitError(body?.code, err);
|
||||
|
||||
if (body && isErrorCodeDTO(body.code) && body.message.trim()) {
|
||||
return {
|
||||
message: body.message.trim(),
|
||||
isRateLimit,
|
||||
};
|
||||
}
|
||||
|
||||
return { message: fallback, isRateLimit: Boolean(isRateLimit) };
|
||||
}
|
||||
@@ -1,14 +1,9 @@
|
||||
@use '../../styles/scrollbar' as *;
|
||||
|
||||
.members-settings-page {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-8);
|
||||
padding: var(--padding-4) var(--padding-2) var(--padding-6) var(--padding-4);
|
||||
height: 100%;
|
||||
overflow-y: auto;
|
||||
|
||||
@include custom-scrollbar;
|
||||
}
|
||||
|
||||
.members-settings {
|
||||
|
||||
@@ -6,7 +6,7 @@ import { DropdownMenuSimple, type MenuItem } from '@signozhq/ui/dropdown-menu';
|
||||
import { Input } from '@signozhq/ui/input';
|
||||
import { useListUsers } from 'api/generated/services/users';
|
||||
import EditMemberDrawer from 'components/EditMemberDrawer/EditMemberDrawer';
|
||||
import InviteMembersModal from 'container/MembersSettings/components/InviteMembersModal/InviteMembersModal';
|
||||
import InviteMembersModal from 'components/InviteMembersModal/InviteMembersModal';
|
||||
import MembersTable, { MemberRow } from 'components/MembersTable/MembersTable';
|
||||
import useUrlQuery from 'hooks/useUrlQuery';
|
||||
import { parseAsBoolean, useQueryState } from 'nuqs';
|
||||
|
||||
@@ -110,9 +110,7 @@ describe('MembersSettings (integration)', () => {
|
||||
|
||||
fireEvent.click(await screen.findByText('Alice Smith'));
|
||||
|
||||
await expect(
|
||||
screen.findByText('Member Details'),
|
||||
).resolves.toBeInTheDocument();
|
||||
await screen.findByText('Member Details');
|
||||
});
|
||||
|
||||
it('opens EditMemberDrawer when a deleted member row is clicked', async () => {
|
||||
@@ -129,7 +127,7 @@ describe('MembersSettings (integration)', () => {
|
||||
fireEvent.click(screen.getByRole('button', { name: /invite member/i }));
|
||||
|
||||
await expect(
|
||||
screen.findAllByPlaceholderText('e.g. john@signoz.io'),
|
||||
screen.findAllByPlaceholderText('john@signoz.io'),
|
||||
).resolves.toHaveLength(3);
|
||||
});
|
||||
|
||||
@@ -139,7 +137,7 @@ describe('MembersSettings (integration)', () => {
|
||||
});
|
||||
|
||||
await expect(
|
||||
screen.findAllByPlaceholderText('e.g. john@signoz.io'),
|
||||
screen.findAllByPlaceholderText('john@signoz.io'),
|
||||
).resolves.toHaveLength(3);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,38 +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: var(--padding-4);
|
||||
}
|
||||
}
|
||||
|
||||
.invite-members-modal__footer {
|
||||
padding-top: var(--padding-4);
|
||||
border-top: 1px solid var(--l1-border);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
gap: var(--spacing-6);
|
||||
}
|
||||
@@ -1,71 +0,0 @@
|
||||
import { useCallback } from 'react';
|
||||
import { X } from '@signozhq/icons';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import { DialogWrapper } from '@signozhq/ui/dialog';
|
||||
import { toast } from '@signozhq/ui/sonner';
|
||||
import InviteMembers from 'components/InviteMembers/InviteMembers';
|
||||
|
||||
import './InviteMembersModal.styles.scss';
|
||||
|
||||
export interface InviteMembersModalProps {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
onComplete?: () => void;
|
||||
}
|
||||
|
||||
function InviteMembersModal({
|
||||
open,
|
||||
onClose,
|
||||
onComplete,
|
||||
}: InviteMembersModalProps): JSX.Element {
|
||||
const handleSuccess = useCallback((): void => {
|
||||
toast.success('Invites sent successfully', { position: 'top-right' });
|
||||
onClose();
|
||||
onComplete?.();
|
||||
}, [onClose, onComplete]);
|
||||
|
||||
const handlePartialSuccess = useCallback((): void => {
|
||||
toast.warning('Some invites failed', { position: 'top-right' });
|
||||
onComplete?.();
|
||||
}, [onComplete]);
|
||||
|
||||
return (
|
||||
<DialogWrapper
|
||||
title="Invite Team Members"
|
||||
open={open}
|
||||
onOpenChange={(isOpen): void => {
|
||||
if (!isOpen) {
|
||||
onClose();
|
||||
}
|
||||
}}
|
||||
showCloseButton
|
||||
width="wide"
|
||||
className="invite-members-modal"
|
||||
>
|
||||
<InviteMembers
|
||||
onSuccess={handleSuccess}
|
||||
onPartialSuccess={handlePartialSuccess}
|
||||
renderFooter={({ submit, canSubmit, isSubmitting }): JSX.Element => (
|
||||
<div className="invite-members-modal__footer">
|
||||
<Button type="button" variant="solid" color="secondary" onClick={onClose}>
|
||||
<X size={12} />
|
||||
Cancel
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant="solid"
|
||||
color="primary"
|
||||
onClick={submit}
|
||||
disabled={!canSubmit}
|
||||
loading={isSubmitting}
|
||||
>
|
||||
{isSubmitting ? 'Inviting...' : 'Invite Team Members'}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
</DialogWrapper>
|
||||
);
|
||||
}
|
||||
|
||||
export default InviteMembersModal;
|
||||
@@ -1,210 +0,0 @@
|
||||
import { toast } from '@signozhq/ui/sonner';
|
||||
import { render, screen, userEvent } from 'tests/test-utils';
|
||||
|
||||
import InviteMembersModal from '../InviteMembersModal';
|
||||
|
||||
jest.mock('@signozhq/ui/sonner', () => ({
|
||||
...jest.requireActual('@signozhq/ui/sonner'),
|
||||
toast: {
|
||||
success: jest.fn(),
|
||||
warning: jest.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
interface MockInviteMembersProps {
|
||||
onSuccess: () => void;
|
||||
onPartialSuccess: () => void;
|
||||
onAllFailed?: () => void;
|
||||
renderFooter: (props: {
|
||||
submit: () => void;
|
||||
canSubmit: boolean;
|
||||
isSubmitting: boolean;
|
||||
}) => JSX.Element;
|
||||
}
|
||||
|
||||
let mockInviteMembersProps: MockInviteMembersProps | null = null;
|
||||
|
||||
jest.mock('components/InviteMembers/InviteMembers', () => {
|
||||
return function MockInviteMembers(props: MockInviteMembersProps): JSX.Element {
|
||||
mockInviteMembersProps = props;
|
||||
return (
|
||||
<div data-testid="mock-invite-members">
|
||||
{props.renderFooter({
|
||||
submit: jest.fn(),
|
||||
canSubmit: true,
|
||||
isSubmitting: false,
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
});
|
||||
|
||||
const defaultProps = {
|
||||
open: true,
|
||||
onClose: jest.fn(),
|
||||
onComplete: jest.fn(),
|
||||
};
|
||||
|
||||
function renderComponent(
|
||||
props: Partial<typeof defaultProps> = {},
|
||||
): ReturnType<typeof render> {
|
||||
return render(<InviteMembersModal {...defaultProps} {...props} />);
|
||||
}
|
||||
|
||||
describe('InviteMembersModal', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
mockInviteMembersProps = null;
|
||||
});
|
||||
|
||||
describe('rendering', () => {
|
||||
it('renders modal with title and InviteMembers component', () => {
|
||||
renderComponent();
|
||||
|
||||
expect(screen.getByRole('dialog')).toBeInTheDocument();
|
||||
expect(screen.getByRole('heading', { level: 2 })).toHaveTextContent(
|
||||
'Invite Team Members',
|
||||
);
|
||||
expect(screen.getByTestId('mock-invite-members')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('does not render when open=false', () => {
|
||||
renderComponent({ open: false });
|
||||
|
||||
expect(screen.queryByText('Invite Team Members')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('footer buttons', () => {
|
||||
it('renders Cancel and Invite buttons', () => {
|
||||
renderComponent();
|
||||
|
||||
expect(screen.getByRole('button', { name: /cancel/i })).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByRole('button', { name: /invite team members/i }),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('disables Invite button when canSubmit=false', () => {
|
||||
const { unmount } = renderComponent();
|
||||
unmount();
|
||||
|
||||
const { getByRole } = render(
|
||||
mockInviteMembersProps?.renderFooter({
|
||||
submit: jest.fn(),
|
||||
canSubmit: false,
|
||||
isSubmitting: false,
|
||||
}) as JSX.Element,
|
||||
);
|
||||
|
||||
expect(getByRole('button', { name: /invite team members/i })).toBeDisabled();
|
||||
});
|
||||
|
||||
it('shows loading state when isSubmitting=true', () => {
|
||||
const { unmount } = renderComponent();
|
||||
unmount();
|
||||
|
||||
const { getByRole } = render(
|
||||
mockInviteMembersProps?.renderFooter({
|
||||
submit: jest.fn(),
|
||||
canSubmit: true,
|
||||
isSubmitting: true,
|
||||
}) as JSX.Element,
|
||||
);
|
||||
|
||||
expect(getByRole('button', { name: /inviting/i })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('calls onClose when Cancel is clicked', async () => {
|
||||
const user = userEvent.setup();
|
||||
const onClose = jest.fn();
|
||||
renderComponent({ onClose });
|
||||
|
||||
await user.click(screen.getByRole('button', { name: /cancel/i }));
|
||||
|
||||
expect(onClose).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('calls submit when Invite button is clicked', async () => {
|
||||
const user = userEvent.setup();
|
||||
const mockSubmit = jest.fn();
|
||||
|
||||
const { unmount } = renderComponent();
|
||||
unmount();
|
||||
|
||||
const { getByRole } = render(
|
||||
mockInviteMembersProps?.renderFooter({
|
||||
submit: mockSubmit,
|
||||
canSubmit: true,
|
||||
isSubmitting: false,
|
||||
}) as JSX.Element,
|
||||
);
|
||||
|
||||
await user.click(getByRole('button', { name: /invite team members/i }));
|
||||
|
||||
expect(mockSubmit).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('handleSuccess callback', () => {
|
||||
it('shows success toast, calls onClose and onComplete', () => {
|
||||
const onClose = jest.fn();
|
||||
const onComplete = jest.fn();
|
||||
renderComponent({ onClose, onComplete });
|
||||
|
||||
mockInviteMembersProps?.onSuccess();
|
||||
|
||||
expect(toast.success).toHaveBeenCalledWith('Invites sent successfully', {
|
||||
position: 'top-right',
|
||||
});
|
||||
expect(onClose).toHaveBeenCalledTimes(1);
|
||||
expect(onComplete).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('works without onComplete prop', () => {
|
||||
const onClose = jest.fn();
|
||||
renderComponent({ onClose, onComplete: undefined });
|
||||
|
||||
mockInviteMembersProps?.onSuccess();
|
||||
|
||||
expect(toast.success).toHaveBeenCalled();
|
||||
expect(onClose).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('handlePartialSuccess callback', () => {
|
||||
it('shows warning toast and calls onComplete', () => {
|
||||
const onComplete = jest.fn();
|
||||
renderComponent({ onComplete });
|
||||
|
||||
mockInviteMembersProps?.onPartialSuccess();
|
||||
|
||||
expect(toast.warning).toHaveBeenCalledWith('Some invites failed', {
|
||||
position: 'top-right',
|
||||
});
|
||||
expect(onComplete).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('does not call onClose on partial success', () => {
|
||||
const onClose = jest.fn();
|
||||
renderComponent({ onClose });
|
||||
|
||||
mockInviteMembersProps?.onPartialSuccess();
|
||||
|
||||
expect(onClose).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('dialog close behavior', () => {
|
||||
it('calls onClose when dialog is closed via close button', async () => {
|
||||
const user = userEvent.setup();
|
||||
const onClose = jest.fn();
|
||||
renderComponent({ onClose });
|
||||
|
||||
const closeButton = screen.getByRole('button', { name: /close/i });
|
||||
await user.click(closeButton);
|
||||
|
||||
expect(onClose).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,12 +1,26 @@
|
||||
import { useMemo } from 'react';
|
||||
import { ArrowRight, LoaderCircle } from '@signozhq/icons';
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { useMutation } from 'react-query';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import { Callout } from '@signozhq/ui/callout';
|
||||
import { Input } from '@signozhq/ui/input';
|
||||
import { Select } from 'antd';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import logEvent from 'api/common/logEvent';
|
||||
import InviteMembers from 'components/InviteMembers/InviteMembers';
|
||||
import { InviteMemberRow, InviteResult } from 'components/InviteMembers/types';
|
||||
import { useRoles } from 'components/RolesSelect/RolesSelect';
|
||||
import inviteUsers from 'api/v1/invite/bulk/create';
|
||||
import AuthError from 'components/AuthError/AuthError';
|
||||
import { useNotifications } from 'hooks/useNotifications';
|
||||
import { cloneDeep, debounce } from 'lodash-es';
|
||||
import {
|
||||
ArrowRight,
|
||||
ChevronDown,
|
||||
CircleAlert,
|
||||
LoaderCircle,
|
||||
Plus,
|
||||
Trash2,
|
||||
} from '@signozhq/icons';
|
||||
import APIError from 'types/api/error';
|
||||
import { getBaseUrl } from 'utils/basePath';
|
||||
import { v4 as uuid } from 'uuid';
|
||||
|
||||
import { OnboardingQuestionHeader } from '../OnboardingQuestionHeader';
|
||||
|
||||
@@ -22,41 +36,101 @@ interface TeamMember {
|
||||
|
||||
interface InviteTeamMembersProps {
|
||||
isLoading: boolean;
|
||||
teamMembers: TeamMember[] | null;
|
||||
setTeamMembers: (teamMembers: TeamMember[]) => void;
|
||||
onNext: () => void;
|
||||
}
|
||||
|
||||
function InviteTeamMembers({
|
||||
isLoading,
|
||||
teamMembers,
|
||||
setTeamMembers,
|
||||
onNext,
|
||||
}: InviteTeamMembersProps): JSX.Element {
|
||||
const [teamMembersToInvite, setTeamMembersToInvite] = useState<
|
||||
TeamMember[] | null
|
||||
>(teamMembers);
|
||||
const [emailValidity, setEmailValidity] = useState<Record<string, boolean>>(
|
||||
{},
|
||||
);
|
||||
const [hasInvalidEmails, setHasInvalidEmails] = useState<boolean>(false);
|
||||
const [hasInvalidRoles, setHasInvalidRoles] = useState<boolean>(false);
|
||||
const [inviteError, setInviteError] = useState<APIError | null>(null);
|
||||
const { notifications } = useNotifications();
|
||||
const { roles } = useRoles();
|
||||
|
||||
const roleIdToName = useMemo(() => {
|
||||
const map: Record<string, string> = {};
|
||||
roles.forEach((role) => {
|
||||
if (role.id && role.name) {
|
||||
map[role.id] = role.name;
|
||||
const defaultTeamMember: TeamMember = {
|
||||
email: '',
|
||||
role: '',
|
||||
name: '',
|
||||
frontendBaseUrl: getBaseUrl(),
|
||||
id: '',
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (teamMembers === null) {
|
||||
const initialTeamMembers = Array.from({ length: 3 }, () => ({
|
||||
...defaultTeamMember,
|
||||
id: uuid(),
|
||||
}));
|
||||
|
||||
setTeamMembersToInvite(initialTeamMembers);
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [teamMembers]);
|
||||
|
||||
const handleAddTeamMember = (): void => {
|
||||
const newTeamMember = {
|
||||
...defaultTeamMember,
|
||||
id: uuid(),
|
||||
};
|
||||
setTeamMembersToInvite((prev) => [...(prev || []), newTeamMember]);
|
||||
};
|
||||
|
||||
const handleRemoveTeamMember = (id: string): void => {
|
||||
setTeamMembersToInvite((prev) => (prev || []).filter((m) => m.id !== id));
|
||||
};
|
||||
|
||||
const isMemberTouched = (member: TeamMember): boolean =>
|
||||
member.email.trim() !== '' ||
|
||||
Boolean(member.role && member.role.trim() !== '');
|
||||
|
||||
const validateAllUsers = (): boolean => {
|
||||
let isValid = true;
|
||||
let hasEmailErrors = false;
|
||||
let hasRoleErrors = false;
|
||||
|
||||
const updatedEmailValidity: Record<string, boolean> = {};
|
||||
|
||||
const touchedMembers = teamMembersToInvite?.filter(isMemberTouched) ?? [];
|
||||
|
||||
touchedMembers?.forEach((member) => {
|
||||
const emailValid = /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(member.email);
|
||||
const roleValid = Boolean(member.role && member.role.trim() !== '');
|
||||
|
||||
if (!emailValid || !member.email) {
|
||||
isValid = false;
|
||||
hasEmailErrors = true;
|
||||
}
|
||||
if (!roleValid) {
|
||||
isValid = false;
|
||||
hasRoleErrors = true;
|
||||
}
|
||||
|
||||
if (member.id) {
|
||||
updatedEmailValidity[member.id] = emailValid;
|
||||
}
|
||||
});
|
||||
return map;
|
||||
}, [roles]);
|
||||
|
||||
const toTeamMembers = (rows: InviteMemberRow[]): TeamMember[] =>
|
||||
rows.map((row) => ({
|
||||
email: row.email,
|
||||
role: roleIdToName[row.roleId] ?? row.roleId,
|
||||
name: '',
|
||||
frontendBaseUrl: getBaseUrl(),
|
||||
id: row.id,
|
||||
}));
|
||||
setEmailValidity(updatedEmailValidity);
|
||||
setHasInvalidEmails(hasEmailErrors);
|
||||
setHasInvalidRoles(hasRoleErrors);
|
||||
|
||||
const handleSuccess = (
|
||||
_results: InviteResult[],
|
||||
rows: InviteMemberRow[],
|
||||
): void => {
|
||||
return isValid;
|
||||
};
|
||||
|
||||
const handleInviteUsersSuccess = (): void => {
|
||||
logEvent('Org Onboarding: Invite Team Members Success', {
|
||||
teamMembers: toTeamMembers(rows),
|
||||
teamMembers: teamMembersToInvite,
|
||||
});
|
||||
notifications.success({
|
||||
message: 'Invites sent successfully!',
|
||||
@@ -66,34 +140,125 @@ function InviteTeamMembers({
|
||||
}, 1000);
|
||||
};
|
||||
|
||||
const handlePartialSuccess = (
|
||||
_results: InviteResult[],
|
||||
rows: InviteMemberRow[],
|
||||
): void => {
|
||||
logEvent('Org Onboarding: Invite Team Members Partial Success', {
|
||||
teamMembers: toTeamMembers(rows),
|
||||
});
|
||||
notifications.warning({
|
||||
message: 'Some invites failed. Check the errors above.',
|
||||
});
|
||||
const { mutate: sendInvites, isLoading: isSendingInvites } = useMutation(
|
||||
inviteUsers,
|
||||
{
|
||||
onSuccess: (): void => {
|
||||
handleInviteUsersSuccess();
|
||||
},
|
||||
onError: (error: APIError): void => {
|
||||
logEvent('Org Onboarding: Invite Team Members Failed', {
|
||||
teamMembers: teamMembersToInvite,
|
||||
});
|
||||
setInviteError(error);
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
const handleNext = (): void => {
|
||||
if (validateAllUsers()) {
|
||||
setTeamMembers(teamMembersToInvite?.filter(isMemberTouched) ?? []);
|
||||
setHasInvalidEmails(false);
|
||||
setHasInvalidRoles(false);
|
||||
setInviteError(null);
|
||||
sendInvites({
|
||||
invites: teamMembersToInvite?.filter(isMemberTouched) ?? [],
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleAllFailed = (
|
||||
_results: InviteResult[],
|
||||
rows: InviteMemberRow[],
|
||||
): void => {
|
||||
logEvent('Org Onboarding: Invite Team Members Failed', {
|
||||
teamMembers: toTeamMembers(rows),
|
||||
});
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
const debouncedValidateEmail = useCallback(
|
||||
debounce((email: string, memberId: string, updatedMembers: TeamMember[]) => {
|
||||
const isValid = /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email);
|
||||
setEmailValidity((prev) => ({ ...prev, [memberId]: isValid }));
|
||||
|
||||
// Clear hasInvalidEmails only when ALL emails are valid
|
||||
if (hasInvalidEmails) {
|
||||
const allEmailsValid = updatedMembers.every(
|
||||
(m) => m.email && /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(m.email),
|
||||
);
|
||||
if (allEmailsValid) {
|
||||
setHasInvalidEmails(false);
|
||||
}
|
||||
}
|
||||
}, 500),
|
||||
[hasInvalidEmails],
|
||||
);
|
||||
|
||||
const handleEmailChange = useCallback(
|
||||
(e: React.ChangeEvent<HTMLInputElement>, member: TeamMember): void => {
|
||||
const { value } = e.target;
|
||||
const updatedMembers = cloneDeep(teamMembersToInvite || []);
|
||||
|
||||
const memberToUpdate = updatedMembers.find((m) => m.id === member.id);
|
||||
if (memberToUpdate && member.id) {
|
||||
memberToUpdate.email = value;
|
||||
setTeamMembersToInvite(updatedMembers);
|
||||
debouncedValidateEmail(value, member.id, updatedMembers);
|
||||
// Clear API error when user starts typing
|
||||
if (inviteError) {
|
||||
setInviteError(null);
|
||||
}
|
||||
}
|
||||
},
|
||||
[debouncedValidateEmail, inviteError, teamMembersToInvite],
|
||||
);
|
||||
|
||||
const createEmailChangeHandler = useCallback(
|
||||
(member: TeamMember) =>
|
||||
(e: React.ChangeEvent<HTMLInputElement>): void => {
|
||||
handleEmailChange(e, member);
|
||||
},
|
||||
[handleEmailChange],
|
||||
);
|
||||
|
||||
const handleRoleChange = (role: string, member: TeamMember): void => {
|
||||
const updatedMembers = cloneDeep(teamMembersToInvite || []);
|
||||
const memberToUpdate = updatedMembers.find((m) => m.id === member.id);
|
||||
if (memberToUpdate && member.id) {
|
||||
memberToUpdate.role = role;
|
||||
setTeamMembersToInvite(updatedMembers);
|
||||
|
||||
// Clear errors when user selects a role
|
||||
if (hasInvalidRoles) {
|
||||
// Check if all roles are now valid
|
||||
const allRolesValid = updatedMembers.every(
|
||||
(m) => m.role && m.role.trim() !== '',
|
||||
);
|
||||
if (allRolesValid) {
|
||||
setHasInvalidRoles(false);
|
||||
}
|
||||
}
|
||||
if (inviteError) {
|
||||
setInviteError(null);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
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 handleDoLater = (): void => {
|
||||
logEvent('Org Onboarding: Clicked Do Later', {
|
||||
currentPageID: 4,
|
||||
});
|
||||
|
||||
onNext();
|
||||
};
|
||||
|
||||
const hasInvites =
|
||||
(teamMembersToInvite?.filter(isMemberTouched) ?? []).length > 0;
|
||||
const isButtonDisabled = isSendingInvites || isLoading;
|
||||
const isInviteButtonDisabled = isButtonDisabled || !hasInvites;
|
||||
|
||||
return (
|
||||
<div className="questions-container">
|
||||
<OnboardingQuestionHeader
|
||||
@@ -108,52 +273,126 @@ function InviteTeamMembers({
|
||||
Invite your team to the SigNoz workspace
|
||||
</div>
|
||||
|
||||
<InviteMembers
|
||||
onSuccess={handleSuccess}
|
||||
onPartialSuccess={handlePartialSuccess}
|
||||
onAllFailed={handleAllFailed}
|
||||
showHeader
|
||||
renderFooter={({ submit, canSubmit, isSubmitting }): JSX.Element => {
|
||||
const isButtonDisabled = isSubmitting || isLoading;
|
||||
const isInviteButtonDisabled = isButtonDisabled || !canSubmit;
|
||||
<div className="invite-team-members-table">
|
||||
<div className="invite-team-members-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>
|
||||
|
||||
return (
|
||||
<div className="onboarding-buttons-container">
|
||||
<Button
|
||||
variant="solid"
|
||||
color="primary"
|
||||
className={`onboarding-next-button ${
|
||||
isInviteButtonDisabled ? 'disabled' : ''
|
||||
}`}
|
||||
onClick={submit}
|
||||
disabled={isInviteButtonDisabled}
|
||||
data-testid="send-invites-button"
|
||||
suffix={
|
||||
isButtonDisabled ? (
|
||||
<LoaderCircle className="animate-spin" size={12} />
|
||||
) : (
|
||||
<ArrowRight size={12} />
|
||||
)
|
||||
}
|
||||
>
|
||||
Send Invites
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
color="secondary"
|
||||
className="onboarding-do-later-button"
|
||||
onClick={handleDoLater}
|
||||
disabled={isButtonDisabled}
|
||||
data-testid="do-later-button"
|
||||
>
|
||||
I'll do this later
|
||||
</Button>
|
||||
<div className="invite-team-members-container">
|
||||
{teamMembersToInvite?.map((member) => (
|
||||
<div className="team-member-row" key={member.id}>
|
||||
<div className="team-member-cell email-cell">
|
||||
<Input
|
||||
placeholder="e.g. john@signoz.io"
|
||||
value={member.email}
|
||||
type="email"
|
||||
id={`email-input-${member.id}`}
|
||||
name={`email-input-${member.id}`}
|
||||
required
|
||||
autoComplete="off"
|
||||
className="team-member-email-input"
|
||||
onChange={createEmailChangeHandler(member)}
|
||||
/>
|
||||
{member.id &&
|
||||
emailValidity[member.id] === false &&
|
||||
member.email.trim() !== '' && (
|
||||
<Typography.Text className="email-error-message">
|
||||
Invalid email address
|
||||
</Typography.Text>
|
||||
)}
|
||||
</div>
|
||||
<div className="team-member-cell role-cell">
|
||||
<Select
|
||||
value={member.role || undefined}
|
||||
onChange={(value): void => handleRoleChange(value, member)}
|
||||
className="team-member-role-select"
|
||||
placeholder="Select roles"
|
||||
suffixIcon={<ChevronDown size={14} />}
|
||||
>
|
||||
<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">
|
||||
{teamMembersToInvite && teamMembersToInvite.length > 1 && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
color="secondary"
|
||||
className="remove-team-member-button"
|
||||
onClick={(): void => handleRemoveTeamMember(member.id)}
|
||||
aria-label="Remove team member"
|
||||
>
|
||||
<Trash2 size={12} />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="invite-team-members-add-another-member-container">
|
||||
<Button
|
||||
variant="dashed"
|
||||
color="secondary"
|
||||
className="add-another-member-button"
|
||||
prefix={<Plus size={12} />}
|
||||
onClick={handleAddTeamMember}
|
||||
>
|
||||
Add another
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{(hasInvalidEmails || hasInvalidRoles) && (
|
||||
<Callout
|
||||
type="error"
|
||||
size="small"
|
||||
showIcon
|
||||
icon={<CircleAlert size={12} />}
|
||||
className="invite-team-members-error-callout"
|
||||
>
|
||||
{getValidationErrorMessage()}
|
||||
</Callout>
|
||||
)}
|
||||
|
||||
{inviteError && !hasInvalidEmails && !hasInvalidRoles && (
|
||||
<AuthError error={inviteError} />
|
||||
)}
|
||||
|
||||
<div className="onboarding-buttons-container">
|
||||
<Button
|
||||
variant="solid"
|
||||
color="primary"
|
||||
className={`onboarding-next-button ${
|
||||
isInviteButtonDisabled ? 'disabled' : ''
|
||||
}`}
|
||||
onClick={handleNext}
|
||||
disabled={isInviteButtonDisabled}
|
||||
suffix={
|
||||
isButtonDisabled ? (
|
||||
<LoaderCircle className="animate-spin" size={12} />
|
||||
) : (
|
||||
<ArrowRight size={12} />
|
||||
)
|
||||
}
|
||||
>
|
||||
Send Invites
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
color="secondary"
|
||||
className="onboarding-do-later-button"
|
||||
onClick={handleDoLater}
|
||||
disabled={isButtonDisabled}
|
||||
>
|
||||
I'll do this later
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,86 +1,97 @@
|
||||
import { rest, server } from 'mocks-server/server';
|
||||
import {
|
||||
InviteMemberRow,
|
||||
InviteMembersProps,
|
||||
InviteResult,
|
||||
} from 'components/InviteMembers/types';
|
||||
import logEvent from 'api/common/logEvent';
|
||||
import { render, screen, userEvent } from 'tests/test-utils';
|
||||
fireEvent,
|
||||
render,
|
||||
screen,
|
||||
userEvent,
|
||||
waitFor,
|
||||
} from 'tests/test-utils';
|
||||
|
||||
import InviteTeamMembers from '../InviteTeamMembers';
|
||||
|
||||
const mockNotificationSuccess = jest.fn();
|
||||
const mockNotificationWarning = jest.fn();
|
||||
const mockNotificationSuccess = jest.fn() as jest.MockedFunction<
|
||||
(args: { message: string }) => void
|
||||
>;
|
||||
const mockNotificationError = jest.fn() as jest.MockedFunction<
|
||||
(args: { message: string }) => void
|
||||
>;
|
||||
|
||||
jest.mock('hooks/useNotifications', () => ({
|
||||
useNotifications: (): any => ({
|
||||
notifications: {
|
||||
success: mockNotificationSuccess,
|
||||
warning: mockNotificationWarning,
|
||||
error: mockNotificationError,
|
||||
},
|
||||
}),
|
||||
}));
|
||||
|
||||
jest.mock('api/common/logEvent', () => jest.fn());
|
||||
const INVITE_USERS_ENDPOINT = '*/api/v1/invite/bulk';
|
||||
|
||||
jest.mock('components/RolesSelect/RolesSelect', () => ({
|
||||
useRoles: (): any => ({
|
||||
roles: [
|
||||
{ id: 'role-viewer-id', name: 'VIEWER' },
|
||||
{ id: 'role-editor-id', name: 'EDITOR' },
|
||||
{ id: 'role-admin-id', name: 'ADMIN' },
|
||||
],
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
error: undefined,
|
||||
refetch: jest.fn(),
|
||||
}),
|
||||
}));
|
||||
interface TeamMember {
|
||||
email: string;
|
||||
role: string;
|
||||
name: string;
|
||||
frontendBaseUrl: string;
|
||||
id: string;
|
||||
}
|
||||
|
||||
jest.mock('utils/basePath', () => ({
|
||||
...jest.requireActual('utils/basePath'),
|
||||
getBaseUrl: (): string => 'http://localhost:3301',
|
||||
}));
|
||||
interface InviteRequestBody {
|
||||
invites: { email: string; role: string }[];
|
||||
}
|
||||
|
||||
let mockInviteMembersProps: InviteMembersProps | null = null;
|
||||
interface RenderProps {
|
||||
isLoading?: boolean;
|
||||
teamMembers?: TeamMember[] | null;
|
||||
}
|
||||
|
||||
jest.mock('components/InviteMembers/InviteMembers', () => {
|
||||
return function MockInviteMembers(props: InviteMembersProps): JSX.Element {
|
||||
mockInviteMembersProps = props;
|
||||
return (
|
||||
<div data-testid="mock-invite-members">
|
||||
{props.renderFooter?.({
|
||||
submit: jest.fn().mockResolvedValue([]),
|
||||
reset: jest.fn(),
|
||||
canSubmit: true,
|
||||
isSubmitting: false,
|
||||
touchedCount: 0,
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
});
|
||||
|
||||
const mockOnNext = jest.fn();
|
||||
const mockOnNext = jest.fn() as jest.MockedFunction<() => void>;
|
||||
const mockSetTeamMembers = jest.fn() as jest.MockedFunction<
|
||||
(members: TeamMember[]) => void
|
||||
>;
|
||||
|
||||
function renderComponent({
|
||||
isLoading = false,
|
||||
}: { isLoading?: boolean } = {}): ReturnType<typeof render> {
|
||||
return render(<InviteTeamMembers isLoading={isLoading} onNext={mockOnNext} />);
|
||||
teamMembers = null,
|
||||
}: RenderProps = {}): ReturnType<typeof render> {
|
||||
return render(
|
||||
<InviteTeamMembers
|
||||
isLoading={isLoading}
|
||||
teamMembers={teamMembers}
|
||||
setTeamMembers={mockSetTeamMembers}
|
||||
onNext={mockOnNext}
|
||||
/>,
|
||||
);
|
||||
}
|
||||
|
||||
async function selectRole(
|
||||
user: ReturnType<typeof userEvent.setup>,
|
||||
selectIndex: number,
|
||||
optionLabel: string,
|
||||
): Promise<void> {
|
||||
const placeholders = screen.getAllByText(/select roles/i);
|
||||
await user.click(placeholders[selectIndex]);
|
||||
const optionContent = await screen.findByText(optionLabel);
|
||||
fireEvent.click(optionContent);
|
||||
}
|
||||
|
||||
describe('InviteTeamMembers', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
jest.useFakeTimers();
|
||||
mockInviteMembersProps = null;
|
||||
|
||||
server.use(
|
||||
rest.post(INVITE_USERS_ENDPOINT, (_, res, ctx) =>
|
||||
res(ctx.status(200), ctx.json({ status: 'success' })),
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.useRealTimers();
|
||||
server.resetHandlers();
|
||||
});
|
||||
|
||||
describe('rendering', () => {
|
||||
it('renders header and InviteMembers component', () => {
|
||||
describe('Initial rendering', () => {
|
||||
it('renders the page header, column labels, default rows, and action buttons', () => {
|
||||
renderComponent();
|
||||
|
||||
expect(
|
||||
@@ -89,20 +100,11 @@ describe('InviteTeamMembers', () => {
|
||||
expect(
|
||||
screen.getByText(/signoz is a lot more useful with collaborators/i),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByTestId('mock-invite-members')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('passes showHeader=true to InviteMembers', () => {
|
||||
renderComponent();
|
||||
|
||||
expect(mockInviteMembersProps?.showHeader).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('footer buttons', () => {
|
||||
it('renders Send Invites and Do Later buttons', () => {
|
||||
renderComponent();
|
||||
|
||||
expect(
|
||||
screen.getAllByPlaceholderText(/e\.g\. john@signoz\.io/i),
|
||||
).toHaveLength(3);
|
||||
expect(screen.getByText('Email address')).toBeInTheDocument();
|
||||
expect(screen.getByText('Roles')).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByRole('button', { name: /send invites/i }),
|
||||
).toBeInTheDocument();
|
||||
@@ -111,7 +113,7 @@ describe('InviteTeamMembers', () => {
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('disables buttons when isLoading=true', () => {
|
||||
it('disables both action buttons while isLoading is true', () => {
|
||||
renderComponent({ isLoading: true });
|
||||
|
||||
expect(screen.getByRole('button', { name: /send invites/i })).toBeDisabled();
|
||||
@@ -119,181 +121,355 @@ describe('InviteTeamMembers', () => {
|
||||
screen.getByRole('button', { name: /i'll do this later/i }),
|
||||
).toBeDisabled();
|
||||
});
|
||||
|
||||
it('disables Send Invites when canSubmit=false from InviteMembers', () => {
|
||||
const { unmount } = renderComponent();
|
||||
unmount();
|
||||
|
||||
const { getByTestId } = render(
|
||||
mockInviteMembersProps?.renderFooter?.({
|
||||
submit: jest.fn().mockResolvedValue([]),
|
||||
reset: jest.fn(),
|
||||
canSubmit: false,
|
||||
isSubmitting: false,
|
||||
touchedCount: 0,
|
||||
}) as JSX.Element,
|
||||
);
|
||||
|
||||
expect(getByTestId('send-invites-button')).toBeDisabled();
|
||||
expect(getByTestId('do-later-button')).not.toBeDisabled();
|
||||
});
|
||||
|
||||
it('disables buttons when isSubmitting=true from InviteMembers', () => {
|
||||
const { unmount } = renderComponent();
|
||||
unmount();
|
||||
|
||||
const { getByTestId } = render(
|
||||
mockInviteMembersProps?.renderFooter?.({
|
||||
submit: jest.fn().mockResolvedValue([]),
|
||||
reset: jest.fn(),
|
||||
canSubmit: true,
|
||||
isSubmitting: true,
|
||||
touchedCount: 0,
|
||||
}) as JSX.Element,
|
||||
);
|
||||
|
||||
expect(getByTestId('send-invites-button')).toBeDisabled();
|
||||
expect(getByTestId('do-later-button')).toBeDisabled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('handleSuccess callback', () => {
|
||||
it('logs event with teamMembers in correct shape, shows success notification, and calls onNext after delay', () => {
|
||||
describe('Row management', () => {
|
||||
it('adds a new empty row when "Add another" is clicked', async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
renderComponent();
|
||||
|
||||
const mockResults: InviteResult[] = [
|
||||
{ email: 'user1@test.com', success: true },
|
||||
{ email: 'user2@test.com', success: true },
|
||||
];
|
||||
const mockRows: InviteMemberRow[] = [
|
||||
{ id: 'row-1', email: 'user1@test.com', roleId: 'role-viewer-id' },
|
||||
{ id: 'row-2', email: 'user2@test.com', roleId: 'role-editor-id' },
|
||||
];
|
||||
mockInviteMembersProps?.onSuccess?.(mockResults, mockRows);
|
||||
expect(
|
||||
screen.getAllByPlaceholderText(/e\.g\. john@signoz\.io/i),
|
||||
).toHaveLength(3);
|
||||
|
||||
expect(logEvent).toHaveBeenCalledWith(
|
||||
'Org Onboarding: Invite Team Members Success',
|
||||
{
|
||||
teamMembers: [
|
||||
{
|
||||
email: 'user1@test.com',
|
||||
role: 'VIEWER',
|
||||
name: '',
|
||||
frontendBaseUrl: 'http://localhost:3301',
|
||||
id: 'row-1',
|
||||
},
|
||||
{
|
||||
email: 'user2@test.com',
|
||||
role: 'EDITOR',
|
||||
name: '',
|
||||
frontendBaseUrl: 'http://localhost:3301',
|
||||
id: 'row-2',
|
||||
},
|
||||
],
|
||||
},
|
||||
);
|
||||
expect(mockNotificationSuccess).toHaveBeenCalledWith({
|
||||
message: 'Invites sent successfully!',
|
||||
});
|
||||
await user.click(screen.getByRole('button', { name: /add another/i }));
|
||||
|
||||
expect(mockOnNext).not.toHaveBeenCalled();
|
||||
jest.advanceTimersByTime(1000);
|
||||
expect(mockOnNext).toHaveBeenCalledTimes(1);
|
||||
expect(
|
||||
screen.getAllByPlaceholderText(/e\.g\. john@signoz\.io/i),
|
||||
).toHaveLength(4);
|
||||
});
|
||||
});
|
||||
|
||||
describe('handlePartialSuccess callback', () => {
|
||||
it('logs event with teamMembers in correct shape and shows warning notification', () => {
|
||||
it('removes the correct row when its trash icon is clicked', async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
renderComponent();
|
||||
|
||||
const mockResults: InviteResult[] = [
|
||||
{ email: 'user1@test.com', success: true },
|
||||
{ email: 'user2@test.com', success: false, error: 'Already exists' },
|
||||
];
|
||||
const mockRows: InviteMemberRow[] = [
|
||||
{ id: 'row-1', email: 'user1@test.com', roleId: 'role-viewer-id' },
|
||||
{ id: 'row-2', email: 'user2@test.com', roleId: 'role-admin-id' },
|
||||
];
|
||||
mockInviteMembersProps?.onPartialSuccess?.(mockResults, mockRows);
|
||||
|
||||
expect(logEvent).toHaveBeenCalledWith(
|
||||
'Org Onboarding: Invite Team Members Partial Success',
|
||||
{
|
||||
teamMembers: [
|
||||
{
|
||||
email: 'user1@test.com',
|
||||
role: 'VIEWER',
|
||||
name: '',
|
||||
frontendBaseUrl: 'http://localhost:3301',
|
||||
id: 'row-1',
|
||||
},
|
||||
{
|
||||
email: 'user2@test.com',
|
||||
role: 'ADMIN',
|
||||
name: '',
|
||||
frontendBaseUrl: 'http://localhost:3301',
|
||||
id: 'row-2',
|
||||
},
|
||||
],
|
||||
},
|
||||
const emailInputs = screen.getAllByPlaceholderText(
|
||||
/e\.g\. john@signoz\.io/i,
|
||||
);
|
||||
expect(mockNotificationWarning).toHaveBeenCalledWith({
|
||||
message: 'Some invites failed. Check the errors above.',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('handleAllFailed callback', () => {
|
||||
it('logs event with teamMembers in correct shape', () => {
|
||||
renderComponent();
|
||||
|
||||
const mockResults: InviteResult[] = [
|
||||
{ email: 'user1@test.com', success: false, error: 'Error 1' },
|
||||
{ email: 'user2@test.com', success: false, error: 'Error 2' },
|
||||
];
|
||||
const mockRows: InviteMemberRow[] = [
|
||||
{ id: 'row-1', email: 'user1@test.com', roleId: 'role-editor-id' },
|
||||
{ id: 'row-2', email: 'user2@test.com', roleId: 'role-viewer-id' },
|
||||
];
|
||||
mockInviteMembersProps?.onAllFailed?.(mockResults, mockRows);
|
||||
|
||||
expect(logEvent).toHaveBeenCalledWith(
|
||||
'Org Onboarding: Invite Team Members Failed',
|
||||
{
|
||||
teamMembers: [
|
||||
{
|
||||
email: 'user1@test.com',
|
||||
role: 'EDITOR',
|
||||
name: '',
|
||||
frontendBaseUrl: 'http://localhost:3301',
|
||||
id: 'row-1',
|
||||
},
|
||||
{
|
||||
email: 'user2@test.com',
|
||||
role: 'VIEWER',
|
||||
name: '',
|
||||
frontendBaseUrl: 'http://localhost:3301',
|
||||
id: 'row-2',
|
||||
},
|
||||
],
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('handleDoLater', () => {
|
||||
it('logs event and calls onNext immediately', async () => {
|
||||
const user = userEvent.setup({ advanceTimers: jest.advanceTimersByTime });
|
||||
renderComponent();
|
||||
await user.type(emailInputs[0], 'first@example.com');
|
||||
await screen.findByDisplayValue('first@example.com');
|
||||
|
||||
await user.click(
|
||||
screen.getByRole('button', { name: /i'll do this later/i }),
|
||||
screen.getAllByRole('button', { name: /remove team member/i })[0],
|
||||
);
|
||||
|
||||
expect(logEvent).toHaveBeenCalledWith('Org Onboarding: Clicked Do Later', {
|
||||
currentPageID: 4,
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen.queryByDisplayValue('first@example.com'),
|
||||
).not.toBeInTheDocument();
|
||||
expect(
|
||||
screen.getAllByPlaceholderText(/e\.g\. john@signoz\.io/i),
|
||||
).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
|
||||
it('hides remove buttons when only one row remains', async () => {
|
||||
renderComponent();
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
|
||||
let removeButtons = screen.getAllByRole('button', {
|
||||
name: /remove team member/i,
|
||||
});
|
||||
while (removeButtons.length > 0) {
|
||||
await user.click(removeButtons[0]);
|
||||
removeButtons = screen.queryAllByRole('button', {
|
||||
name: /remove team member/i,
|
||||
});
|
||||
}
|
||||
|
||||
expect(
|
||||
screen.queryByRole('button', { name: /remove team member/i }),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Inline email validation', () => {
|
||||
it('shows an inline error after typing an invalid email and clears it when a valid email is entered', async () => {
|
||||
jest.useFakeTimers();
|
||||
const user = userEvent.setup({
|
||||
advanceTimers: (ms) => jest.advanceTimersByTime(ms),
|
||||
});
|
||||
renderComponent();
|
||||
|
||||
const [firstInput] = screen.getAllByPlaceholderText(
|
||||
/e\.g\. john@signoz\.io/i,
|
||||
);
|
||||
|
||||
await user.type(firstInput, 'not-an-email');
|
||||
jest.advanceTimersByTime(600);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/invalid email address/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
await user.clear(firstInput);
|
||||
await user.type(firstInput, 'good@example.com');
|
||||
jest.advanceTimersByTime(600);
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen.queryByText(/invalid email address/i),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('does not show an inline error when the field is cleared back to empty', async () => {
|
||||
jest.useFakeTimers();
|
||||
const user = userEvent.setup({
|
||||
advanceTimers: (ms) => jest.advanceTimersByTime(ms),
|
||||
});
|
||||
renderComponent();
|
||||
|
||||
const [firstInput] = screen.getAllByPlaceholderText(
|
||||
/e\.g\. john@signoz\.io/i,
|
||||
);
|
||||
await user.type(firstInput, 'a');
|
||||
await user.clear(firstInput);
|
||||
jest.advanceTimersByTime(600);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen.queryByText(/invalid email address/i),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Validation callout on Complete', () => {
|
||||
it('shows the correct callout message for each combination of email/role validity', async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0, delay: null });
|
||||
renderComponent();
|
||||
|
||||
const removeButtons = screen.getAllByRole('button', {
|
||||
name: /remove team member/i,
|
||||
});
|
||||
await user.click(removeButtons[0]);
|
||||
await user.click(
|
||||
screen.getAllByRole('button', { name: /remove team member/i })[0],
|
||||
);
|
||||
|
||||
const [firstInput] = screen.getAllByPlaceholderText(
|
||||
/e\.g\. john@signoz\.io/i,
|
||||
);
|
||||
|
||||
await user.type(firstInput, 'bad-email');
|
||||
await user.click(screen.getByRole('button', { name: /send invites/i }));
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen.getByText(
|
||||
/please enter valid emails and select roles for team members/i,
|
||||
),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByText(/please enter valid emails for team members/i),
|
||||
).not.toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByText(/please select roles for team members/i),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
await selectRole(user, 0, 'Viewer');
|
||||
await user.click(screen.getByRole('button', { name: /send invites/i }));
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen.getByText(/please enter valid emails for team members/i),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByText(/please select roles for team members/i),
|
||||
).not.toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByText(/please enter valid emails and select roles/i),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
await user.clear(firstInput);
|
||||
await user.type(firstInput, 'valid@example.com');
|
||||
await user.click(screen.getByRole('button', { name: /add another/i }));
|
||||
const allInputs = screen.getAllByPlaceholderText(/e\.g\. john@signoz\.io/i);
|
||||
await user.type(allInputs[1], 'norole@example.com');
|
||||
await user.click(screen.getByRole('button', { name: /send invites/i }));
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen.getByText(/please select roles for team members/i),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByText(/please enter valid emails for team members/i),
|
||||
).not.toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByText(/please enter valid emails and select roles/i),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
}, 15000);
|
||||
|
||||
it('treats whitespace as untouched, clears the callout on fix-and-resubmit, and clears role error on role select', async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0, delay: null });
|
||||
renderComponent();
|
||||
|
||||
const removeButtons = screen.getAllByRole('button', {
|
||||
name: /remove team member/i,
|
||||
});
|
||||
await user.click(removeButtons[0]);
|
||||
await user.click(
|
||||
screen.getAllByRole('button', { name: /remove team member/i })[0],
|
||||
);
|
||||
|
||||
const [firstInput] = screen.getAllByPlaceholderText(
|
||||
/e\.g\. john@signoz\.io/i,
|
||||
);
|
||||
|
||||
await user.type(firstInput, ' ');
|
||||
await user.click(screen.getByRole('button', { name: /send invites/i }));
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen.queryByText(/please enter valid emails/i),
|
||||
).not.toBeInTheDocument();
|
||||
expect(screen.queryByText(/please select roles/i)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
await user.clear(firstInput);
|
||||
await user.type(firstInput, 'bad-email');
|
||||
await user.click(screen.getByRole('button', { name: /send invites/i }));
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen.getByText(
|
||||
/please enter valid emails and select roles for team members/i,
|
||||
),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByText(/please enter valid emails for team members/i),
|
||||
).not.toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByText(/please select roles for team members/i),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
await user.clear(firstInput);
|
||||
await user.type(firstInput, 'good@example.com');
|
||||
await selectRole(user, 0, 'Admin');
|
||||
await user.click(screen.getByRole('button', { name: /send invites/i }));
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen.queryByText(/please enter valid emails and select roles/i),
|
||||
).not.toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByText(/please enter valid emails for team members/i),
|
||||
).not.toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByText(/please select roles for team members/i),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
await waitFor(() => expect(mockOnNext).toHaveBeenCalledTimes(1), {
|
||||
timeout: 1200,
|
||||
});
|
||||
}, 15000);
|
||||
|
||||
it('disables the Send Invites button when all rows are untouched (empty)', async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
renderComponent();
|
||||
|
||||
const sendInvitesBtn = screen.getByRole('button', { name: /send invites/i });
|
||||
expect(sendInvitesBtn).toBeDisabled();
|
||||
|
||||
// Type something to make a row touched
|
||||
const [firstInput] = screen.getAllByPlaceholderText(
|
||||
/e\.g\. john@signoz\.io/i,
|
||||
);
|
||||
await user.type(firstInput, 'a');
|
||||
|
||||
expect(sendInvitesBtn).not.toBeDisabled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('API integration', () => {
|
||||
it('only sends touched (non-empty) rows — empty rows are excluded from the invite payload', async () => {
|
||||
let capturedBody: InviteRequestBody | null = null;
|
||||
|
||||
server.use(
|
||||
rest.post(INVITE_USERS_ENDPOINT, async (req, res, ctx) => {
|
||||
capturedBody = await req.json<InviteRequestBody>();
|
||||
return res(ctx.status(200), ctx.json({ status: 'success' }));
|
||||
}),
|
||||
);
|
||||
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
renderComponent();
|
||||
|
||||
const [firstInput] = screen.getAllByPlaceholderText(
|
||||
/e\.g\. john@signoz\.io/i,
|
||||
);
|
||||
await user.type(firstInput, 'only@example.com');
|
||||
await selectRole(user, 0, 'Admin');
|
||||
await user.click(screen.getByRole('button', { name: /send invites/i }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(capturedBody).not.toBeNull();
|
||||
expect(capturedBody?.invites).toHaveLength(1);
|
||||
expect(capturedBody?.invites[0]).toMatchObject({
|
||||
email: 'only@example.com',
|
||||
role: 'ADMIN',
|
||||
});
|
||||
});
|
||||
await waitFor(() => expect(mockOnNext).toHaveBeenCalled(), {
|
||||
timeout: 1200,
|
||||
});
|
||||
});
|
||||
|
||||
it('calls the invite API, shows a success notification, and calls onNext after the 1 s delay', async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
renderComponent();
|
||||
|
||||
const [firstInput] = screen.getAllByPlaceholderText(
|
||||
/e\.g\. john@signoz\.io/i,
|
||||
);
|
||||
await user.type(firstInput, 'alice@example.com');
|
||||
await selectRole(user, 0, 'Admin');
|
||||
await user.click(screen.getByRole('button', { name: /send invites/i }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockNotificationSuccess).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ message: 'Invites sent successfully!' }),
|
||||
);
|
||||
});
|
||||
|
||||
await waitFor(
|
||||
() => {
|
||||
expect(mockOnNext).toHaveBeenCalledTimes(1);
|
||||
},
|
||||
{ timeout: 1200 },
|
||||
);
|
||||
});
|
||||
|
||||
it('renders an API error container when the invite request fails', async () => {
|
||||
server.use(
|
||||
rest.post(INVITE_USERS_ENDPOINT, (_, res, ctx) =>
|
||||
res(
|
||||
ctx.status(500),
|
||||
ctx.json({
|
||||
errors: [{ code: 'INTERNAL_ERROR', msg: 'Something went wrong' }],
|
||||
}),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
renderComponent();
|
||||
|
||||
const [firstInput] = screen.getAllByPlaceholderText(
|
||||
/e\.g\. john@signoz\.io/i,
|
||||
);
|
||||
await user.type(firstInput, 'fail@example.com');
|
||||
await selectRole(user, 0, 'Viewer');
|
||||
await user.click(screen.getByRole('button', { name: /send invites/i }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(document.querySelector('.auth-error-container')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
await user.type(firstInput, 'x');
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
document.querySelector('.auth-error-container'),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
expect(mockOnNext).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -143,8 +143,6 @@
|
||||
}
|
||||
|
||||
&.invite-team-members-form {
|
||||
--invite-members-field-background: var(--l3-background);
|
||||
|
||||
padding-right: 12px;
|
||||
|
||||
.form-group {
|
||||
|
||||
@@ -22,8 +22,7 @@ const ORG_PREFERENCES_ENDPOINT = '*/api/v1/org/preferences/list';
|
||||
const UPDATE_ORG_PREFERENCE_ENDPOINT = '*/api/v1/org/preferences/name/update';
|
||||
const UPDATE_PROFILE_ENDPOINT = '*/api/v2/zeus/profiles';
|
||||
const EDIT_ORG_ENDPOINT = '*/api/v2/orgs/me';
|
||||
const CREATE_USER_ENDPOINT = '*/api/v2/users';
|
||||
const LIST_ROLES_ENDPOINT = '*/api/v1/roles';
|
||||
const INVITE_USERS_ENDPOINT = '*/api/v1/invite/bulk/create';
|
||||
|
||||
const mockOrgPreferences = {
|
||||
data: {
|
||||
@@ -32,12 +31,6 @@ const mockOrgPreferences = {
|
||||
status: 'success',
|
||||
};
|
||||
|
||||
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' },
|
||||
];
|
||||
|
||||
describe('OnboardingQuestionaire Component', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
@@ -55,11 +48,8 @@ describe('OnboardingQuestionaire Component', () => {
|
||||
rest.post(UPDATE_ORG_PREFERENCE_ENDPOINT, (_, res, ctx) =>
|
||||
res(ctx.status(200), ctx.json({ status: 'success' })),
|
||||
),
|
||||
rest.get(LIST_ROLES_ENDPOINT, (_, res, ctx) =>
|
||||
res(ctx.status(200), ctx.json({ data: MOCK_ROLES })),
|
||||
),
|
||||
rest.post(CREATE_USER_ENDPOINT, (_, res, ctx) =>
|
||||
res(ctx.status(201), ctx.json({ data: { id: 'user-123' } })),
|
||||
rest.post(INVITE_USERS_ENDPOINT, (_, res, ctx) =>
|
||||
res(ctx.status(200), ctx.json({ status: 'success' })),
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
@@ -11,6 +11,7 @@ import { AxiosError } from 'axios';
|
||||
import { SOMETHING_WENT_WRONG } from 'constants/api';
|
||||
import { ORG_PREFERENCES } from 'constants/orgPreferences';
|
||||
import ROUTES from 'constants/routes';
|
||||
import { InviteTeamMembersProps } from 'container/OrganizationSettings/utils';
|
||||
import { useNotifications } from 'hooks/useNotifications';
|
||||
import history from 'lib/history';
|
||||
import { useAppContext } from 'providers/App/App';
|
||||
@@ -70,6 +71,9 @@ function OnboardingQuestionaire(): JSX.Element {
|
||||
|
||||
const [optimiseSignozDetails, setOptimiseSignozDetails] =
|
||||
useState<OptimiseSignozDetails>(INITIAL_OPTIMISE_SIGNOZ_DETAILS);
|
||||
const [teamMembers, setTeamMembers] = useState<
|
||||
InviteTeamMembersProps[] | null
|
||||
>(null);
|
||||
|
||||
const [updatingOrgOnboardingStatus, setUpdatingOrgOnboardingStatus] =
|
||||
useState<boolean>(false);
|
||||
@@ -228,6 +232,8 @@ function OnboardingQuestionaire(): JSX.Element {
|
||||
{currentStep === 4 && (
|
||||
<InviteTeamMembers
|
||||
isLoading={updatingOrgOnboardingStatus}
|
||||
teamMembers={teamMembers}
|
||||
setTeamMembers={setTeamMembers}
|
||||
onNext={handleOnboardingComplete}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React, { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { ArrowRight, Check, Goal, Search, UserPlus, X } from '@signozhq/icons';
|
||||
import { Check, Goal, Search, UserPlus, X } from '@signozhq/icons';
|
||||
import {
|
||||
Button,
|
||||
Flex,
|
||||
@@ -10,8 +10,6 @@ import {
|
||||
Space,
|
||||
Steps,
|
||||
} from 'antd';
|
||||
import { Button as SignozButton } from '@signozhq/ui/button';
|
||||
import { toast } from '@signozhq/ui/sonner';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import logEvent from 'api/common/logEvent';
|
||||
import LaunchChatSupport from 'components/LaunchChatSupport/LaunchChatSupport';
|
||||
@@ -29,7 +27,7 @@ import { isModifierKeyPressed } from 'utils/app';
|
||||
import signozBrandLogoUrl from '@/assets/Logos/signoz-brand-logo.svg';
|
||||
|
||||
import OnboardingIngestionDetails from '../IngestionDetails/IngestionDetails';
|
||||
import InviteMembers from 'components/InviteMembers/InviteMembers';
|
||||
import InviteTeamMembers from '../InviteTeamMembers/InviteTeamMembers';
|
||||
import onboardingConfigWithLinks from '../onboarding-configs/onboarding-config-with-links';
|
||||
|
||||
import '../OnboardingV2.styles.scss';
|
||||
@@ -121,10 +119,6 @@ const ONBOARDING_V3_ANALYTICS_EVENTS_MAP = {
|
||||
GET_HELP_BUTTON_CLICKED: 'Get help clicked',
|
||||
GET_EXPERT_ASSISTANCE_BUTTON_CLICKED: 'Get expert assistance clicked',
|
||||
INVITE_TEAM_MEMBER_BUTTON_CLICKED: 'Invite team member clicked',
|
||||
INVITE_TEAM_MEMBER_SEND_CLICKED: 'Send invites clicked',
|
||||
INVITE_TEAM_MEMBER_SUCCESS: 'Invite team members success',
|
||||
INVITE_TEAM_MEMBER_PARTIAL_SUCCESS: 'Invite team members partial success',
|
||||
INVITE_TEAM_MEMBER_FAILED: 'Invite team members failed',
|
||||
CLOSE_ONBOARDING_CLICKED: 'Close onboarding clicked',
|
||||
DATA_SOURCE_REQUESTED: 'Datasource requested',
|
||||
DATA_SOURCE_SEARCHED: 'Searched',
|
||||
@@ -1153,54 +1147,12 @@ function OnboardingAddDataSource(): JSX.Element {
|
||||
destroyOnClose
|
||||
>
|
||||
<div className="invite-team-member-modal-content">
|
||||
<InviteMembers
|
||||
onSuccess={(): void => {
|
||||
void logEvent(
|
||||
`${ONBOARDING_V3_ANALYTICS_EVENTS_MAP?.BASE}: ${ONBOARDING_V3_ANALYTICS_EVENTS_MAP?.INVITE_TEAM_MEMBER_SUCCESS}`,
|
||||
{},
|
||||
);
|
||||
setShowInviteTeamMembersModal(false);
|
||||
|
||||
toast.success('Invites sent successfully', { position: 'top-center' });
|
||||
}}
|
||||
onPartialSuccess={(): void => {
|
||||
void logEvent(
|
||||
`${ONBOARDING_V3_ANALYTICS_EVENTS_MAP?.BASE}: ${ONBOARDING_V3_ANALYTICS_EVENTS_MAP?.INVITE_TEAM_MEMBER_PARTIAL_SUCCESS}`,
|
||||
{},
|
||||
);
|
||||
}}
|
||||
onAllFailed={(): void => {
|
||||
void logEvent(
|
||||
`${ONBOARDING_V3_ANALYTICS_EVENTS_MAP?.BASE}: ${ONBOARDING_V3_ANALYTICS_EVENTS_MAP?.INVITE_TEAM_MEMBER_FAILED}`,
|
||||
{},
|
||||
);
|
||||
}}
|
||||
renderFooter={({ submit, canSubmit, isSubmitting }): JSX.Element => (
|
||||
<div className="invite-team-member-modal-footer">
|
||||
<SignozButton
|
||||
variant="solid"
|
||||
color="secondary"
|
||||
onClick={(): void => setShowInviteTeamMembersModal(false)}
|
||||
>
|
||||
Cancel
|
||||
</SignozButton>
|
||||
<SignozButton
|
||||
variant="solid"
|
||||
onClick={(): void => {
|
||||
void logEvent(
|
||||
`${ONBOARDING_V3_ANALYTICS_EVENTS_MAP?.BASE}: ${ONBOARDING_V3_ANALYTICS_EVENTS_MAP?.INVITE_TEAM_MEMBER_SEND_CLICKED}`,
|
||||
{},
|
||||
);
|
||||
void submit();
|
||||
}}
|
||||
disabled={!canSubmit}
|
||||
loading={isSubmitting}
|
||||
suffix={<ArrowRight size={14} />}
|
||||
>
|
||||
Send Invites
|
||||
</SignozButton>
|
||||
</div>
|
||||
)}
|
||||
<InviteTeamMembers
|
||||
isLoading={false}
|
||||
teamMembers={null}
|
||||
setTeamMembers={(): void => {}}
|
||||
onNext={(): void => setShowInviteTeamMembersModal(false)}
|
||||
onClose={(): void => setShowInviteTeamMembersModal(false)}
|
||||
/>
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
.team-member-container {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
.invite-team-members-form {
|
||||
padding: 16px 0px;
|
||||
}
|
||||
|
||||
.team-member-email-input {
|
||||
width: 80%;
|
||||
background-color: var(--l1-background);
|
||||
border-top-right-radius: 0px;
|
||||
border-bottom-right-radius: 0px;
|
||||
|
||||
.ant-input,
|
||||
.ant-input-group-addon {
|
||||
background-color: var(--l1-background) !important;
|
||||
border-right: 0px;
|
||||
border-top-right-radius: 0px;
|
||||
border-bottom-right-radius: 0px;
|
||||
}
|
||||
}
|
||||
|
||||
.team-member-role-select {
|
||||
width: 20%;
|
||||
|
||||
.ant-select-selector {
|
||||
border: 1px solid var(--l1-border);
|
||||
border-top-left-radius: 0px;
|
||||
border-bottom-left-radius: 0px;
|
||||
|
||||
border-top-right-radius: 0px;
|
||||
border-bottom-right-radius: 0px;
|
||||
}
|
||||
}
|
||||
|
||||
.remove-team-member-button {
|
||||
border-top-left-radius: 0px;
|
||||
border-bottom-left-radius: 0px;
|
||||
}
|
||||
}
|
||||
|
||||
.invite-team-members-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
|
||||
.invite-team-members-add-another-member-container {
|
||||
margin: 16px 0px;
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.next-prev-container {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
.error-message-container,
|
||||
.success-message-container,
|
||||
.partially-sent-invites-container {
|
||||
border-radius: 4px;
|
||||
width: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
.error-message,
|
||||
.success-message {
|
||||
font-size: 12px;
|
||||
font-weight: 400;
|
||||
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
}
|
||||
|
||||
.invite-users-error-message-container,
|
||||
.invite-users-success-message-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 8px;
|
||||
|
||||
.success-message {
|
||||
color: var(--bg-success-500, #00b37e);
|
||||
}
|
||||
}
|
||||
|
||||
.partially-sent-invites-container {
|
||||
margin-top: 16px;
|
||||
padding: 8px;
|
||||
border: 1px solid var(--l1-border);
|
||||
background-color: var(--l1-background);
|
||||
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 8px;
|
||||
box-sizing: border-box;
|
||||
|
||||
.partially-sent-invites-message {
|
||||
color: var(--bg-warning-500, #fbbd23);
|
||||
|
||||
font-size: 12px;
|
||||
font-weight: 400;
|
||||
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,298 @@
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { useMutation } from 'react-query';
|
||||
import { Color } from '@signozhq/design-tokens';
|
||||
import { Button, Input, Select } from 'antd';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import logEvent from 'api/common/logEvent';
|
||||
import inviteUsers from 'api/v1/invite/bulk/create';
|
||||
import { useNotifications } from 'hooks/useNotifications';
|
||||
import { cloneDeep, debounce, isEmpty } from 'lodash-es';
|
||||
import {
|
||||
ArrowRight,
|
||||
CircleCheck,
|
||||
Plus,
|
||||
TriangleAlert,
|
||||
X,
|
||||
} from '@signozhq/icons';
|
||||
import APIError from 'types/api/error';
|
||||
import { getBaseUrl } from 'utils/basePath';
|
||||
import { v4 as uuid } from 'uuid';
|
||||
|
||||
import './InviteTeamMembers.styles.scss';
|
||||
|
||||
interface TeamMember {
|
||||
email: string;
|
||||
role: string;
|
||||
name: string;
|
||||
frontendBaseUrl: string;
|
||||
id: string;
|
||||
}
|
||||
|
||||
interface InviteTeamMembersProps {
|
||||
isLoading: boolean;
|
||||
teamMembers: TeamMember[] | null;
|
||||
setTeamMembers: (teamMembers: TeamMember[]) => void;
|
||||
onNext: () => void;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
const ONBOARDING_V3_ANALYTICS_EVENTS_MAP = {
|
||||
BASE: 'Onboarding V3',
|
||||
INVITE_TEAM_MEMBER_BUTTON_CLICKED: 'Send invites clicked',
|
||||
INVITE_TEAM_MEMBER_SUCCESS: 'Invite team members success',
|
||||
INVITE_TEAM_MEMBER_PARTIAL_SUCCESS: 'Invite team members partial success',
|
||||
INVITE_TEAM_MEMBER_FAILED: 'Invite team members failed',
|
||||
};
|
||||
|
||||
function InviteTeamMembers({
|
||||
isLoading,
|
||||
teamMembers,
|
||||
setTeamMembers,
|
||||
onNext,
|
||||
onClose,
|
||||
}: InviteTeamMembersProps): JSX.Element {
|
||||
const [teamMembersToInvite, setTeamMembersToInvite] = useState<
|
||||
TeamMember[] | null
|
||||
>(teamMembers);
|
||||
const [emailValidity, setEmailValidity] = useState<Record<string, boolean>>(
|
||||
{},
|
||||
);
|
||||
const [hasInvalidEmails, setHasInvalidEmails] = useState<boolean>(false);
|
||||
const { notifications } = useNotifications();
|
||||
|
||||
const defaultTeamMember: TeamMember = {
|
||||
email: '',
|
||||
role: 'EDITOR',
|
||||
name: '',
|
||||
frontendBaseUrl: getBaseUrl(),
|
||||
id: '',
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (isEmpty(teamMembers)) {
|
||||
const teamMember = {
|
||||
...defaultTeamMember,
|
||||
id: uuid(),
|
||||
};
|
||||
|
||||
setTeamMembersToInvite([teamMember]);
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [teamMembers]);
|
||||
|
||||
const handleAddTeamMember = (): void => {
|
||||
const newTeamMember = {
|
||||
...defaultTeamMember,
|
||||
id: uuid(),
|
||||
};
|
||||
setTeamMembersToInvite((prev) => [...(prev || []), newTeamMember]);
|
||||
};
|
||||
|
||||
const handleRemoveTeamMember = (id: string): void => {
|
||||
setTeamMembersToInvite((prev) => (prev || []).filter((m) => m.id !== id));
|
||||
};
|
||||
|
||||
// Validation function to check all users
|
||||
const validateAllUsers = (): boolean => {
|
||||
let isValid = true;
|
||||
|
||||
const updatedValidity: Record<string, boolean> = {};
|
||||
|
||||
teamMembersToInvite?.forEach((member) => {
|
||||
const emailValid = /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(member.email);
|
||||
if (!emailValid || !member.email) {
|
||||
isValid = false;
|
||||
setHasInvalidEmails(true);
|
||||
}
|
||||
updatedValidity[member.id!] = emailValid;
|
||||
});
|
||||
|
||||
setEmailValidity(updatedValidity);
|
||||
|
||||
return isValid;
|
||||
};
|
||||
|
||||
const handleInviteUsersSuccess = (): void => {
|
||||
logEvent(
|
||||
`${ONBOARDING_V3_ANALYTICS_EVENTS_MAP?.BASE}: ${ONBOARDING_V3_ANALYTICS_EVENTS_MAP?.INVITE_TEAM_MEMBER_SUCCESS}`,
|
||||
{
|
||||
teamMembers: teamMembersToInvite,
|
||||
},
|
||||
);
|
||||
setTimeout(() => {
|
||||
onNext();
|
||||
}, 1000);
|
||||
};
|
||||
|
||||
const { mutate: sendInvites, isLoading: isSendingInvites } = useMutation(
|
||||
inviteUsers,
|
||||
{
|
||||
onSuccess: (): void => {
|
||||
handleInviteUsersSuccess();
|
||||
notifications.success({
|
||||
message: 'Invites sent successfully!',
|
||||
});
|
||||
},
|
||||
onError: (error: APIError): void => {
|
||||
logEvent(
|
||||
`${ONBOARDING_V3_ANALYTICS_EVENTS_MAP?.BASE}: ${ONBOARDING_V3_ANALYTICS_EVENTS_MAP?.INVITE_TEAM_MEMBER_FAILED}`,
|
||||
{
|
||||
teamMembers: teamMembersToInvite,
|
||||
error,
|
||||
},
|
||||
);
|
||||
notifications.error({
|
||||
message: error.getErrorCode(),
|
||||
description: error.getErrorMessage(),
|
||||
});
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
const handleNext = (): void => {
|
||||
if (validateAllUsers()) {
|
||||
setTeamMembers(teamMembersToInvite || []);
|
||||
|
||||
logEvent(
|
||||
`${ONBOARDING_V3_ANALYTICS_EVENTS_MAP?.BASE}: ${ONBOARDING_V3_ANALYTICS_EVENTS_MAP?.INVITE_TEAM_MEMBER_BUTTON_CLICKED}`,
|
||||
{
|
||||
teamMembers: teamMembersToInvite,
|
||||
},
|
||||
);
|
||||
|
||||
setHasInvalidEmails(false);
|
||||
sendInvites({
|
||||
invites: teamMembersToInvite || [],
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
const debouncedValidateEmail = useCallback(
|
||||
debounce((email: string, memberId: string) => {
|
||||
const isValid = /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email);
|
||||
setEmailValidity((prev) => ({ ...prev, [memberId]: isValid }));
|
||||
}, 500),
|
||||
[],
|
||||
);
|
||||
|
||||
const handleEmailChange = (
|
||||
e: React.ChangeEvent<HTMLInputElement>,
|
||||
member: TeamMember,
|
||||
): void => {
|
||||
const { value } = e.target;
|
||||
const updatedMembers = cloneDeep(teamMembersToInvite || []);
|
||||
|
||||
const memberToUpdate = updatedMembers.find((m) => m.id === member.id);
|
||||
if (memberToUpdate) {
|
||||
memberToUpdate.email = value;
|
||||
setTeamMembersToInvite(updatedMembers);
|
||||
debouncedValidateEmail(value, member.id!);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRoleChange = (role: string, member: TeamMember): void => {
|
||||
const updatedMembers = cloneDeep(teamMembersToInvite || []);
|
||||
const memberToUpdate = updatedMembers.find((m) => m.id === member.id);
|
||||
if (memberToUpdate) {
|
||||
memberToUpdate.role = role;
|
||||
setTeamMembersToInvite(updatedMembers);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="invite-team-members-container">
|
||||
<div className="invite-team-members-form">
|
||||
<div className="form-group">
|
||||
<div className="invite-team-members-container">
|
||||
{teamMembersToInvite?.map((member) => (
|
||||
<div className="team-member-container" key={member.id}>
|
||||
<Input
|
||||
placeholder="your-teammate@org.com"
|
||||
value={member.email}
|
||||
type="email"
|
||||
required
|
||||
autoFocus
|
||||
autoComplete="off"
|
||||
className="team-member-email-input"
|
||||
onChange={(e: React.ChangeEvent<HTMLInputElement>): void =>
|
||||
handleEmailChange(e, member)
|
||||
}
|
||||
addonAfter={
|
||||
emailValidity[member.id!] === undefined ? null : emailValidity[
|
||||
member.id!
|
||||
] ? (
|
||||
<CircleCheck size={14} color={Color.BG_FOREST_500} />
|
||||
) : (
|
||||
<TriangleAlert size={14} color={Color.BG_SIENNA_500} />
|
||||
)
|
||||
}
|
||||
/>
|
||||
<Select
|
||||
defaultValue={member.role}
|
||||
onChange={(value): void => handleRoleChange(value, member)}
|
||||
className="team-member-role-select"
|
||||
>
|
||||
<Select.Option value="VIEWER">Viewer</Select.Option>
|
||||
<Select.Option value="EDITOR">Editor</Select.Option>
|
||||
<Select.Option value="ADMIN">Admin</Select.Option>
|
||||
</Select>
|
||||
|
||||
{teamMembersToInvite?.length > 1 && (
|
||||
<Button
|
||||
type="default"
|
||||
className="remove-team-member-button periscope-btn"
|
||||
icon={<X size={14} />}
|
||||
onClick={(): void => handleRemoveTeamMember(member.id)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="invite-team-members-add-another-member-container">
|
||||
<Button
|
||||
type="primary"
|
||||
className="add-another-member-button periscope-btn"
|
||||
icon={<Plus size={14} />}
|
||||
onClick={handleAddTeamMember}
|
||||
>
|
||||
Member
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{hasInvalidEmails && (
|
||||
<div className="error-message-container">
|
||||
<Typography.Text className="error-message" color="danger">
|
||||
<TriangleAlert size={14} /> Please enter valid emails for all team
|
||||
members
|
||||
</Typography.Text>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="next-prev-container">
|
||||
<Button
|
||||
type="default"
|
||||
className="next-button periscope-btn"
|
||||
onClick={onClose}
|
||||
>
|
||||
<X size={14} />
|
||||
Cancel
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
type="primary"
|
||||
className="next-button periscope-btn primary"
|
||||
onClick={handleNext}
|
||||
loading={isSendingInvites || isLoading}
|
||||
>
|
||||
Send Invites
|
||||
<ArrowRight size={14} />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default InviteTeamMembers;
|
||||
@@ -1220,14 +1220,6 @@
|
||||
.request-data-source-modal-input {
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.invite-team-member-modal-footer {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: var(--spacing-8);
|
||||
border-top: 1px solid var(--l1-border);
|
||||
padding-top: var(--spacing-6);
|
||||
}
|
||||
}
|
||||
|
||||
.request-data-source-modal {
|
||||
|
||||
@@ -79,7 +79,7 @@
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
input:not(.ant-select-selection-search-input),
|
||||
input,
|
||||
textarea {
|
||||
height: 32px;
|
||||
background: var(--l2-background) !important;
|
||||
|
||||
@@ -111,9 +111,31 @@
|
||||
&__select {
|
||||
width: 100%;
|
||||
|
||||
.ant-select-selection-search {
|
||||
inset-inline-start: var(--padding-2) !important;
|
||||
inset-inline-end: var(--padding-2) !important;
|
||||
&.ant-select {
|
||||
.ant-select-selector {
|
||||
height: 32px;
|
||||
background: var(--l2-background) !important;
|
||||
border: 1px solid var(--l2-border) !important;
|
||||
border-radius: 2px;
|
||||
color: var(--l2-foreground) !important;
|
||||
|
||||
.ant-select-selection-item {
|
||||
color: var(--l2-foreground) !important;
|
||||
}
|
||||
}
|
||||
|
||||
&:hover .ant-select-selector {
|
||||
border-color: var(--l2-border) !important;
|
||||
}
|
||||
|
||||
&.ant-select-focused .ant-select-selector {
|
||||
border-color: var(--primary) !important;
|
||||
box-shadow: none !important;
|
||||
}
|
||||
|
||||
.ant-select-arrow {
|
||||
color: var(--l2-foreground);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -163,7 +185,7 @@
|
||||
|
||||
&--role {
|
||||
flex: 1;
|
||||
min-width: 180px;
|
||||
min-width: 120px;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -250,7 +272,7 @@
|
||||
}
|
||||
|
||||
// todo: https://github.com/SigNoz/components/issues/116
|
||||
input:not(.ant-select-selection-search-input) {
|
||||
input {
|
||||
height: 32px;
|
||||
background: var(--l2-background) !important;
|
||||
border: 1px solid var(--l2-border) !important;
|
||||
|
||||
@@ -11,20 +11,23 @@ import {
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import { Checkbox } from '@signozhq/ui/checkbox';
|
||||
import { Input } from '@signozhq/ui/input';
|
||||
import { Collapse, Form, Tooltip } from 'antd';
|
||||
import RolesSelect, { useRoles } from 'components/RolesSelect';
|
||||
import { Collapse, Form, Select, Tooltip } from 'antd';
|
||||
import { useCollapseSectionErrors } from 'hooks/useCollapseSectionErrors';
|
||||
|
||||
import './RoleMappingSection.styles.scss';
|
||||
|
||||
const ROLE_OPTIONS = [
|
||||
{ value: 'VIEWER', label: 'VIEWER' },
|
||||
{ value: 'EDITOR', label: 'EDITOR' },
|
||||
{ value: 'ADMIN', label: 'ADMIN' },
|
||||
];
|
||||
|
||||
interface RoleMappingSectionProps {
|
||||
fieldNamePrefix: string[];
|
||||
isExpanded?: boolean;
|
||||
onExpandChange?: (expanded: boolean) => void;
|
||||
}
|
||||
|
||||
const SIGNOZ_VIEWER_ROLE = 'signoz-viewer';
|
||||
|
||||
function RoleMappingSection({
|
||||
fieldNamePrefix,
|
||||
isExpanded,
|
||||
@@ -35,7 +38,6 @@ function RoleMappingSection({
|
||||
[...fieldNamePrefix, 'useRoleAttribute'],
|
||||
form,
|
||||
);
|
||||
const { roles, isLoading, isError, error, refetch } = useRoles();
|
||||
|
||||
// Support both controlled and uncontrolled modes
|
||||
const [internalExpanded, setInternalExpanded] = useState(false);
|
||||
@@ -106,26 +108,19 @@ function RoleMappingSection({
|
||||
<div className="role-mapping-section__field-group">
|
||||
<label className="role-mapping-section__label" htmlFor="default-role">
|
||||
Default Role
|
||||
<Tooltip title='The default role assigned to new SSO users if no other role mapping applies. Default: "signoz-viewer"'>
|
||||
<Tooltip title='The default role assigned to new SSO users if no other role mapping applies. Default: "VIEWER"'>
|
||||
<CircleHelp size={14} color={Style.L3_FOREGROUND} cursor="help" />
|
||||
</Tooltip>
|
||||
</label>
|
||||
<Form.Item
|
||||
name={[...fieldNamePrefix, 'defaultRole']}
|
||||
className="role-mapping-section__form-item"
|
||||
initialValue={SIGNOZ_VIEWER_ROLE}
|
||||
initialValue="VIEWER"
|
||||
>
|
||||
<RolesSelect
|
||||
<Select
|
||||
id="default-role"
|
||||
valueField="name"
|
||||
roles={roles}
|
||||
loading={isLoading}
|
||||
isError={isError}
|
||||
error={error}
|
||||
onRefetch={refetch}
|
||||
options={ROLE_OPTIONS}
|
||||
className="role-mapping-section__select"
|
||||
allowClear={false}
|
||||
getPopupContainer={(): HTMLElement => document.body}
|
||||
/>
|
||||
</Form.Item>
|
||||
</div>
|
||||
@@ -145,7 +140,7 @@ function RoleMappingSection({
|
||||
Use Role Attribute Directly
|
||||
</Checkbox>
|
||||
</Form.Item>
|
||||
<Tooltip title="If enabled, the role claim/attribute from the IDP will be used directly instead of group mappings. The role value must match a SigNoz role name (e.g. signoz-viewer, signoz-editor, signoz-admin, or a custom role).">
|
||||
<Tooltip title="If enabled, the role claim/attribute from the IDP will be used directly instead of group mappings. The role value must match a SigNoz role (VIEWER, EDITOR, or ADMIN).">
|
||||
<CircleHelp size={14} color={Style.L3_FOREGROUND} cursor="help" />
|
||||
</Tooltip>
|
||||
</div>
|
||||
@@ -179,17 +174,11 @@ function RoleMappingSection({
|
||||
name={[field.name, 'role']}
|
||||
className="role-mapping-section__field role-mapping-section__field--role"
|
||||
rules={[{ required: true, message: 'Role is required' }]}
|
||||
initialValue={SIGNOZ_VIEWER_ROLE}
|
||||
initialValue="VIEWER"
|
||||
>
|
||||
<RolesSelect
|
||||
valueField="name"
|
||||
roles={roles}
|
||||
loading={isLoading}
|
||||
isError={isError}
|
||||
error={error}
|
||||
onRefetch={refetch}
|
||||
allowClear={false}
|
||||
getPopupContainer={(): HTMLElement => document.body}
|
||||
<Select
|
||||
options={ROLE_OPTIONS}
|
||||
className="role-mapping-section__select"
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
@@ -208,9 +197,7 @@ function RoleMappingSection({
|
||||
<Button
|
||||
variant="outlined"
|
||||
color="secondary"
|
||||
onClick={(): void =>
|
||||
add({ groupName: '', role: SIGNOZ_VIEWER_ROLE })
|
||||
}
|
||||
onClick={(): void => add({ groupName: '', role: 'VIEWER' })}
|
||||
prefix={<Plus size={14} />}
|
||||
>
|
||||
Add Group Mapping
|
||||
|
||||
@@ -9,7 +9,6 @@ import {
|
||||
mockUpdateSuccessResponse,
|
||||
} from './mocks';
|
||||
|
||||
// TODO: https://github.com/SigNoz/platform-pod/issues/2602
|
||||
// The real @signozhq/ui/button has internal effects that prevent form.validateFields()
|
||||
// from resolving inside act(). Mirror the pattern from SSOEnforcementToggle.test.tsx
|
||||
// which mocks @signozhq/ui/switch for the same reason.
|
||||
|
||||
@@ -1,316 +0,0 @@
|
||||
import { render, screen, userEvent, waitFor } from 'tests/test-utils';
|
||||
import { rest, server } from 'mocks-server/server';
|
||||
import {
|
||||
allRoles,
|
||||
listRolesSuccessResponse,
|
||||
managedRoles,
|
||||
} from 'mocks-server/__mockdata__/roles';
|
||||
|
||||
import CreateEdit from '../CreateEdit/CreateEdit';
|
||||
import {
|
||||
AUTH_DOMAINS_UPDATE_ENDPOINT,
|
||||
mockDomainWithDirectRoleAttribute,
|
||||
mockDomainWithRoleMapping,
|
||||
mockSamlAuthDomain,
|
||||
mockUpdateSuccessResponse,
|
||||
} from './mocks';
|
||||
|
||||
// TODO: https://github.com/SigNoz/platform-pod/issues/2602
|
||||
// The @signozhq/ui Button uses Radix Slot and has CSS infinite animations that
|
||||
// prevent form.validateFields() from resolving inside act(). Replacing with a
|
||||
// simple native button avoids the issue.
|
||||
jest.mock('@signozhq/ui/button', () => ({
|
||||
...jest.requireActual('@signozhq/ui/button'),
|
||||
Button: ({
|
||||
children,
|
||||
onClick,
|
||||
loading,
|
||||
disabled,
|
||||
'aria-label': ariaLabel,
|
||||
prefix,
|
||||
suffix,
|
||||
}: {
|
||||
children?: React.ReactNode;
|
||||
onClick?: React.MouseEventHandler<HTMLButtonElement>;
|
||||
loading?: boolean;
|
||||
disabled?: boolean;
|
||||
'aria-label'?: string;
|
||||
prefix?: React.ReactNode;
|
||||
suffix?: React.ReactNode;
|
||||
}) => (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
disabled={disabled || loading}
|
||||
aria-label={ariaLabel}
|
||||
>
|
||||
{prefix}
|
||||
{children}
|
||||
{suffix}
|
||||
</button>
|
||||
),
|
||||
}));
|
||||
|
||||
// These are heavy real-timer integration tests (antd Select dropdown render +
|
||||
// form.validateFields() + a react-query mutation, all driven through userEvent).
|
||||
// Under a CPU-saturated parallel `jest` run the wall-clock roughly triples, which
|
||||
// pushes the longest tests past the 5000ms default and makes them flaky. Give the
|
||||
// whole file a wider budget (matches LogsPanelComponent.test.tsx).
|
||||
jest.setTimeout(20000);
|
||||
|
||||
const ROLES_ENDPOINT = '*/api/v1/roles';
|
||||
|
||||
type User = ReturnType<typeof userEvent.setup>;
|
||||
|
||||
// antd renders pointer-events:none on parts of its Select, so disable the
|
||||
// userEvent pointer-events guard (mirrors CreateEdit.test.tsx).
|
||||
const setupUser = (): User => userEvent.setup({ pointerEventsCheck: 0 });
|
||||
|
||||
function getRole(name: string): (typeof managedRoles)[number] {
|
||||
const role = managedRoles.find((r) => r.name === name);
|
||||
if (!role) {
|
||||
throw new Error(`missing mock role: ${name}`);
|
||||
}
|
||||
return role;
|
||||
}
|
||||
|
||||
const viewerRole = getRole('signoz-viewer');
|
||||
const editorRole = getRole('signoz-editor');
|
||||
|
||||
function mockRoles(
|
||||
response: Record<string, unknown> = listRolesSuccessResponse,
|
||||
status = 200,
|
||||
): { count: () => number } {
|
||||
let requested = 0;
|
||||
server.use(
|
||||
rest.get(ROLES_ENDPOINT, (_req, res, ctx) => {
|
||||
requested += 1;
|
||||
return res(ctx.status(status), ctx.json(response));
|
||||
}),
|
||||
);
|
||||
return { count: (): number => requested };
|
||||
}
|
||||
|
||||
function captureUpdatePayload(): { get: () => any } {
|
||||
let payload: unknown = null;
|
||||
server.use(
|
||||
rest.put(AUTH_DOMAINS_UPDATE_ENDPOINT, async (req, res, ctx) => {
|
||||
payload = await req.json();
|
||||
return res(ctx.status(200), ctx.json(mockUpdateSuccessResponse));
|
||||
}),
|
||||
);
|
||||
return { get: (): any => payload };
|
||||
}
|
||||
|
||||
const expandRoleMapping = (user: User): Promise<void> =>
|
||||
user.click(screen.getByText(/role mapping \(advanced\)/i));
|
||||
|
||||
const openDefaultRoleSelect = (user: User): Promise<void> =>
|
||||
user.click(screen.getByLabelText(/default role/i));
|
||||
|
||||
const saveChanges = (user: User): Promise<void> =>
|
||||
user.click(screen.getByRole('button', { name: /save changes/i }));
|
||||
|
||||
describe('CreateEdit — role mapping uses API roles', () => {
|
||||
afterEach(() => {
|
||||
server.resetHandlers();
|
||||
});
|
||||
|
||||
it('fetches the roles list from the API when the form mounts', async () => {
|
||||
const roles = mockRoles();
|
||||
|
||||
render(
|
||||
<CreateEdit
|
||||
isCreate={false}
|
||||
record={mockDomainWithDirectRoleAttribute}
|
||||
onClose={jest.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
await waitFor(() => expect(roles.count()).toBeGreaterThan(0));
|
||||
});
|
||||
|
||||
it('renders the default-role options from the API (managed + custom), not the old hardcoded VIEWER/EDITOR/ADMIN', async () => {
|
||||
const user = setupUser();
|
||||
mockRoles();
|
||||
|
||||
// mockSamlAuthDomain has no stored defaultRole, so nothing stale (e.g.
|
||||
// "VIEWER") is rendered as a selected tag to pollute the title lookups.
|
||||
render(
|
||||
<CreateEdit
|
||||
isCreate={false}
|
||||
record={mockSamlAuthDomain}
|
||||
onClose={jest.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
await expandRoleMapping(user);
|
||||
|
||||
// Open the Select and wait for the async roles fetch to populate it.
|
||||
await openDefaultRoleSelect(user);
|
||||
await screen.findByTitle(allRoles[0].name);
|
||||
|
||||
// Every role returned by the API is offered as an option, including the
|
||||
// custom (non-managed) roles — the whole point of the refactor. Use
|
||||
// getAllByTitle: the preselected default role also renders its name on
|
||||
// the selection item, so a role may legitimately appear more than once.
|
||||
allRoles.forEach((role) => {
|
||||
expect(screen.getAllByTitle(role.name).length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
// The old hardcoded uppercase role values must NOT appear as options.
|
||||
expect(screen.queryByTitle('VIEWER')).not.toBeInTheDocument();
|
||||
expect(screen.queryByTitle('EDITOR')).not.toBeInTheDocument();
|
||||
expect(screen.queryByTitle('ADMIN')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('submits the selected role name (not the role id) as defaultRole', async () => {
|
||||
const user = setupUser();
|
||||
mockRoles();
|
||||
const payload = captureUpdatePayload();
|
||||
|
||||
render(
|
||||
<CreateEdit
|
||||
isCreate={false}
|
||||
record={mockDomainWithDirectRoleAttribute}
|
||||
onClose={jest.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
await expandRoleMapping(user);
|
||||
|
||||
await openDefaultRoleSelect(user);
|
||||
await user.click(await screen.findByTitle(editorRole.name));
|
||||
|
||||
await saveChanges(user);
|
||||
|
||||
await waitFor(() => expect(payload.get()).not.toBeNull());
|
||||
|
||||
// SSO role mapping matches roles by name, so the payload carries the
|
||||
// role *name*, not the opaque id.
|
||||
expect(payload.get().config.roleMapping.defaultRole).toBe(editorRole.name);
|
||||
expect(payload.get().config.roleMapping.defaultRole).not.toBe(editorRole.id);
|
||||
});
|
||||
|
||||
it('defaults a fresh role mapping to the signoz-viewer role name', async () => {
|
||||
const user = setupUser();
|
||||
const roles = mockRoles();
|
||||
const payload = captureUpdatePayload();
|
||||
|
||||
// mockSamlAuthDomain has no roleMapping, so the defaultRole field falls
|
||||
// back to the Form.Item initialValue (viewerRole.name). That initialValue
|
||||
// is only applied when the field mounts, so the roles fetch MUST resolve
|
||||
// before the panel is expanded — otherwise viewerRole is still undefined.
|
||||
render(
|
||||
<CreateEdit
|
||||
isCreate={false}
|
||||
record={mockSamlAuthDomain}
|
||||
onClose={jest.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
await waitFor(() => expect(roles.count()).toBeGreaterThan(0));
|
||||
// Flush the react-query commit so `useRoles` exposes the loaded roles
|
||||
// before the collapse panel (and thus the default-role field) mounts.
|
||||
await screen.findByText(/edit saml authentication/i);
|
||||
|
||||
await expandRoleMapping(user);
|
||||
await screen.findByText(/default role/i);
|
||||
|
||||
await saveChanges(user);
|
||||
|
||||
await waitFor(() => expect(payload.get()).not.toBeNull());
|
||||
|
||||
expect(payload.get().config.roleMapping.defaultRole).toBe(viewerRole.name);
|
||||
expect(payload.get().config.roleMapping.defaultRole).not.toBe(viewerRole.id);
|
||||
});
|
||||
|
||||
it('still defaults to signoz-viewer when the roles fetch returns empty', async () => {
|
||||
const user = setupUser();
|
||||
// signoz-viewer is a managed role that always exists server-side, so even
|
||||
// a degenerate/empty roles response must not strip the hardcoded default.
|
||||
mockRoles({ status: 'success', data: [] });
|
||||
const payload = captureUpdatePayload();
|
||||
|
||||
render(
|
||||
<CreateEdit
|
||||
isCreate={false}
|
||||
record={mockSamlAuthDomain}
|
||||
onClose={jest.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
// Section still renders without crashing even though the fetch was empty.
|
||||
await expandRoleMapping(user);
|
||||
await expect(screen.findByText(/default role/i)).resolves.toBeInTheDocument();
|
||||
|
||||
await saveChanges(user);
|
||||
|
||||
await waitFor(() => expect(payload.get()).not.toBeNull());
|
||||
|
||||
// The Form.Item initialValue (signoz-viewer) survives an empty roles list.
|
||||
expect(payload.get().config.roleMapping.defaultRole).toBe(viewerRole.name);
|
||||
});
|
||||
|
||||
it('loads a stored role mapping by role name and round-trips it on save', async () => {
|
||||
const user = setupUser();
|
||||
mockRoles();
|
||||
const payload = captureUpdatePayload();
|
||||
|
||||
// mockDomainWithRoleMapping stores defaultRole "signoz-editor" plus three
|
||||
// group mappings, all keyed by role *name*. Editing must surface each
|
||||
// stored value as the matching option and submit it unchanged — the
|
||||
// backward-compatible read path for already-saved SSO domains.
|
||||
render(
|
||||
<CreateEdit
|
||||
isCreate={false}
|
||||
record={mockDomainWithRoleMapping}
|
||||
onClose={jest.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
await expandRoleMapping(user);
|
||||
|
||||
// The stored default role renders as a real selection, not a raw token.
|
||||
await waitFor(() =>
|
||||
expect(screen.getAllByTitle(editorRole.name).length).toBeGreaterThan(0),
|
||||
);
|
||||
|
||||
await saveChanges(user);
|
||||
|
||||
await waitFor(() => expect(payload.get()).not.toBeNull());
|
||||
|
||||
expect(payload.get().config.roleMapping.defaultRole).toBe(editorRole.name);
|
||||
expect(payload.get().config.roleMapping.groupMappings).toStrictEqual({
|
||||
'admin-group': 'signoz-admin',
|
||||
'dev-team': 'signoz-editor',
|
||||
viewers: 'signoz-viewer',
|
||||
});
|
||||
});
|
||||
|
||||
it('shows an error state in the default-role select when the roles request fails', async () => {
|
||||
const user = setupUser();
|
||||
mockRoles(
|
||||
{ error: { code: 'internal_error', message: 'boom', url: '' } },
|
||||
500,
|
||||
);
|
||||
|
||||
render(
|
||||
<CreateEdit
|
||||
isCreate={false}
|
||||
record={mockSamlAuthDomain}
|
||||
onClose={jest.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
await expandRoleMapping(user);
|
||||
|
||||
// Open the select and confirm the error UI (with retry) is surfaced
|
||||
// instead of crashing the form. The error message comes straight from
|
||||
// the failed request; the Retry affordance is always present.
|
||||
await openDefaultRoleSelect(user);
|
||||
|
||||
await expect(screen.findByTitle('Retry')).resolves.toBeInTheDocument();
|
||||
expect(screen.getByText('boom')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -186,9 +186,9 @@ describe('CreateEdit — payload sanitization', () => {
|
||||
|
||||
expect(payload.config.roleMapping?.useRoleAttribute).toBe(false);
|
||||
expect(payload.config.roleMapping?.groupMappings).toStrictEqual({
|
||||
'admin-group': 'signoz-admin',
|
||||
'dev-team': 'signoz-editor',
|
||||
viewers: 'signoz-viewer',
|
||||
'admin-group': 'ADMIN',
|
||||
'dev-team': 'EDITOR',
|
||||
viewers: 'VIEWER',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -75,12 +75,12 @@ export const mockDomainWithRoleMapping: AuthtypesGettableAuthDomainDTO = {
|
||||
samlCert: 'MOCK_CERTIFICATE',
|
||||
},
|
||||
roleMapping: {
|
||||
defaultRole: 'signoz-editor',
|
||||
defaultRole: 'EDITOR',
|
||||
useRoleAttribute: false,
|
||||
groupMappings: {
|
||||
'admin-group': 'signoz-admin',
|
||||
'dev-team': 'signoz-editor',
|
||||
viewers: 'signoz-viewer',
|
||||
'admin-group': 'ADMIN',
|
||||
'dev-team': 'EDITOR',
|
||||
viewers: 'VIEWER',
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -103,7 +103,7 @@ export const mockDomainWithDirectRoleAttribute: AuthtypesGettableAuthDomainDTO =
|
||||
clientSecret: 'direct-role-client-secret',
|
||||
},
|
||||
roleMapping: {
|
||||
defaultRole: 'signoz-viewer',
|
||||
defaultRole: 'VIEWER',
|
||||
useRoleAttribute: true,
|
||||
},
|
||||
},
|
||||
|
||||
@@ -105,8 +105,3 @@
|
||||
height: 1px;
|
||||
background: var(--l1-border);
|
||||
}
|
||||
|
||||
.errorInPlaceContainer {
|
||||
border-color: var(--callout-error-border) !important;
|
||||
background: var(--callout-error-background) !important;
|
||||
}
|
||||
|
||||
@@ -15,7 +15,7 @@ import APIError from 'types/api/error';
|
||||
|
||||
import PermissionEditor from './components/PermissionEditor';
|
||||
import { useCreateEditRolePageActions } from './useCreateEditRolePageActions';
|
||||
import { useNavigationBlocker } from 'hooks/useNavigationBlocker';
|
||||
import { useNavigationBlocker } from '../../../hooks/useNavigationBlocker';
|
||||
|
||||
import styles from './CreateEditRolePage.module.scss';
|
||||
|
||||
@@ -212,10 +212,8 @@ function CreateEditRolePage(): JSX.Element {
|
||||
<ErrorInPlace
|
||||
error={saveError}
|
||||
height="auto"
|
||||
bordered
|
||||
data-testid="save-error-banner"
|
||||
padding={0}
|
||||
bordered={true}
|
||||
className={styles.errorInPlaceContainer}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
||||
@@ -216,47 +216,6 @@ describe('CreateRolePage', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('shows error banner with "Role name is required" when saving with empty name', async () => {
|
||||
const user = userEvent.setup();
|
||||
renderCreatePage();
|
||||
|
||||
const descInput = screen.getByTestId('role-description-input');
|
||||
await user.type(descInput, 'Description only');
|
||||
|
||||
const saveBtn = screen.getByTestId('save-button');
|
||||
await user.click(saveBtn);
|
||||
|
||||
await expect(
|
||||
screen.findByTestId('save-error-banner'),
|
||||
).resolves.toBeInTheDocument();
|
||||
|
||||
await expect(
|
||||
screen.findByText('Role name is required'),
|
||||
).resolves.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('clears error banner when user starts typing in name field', async () => {
|
||||
const user = userEvent.setup();
|
||||
renderCreatePage();
|
||||
|
||||
const descInput = screen.getByTestId('role-description-input');
|
||||
await user.type(descInput, 'Description only');
|
||||
|
||||
const saveBtn = screen.getByTestId('save-button');
|
||||
await user.click(saveBtn);
|
||||
|
||||
await expect(
|
||||
screen.findByTestId('save-error-banner'),
|
||||
).resolves.toBeInTheDocument();
|
||||
|
||||
const nameInput = screen.getByTestId('role-name-input');
|
||||
await user.type(nameInput, 'a');
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByTestId('save-error-banner')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('shows error banner when API fails', async () => {
|
||||
server.use(
|
||||
rest.post(rolesApiBase, (_req, res, ctx) =>
|
||||
|
||||
@@ -520,115 +520,4 @@ describe('PermissionEditor', () => {
|
||||
expect(header).toHaveAttribute('aria-expanded', 'true');
|
||||
});
|
||||
});
|
||||
|
||||
describe('resource card error states', () => {
|
||||
it('shows error border on collapsed card with validation error', async () => {
|
||||
const user = userEvent.setup();
|
||||
renderPage();
|
||||
|
||||
const nameInput = screen.getByTestId('role-name-input');
|
||||
await user.type(nameInput, 'valid-role');
|
||||
|
||||
const apiKeyCard = screen.getByTestId('resource-card-factor-api-key');
|
||||
const header = within(apiKeyCard).getByTestId(
|
||||
'resource-card-header-factor-api-key',
|
||||
);
|
||||
await user.click(header);
|
||||
|
||||
const readToggle = within(apiKeyCard).getByTestId(
|
||||
'action-toggle-factor-api-key-read',
|
||||
);
|
||||
const onlySelectedBtn = await within(readToggle).findByText('Only selected');
|
||||
await user.click(onlySelectedBtn);
|
||||
|
||||
await user.click(header);
|
||||
|
||||
const saveBtn = screen.getByTestId('save-button');
|
||||
await user.click(saveBtn);
|
||||
|
||||
await waitFor(() => {
|
||||
const card = screen.getByTestId('resource-card-factor-api-key');
|
||||
expect(card).toHaveAttribute('data-state', 'error');
|
||||
});
|
||||
});
|
||||
|
||||
it('hides error border when card is expanded', async () => {
|
||||
const user = userEvent.setup();
|
||||
renderPage();
|
||||
|
||||
const nameInput = screen.getByTestId('role-name-input');
|
||||
await user.type(nameInput, 'valid-role');
|
||||
|
||||
const apiKeyCard = screen.getByTestId('resource-card-factor-api-key');
|
||||
const header = within(apiKeyCard).getByTestId(
|
||||
'resource-card-header-factor-api-key',
|
||||
);
|
||||
await user.click(header);
|
||||
|
||||
const readToggle = within(apiKeyCard).getByTestId(
|
||||
'action-toggle-factor-api-key-read',
|
||||
);
|
||||
const onlySelectedBtn = await within(readToggle).findByText('Only selected');
|
||||
await user.click(onlySelectedBtn);
|
||||
|
||||
await user.click(header);
|
||||
|
||||
const saveBtn = screen.getByTestId('save-button');
|
||||
await user.click(saveBtn);
|
||||
|
||||
await waitFor(() => {
|
||||
const card = screen.getByTestId('resource-card-factor-api-key');
|
||||
expect(card).toHaveAttribute('data-state', 'error');
|
||||
});
|
||||
|
||||
await user.click(header);
|
||||
|
||||
await waitFor(() => {
|
||||
const card = screen.getByTestId('resource-card-factor-api-key');
|
||||
expect(card).not.toHaveAttribute('data-state');
|
||||
});
|
||||
});
|
||||
|
||||
it('clears validation error when permission is changed', async () => {
|
||||
const user = userEvent.setup();
|
||||
renderPage();
|
||||
|
||||
const nameInput = screen.getByTestId('role-name-input');
|
||||
await user.type(nameInput, 'valid-role');
|
||||
|
||||
const apiKeyCard = screen.getByTestId('resource-card-factor-api-key');
|
||||
const header = within(apiKeyCard).getByTestId(
|
||||
'resource-card-header-factor-api-key',
|
||||
);
|
||||
await user.click(header);
|
||||
|
||||
const readToggle = within(apiKeyCard).getByTestId(
|
||||
'action-toggle-factor-api-key-read',
|
||||
);
|
||||
const onlySelectedBtn = await within(readToggle).findByText('Only selected');
|
||||
await user.click(onlySelectedBtn);
|
||||
|
||||
await user.click(header);
|
||||
|
||||
const saveBtn = screen.getByTestId('save-button');
|
||||
await user.click(saveBtn);
|
||||
|
||||
await expect(
|
||||
screen.findByTestId('save-error-banner'),
|
||||
).resolves.toBeInTheDocument();
|
||||
|
||||
await user.click(header);
|
||||
|
||||
const freshCard = screen.getByTestId('resource-card-factor-api-key');
|
||||
const freshToggle = within(freshCard).getByTestId(
|
||||
'action-toggle-factor-api-key-read',
|
||||
);
|
||||
const noneBtn = await within(freshToggle).findByText('None');
|
||||
await user.click(noneBtn);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByTestId('save-error-banner')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -8,10 +8,6 @@
|
||||
transition: border-color 0.15s ease;
|
||||
}
|
||||
|
||||
.resourceCardError {
|
||||
border-color: var(--destructive);
|
||||
}
|
||||
|
||||
.resourceCardHeader {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useCallback, useMemo, useState } from 'react';
|
||||
import { useCallback, useState } from 'react';
|
||||
import { ChevronDown, ChevronRight } from '@signozhq/icons';
|
||||
import type { AuthZResource, AuthZVerb } from 'hooks/useAuthZ/types';
|
||||
|
||||
@@ -10,7 +10,6 @@ import ActionToggle from './ActionToggle';
|
||||
|
||||
import styles from './ResourceCard.module.scss';
|
||||
import { PermissionScope, ResourcePermissions } from '../../types';
|
||||
import cx from 'classnames';
|
||||
|
||||
interface ResourceCardProps {
|
||||
resource: ResourcePermissions;
|
||||
@@ -75,22 +74,10 @@ function ResourceCard({
|
||||
|
||||
const [grantedCount, totalCount] = useRoleGrantedCount(resource);
|
||||
|
||||
const hasErrorOnResource = useMemo(
|
||||
() =>
|
||||
Array.from(validationErrors ?? []).some((r) =>
|
||||
r.startsWith(resource.resourceId),
|
||||
),
|
||||
[validationErrors, resource.resourceId],
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cx(
|
||||
styles.resourceCard,
|
||||
hasErrorOnResource && !isExpanded && styles.resourceCardError,
|
||||
)}
|
||||
className={styles.resourceCard}
|
||||
data-testid={`resource-card-${resource.resourceId}`}
|
||||
data-state={hasErrorOnResource && !isExpanded ? 'error' : undefined}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
|
||||
@@ -125,10 +125,8 @@ export function useCreateEditRolePageActions(
|
||||
...prev,
|
||||
[field]: value,
|
||||
}));
|
||||
clearValidationErrors();
|
||||
setSaveError(null);
|
||||
},
|
||||
[clearValidationErrors],
|
||||
[],
|
||||
);
|
||||
|
||||
const handleModeChange = useCallback(
|
||||
@@ -141,10 +139,8 @@ export function useCreateEditRolePageActions(
|
||||
const handleResourcesChange = useCallback(
|
||||
(resources: ResourcePermissions[]): void => {
|
||||
setLocalResources(resources);
|
||||
clearValidationErrors();
|
||||
setSaveError(null);
|
||||
},
|
||||
[clearValidationErrors],
|
||||
[],
|
||||
);
|
||||
|
||||
const hasUnsavedChanges = useRoleUnsavedChanges(
|
||||
@@ -157,17 +153,7 @@ export function useCreateEditRolePageActions(
|
||||
|
||||
const handleSave = useCallback(async (): Promise<boolean> => {
|
||||
if (!formData.name.trim()) {
|
||||
setSaveError(
|
||||
new APIError({
|
||||
httpStatusCode: 400,
|
||||
error: {
|
||||
code: 'VALIDATION_ERROR',
|
||||
message: 'Role name is required',
|
||||
url: '',
|
||||
errors: [],
|
||||
},
|
||||
}),
|
||||
);
|
||||
toast.error('Role name is required', { position: 'bottom-center' });
|
||||
return false;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
.rolesListingTable {
|
||||
margin-top: 12px;
|
||||
border-radius: 4px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.scrollContainer {
|
||||
|
||||
@@ -40,7 +40,6 @@
|
||||
|
||||
.rolesSettingsContent {
|
||||
padding: 0 16px;
|
||||
padding-bottom: 16px;
|
||||
}
|
||||
|
||||
.rolesSettingsToolbar {
|
||||
|
||||
@@ -41,11 +41,6 @@ export function parsePermission(
|
||||
return { relation: relation as AuthZRelation, object };
|
||||
}
|
||||
|
||||
export function formatPermission(permission: BrandedPermission): string {
|
||||
const { relation, object } = parsePermission(permission);
|
||||
return `${relation}:${object}`;
|
||||
}
|
||||
|
||||
const kindsByType = permissionsType.data.resources.reduce(
|
||||
(acc, r) => {
|
||||
if (!acc[r.type]) {
|
||||
|
||||
@@ -68,3 +68,13 @@
|
||||
border-radius: 2px;
|
||||
border: 1px solid var(--l2-border);
|
||||
}
|
||||
|
||||
// the V1 tags input ships borderless; give the field a visible box to match
|
||||
.tagsField {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 6px 8px;
|
||||
border-radius: 2px;
|
||||
border: 1px solid var(--l2-border);
|
||||
// background: var(--l3-background);
|
||||
}
|
||||
|
||||
@@ -9,7 +9,7 @@ import {
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
// eslint-disable-next-line signoz/no-antd-components -- multiline TextArea has no @signozhq/ui equivalent yet
|
||||
import { Input as AntdInput } from 'antd';
|
||||
import TagKeyValueInput from 'components/TagKeyValueInput/TagKeyValueInput';
|
||||
import AddTags from 'container/DashboardContainer/DashboardSettings/General/AddBadges';
|
||||
|
||||
import { Base64Icons } from '../utils';
|
||||
import settingsStyles from '../../DashboardSettings.module.scss';
|
||||
@@ -89,7 +89,9 @@ function DashboardInfoForm({
|
||||
|
||||
<div className={styles.infoItemContainer}>
|
||||
<Typography className={styles.infoTitle}>Tags</Typography>
|
||||
<TagKeyValueInput tags={tags} onTagsChange={onTagsChange} />
|
||||
<div className={styles.tagsField}>
|
||||
<AddTags tags={tags} setTags={onTagsChange} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import type { TagtypesPostableTagDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
|
||||
export { Base64Icons } from 'container/DashboardContainer/DashboardSettings/General/utils';
|
||||
export { parseKeyValueTag } from 'components/TagKeyValueInput/utils';
|
||||
|
||||
// tag UX, a string with no ':' is round-tripped as `{key: x, value: x}` and
|
||||
// collapsed back to just `x` for display.
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import { Input } from 'antd';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import type { DashboardtypesPanelSpecDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import type {
|
||||
DashboardtypesPanelSpecDTO,
|
||||
TelemetrytypesSignalDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import { getPanelDefinition } from 'pages/DashboardPageV2/DashboardContainer/Panels/registry';
|
||||
import { resolveSignal } from 'pages/DashboardPageV2/DashboardContainer/Panels/utils/getBuilderQueries';
|
||||
import type { EQueryType } from 'types/common/dashboard';
|
||||
import { getBuilderQueries } from 'pages/DashboardPageV2/DashboardContainer/Panels/utils/getBuilderQueries';
|
||||
|
||||
import type { LegendSeries } from '../hooks/useLegendSeries';
|
||||
import type { TableColumnOption } from '../hooks/useTableColumns';
|
||||
@@ -18,20 +20,10 @@ interface ConfigPaneProps {
|
||||
/** The panel spec — the single editing surface (title/description + section slices). */
|
||||
spec: DashboardtypesPanelSpecDTO;
|
||||
onChangeSpec: (next: DashboardtypesPanelSpecDTO) => void;
|
||||
/** Switch the panel to another visualization kind. */
|
||||
onChangePanelKind: (kind: PanelKind) => void;
|
||||
/**
|
||||
* Active query type from the query-builder provider (the selected tab). Drives which
|
||||
* panel types the visualization switcher disables — read from the provider, not the
|
||||
* spec, because a new panel's spec has no query until staged.
|
||||
*/
|
||||
queryType: EQueryType;
|
||||
/** Panel's resolved series, provided to sections that need them (legend colors). */
|
||||
legendSeries: LegendSeries[];
|
||||
/** Table panel's resolved value columns, for the table-only editors. */
|
||||
tableColumns: TableColumnOption[];
|
||||
/** Query step interval (seconds), for the chart-appearance span-gaps floor. */
|
||||
stepInterval?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -44,16 +36,15 @@ function ConfigPane({
|
||||
panelKind,
|
||||
spec,
|
||||
onChangeSpec,
|
||||
onChangePanelKind,
|
||||
queryType,
|
||||
legendSeries,
|
||||
tableColumns,
|
||||
stepInterval,
|
||||
}: ConfigPaneProps): JSX.Element {
|
||||
const definition = getPanelDefinition(panelKind);
|
||||
const sections = definition.sections;
|
||||
|
||||
const signal = resolveSignal(spec.queries, definition.supportedSignals[0]);
|
||||
const signal = getBuilderQueries(spec.queries || [])[0]?.signal as
|
||||
| TelemetrytypesSignalDTO
|
||||
| undefined;
|
||||
|
||||
// Title/description are just a slice of the spec — edit them through the same
|
||||
// onChangeSpec path the sections use, so there's a single editing surface.
|
||||
@@ -104,10 +95,6 @@ function ConfigPane({
|
||||
legendSeries={legendSeries}
|
||||
tableColumns={tableColumns}
|
||||
signal={signal}
|
||||
panelKind={panelKind}
|
||||
onChangePanelKind={onChangePanelKind}
|
||||
queryType={queryType}
|
||||
stepInterval={stepInterval}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -1,6 +0,0 @@
|
||||
// Matches ConfigPane's `.field` so the switcher lines up with the title/description fields.
|
||||
.field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
@@ -1,64 +0,0 @@
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import type { TelemetrytypesSignalDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { EQueryType } from 'types/common/dashboard';
|
||||
|
||||
import type { PanelKind } from '../../../Panels/types/panelKind';
|
||||
import { PANEL_TYPES } from '../../../PanelsAndSectionsLayout/Panel/PanelTypeSelectionModal/constants';
|
||||
import ConfigSelect from '../controls/ConfigSelect/ConfigSelect';
|
||||
|
||||
import styles from './PanelTypeSwitcher.module.scss';
|
||||
import { getPanelTypeDisabledReason } from './utils';
|
||||
|
||||
interface PanelTypeSwitcherProps {
|
||||
/** The current panel kind (selected value). */
|
||||
panelKind: PanelKind;
|
||||
/** Active query type — a kind that can't be authored in it is disabled (e.g. List is Query-Builder-only, so PromQL/ClickHouse disable it). Defaults to Query Builder. */
|
||||
queryType?: EQueryType;
|
||||
/** Panel's current signal — also gates the disabled rule (List needs logs/traces, not metrics). */
|
||||
signal?: TelemetrytypesSignalDTO;
|
||||
onChange: (kind: PanelKind) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Visualization-type selector (rendered inside the Visualization section). A type is
|
||||
* disabled when the active query type or signal is incompatible with it — resolved
|
||||
* through the capabilities guard. The signal is unknown for PromQL/ClickHouse, but
|
||||
* those query types still disable kinds that only support Query Builder (e.g. List).
|
||||
*/
|
||||
function PanelTypeSwitcher({
|
||||
panelKind,
|
||||
queryType,
|
||||
signal,
|
||||
onChange,
|
||||
}: PanelTypeSwitcherProps): JSX.Element {
|
||||
const items = PANEL_TYPES.map(({ panelKind, label, Icon }) => {
|
||||
// One reason drives both the disabled flag and the tooltip, so they can't disagree.
|
||||
const disabledReason = getPanelTypeDisabledReason({
|
||||
kind: panelKind,
|
||||
queryType: queryType ?? EQueryType.QUERY_BUILDER,
|
||||
signal,
|
||||
label,
|
||||
});
|
||||
return {
|
||||
value: panelKind,
|
||||
label,
|
||||
icon: <Icon size={14} />,
|
||||
disabled: !!disabledReason,
|
||||
tooltip: disabledReason,
|
||||
};
|
||||
});
|
||||
|
||||
return (
|
||||
<div className={styles.field}>
|
||||
<Typography.Text>Panel Type</Typography.Text>
|
||||
<ConfigSelect
|
||||
testId="panel-editor-v2-type-switcher"
|
||||
value={panelKind}
|
||||
items={items}
|
||||
onChange={(value): void => onChange(value)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default PanelTypeSwitcher;
|
||||
@@ -1,122 +0,0 @@
|
||||
import { fireEvent, render, screen } from '@testing-library/react';
|
||||
import { getPanelDefinition } from 'pages/DashboardPageV2/DashboardContainer/Panels/registry';
|
||||
|
||||
import PanelTypeSwitcher from '../PanelTypeSwitcher';
|
||||
import { TelemetrytypesSignalDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { EQueryType } from 'types/common/dashboard';
|
||||
|
||||
jest.mock('pages/DashboardPageV2/DashboardContainer/Panels/registry', () => ({
|
||||
getPanelDefinition: jest.fn(),
|
||||
}));
|
||||
|
||||
const mockGetPanelDefinition = getPanelDefinition as unknown as jest.Mock;
|
||||
|
||||
// Query-type support per kind: List is Query-Builder-only; Table/Pie drop PromQL.
|
||||
const SUPPORTED_QUERY_TYPES: Record<string, EQueryType[]> = {
|
||||
'signoz/ListPanel': [EQueryType.QUERY_BUILDER],
|
||||
'signoz/TablePanel': [EQueryType.QUERY_BUILDER, EQueryType.CLICKHOUSE],
|
||||
'signoz/PieChartPanel': [EQueryType.QUERY_BUILDER, EQueryType.CLICKHOUSE],
|
||||
};
|
||||
|
||||
function disabledLabels(): (string | null)[] {
|
||||
return Array.from(
|
||||
document.querySelectorAll('.ant-select-item-option-disabled'),
|
||||
).map((el) => el.textContent);
|
||||
}
|
||||
|
||||
function openDropdown(): void {
|
||||
fireEvent.mouseDown(screen.getByRole('combobox'));
|
||||
}
|
||||
|
||||
describe('PanelTypeSwitcher', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
// List supports only logs/traces; every other kind also supports metrics.
|
||||
// Query-type support comes from SUPPORTED_QUERY_TYPES (all three by default).
|
||||
mockGetPanelDefinition.mockImplementation((kind: string) => ({
|
||||
supportedSignals:
|
||||
kind === 'signoz/ListPanel'
|
||||
? ['logs', 'traces']
|
||||
: ['metrics', 'logs', 'traces'],
|
||||
supportedQueryTypes: SUPPORTED_QUERY_TYPES[kind] ?? [
|
||||
EQueryType.QUERY_BUILDER,
|
||||
EQueryType.CLICKHOUSE,
|
||||
EQueryType.PROM,
|
||||
],
|
||||
}));
|
||||
});
|
||||
|
||||
it('fires onChange with the chosen plugin kind', () => {
|
||||
const onChange = jest.fn();
|
||||
render(
|
||||
<PanelTypeSwitcher panelKind="signoz/TimeSeriesPanel" onChange={onChange} />,
|
||||
);
|
||||
|
||||
openDropdown();
|
||||
fireEvent.click(screen.getByText('List'));
|
||||
|
||||
expect(onChange).toHaveBeenCalledWith('signoz/ListPanel');
|
||||
});
|
||||
|
||||
it('disables types whose supported signals exclude the current signal', () => {
|
||||
render(
|
||||
<PanelTypeSwitcher
|
||||
panelKind="signoz/TimeSeriesPanel"
|
||||
signal={TelemetrytypesSignalDTO.metrics}
|
||||
onChange={jest.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
openDropdown();
|
||||
// List can't render a metrics query, so it's disabled; Time Series stays enabled.
|
||||
expect(disabledLabels()).toContain('List');
|
||||
expect(disabledLabels()).not.toContain('Time Series');
|
||||
});
|
||||
|
||||
it('does not disable any type when the signal is unknown (builder, no signal)', () => {
|
||||
render(
|
||||
<PanelTypeSwitcher
|
||||
panelKind="signoz/TimeSeriesPanel"
|
||||
onChange={jest.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
openDropdown();
|
||||
expect(
|
||||
document.querySelectorAll('.ant-select-item-option-disabled'),
|
||||
).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('disables Query-Builder-only kinds under PromQL even without a signal', () => {
|
||||
render(
|
||||
<PanelTypeSwitcher
|
||||
panelKind="signoz/TimeSeriesPanel"
|
||||
queryType={EQueryType.PROM}
|
||||
onChange={jest.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
openDropdown();
|
||||
// List/Table/Pie can't be authored in PromQL; Time Series can.
|
||||
expect(disabledLabels()).toContain('List');
|
||||
expect(disabledLabels()).toContain('Table');
|
||||
expect(disabledLabels()).toContain('Pie Chart');
|
||||
expect(disabledLabels()).not.toContain('Time Series');
|
||||
});
|
||||
|
||||
it('disables List under ClickHouse while Table/Pie stay enabled', () => {
|
||||
render(
|
||||
<PanelTypeSwitcher
|
||||
panelKind="signoz/TablePanel"
|
||||
queryType={EQueryType.CLICKHOUSE}
|
||||
onChange={jest.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
openDropdown();
|
||||
expect(disabledLabels()).toContain('List');
|
||||
expect(disabledLabels()).not.toContain('Table');
|
||||
expect(disabledLabels()).not.toContain('Pie Chart');
|
||||
expect(disabledLabels()).not.toContain('Time Series');
|
||||
});
|
||||
});
|
||||
@@ -1,73 +0,0 @@
|
||||
import { TelemetrytypesSignalDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { EQueryType } from 'types/common/dashboard';
|
||||
|
||||
import { getPanelTypeDisabledReason } from '../utils';
|
||||
|
||||
const { QUERY_BUILDER, CLICKHOUSE, PROM } = EQueryType;
|
||||
const { logs, metrics } = TelemetrytypesSignalDTO;
|
||||
|
||||
describe('getPanelTypeDisabledReason', () => {
|
||||
it('returns undefined for a supported combination', () => {
|
||||
expect(
|
||||
getPanelTypeDisabledReason({
|
||||
kind: 'signoz/TimeSeriesPanel',
|
||||
queryType: PROM,
|
||||
label: 'Time Series',
|
||||
}),
|
||||
).toBeUndefined();
|
||||
expect(
|
||||
getPanelTypeDisabledReason({
|
||||
kind: 'signoz/ListPanel',
|
||||
queryType: QUERY_BUILDER,
|
||||
signal: logs,
|
||||
label: 'List',
|
||||
}),
|
||||
).toBeUndefined();
|
||||
});
|
||||
|
||||
it('explains an unsupported query type', () => {
|
||||
expect(
|
||||
getPanelTypeDisabledReason({
|
||||
kind: 'signoz/ListPanel',
|
||||
queryType: PROM,
|
||||
label: 'List',
|
||||
}),
|
||||
).toBe("List isn't available for PromQL queries");
|
||||
expect(
|
||||
getPanelTypeDisabledReason({
|
||||
kind: 'signoz/ListPanel',
|
||||
queryType: CLICKHOUSE,
|
||||
label: 'List',
|
||||
}),
|
||||
).toBe("List isn't available for ClickHouse queries");
|
||||
expect(
|
||||
getPanelTypeDisabledReason({
|
||||
kind: 'signoz/TablePanel',
|
||||
queryType: PROM,
|
||||
label: 'Table',
|
||||
}),
|
||||
).toBe("Table isn't available for PromQL queries");
|
||||
});
|
||||
|
||||
it('explains an unsupported signal', () => {
|
||||
expect(
|
||||
getPanelTypeDisabledReason({
|
||||
kind: 'signoz/ListPanel',
|
||||
queryType: QUERY_BUILDER,
|
||||
signal: metrics,
|
||||
label: 'List',
|
||||
}),
|
||||
).toBe("List doesn't support metrics data");
|
||||
});
|
||||
|
||||
it('prefers the query-type reason when both are incompatible', () => {
|
||||
expect(
|
||||
getPanelTypeDisabledReason({
|
||||
kind: 'signoz/ListPanel',
|
||||
queryType: PROM,
|
||||
signal: metrics,
|
||||
label: 'List',
|
||||
}),
|
||||
).toBe("List isn't available for PromQL queries");
|
||||
});
|
||||
});
|
||||
@@ -1,46 +0,0 @@
|
||||
import { TelemetrytypesSignalDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { EQueryType } from 'types/common/dashboard';
|
||||
|
||||
import {
|
||||
isQueryTypeSupported,
|
||||
isSignalSupported,
|
||||
} from '../../../Panels/capabilities';
|
||||
import type { PanelKind } from '../../../Panels/types/panelKind';
|
||||
|
||||
const QUERY_TYPE_LABEL: Record<EQueryType, string> = {
|
||||
[EQueryType.QUERY_BUILDER]: 'Query Builder',
|
||||
[EQueryType.CLICKHOUSE]: 'ClickHouse',
|
||||
[EQueryType.PROM]: 'PromQL',
|
||||
};
|
||||
|
||||
const SIGNAL_LABEL: Record<TelemetrytypesSignalDTO, string> = {
|
||||
[TelemetrytypesSignalDTO.logs]: 'logs',
|
||||
[TelemetrytypesSignalDTO.traces]: 'traces',
|
||||
[TelemetrytypesSignalDTO.metrics]: 'metrics',
|
||||
};
|
||||
|
||||
/**
|
||||
* Why a panel kind can't be selected for the current query type / signal, or
|
||||
* `undefined` when it can. Drives both the type switcher's disabled state and its
|
||||
* tooltip, so the two never disagree. The query-type reason takes precedence (it's the
|
||||
* outer choice): query types carry no signal, so the signal only matters in builder.
|
||||
*/
|
||||
export function getPanelTypeDisabledReason({
|
||||
kind,
|
||||
queryType,
|
||||
signal,
|
||||
label,
|
||||
}: {
|
||||
kind: PanelKind;
|
||||
queryType: EQueryType;
|
||||
signal?: TelemetrytypesSignalDTO;
|
||||
label: string;
|
||||
}): string | undefined {
|
||||
if (!isQueryTypeSupported(kind, queryType)) {
|
||||
return `${label} isn't available for ${QUERY_TYPE_LABEL[queryType]} queries`;
|
||||
}
|
||||
if (signal !== undefined && !isSignalSupported(kind, signal)) {
|
||||
return `${label} doesn't support ${SIGNAL_LABEL[signal]} data`;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
@@ -1,21 +1,29 @@
|
||||
import type { DashboardtypesPanelSpecDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import type {
|
||||
DashboardtypesPanelSpecDTO,
|
||||
TelemetrytypesSignalDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import {
|
||||
type PanelFormattingSlice,
|
||||
SECTION_METADATA,
|
||||
type SectionConfig,
|
||||
SectionKind,
|
||||
} from 'pages/DashboardPageV2/DashboardContainer/Panels/types/sections';
|
||||
|
||||
import type { SectionEditorContext } from '../sectionContext';
|
||||
import type { LegendSeries } from '../../hooks/useLegendSeries';
|
||||
import type { TableColumnOption } from '../../hooks/useTableColumns';
|
||||
import { resolveSectionEditor } from '../sectionRegistry';
|
||||
import SettingsSection from '../SettingsSection/SettingsSection';
|
||||
|
||||
// `yAxisUnit` is derived from the spec below, not forwarded, so it's omitted.
|
||||
type SectionSlotProps = {
|
||||
interface SectionSlotProps {
|
||||
config: SectionConfig;
|
||||
spec: DashboardtypesPanelSpecDTO;
|
||||
onChangeSpec: (next: DashboardtypesPanelSpecDTO) => void;
|
||||
} & Omit<SectionEditorContext, 'yAxisUnit'>;
|
||||
/** Resolved series, forwarded to editors that need them (legend colors). */
|
||||
legendSeries: LegendSeries[];
|
||||
/** Table panel's resolved value columns, for the table-only editors. */
|
||||
tableColumns: TableColumnOption[];
|
||||
/** Panel's telemetry signal, for editors that fetch field suggestions (List columns). */
|
||||
signal?: TelemetrytypesSignalDTO;
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders one configuration section: its collapsible wrapper plus the registered editor
|
||||
@@ -30,10 +38,6 @@ function SectionSlot({
|
||||
legendSeries,
|
||||
tableColumns,
|
||||
signal,
|
||||
panelKind,
|
||||
onChangePanelKind,
|
||||
queryType,
|
||||
stepInterval,
|
||||
}: SectionSlotProps): JSX.Element | null {
|
||||
// A kind can hide a section based on current spec state (e.g. Histogram legend once
|
||||
// queries are merged) — skip it before resolving the editor.
|
||||
@@ -56,12 +60,7 @@ function SectionSlot({
|
||||
.formatting?.unit;
|
||||
|
||||
return (
|
||||
<SettingsSection
|
||||
title={title}
|
||||
icon={<Icon size={15} />}
|
||||
// Open Visualization by default so the type switcher is visible.
|
||||
defaultOpen={config.kind === SectionKind.Visualization}
|
||||
>
|
||||
<SettingsSection title={title} icon={<Icon size={15} />}>
|
||||
<Component
|
||||
value={get(spec)}
|
||||
controls={controls}
|
||||
@@ -70,10 +69,6 @@ function SectionSlot({
|
||||
yAxisUnit={yAxisUnit}
|
||||
tableColumns={tableColumns}
|
||||
signal={signal}
|
||||
panelKind={panelKind}
|
||||
onChangePanelKind={onChangePanelKind}
|
||||
queryType={queryType}
|
||||
stepInterval={stepInterval}
|
||||
/>
|
||||
</SettingsSection>
|
||||
);
|
||||
|
||||
@@ -26,15 +26,13 @@ function SettingsSection({
|
||||
}: SettingsSectionProps): JSX.Element {
|
||||
const [isOpen, setIsOpen] = useState(defaultOpen);
|
||||
|
||||
const serializedTitle = title.toLowerCase().replace(/\s+/g, '-');
|
||||
|
||||
return (
|
||||
<section className={styles.section}>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.header}
|
||||
aria-expanded={isOpen}
|
||||
data-testid={`config-section-${serializedTitle}`}
|
||||
data-testid={`config-section-${title}`}
|
||||
onClick={(): void => setIsOpen((prev) => !prev)}
|
||||
>
|
||||
{icon && (
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { fireEvent, render, screen } from '@testing-library/react';
|
||||
import type { DashboardtypesPanelSpecDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { EQueryType } from 'types/common/dashboard';
|
||||
|
||||
import ConfigPane from '../ConfigPane';
|
||||
|
||||
@@ -22,8 +21,6 @@ function renderConfigPane(
|
||||
panelKind: 'signoz/TimeSeriesPanel',
|
||||
spec: spec(),
|
||||
onChangeSpec: jest.fn(),
|
||||
onChangePanelKind: jest.fn(),
|
||||
queryType: EQueryType.QUERY_BUILDER,
|
||||
legendSeries: [],
|
||||
tableColumns: [],
|
||||
...overrides,
|
||||
@@ -59,8 +56,6 @@ describe('ConfigPane', () => {
|
||||
it('renders the Formatting section for a kind that declares it', () => {
|
||||
renderConfigPane();
|
||||
// The TimeSeries kind declares a Formatting section; its collapsible header shows.
|
||||
expect(
|
||||
screen.getByTestId('config-section-formatting-&-units'),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByTestId('config-section-Formatting')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
.group {
|
||||
width: 100%;
|
||||
width: min(350px, 100%);
|
||||
}
|
||||
|
||||
.segment {
|
||||
|
||||
@@ -10,11 +10,11 @@ export interface ConfigSegmentedItem {
|
||||
icon?: SegmentIconName;
|
||||
}
|
||||
|
||||
interface ConfigSegmentedProps<T extends string = string> {
|
||||
interface ConfigSegmentedProps {
|
||||
testId: string;
|
||||
value: T | undefined;
|
||||
value: string | undefined;
|
||||
items: ConfigSegmentedItem[];
|
||||
onChange: (value: T) => void;
|
||||
onChange: (value: string) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -23,12 +23,12 @@ interface ConfigSegmentedProps<T extends string = string> {
|
||||
* brightens with the selected state (it inherits the toggle's `currentColor`). Built on
|
||||
* the Periscope ToggleGroup so it stays theme-faithful.
|
||||
*/
|
||||
function ConfigSegmented<T extends string = string>({
|
||||
function ConfigSegmented({
|
||||
testId,
|
||||
value,
|
||||
items,
|
||||
onChange,
|
||||
}: ConfigSegmentedProps<T>): JSX.Element {
|
||||
}: ConfigSegmentedProps): JSX.Element {
|
||||
return (
|
||||
<ToggleGroupSimple
|
||||
type="single"
|
||||
@@ -47,7 +47,7 @@ function ConfigSegmented<T extends string = string>({
|
||||
}))}
|
||||
// Single toggle-groups emit '' when the active segment is re-clicked; ignore that
|
||||
// so a required choice (e.g. scale, position) can't be cleared to an empty value.
|
||||
onChange={(next: T): void => {
|
||||
onChange={(next: string): void => {
|
||||
if (next) {
|
||||
onChange(next);
|
||||
}
|
||||
|
||||
@@ -8,11 +8,3 @@
|
||||
align-items: center;
|
||||
gap: 9px;
|
||||
}
|
||||
|
||||
// Wraps a tooltip-bearing option so the hover target fills the row and still receives
|
||||
// pointer events when the option is disabled (antd dims it but doesn't block events).
|
||||
.tooltipTrigger {
|
||||
display: block;
|
||||
width: 100%;
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
@@ -1,24 +1,21 @@
|
||||
import type { ReactNode } from 'react';
|
||||
import { Select, Tooltip } from 'antd';
|
||||
import { Select } from 'antd';
|
||||
|
||||
import { SegmentIcon, type SegmentIconName } from '../segmentIcons';
|
||||
|
||||
import styles from './ConfigSelect.module.scss';
|
||||
|
||||
export interface ConfigSelectItem<T extends string = string> {
|
||||
value: T;
|
||||
export interface ConfigSelectItem {
|
||||
value: string;
|
||||
label: string;
|
||||
/** Optional leading icon node rendered before the label. */
|
||||
icon?: ReactNode;
|
||||
disabled?: boolean;
|
||||
/** Hover hint shown on the option — typically the reason a disabled item is disabled. */
|
||||
tooltip?: string;
|
||||
icon?: SegmentIconName;
|
||||
}
|
||||
|
||||
interface ConfigSelectProps<T extends string = string> {
|
||||
interface ConfigSelectProps {
|
||||
testId: string;
|
||||
value: T | undefined;
|
||||
value: string | undefined;
|
||||
placeholder?: string;
|
||||
items: ConfigSelectItem<T>[];
|
||||
onChange: (value: T) => void;
|
||||
items: ConfigSelectItem[];
|
||||
onChange: (value: string) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -26,42 +23,32 @@ interface ConfigSelectProps<T extends string = string> {
|
||||
* `Select` so it matches the rest of the editor's antd controls; the menu portals to
|
||||
* `document.body` (antd default) so the surrounding `overflow:auto` pane can't clip it.
|
||||
*/
|
||||
function ConfigSelect<T extends string = string>({
|
||||
function ConfigSelect({
|
||||
testId,
|
||||
value,
|
||||
placeholder,
|
||||
items,
|
||||
onChange,
|
||||
}: ConfigSelectProps<T>): JSX.Element {
|
||||
}: ConfigSelectProps): JSX.Element {
|
||||
return (
|
||||
<Select<T>
|
||||
<Select<string>
|
||||
className={styles.select}
|
||||
data-testid={testId}
|
||||
value={value}
|
||||
placeholder={placeholder}
|
||||
onChange={onChange}
|
||||
virtual={false}
|
||||
options={items.map((item) => {
|
||||
const content = item.icon ? (
|
||||
options={items.map((item) => ({
|
||||
value: item.value,
|
||||
label: item.icon ? (
|
||||
<span className={styles.item}>
|
||||
{item.icon}
|
||||
<SegmentIcon name={item.icon} />
|
||||
{item.label}
|
||||
</span>
|
||||
) : (
|
||||
item.label
|
||||
);
|
||||
return {
|
||||
value: item.value,
|
||||
disabled: item.disabled,
|
||||
label: item.tooltip ? (
|
||||
<Tooltip title={item.tooltip} placement="top">
|
||||
<span className={styles.tooltipTrigger}>{content}</span>
|
||||
</Tooltip>
|
||||
) : (
|
||||
content
|
||||
),
|
||||
};
|
||||
})}
|
||||
),
|
||||
}))}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||