diff --git a/README.de-de.md b/README.de-de.md index 6587756b9b..5c61097a15 100644 --- a/README.de-de.md +++ b/README.de-de.md @@ -1,40 +1,75 @@

SigNoz-logo - +

Überwache deine Anwendungen und behebe Probleme in deinen bereitgestellten Anwendungen. SigNoz ist eine Open Source Alternative zu DataDog, New Relic, etc.

- Downloads + Downloads GitHub issues - - tweet + + tweet

- - + +

Dokumentation • + Readme auf Englisch ReadMe auf ChinesischReadMe auf PortugiesischSlack Community • - Twitter + Twitter

-## +## -SigNoz hilft Entwicklern, Anwendungen zu überwachen und Probleme in ihren bereitgestellten Anwendungen zu beheben. SigNoz benutzt verteilte Einzelschritt-Fehlersuchen, um Einblick in deinen Software-Stack zu bekommen. +SigNoz hilft Entwicklern, Anwendungen zu überwachen und Probleme in ihren bereitgestellten Anwendungen zu beheben. Mit SigNoz können Sie Folgendes tun: -👉 Du kannst Werte wie die P99-Latenz und die Fehler Häufigkeit von deinen Services, externen API Aufrufen und einzelnen Endpunkten sehen. +👉 Visualisieren Sie Metriken, Traces und Logs in einer einzigen Oberfläche. -👉 Du kannst die Ursache des Problems finden, indem du zu dem Einzelschritt gehst, der das Problem verursacht und dir detaillierte Flamegraphs von einzelnen Abfragefehlersuchen anzeigen lassen. +👉 Sie können Metriken wie die p99-Latenz, Fehlerquoten für Ihre Dienste, externe API-Aufrufe und individuelle Endpunkte anzeigen. -👉 Erstelle Aggregate auf Basis von Fehlersuche Daten, um geschäftsrelevante Metriken zu erhalten. +👉 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. + +👉 Führen Sie Aggregationen auf Trace-Daten durch, um geschäftsrelevante Metriken zu erhalten. + +👉 Filtern und Abfragen von Logs, Erstellen von Dashboards und Benachrichtigungen basierend auf Attributen in den Logs. + +👉 Automatische Aufzeichnung von Ausnahmen in Python, Java, Ruby und Javascript. + +👉 Einfache Einrichtung von Benachrichtigungen mit dem selbst erstellbaren Abfrage-Builder. + +## +### Anwendung Metriken + +![application_metrics](https://user-images.githubusercontent.com/83692067/226637410-900dbc5e-6705-4b11-a10c-bd0faeb2a92f.png) + + +### Verteiltes Tracing +distributed_tracing_2 2 + +distributed_tracing_1 + +### Log Verwaltung + +logs_management + +### Infrastruktur Überwachung + +infrastructure_monitoring + +### Exceptions Monitoring + +![exceptions_light](https://user-images.githubusercontent.com/83692067/226637967-4188d024-3ac9-4799-be95-f5ea9c45436f.png) + + +### Alarme + +alerts_management -![SigNoz Feature](https://signoz-public.s3.us-east-2.amazonaws.com/signoz_hero_github.png)

- ## Werde Teil unserer Slack Community @@ -42,20 +77,22 @@ Sag Hi zu uns auf [Slack](https://signoz.io/slack) 👋

- ## Funktionen: -- Übersichtsmetriken deiner Anwendung wie RPS, 50tes/90tes/99tes Quantil Latenzen und Fehler Häufigkeiten. -- Übersicht der langsamsten Endpunkte deiner Anwendung. -- Sieh dir die genaue Einzelschritt-Fehlersuche deiner Abfrage an, um Fehler in nachgelagerten Diensten, langsamen Datenbank Abfragen und Aufrufen von Drittanbieter Diensten wie Zahlungsportalen, etc. zu finden. -- Filtere Einzelschritt-Fehlersuchen nach Dienstname, Latenz, Fehler, Stichworten/ Anmerkungen. -- Führe Aggregate auf Basis von Einzelschritt-Fehlersuche Daten (Ereignisse/Abstände) aus, um geschäftsrelevante Metriken zu erhalten. Du kannst dir z. B. die Fehlerrate und 99tes Quantil Latenz von `customer_type: gold`, `deployment_version: v2` oder `external_call: paypal` ausgeben lassen. -- Einheitliche Benutzeroberfläche für Metriken und Einzelschritt-Fehlersuchen. Du musst nicht zwischen Prometheus und Jaeger hin und her wechseln, um Fehler zu beheben. +- 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.

- ## Wieso SigNoz? @@ -65,24 +102,28 @@ Wir wollten eine selbst gehostete, Open Source Variante von Lösungen wie DataDo 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. -### Unterstützte Programmiersprachen: +### Languages supported: -Wir unterstützen [OpenTelemetry](https://opentelemetry.io) als die Software Library, die du nutzen kannst um deine Anwendungen auszuführen. Jedes Framework und jede Sprache die von OpenTelemetry unterstützt wird, wird auch von SigNoz unterstützt. Einige der unterstützten, größeren Programmiersprachen sind: +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: - Java - Python - NodeJS - Go +- PHP +- .NET +- Ruby +- Elixir +- Rust + Hier findest du die vollständige Liste von unterstützten Programmiersprachen - https://opentelemetry.io/docs/

- ## Erste Schritte mit SigNoz - - + ### Bereitstellung mit Docker Bitte folge den [hier](https://signoz.io/docs/install/docker/) aufgelisteten Schritten um deine Anwendung mit Docker bereitzustellen. @@ -90,20 +131,17 @@ Bitte folge den [hier](https://signoz.io/docs/install/docker/) aufgelisteten Sch Die [Anleitungen zur Fehlerbehebung](https://signoz.io/docs/install/troubleshooting/) könnten hilfreich sein, falls du auf irgendwelche Schwierigkeiten stößt.

 

- - -### Bereitstellung mit Kubernetes und Helm + +### Deploy in Kubernetes using Helm Bitte folge den [hier](https://signoz.io/docs/deployment/helm_chart) aufgelisteten Schritten, um deine Anwendung mit Helm Charts bereitzustellen. -

- -## Vergleiche mit anderen Lösungen +## Vergleiche mit bekannten Tools -### SigNoz vs. Prometheus +### SigNoz vs Prometheus 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. @@ -111,49 +149,79 @@ Unser Ziel ist es, eine integrierte Benutzeroberfläche aus Metriken und Einzels

 

-### SigNoz vs. Jaeger +### SigNoz vs Jaeger 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. Außerdem hat SigNoz noch mehr spezielle Funktionen im Vergleich zu Jaeger: -- 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. +- 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. + +

 

+ +### SigNoz vs Elastic + +- 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. + +Wir haben Benchmarks veröffentlicht, die Elastic mit SignNoz vergleichen. Schauen Sie es sich [hier](https://signoz.io/blog/logs-performance-benchmark/?utm_source=github-readme&utm_medium=logs-benchmark) + +

 

+ +### SigNoz vs Loki + +- 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. + +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)

- ## Zum Projekt beitragen +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) -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. +### Unsere Projektbetreuer -Du bist dir nicht sicher, wie du anfangen sollst? Schreib uns einfach auf dem `#contributing` Kanal in unserer [Slack Community](https://signoz.io/slack). +#### Backend + +- [Ankit Nayan](https://github.com/ankitnayan) +- [Nityananda Gohain](https://github.com/nityanandagohain) +- [Srikanth Chekuri](https://github.com/srikanthccv) +- [Vishal Sharma](https://github.com/makeavish) + +#### Frontend + +- [Palash Gupta](https://github.com/palashgdev) + +#### DevOps + +- [Prashant Shahi](https://github.com/prashant-shahi)

- ## 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. + +

- -## Community +## 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. +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, danke an unsere großartigen Unterstützer! +Wie immer, Dank an unsere großartigen Mitwirkenden! - - - diff --git a/deploy/docker-swarm/clickhouse-setup/docker-compose.yaml b/deploy/docker-swarm/clickhouse-setup/docker-compose.yaml index 34c4073389..db2f162718 100644 --- a/deploy/docker-swarm/clickhouse-setup/docker-compose.yaml +++ b/deploy/docker-swarm/clickhouse-setup/docker-compose.yaml @@ -137,7 +137,7 @@ services: condition: on-failure query-service: - image: signoz/query-service:0.25.0 + image: signoz/query-service:0.25.1 command: ["-config=/root/config/prometheus.yml"] # ports: # - "6060:6060" # pprof port @@ -166,7 +166,7 @@ services: <<: *clickhouse-depend frontend: - image: signoz/frontend:0.25.0 + image: signoz/frontend:0.25.1 deploy: restart_policy: condition: on-failure diff --git a/deploy/docker/clickhouse-setup/docker-compose.yaml b/deploy/docker/clickhouse-setup/docker-compose.yaml index 6998e59236..1c5839f232 100644 --- a/deploy/docker/clickhouse-setup/docker-compose.yaml +++ b/deploy/docker/clickhouse-setup/docker-compose.yaml @@ -153,7 +153,7 @@ services: # Notes for Maintainers/Contributors who will change Line Numbers of Frontend & Query-Section. Please Update Line Numbers in `./scripts/commentLinesForSetup.sh` & `./CONTRIBUTING.md` query-service: - image: signoz/query-service:${DOCKER_TAG:-0.25.0} + image: signoz/query-service:${DOCKER_TAG:-0.25.1} container_name: query-service command: ["-config=/root/config/prometheus.yml"] # ports: @@ -181,7 +181,7 @@ services: <<: *clickhouse-depend frontend: - image: signoz/frontend:${DOCKER_TAG:-0.25.0} + image: signoz/frontend:${DOCKER_TAG:-0.25.1} container_name: frontend restart: on-failure depends_on: diff --git a/ee/query-service/app/api/api.go b/ee/query-service/app/api/api.go index c9d839fd39..89a9ed24cb 100644 --- a/ee/query-service/app/api/api.go +++ b/ee/query-service/app/api/api.go @@ -8,6 +8,7 @@ import ( "go.signoz.io/signoz/ee/query-service/interfaces" "go.signoz.io/signoz/ee/query-service/license" baseapp "go.signoz.io/signoz/pkg/query-service/app" + "go.signoz.io/signoz/pkg/query-service/app/logparsingpipeline" baseint "go.signoz.io/signoz/pkg/query-service/interfaces" basemodel "go.signoz.io/signoz/pkg/query-service/model" rules "go.signoz.io/signoz/pkg/query-service/rules" @@ -15,14 +16,15 @@ import ( ) type APIHandlerOptions struct { - DataConnector interfaces.DataConnector - SkipConfig *basemodel.SkipConfig - PreferDelta bool - PreferSpanMetrics bool - AppDao dao.ModelDao - RulesManager *rules.Manager - FeatureFlags baseint.FeatureLookup - LicenseManager *license.Manager + DataConnector interfaces.DataConnector + SkipConfig *basemodel.SkipConfig + PreferDelta bool + PreferSpanMetrics bool + AppDao dao.ModelDao + RulesManager *rules.Manager + FeatureFlags baseint.FeatureLookup + LicenseManager *license.Manager + LogsParsingPipelineController *logparsingpipeline.LogParsingPipelineController } type APIHandler struct { @@ -34,13 +36,15 @@ type APIHandler struct { func NewAPIHandler(opts APIHandlerOptions) (*APIHandler, error) { baseHandler, err := baseapp.NewAPIHandler(baseapp.APIHandlerOpts{ - Reader: opts.DataConnector, - SkipConfig: opts.SkipConfig, - PerferDelta: opts.PreferDelta, - PreferSpanMetrics: opts.PreferSpanMetrics, - AppDao: opts.AppDao, - RuleManager: opts.RulesManager, - FeatureFlags: opts.FeatureFlags}) + Reader: opts.DataConnector, + SkipConfig: opts.SkipConfig, + PerferDelta: opts.PreferDelta, + PreferSpanMetrics: opts.PreferSpanMetrics, + AppDao: opts.AppDao, + RuleManager: opts.RulesManager, + FeatureFlags: opts.FeatureFlags, + LogsParsingPipelineController: opts.LogsParsingPipelineController, + }) if err != nil { return nil, err diff --git a/ee/query-service/app/server.go b/ee/query-service/app/server.go index a74738eef5..497100e573 100644 --- a/ee/query-service/app/server.go +++ b/ee/query-service/app/server.go @@ -31,6 +31,7 @@ import ( baseapp "go.signoz.io/signoz/pkg/query-service/app" "go.signoz.io/signoz/pkg/query-service/app/dashboards" baseexplorer "go.signoz.io/signoz/pkg/query-service/app/explorer" + "go.signoz.io/signoz/pkg/query-service/app/logparsingpipeline" "go.signoz.io/signoz/pkg/query-service/app/opamp" opAmpModel "go.signoz.io/signoz/pkg/query-service/app/opamp/model" baseauth "go.signoz.io/signoz/pkg/query-service/auth" @@ -78,6 +79,9 @@ type Server struct { // feature flags featureLookup baseint.FeatureLookup + // Usage manager + usageManager *usage.Manager + unavailableChannel chan healthcheck.Status } @@ -157,6 +161,12 @@ func NewServer(serverOptions *ServerOptions) (*Server, error) { return nil, err } + // ingestion pipelines manager + logParsingPipelineController, err := logparsingpipeline.NewLogParsingPipelinesController(localDB, "sqlite") + if err != nil { + return nil, err + } + // start the usagemanager usageManager, err := usage.New("sqlite", localDB, lm.GetRepo(), reader.GetConn()) if err != nil { @@ -170,14 +180,15 @@ func NewServer(serverOptions *ServerOptions) (*Server, error) { telemetry.GetInstance().SetReader(reader) apiOpts := api.APIHandlerOptions{ - DataConnector: reader, - SkipConfig: skipConfig, - PreferDelta: serverOptions.PreferDelta, - PreferSpanMetrics: serverOptions.PreferSpanMetrics, - AppDao: modelDao, - RulesManager: rm, - FeatureFlags: lm, - LicenseManager: lm, + DataConnector: reader, + SkipConfig: skipConfig, + PreferDelta: serverOptions.PreferDelta, + PreferSpanMetrics: serverOptions.PreferSpanMetrics, + AppDao: modelDao, + RulesManager: rm, + FeatureFlags: lm, + LicenseManager: lm, + LogsParsingPipelineController: logParsingPipelineController, } apiHandler, err := api.NewAPIHandler(apiOpts) @@ -191,6 +202,7 @@ func NewServer(serverOptions *ServerOptions) (*Server, error) { ruleManager: rm, serverOptions: serverOptions, unavailableChannel: make(chan healthcheck.Status), + usageManager: usageManager, } httpServer, err := s.createPublicServer(apiHandler) @@ -552,6 +564,9 @@ func (s *Server) Stop() error { s.ruleManager.Stop() } + // stop usage manager + s.usageManager.Stop() + return nil } diff --git a/ee/query-service/main.go b/ee/query-service/main.go index 08a807c861..ffd439cd32 100644 --- a/ee/query-service/main.go +++ b/ee/query-service/main.go @@ -144,6 +144,7 @@ func main() { logger.Info("Received HealthCheck status: ", zap.Int("status", int(status))) case <-signalsChannel: logger.Fatal("Received OS Interrupt Signal ... ") + server.Stop() } } } diff --git a/ee/query-service/usage/manager.go b/ee/query-service/usage/manager.go index 4f361d4651..335eae8143 100644 --- a/ee/query-service/usage/manager.go +++ b/ee/query-service/usage/manager.go @@ -9,6 +9,7 @@ import ( "time" "github.com/ClickHouse/clickhouse-go/v2" + "github.com/go-co-op/gocron" "github.com/google/uuid" "github.com/jmoiron/sqlx" @@ -28,9 +29,6 @@ const ( ) var ( - // send usage every 24 hour - uploadFrequency = 24 * time.Hour - locker = stateUnlocked ) @@ -39,12 +37,7 @@ type Manager struct { licenseRepo *license.Repo - // end the usage routine, this is important to gracefully - // stopping usage reporting and protect in-consistent updates - done chan struct{} - - // terminated waits for the UsageExporter go routine to end - terminated chan struct{} + scheduler *gocron.Scheduler } func New(dbType string, db *sqlx.DB, licenseRepo *license.Repo, clickhouseConn clickhouse.Conn) (*Manager, error) { @@ -53,6 +46,7 @@ func New(dbType string, db *sqlx.DB, licenseRepo *license.Repo, clickhouseConn c // repository: repo, clickhouseConn: clickhouseConn, licenseRepo: licenseRepo, + scheduler: gocron.NewScheduler(time.UTC).Every(1).Day().At("00:00"), // send usage every at 00:00 UTC } return m, nil } @@ -64,37 +58,30 @@ func (lm *Manager) Start() error { return fmt.Errorf("usage exporter is locked") } - go lm.UsageExporter(context.Background()) + _, err := lm.scheduler.Do(func() { lm.UploadUsage() }) + if err != nil { + return err + } + + // upload usage once when starting the service + lm.UploadUsage() + + lm.scheduler.StartAsync() return nil } - -func (lm *Manager) UsageExporter(ctx context.Context) { - defer close(lm.terminated) - - uploadTicker := time.NewTicker(uploadFrequency) - defer uploadTicker.Stop() - - for { - select { - case <-lm.done: - return - case <-uploadTicker.C: - lm.UploadUsage(ctx) - } - } -} - -func (lm *Manager) UploadUsage(ctx context.Context) error { +func (lm *Manager) UploadUsage() { + ctx := context.Background() // check if license is present or not - license, err := lm.licenseRepo.GetActiveLicense(context.Background()) + license, err := lm.licenseRepo.GetActiveLicense(ctx) if err != nil { - return fmt.Errorf("failed to get active license") + zap.S().Errorf("failed to get active license: %v", zap.Error(err)) + return } if license == nil { // we will not start the usage reporting if license is not present. zap.S().Info("no license present, skipping usage reporting") - return nil + return } usages := []model.UsageDB{} @@ -120,7 +107,8 @@ func (lm *Manager) UploadUsage(ctx context.Context) error { dbusages := []model.UsageDB{} err := lm.clickhouseConn.Select(ctx, &dbusages, fmt.Sprintf(query, db, db), time.Now().Add(-(24 * time.Hour))) if err != nil && !strings.Contains(err.Error(), "doesn't exist") { - return err + zap.S().Errorf("failed to get usage from clickhouse: %v", zap.Error(err)) + return } for _, u := range dbusages { u.Type = db @@ -130,7 +118,7 @@ func (lm *Manager) UploadUsage(ctx context.Context) error { if len(usages) <= 0 { zap.S().Info("no snapshots to upload, skipping.") - return nil + return } zap.S().Info("uploading usage data") @@ -139,13 +127,15 @@ func (lm *Manager) UploadUsage(ctx context.Context) error { for _, usage := range usages { usageDataBytes, err := encryption.Decrypt([]byte(usage.ExporterID[:32]), []byte(usage.Data)) if err != nil { - return err + zap.S().Errorf("error while decrypting usage data: %v", zap.Error(err)) + return } usageData := model.Usage{} err = json.Unmarshal(usageDataBytes, &usageData) if err != nil { - return err + zap.S().Errorf("error while unmarshalling usage data: %v", zap.Error(err)) + return } usageData.CollectorID = usage.CollectorID @@ -160,20 +150,16 @@ func (lm *Manager) UploadUsage(ctx context.Context) error { LicenseKey: key, Usage: usagesPayload, } - err = lm.UploadUsageWithExponentalBackOff(ctx, payload) - if err != nil { - return err - } - return nil + lm.UploadUsageWithExponentalBackOff(ctx, payload) } -func (lm *Manager) UploadUsageWithExponentalBackOff(ctx context.Context, payload model.UsagePayload) error { +func (lm *Manager) UploadUsageWithExponentalBackOff(ctx context.Context, payload model.UsagePayload) { for i := 1; i <= MaxRetries; i++ { apiErr := licenseserver.SendUsage(ctx, payload) if apiErr != nil && i == MaxRetries { zap.S().Errorf("retries stopped : %v", zap.Error(apiErr)) // not returning error here since it is captured in the failed count - return nil + return } else if apiErr != nil { // sleeping for exponential backoff sleepDuration := RetryInterval * time.Duration(i) @@ -183,11 +169,14 @@ func (lm *Manager) UploadUsageWithExponentalBackOff(ctx context.Context, payload break } } - return nil } func (lm *Manager) Stop() { - close(lm.done) + lm.scheduler.Stop() + + zap.S().Debug("sending usage data before shutting down") + // send usage before shutting down + lm.UploadUsage() + atomic.StoreUint32(&locker, stateUnlocked) - <-lm.terminated } diff --git a/frontend/jest.config.ts b/frontend/jest.config.ts index 51d6776579..00bd8505c8 100644 --- a/frontend/jest.config.ts +++ b/frontend/jest.config.ts @@ -21,7 +21,9 @@ const config: Config.InitialOptions = { '^.+\\.(ts|tsx)?$': 'ts-jest', '^.+\\.(js|jsx)$': 'babel-jest', }, - transformIgnorePatterns: ['node_modules/(?!(lodash-es)/)'], + transformIgnorePatterns: [ + 'node_modules/(?!(lodash-es|react-dnd|core-dnd|@react-dnd|dnd-core|react-dnd-html5-backend)/)', + ], setupFilesAfterEnv: ['jest.setup.ts'], testPathIgnorePatterns: ['/node_modules/', '/public/'], moduleDirectories: ['node_modules', 'src'], diff --git a/frontend/package.json b/frontend/package.json index c6208a9a82..9ea7ddd00a 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -46,6 +46,7 @@ "chartjs-adapter-date-fns": "^2.0.0", "chartjs-plugin-annotation": "^1.4.0", "color": "^4.2.1", + "color-alpha": "1.1.3", "cross-env": "^7.0.3", "css-loader": "4.3.0", "css-minimizer-webpack-plugin": "^3.2.0", @@ -53,6 +54,7 @@ "dompurify": "3.0.0", "dotenv": "8.2.0", "event-source-polyfill": "1.0.31", + "eventemitter3": "5.0.1", "file-loader": "6.1.1", "fontfaceobserver": "2.3.0", "history": "4.10.1", @@ -68,10 +70,14 @@ "mini-css-extract-plugin": "2.4.5", "papaparse": "5.4.1", "react": "18.2.0", + "react-addons-update": "15.6.3", + "react-dnd": "16.0.1", + "react-dnd-html5-backend": "16.0.1", "react-dom": "18.2.0", "react-drag-listview": "2.0.0", "react-force-graph": "^1.41.0", "react-grid-layout": "^1.3.4", + "react-helmet-async": "1.3.0", "react-i18next": "^11.16.1", "react-intersection-observer": "9.4.1", "react-query": "^3.34.19", @@ -133,8 +139,10 @@ "@types/node": "^16.10.3", "@types/papaparse": "5.3.7", "@types/react": "18.0.26", + "@types/react-addons-update": "0.14.21", "@types/react-dom": "18.0.10", "@types/react-grid-layout": "^1.1.2", + "@types/react-helmet-async": "1.0.3", "@types/react-redux": "^7.1.11", "@types/react-resizable": "3.0.3", "@types/react-router-dom": "^5.1.6", diff --git a/frontend/public/locales/en-GB/routes.json b/frontend/public/locales/en-GB/routes.json index 625638d85f..ac17990f3b 100644 --- a/frontend/public/locales/en-GB/routes.json +++ b/frontend/public/locales/en-GB/routes.json @@ -5,5 +5,8 @@ "my_settings": "My Settings", "overview_metrics": "Overview Metrics", "dbcall_metrics": "Database Calls", - "external_metrics": "External Calls" + "external_metrics": "External Calls", + "pipelines": "Pipelines", + "archives": "Archives", + "logs_to_metrics": "Logs To Metrics" } diff --git a/frontend/public/locales/en-GB/titles.json b/frontend/public/locales/en-GB/titles.json new file mode 100644 index 0000000000..53ac325f11 --- /dev/null +++ b/frontend/public/locales/en-GB/titles.json @@ -0,0 +1,36 @@ +{ + "SIGN_UP": "SigNoz | Sign Up", + "LOGIN": "SigNoz | Login", + "SERVICE_METRICS": "SigNoz | Service Metrics", + "SERVICE_MAP": "SigNoz | Service Map", + "TRACE": "SigNoz | Trace", + "TRACE_DETAIL": "SigNoz | Trace Detail", + "TRACES_EXPLORER": "SigNoz | Traces Explorer", + "SETTINGS": "SigNoz | Settings", + "INSTRUMENTATION": "SigNoz | Get Started", + "USAGE_EXPLORER": "SigNoz | Usage Explorer", + "APPLICATION": "SigNoz | Home", + "ALL_DASHBOARD": "SigNoz | All Dashboards", + "DASHBOARD": "SigNoz | Dashboard", + "DASHBOARD_WIDGET": "SigNoz | Dashboard Widget", + "EDIT_ALERTS": "SigNoz | Edit Alerts", + "LIST_ALL_ALERT": "SigNoz | All Alerts", + "ALERTS_NEW": "SigNoz | New Alert", + "ALL_CHANNELS": "SigNoz | All Channels", + "CHANNELS_NEW": "SigNoz | New Channel", + "CHANNELS_EDIT": "SigNoz | Edit Channel", + "ALL_ERROR": "SigNoz | All Errors", + "ERROR_DETAIL": "SigNoz | Error Detail", + "VERSION": "SigNoz | Version", + "MY_SETTINGS": "SigNoz | My Settings", + "ORG_SETTINGS": "SigNoz | Organization Settings", + "SOMETHING_WENT_WRONG": "SigNoz | Something Went Wrong", + "UN_AUTHORIZED": "SigNoz | Unauthorized", + "NOT_FOUND": "SigNoz | Page Not Found", + "LOGS": "SigNoz | Logs", + "LOGS_EXPLORER": "SigNoz | Logs Explorer", + "HOME_PAGE": "Open source Observability Platform | SigNoz", + "PASSWORD_RESET": "SigNoz | Password Reset", + "LIST_LICENSES": "SigNoz | List of Licenses", + "DEFAULT": "Open source Observability Platform | SigNoz" +} diff --git a/frontend/public/locales/en-GB/translation.json b/frontend/public/locales/en-GB/translation.json index c37480259b..33442c8369 100644 --- a/frontend/public/locales/en-GB/translation.json +++ b/frontend/public/locales/en-GB/translation.json @@ -12,6 +12,7 @@ "routes": { "general": "General", "alert_channels": "Alert Channels", - "all_errors": "All Exceptions" + "all_errors": "All Exceptions", + "pipelines": "Pipelines" } } diff --git a/frontend/public/locales/en/pipeline.json b/frontend/public/locales/en/pipeline.json new file mode 100644 index 0000000000..515ce19003 --- /dev/null +++ b/frontend/public/locales/en/pipeline.json @@ -0,0 +1,44 @@ +{ + "delete": "Delete", + "filter": "Filter", + "update": "Update", + "create": "Create", + "reorder": "Reorder", + "cancel": "Cancel", + "reorder_pipeline": "Do you want to reorder pipeline?", + "reorder_pipeline_description": "Logs are processed sequentially in processors and pipelines. Reordering it may change how data is processed by them.", + "delete_pipeline": "Do you want to delete pipeline", + "delete_pipeline_description": "Logs are processed sequentially in processors and pipelines. Deleting a pipeline may change content of data processed by other pipelines & processors", + "add_new_pipeline": "Add a New Pipeline", + "new_pipeline": "New Pipeline", + "enter_edit_mode": "Enter Edit Mode", + "save_configuration": "Save Configuration", + "edit_pipeline": "Edit Pipeline", + "create_pipeline": "Create New Pipeline", + "add_new_processor": "Add Processor", + "edit_processor": "Edit Processor", + "create_processor": "Create New Processor", + "processor_type": "Select Processor Type", + "reorder_processor": "Do you want to reorder processor?", + "reorder_processor_description": "Logs are processed sequentially in processors. Reordering it may change how data is processed by them.", + "delete_processor": "Do you want to delete processor", + "delete_processor_description": "Logs are processed sequentially in processors. Deleting a processor may change content of data processed by other processors", + "search_pipeline_placeholder": "Filter Pipelines", + "pipeline_name_placeholder": "Name", + "pipeline_tags_placeholder": "Tags", + "pipeline_description_placeholder": "Enter description for your pipeline", + "processor_name_placeholder": "Name", + "processor_regex_placeholder": "Regex", + "processor_parsefrom_placeholder": "Parse From", + "processor_parseto_placeholder": "Parse From", + "processor_onerror_placeholder": "on Error", + "processor_pattern_placeholder": "Pattern", + "processor_field_placeholder": "Field", + "processor_value_placeholder": "Value", + "processor_description_placeholder": "example rule: %{word:first}", + "processor_trace_id_placeholder": "Trace Id Parce From", + "processor_span_id_placeholder": "Span id Parse From", + "processor_trace_flags_placeholder": "Trace flags parse from", + "processor_from_placeholder": "From", + "processor_to_placeholder": "To" +} diff --git a/frontend/public/locales/en/routes.json b/frontend/public/locales/en/routes.json index 625638d85f..ac17990f3b 100644 --- a/frontend/public/locales/en/routes.json +++ b/frontend/public/locales/en/routes.json @@ -5,5 +5,8 @@ "my_settings": "My Settings", "overview_metrics": "Overview Metrics", "dbcall_metrics": "Database Calls", - "external_metrics": "External Calls" + "external_metrics": "External Calls", + "pipelines": "Pipelines", + "archives": "Archives", + "logs_to_metrics": "Logs To Metrics" } diff --git a/frontend/public/locales/en/titles.json b/frontend/public/locales/en/titles.json new file mode 100644 index 0000000000..53ac325f11 --- /dev/null +++ b/frontend/public/locales/en/titles.json @@ -0,0 +1,36 @@ +{ + "SIGN_UP": "SigNoz | Sign Up", + "LOGIN": "SigNoz | Login", + "SERVICE_METRICS": "SigNoz | Service Metrics", + "SERVICE_MAP": "SigNoz | Service Map", + "TRACE": "SigNoz | Trace", + "TRACE_DETAIL": "SigNoz | Trace Detail", + "TRACES_EXPLORER": "SigNoz | Traces Explorer", + "SETTINGS": "SigNoz | Settings", + "INSTRUMENTATION": "SigNoz | Get Started", + "USAGE_EXPLORER": "SigNoz | Usage Explorer", + "APPLICATION": "SigNoz | Home", + "ALL_DASHBOARD": "SigNoz | All Dashboards", + "DASHBOARD": "SigNoz | Dashboard", + "DASHBOARD_WIDGET": "SigNoz | Dashboard Widget", + "EDIT_ALERTS": "SigNoz | Edit Alerts", + "LIST_ALL_ALERT": "SigNoz | All Alerts", + "ALERTS_NEW": "SigNoz | New Alert", + "ALL_CHANNELS": "SigNoz | All Channels", + "CHANNELS_NEW": "SigNoz | New Channel", + "CHANNELS_EDIT": "SigNoz | Edit Channel", + "ALL_ERROR": "SigNoz | All Errors", + "ERROR_DETAIL": "SigNoz | Error Detail", + "VERSION": "SigNoz | Version", + "MY_SETTINGS": "SigNoz | My Settings", + "ORG_SETTINGS": "SigNoz | Organization Settings", + "SOMETHING_WENT_WRONG": "SigNoz | Something Went Wrong", + "UN_AUTHORIZED": "SigNoz | Unauthorized", + "NOT_FOUND": "SigNoz | Page Not Found", + "LOGS": "SigNoz | Logs", + "LOGS_EXPLORER": "SigNoz | Logs Explorer", + "HOME_PAGE": "Open source Observability Platform | SigNoz", + "PASSWORD_RESET": "SigNoz | Password Reset", + "LIST_LICENSES": "SigNoz | List of Licenses", + "DEFAULT": "Open source Observability Platform | SigNoz" +} diff --git a/frontend/public/locales/en/translation.json b/frontend/public/locales/en/translation.json index a563ce92cb..39e7a79fda 100644 --- a/frontend/public/locales/en/translation.json +++ b/frontend/public/locales/en/translation.json @@ -12,6 +12,7 @@ "routes": { "general": "General", "alert_channels": "Alert Channels", - "all_errors": "All Exceptions" + "all_errors": "All Exceptions", + "pipelines": "Pipelines" } } diff --git a/frontend/src/AppRoutes/pageComponents.ts b/frontend/src/AppRoutes/pageComponents.ts index f3d40ccbcd..b993a4790a 100644 --- a/frontend/src/AppRoutes/pageComponents.ts +++ b/frontend/src/AppRoutes/pageComponents.ts @@ -132,3 +132,7 @@ export const SomethingWentWrong = Loadable( export const LicensePage = Loadable( () => import(/* webpackChunkName: "All Channels" */ 'pages/License'), ); + +export const PipelinePage = Loadable( + () => import(/* webpackChunkName: "Pipelines" */ 'pages/Pipelines'), +); diff --git a/frontend/src/AppRoutes/routes.ts b/frontend/src/AppRoutes/routes.ts index 49d48de066..ef7bd5d302 100644 --- a/frontend/src/AppRoutes/routes.ts +++ b/frontend/src/AppRoutes/routes.ts @@ -21,6 +21,7 @@ import { NewDashboardPage, OrganizationSettings, PasswordReset, + PipelinePage, ServiceMapPage, ServiceMetricsPage, ServicesTablePage, @@ -253,6 +254,13 @@ const routes: AppRoutes[] = [ key: 'SOMETHING_WENT_WRONG', isPrivate: false, }, + { + path: ROUTES.PIPELINES, + exact: true, + component: PipelinePage, + key: 'PIPELINES', + isPrivate: true, + }, ]; export interface AppRoutes { diff --git a/frontend/src/api/metrics/getQueryRange.ts b/frontend/src/api/metrics/getQueryRange.ts index 9a0a22bda7..bc70d19832 100644 --- a/frontend/src/api/metrics/getQueryRange.ts +++ b/frontend/src/api/metrics/getQueryRange.ts @@ -18,6 +18,7 @@ export const getMetricsQueryRange = async ( error: null, message: response.data.status, payload: response.data, + params: props, }; } catch (error) { return ErrorResponseHandler(error as AxiosError); diff --git a/frontend/src/api/pipeline/get.ts b/frontend/src/api/pipeline/get.ts new file mode 100644 index 0000000000..ff4dd7fc3d --- /dev/null +++ b/frontend/src/api/pipeline/get.ts @@ -0,0 +1,25 @@ +import axios from 'api'; +import { ErrorResponseHandler } from 'api/ErrorResponseHandler'; +import { AxiosError } from 'axios'; +import { ErrorResponse, SuccessResponse } from 'types/api'; +import { Pipeline } from 'types/api/pipeline/def'; +import { Props } from 'types/api/pipeline/get'; + +const get = async ( + props: Props, +): Promise | ErrorResponse> => { + try { + const response = await axios.get(`/logs/pipelines/${props.version}`); + + return { + statusCode: 200, + error: null, + message: response.data.status, + payload: response?.data?.data, + }; + } catch (error) { + return ErrorResponseHandler(error as AxiosError); + } +}; + +export default get; diff --git a/frontend/src/api/pipeline/post.ts b/frontend/src/api/pipeline/post.ts new file mode 100644 index 0000000000..c2e7ca2757 --- /dev/null +++ b/frontend/src/api/pipeline/post.ts @@ -0,0 +1,25 @@ +import axios from 'api'; +import { ErrorResponseHandler } from 'api/ErrorResponseHandler'; +import { AxiosError } from 'axios'; +import { ErrorResponse, SuccessResponse } from 'types/api'; +import { Pipeline } from 'types/api/pipeline/def'; +import { Props } from 'types/api/pipeline/post'; + +const post = async ( + props: Props, +): Promise | ErrorResponse> => { + try { + const response = await axios.post('/logs/pipelines', props.data); + + return { + statusCode: 200, + error: null, + message: response.data.status, + payload: response.data.data, + }; + } catch (error) { + return ErrorResponseHandler(error as AxiosError); + } +}; + +export default post; diff --git a/frontend/src/components/DraggableTableRow/index.tsx b/frontend/src/components/DraggableTableRow/index.tsx new file mode 100644 index 0000000000..7c4a48336b --- /dev/null +++ b/frontend/src/components/DraggableTableRow/index.tsx @@ -0,0 +1,54 @@ +import React, { useCallback, useRef } from 'react'; +import { useDrag, useDrop } from 'react-dnd'; + +import { dragHandler, dropHandler } from './utils'; + +const type = 'DraggableTableRow'; + +function DraggableTableRow({ + index, + moveRow, + className, + style, + ...restProps +}: DraggableTableRowProps): JSX.Element { + const ref = useRef(null); + + const handleDrop = useCallback( + (item: { index: number }) => { + if (moveRow) moveRow(item.index, index); + }, + [moveRow, index], + ); + + const [, drop] = useDrop({ + accept: type, + collect: dropHandler, + drop: handleDrop, + }); + + const [, drag] = useDrag({ + type, + item: { index }, + collect: dragHandler, + }); + drop(drag(ref)); + + return ( + + ); +} + +interface DraggableTableRowProps + extends React.HTMLAttributes { + index: number; + moveRow: (dragIndex: number, hoverIndex: number) => void; +} + +export default DraggableTableRow; diff --git a/frontend/src/components/DraggableTableRow/tests/DraggableTableRow.test.tsx b/frontend/src/components/DraggableTableRow/tests/DraggableTableRow.test.tsx new file mode 100644 index 0000000000..f938a19203 --- /dev/null +++ b/frontend/src/components/DraggableTableRow/tests/DraggableTableRow.test.tsx @@ -0,0 +1,38 @@ +import { render } from '@testing-library/react'; +import { Table } from 'antd'; +import { matchMedia } from 'container/PipelinePage/tests/AddNewPipeline.test'; +import { I18nextProvider } from 'react-i18next'; +import { Provider } from 'react-redux'; +import i18n from 'ReactI18'; +import store from 'store'; + +import DraggableTableRow from '..'; + +beforeAll(() => { + matchMedia(); +}); + +jest.mock('react-dnd', () => ({ + useDrop: jest.fn().mockImplementation(() => [jest.fn(), jest.fn(), jest.fn()]), + useDrag: jest.fn().mockImplementation(() => [jest.fn(), jest.fn(), jest.fn()]), +})); + +describe('DraggableTableRow Snapshot test', () => { + it('should render DraggableTableRow', async () => { + const { asFragment } = render( + + + + + , + ); + expect(asFragment()).toMatchSnapshot(); + }); +}); diff --git a/frontend/src/components/DraggableTableRow/tests/__snapshots__/DraggableTableRow.test.tsx.snap b/frontend/src/components/DraggableTableRow/tests/__snapshots__/DraggableTableRow.test.tsx.snap new file mode 100644 index 0000000000..4c70482cb6 --- /dev/null +++ b/frontend/src/components/DraggableTableRow/tests/__snapshots__/DraggableTableRow.test.tsx.snap @@ -0,0 +1,103 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`DraggableTableRow Snapshot test should render DraggableTableRow 1`] = ` + +
+
+
+
+
+
+
+ + + + + + + + + + +
+
+
+
+ + + + + + + + + +
+
+ No data +
+
+
+ + + + + + + +`; + +exports[`PipelinePage container test should render AddNewPipeline section 1`] = ``; diff --git a/frontend/src/components/DraggableTableRow/tests/utils.test.ts b/frontend/src/components/DraggableTableRow/tests/utils.test.ts new file mode 100644 index 0000000000..80854944c7 --- /dev/null +++ b/frontend/src/components/DraggableTableRow/tests/utils.test.ts @@ -0,0 +1,44 @@ +import { dragHandler, dropHandler } from '../utils'; + +jest.mock('react-dnd', () => ({ + useDrop: jest.fn().mockImplementation(() => [jest.fn(), jest.fn(), jest.fn()]), + useDrag: jest.fn().mockImplementation(() => [jest.fn(), jest.fn(), jest.fn()]), +})); + +describe('Utils testing of DraggableTableRow component', () => { + test('Should dropHandler return true', () => { + const monitor = { + isOver: jest.fn().mockReturnValueOnce(true), + } as never; + const dropDataTruthy = dropHandler(monitor); + + expect(dropDataTruthy).toEqual({ isOver: true }); + }); + + test('Should dropHandler return false', () => { + const monitor = { + isOver: jest.fn().mockReturnValueOnce(false), + } as never; + const dropDataFalsy = dropHandler(monitor); + + expect(dropDataFalsy).toEqual({ isOver: false }); + }); + + test('Should dragHandler return true', () => { + const monitor = { + isDragging: jest.fn().mockReturnValueOnce(true), + } as never; + const dragDataTruthy = dragHandler(monitor); + + expect(dragDataTruthy).toEqual({ isDragging: true }); + }); + + test('Should dragHandler return false', () => { + const monitor = { + isDragging: jest.fn().mockReturnValueOnce(false), + } as never; + const dragDataFalsy = dragHandler(monitor); + + expect(dragDataFalsy).toEqual({ isDragging: false }); + }); +}); diff --git a/frontend/src/components/DraggableTableRow/utils.ts b/frontend/src/components/DraggableTableRow/utils.ts new file mode 100644 index 0000000000..475145fdee --- /dev/null +++ b/frontend/src/components/DraggableTableRow/utils.ts @@ -0,0 +1,15 @@ +import { DragSourceMonitor, DropTargetMonitor } from 'react-dnd'; + +export function dropHandler(monitor: DropTargetMonitor): { isOver: boolean } { + return { + isOver: monitor.isOver(), + }; +} + +export function dragHandler( + monitor: DragSourceMonitor, +): { isDragging: boolean } { + return { + isDragging: monitor.isDragging(), + }; +} diff --git a/frontend/src/components/Graph/Plugin/Legend.ts b/frontend/src/components/Graph/Plugin/Legend.ts index 8880657855..809e0d135c 100644 --- a/frontend/src/components/Graph/Plugin/Legend.ts +++ b/frontend/src/components/Graph/Plugin/Legend.ts @@ -1,6 +1,8 @@ import { Chart, ChartType, Plugin } from 'chart.js'; +import { Events } from 'constants/events'; import { colors } from 'lib/getRandomColor'; import { get } from 'lodash-es'; +import { eventEmitter } from 'utils/getEventEmitter'; const getOrCreateLegendList = ( chart: Chart, @@ -74,6 +76,10 @@ export const legend = (id: string, isLonger: boolean): Plugin => ({ item.datasetIndex, !chart.isDatasetVisible(item.datasetIndex), ); + eventEmitter.emit(Events.UPDATE_GRAPH_MANAGER_TABLE, { + name: id, + index: item.datasetIndex, + }); } chart.update(); }; diff --git a/frontend/src/components/Graph/index.tsx b/frontend/src/components/Graph/index.tsx index 621f57ebf7..d1deb08c4e 100644 --- a/frontend/src/components/Graph/index.tsx +++ b/frontend/src/components/Graph/index.tsx @@ -1,12 +1,8 @@ import { - ActiveElement, BarController, BarElement, CategoryScale, Chart, - ChartData, - ChartEvent, - ChartOptions, ChartType, Decimation, Filler, @@ -21,33 +17,28 @@ import { Title, Tooltip, } from 'chart.js'; -import * as chartjsAdapter from 'chartjs-adapter-date-fns'; import annotationPlugin from 'chartjs-plugin-annotation'; -import dayjs from 'dayjs'; import { useIsDarkMode } from 'hooks/useDarkMode'; import isEqual from 'lodash-es/isEqual'; -import { memo, useCallback, useEffect, useRef } from 'react'; +import { + forwardRef, + memo, + useCallback, + useEffect, + useImperativeHandle, + useRef, +} from 'react'; import { hasData } from './hasData'; -import { getAxisLabelColor } from './helpers'; import { legend } from './Plugin'; -import { - createDragSelectPlugin, - createDragSelectPluginOptions, - dragSelectPluginId, - DragSelectPluginOptions, -} from './Plugin/DragSelect'; +import { createDragSelectPlugin } from './Plugin/DragSelect'; import { emptyGraph } from './Plugin/EmptyGraph'; -import { - createIntersectionCursorPlugin, - createIntersectionCursorPluginOptions, - intersectionCursorPluginId, - IntersectionCursorPluginOptions, -} from './Plugin/IntersectionCursor'; +import { createIntersectionCursorPlugin } from './Plugin/IntersectionCursor'; import { TooltipPosition as TooltipPositionHandler } from './Plugin/Tooltip'; import { LegendsContainer } from './styles'; +import { CustomChartOptions, GraphProps, ToggleGraphProps } from './types'; +import { getGraphOptions, toggleGraph } from './utils'; import { useXAxisTimeUnit } from './xAxisConfig'; -import { getToolTipValue, getYAxisFormattedValue } from './yAxisConfig'; Chart.register( LineElement, @@ -70,265 +61,125 @@ Chart.register( Tooltip.positioners.custom = TooltipPositionHandler; -function Graph({ - animate = true, - data, - type, - title, - isStacked, - onClickHandler, - name, - yAxisUnit = 'short', - forceReRender, - staticLine, - containerHeight, - onDragSelect, - dragSelectColor, -}: GraphProps): JSX.Element { - const nearestDatasetIndex = useRef(null); - const chartRef = useRef(null); - const isDarkMode = useIsDarkMode(); +const Graph = forwardRef( + ( + { + animate = true, + data, + type, + title, + isStacked, + onClickHandler, + name, + yAxisUnit = 'short', + forceReRender, + staticLine, + containerHeight, + onDragSelect, + dragSelectColor, + }, + ref, + ): JSX.Element => { + const nearestDatasetIndex = useRef(null); + const chartRef = useRef(null); + const isDarkMode = useIsDarkMode(); - const currentTheme = isDarkMode ? 'dark' : 'light'; - const xAxisTimeUnit = useXAxisTimeUnit(data); // Computes the relevant time unit for x axis by analyzing the time stamp data + const currentTheme = isDarkMode ? 'dark' : 'light'; + const xAxisTimeUnit = useXAxisTimeUnit(data); // Computes the relevant time unit for x axis by analyzing the time stamp data - const lineChartRef = useRef(); - const getGridColor = useCallback(() => { - if (currentTheme === undefined) { - return 'rgba(231,233,237,0.1)'; - } + const lineChartRef = useRef(); - if (currentTheme === 'dark') { - return 'rgba(231,233,237,0.1)'; - } - - return 'rgba(231,233,237,0.8)'; - }, [currentTheme]); - - // eslint-disable-next-line sonarjs/cognitive-complexity - const buildChart = useCallback(() => { - if (lineChartRef.current !== undefined) { - lineChartRef.current.destroy(); - } - - if (chartRef.current !== null) { - const options: CustomChartOptions = { - animation: { - duration: animate ? 200 : 0, + useImperativeHandle( + ref, + (): ToggleGraphProps => ({ + toggleGraph(graphIndex: number, isVisible: boolean): void { + toggleGraph(graphIndex, isVisible, lineChartRef); }, - responsive: true, - maintainAspectRatio: false, - interaction: { - mode: 'index', - intersect: false, - }, - plugins: { - annotation: staticLine - ? { - annotations: [ - { - type: 'line', - yMin: staticLine.yMin, - yMax: staticLine.yMax, - borderColor: staticLine.borderColor, - borderWidth: staticLine.borderWidth, - label: { - content: staticLine.lineText, - enabled: true, - font: { - size: 10, - }, - borderWidth: 0, - position: 'start', - backgroundColor: 'transparent', - color: staticLine.textColor, - }, - }, - ], - } - : undefined, - title: { - display: title !== undefined, - text: title, - }, - legend: { - display: false, - }, - tooltip: { - callbacks: { - title(context) { - const date = dayjs(context[0].parsed.x); - return date.format('MMM DD, YYYY, HH:mm:ss'); - }, - label(context) { - let label = context.dataset.label || ''; + }), + ); - if (label) { - label += ': '; - } - if (context.parsed.y !== null) { - label += getToolTipValue(context.parsed.y.toString(), yAxisUnit); - } - - return label; - }, - labelTextColor(labelData) { - if (labelData.datasetIndex === nearestDatasetIndex.current) { - return 'rgba(255, 255, 255, 1)'; - } - - return 'rgba(255, 255, 255, 0.75)'; - }, - }, - position: 'custom', - itemSort(item1, item2) { - return item2.parsed.y - item1.parsed.y; - }, - }, - [dragSelectPluginId]: createDragSelectPluginOptions( - !!onDragSelect, - onDragSelect, - dragSelectColor, - ), - [intersectionCursorPluginId]: createIntersectionCursorPluginOptions( - !!onDragSelect, - currentTheme === 'dark' ? 'white' : 'black', - ), - }, - layout: { - padding: 0, - }, - scales: { - x: { - grid: { - display: true, - color: getGridColor(), - drawTicks: true, - }, - adapters: { - date: chartjsAdapter, - }, - time: { - unit: xAxisTimeUnit?.unitName || 'minute', - stepSize: xAxisTimeUnit?.stepSize || 1, - displayFormats: { - millisecond: 'HH:mm:ss', - second: 'HH:mm:ss', - minute: 'HH:mm', - hour: 'MM/dd HH:mm', - day: 'MM/dd', - week: 'MM/dd', - month: 'yy-MM', - year: 'yy', - }, - }, - type: 'time', - ticks: { color: getAxisLabelColor(currentTheme) }, - }, - y: { - display: true, - grid: { - display: true, - color: getGridColor(), - }, - ticks: { - color: getAxisLabelColor(currentTheme), - // Include a dollar sign in the ticks - callback(value) { - return getYAxisFormattedValue(value.toString(), yAxisUnit); - }, - }, - }, - stacked: { - display: isStacked === undefined ? false : 'auto', - }, - }, - elements: { - line: { - tension: 0, - cubicInterpolationMode: 'monotone', - }, - point: { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - hoverBackgroundColor: (ctx: any) => { - if (ctx?.element?.options?.borderColor) { - return ctx.element.options.borderColor; - } - return 'rgba(0,0,0,0.1)'; - }, - hoverRadius: 5, - }, - }, - onClick: (event, element, chart) => { - if (onClickHandler) { - onClickHandler(event, element, chart, data); - } - }, - onHover: (event, _, chart) => { - if (event.native) { - const interactions = chart.getElementsAtEventForMode( - event.native, - 'nearest', - { - intersect: false, - }, - true, - ); - - if (interactions[0]) { - nearestDatasetIndex.current = interactions[0].datasetIndex; - } - } - }, - }; - - const chartHasData = hasData(data); - const chartPlugins = []; - - if (chartHasData) { - chartPlugins.push(createIntersectionCursorPlugin()); - chartPlugins.push(createDragSelectPlugin()); - chartPlugins.push(legend(name, data.datasets.length > 3)); - } else { - chartPlugins.push(emptyGraph); + const getGridColor = useCallback(() => { + if (currentTheme === undefined) { + return 'rgba(231,233,237,0.1)'; } - lineChartRef.current = new Chart(chartRef.current, { - type, - data, - options, - plugins: chartPlugins, - }); - } - }, [ - animate, - title, - getGridColor, - xAxisTimeUnit?.unitName, - xAxisTimeUnit?.stepSize, - isStacked, - type, - data, - name, - yAxisUnit, - onClickHandler, - staticLine, - onDragSelect, - dragSelectColor, - currentTheme, - ]); + if (currentTheme === 'dark') { + return 'rgba(231,233,237,0.1)'; + } - useEffect(() => { - buildChart(); - }, [buildChart, forceReRender]); + return 'rgba(231,233,237,0.8)'; + }, [currentTheme]); - return ( -
- - -
- ); -} + const buildChart = useCallback(() => { + if (lineChartRef.current !== undefined) { + lineChartRef.current.destroy(); + } + + if (chartRef.current !== null) { + const options: CustomChartOptions = getGraphOptions( + animate, + staticLine, + title, + nearestDatasetIndex, + yAxisUnit, + onDragSelect, + dragSelectColor, + currentTheme, + getGridColor, + xAxisTimeUnit, + isStacked, + onClickHandler, + data, + ); + + const chartHasData = hasData(data); + const chartPlugins = []; + + if (chartHasData) { + chartPlugins.push(createIntersectionCursorPlugin()); + chartPlugins.push(createDragSelectPlugin()); + } else { + chartPlugins.push(emptyGraph); + } + + chartPlugins.push(legend(name, data.datasets.length > 3)); + + lineChartRef.current = new Chart(chartRef.current, { + type, + data, + options, + plugins: chartPlugins, + }); + } + }, [ + animate, + staticLine, + title, + yAxisUnit, + onDragSelect, + dragSelectColor, + currentTheme, + getGridColor, + xAxisTimeUnit, + isStacked, + onClickHandler, + data, + name, + type, + ]); + + useEffect(() => { + buildChart(); + }, [buildChart, forceReRender]); + + return ( +
+ + +
+ ); + }, +); declare module 'chart.js' { interface TooltipPositionerMap { @@ -336,45 +187,6 @@ declare module 'chart.js' { } } -type CustomChartOptions = ChartOptions & { - plugins: { - [dragSelectPluginId]: DragSelectPluginOptions | false; - [intersectionCursorPluginId]: IntersectionCursorPluginOptions | false; - }; -}; - -export interface GraphProps { - animate?: boolean; - type: ChartType; - data: Chart['data']; - title?: string; - isStacked?: boolean; - onClickHandler?: GraphOnClickHandler; - name: string; - yAxisUnit?: string; - forceReRender?: boolean | null | number; - staticLine?: StaticLineProps | undefined; - containerHeight?: string | number; - onDragSelect?: (start: number, end: number) => void; - dragSelectColor?: string; -} - -export interface StaticLineProps { - yMin: number | undefined; - yMax: number | undefined; - borderColor: string; - borderWidth: number; - lineText: string; - textColor: string; -} - -export type GraphOnClickHandler = ( - event: ChartEvent, - elements: ActiveElement[], - chart: Chart, - data: ChartData, -) => void; - Graph.defaultProps = { animate: undefined, title: undefined, @@ -388,6 +200,8 @@ Graph.defaultProps = { dragSelectColor: undefined, }; +Graph.displayName = 'Graph'; + export default memo(Graph, (prevProps, nextProps) => isEqual(prevProps.data, nextProps.data), ); diff --git a/frontend/src/components/Graph/types.ts b/frontend/src/components/Graph/types.ts new file mode 100644 index 0000000000..dcb9607a0c --- /dev/null +++ b/frontend/src/components/Graph/types.ts @@ -0,0 +1,78 @@ +import { + ActiveElement, + Chart, + ChartData, + ChartEvent, + ChartOptions, + ChartType, + TimeUnit, +} from 'chart.js'; +import { ForwardedRef } from 'react'; + +import { + dragSelectPluginId, + DragSelectPluginOptions, +} from './Plugin/DragSelect'; +import { + intersectionCursorPluginId, + IntersectionCursorPluginOptions, +} from './Plugin/IntersectionCursor'; + +export interface StaticLineProps { + yMin: number | undefined; + yMax: number | undefined; + borderColor: string; + borderWidth: number; + lineText: string; + textColor: string; +} + +export type GraphOnClickHandler = ( + event: ChartEvent, + elements: ActiveElement[], + chart: Chart, + data: ChartData, +) => void; + +export type ToggleGraphProps = { + toggleGraph(graphIndex: number, isVisible: boolean): void; +}; + +export type CustomChartOptions = ChartOptions & { + plugins: { + [dragSelectPluginId]: DragSelectPluginOptions | false; + [intersectionCursorPluginId]: IntersectionCursorPluginOptions | false; + }; +}; + +export interface GraphProps { + animate?: boolean; + type: ChartType; + data: Chart['data']; + title?: string; + isStacked?: boolean; + onClickHandler?: GraphOnClickHandler; + name: string; + yAxisUnit?: string; + forceReRender?: boolean | null | number; + staticLine?: StaticLineProps | undefined; + containerHeight?: string | number; + onDragSelect?: (start: number, end: number) => void; + dragSelectColor?: string; + ref?: ForwardedRef; +} + +export interface IAxisTimeUintConfig { + unitName: TimeUnit; + multiplier: number; +} + +export interface IAxisTimeConfig { + unitName: TimeUnit; + stepSize: number; +} + +export interface ITimeRange { + minTime: number | null; + maxTime: number | null; +} diff --git a/frontend/src/components/Graph/utils.ts b/frontend/src/components/Graph/utils.ts new file mode 100644 index 0000000000..db30b6a8ce --- /dev/null +++ b/frontend/src/components/Graph/utils.ts @@ -0,0 +1,223 @@ +import { Chart, ChartConfiguration, ChartData, Color } from 'chart.js'; +import * as chartjsAdapter from 'chartjs-adapter-date-fns'; +import dayjs from 'dayjs'; +import { MutableRefObject } from 'react'; + +import { getAxisLabelColor } from './helpers'; +import { + createDragSelectPluginOptions, + dragSelectPluginId, +} from './Plugin/DragSelect'; +import { + createIntersectionCursorPluginOptions, + intersectionCursorPluginId, +} from './Plugin/IntersectionCursor'; +import { + CustomChartOptions, + GraphOnClickHandler, + IAxisTimeConfig, + StaticLineProps, +} from './types'; +import { getToolTipValue, getYAxisFormattedValue } from './yAxisConfig'; + +export const toggleGraph = ( + graphIndex: number, + isVisible: boolean, + lineChartRef: MutableRefObject, +): void => { + if (lineChartRef && lineChartRef.current) { + const { type } = lineChartRef.current?.config as ChartConfiguration; + if (type === 'pie' || type === 'doughnut') { + lineChartRef.current?.toggleDataVisibility(graphIndex); + } else { + lineChartRef.current?.setDatasetVisibility(graphIndex, isVisible); + } + lineChartRef.current?.update(); + } +}; + +export const getGraphOptions = ( + animate: boolean, + staticLine: StaticLineProps | undefined, + title: string | undefined, + nearestDatasetIndex: MutableRefObject, + yAxisUnit: string, + onDragSelect: ((start: number, end: number) => void) | undefined, + dragSelectColor: string | undefined, + currentTheme: 'dark' | 'light', + getGridColor: () => 'rgba(231,233,237,0.1)' | 'rgba(231,233,237,0.8)', + xAxisTimeUnit: IAxisTimeConfig, + isStacked: boolean | undefined, + onClickHandler: GraphOnClickHandler | undefined, + data: ChartData, + // eslint-disable-next-line sonarjs/cognitive-complexity +): CustomChartOptions => ({ + animation: { + duration: animate ? 200 : 0, + }, + responsive: true, + maintainAspectRatio: false, + interaction: { + mode: 'index', + intersect: false, + }, + plugins: { + annotation: staticLine + ? { + annotations: [ + { + type: 'line', + yMin: staticLine.yMin, + yMax: staticLine.yMax, + borderColor: staticLine.borderColor, + borderWidth: staticLine.borderWidth, + label: { + content: staticLine.lineText, + enabled: true, + font: { + size: 10, + }, + borderWidth: 0, + position: 'start', + backgroundColor: 'transparent', + color: staticLine.textColor, + }, + }, + ], + } + : undefined, + title: { + display: title !== undefined, + text: title, + }, + legend: { + display: false, + }, + tooltip: { + callbacks: { + title(context): string | string[] { + const date = dayjs(context[0].parsed.x); + return date.format('MMM DD, YYYY, HH:mm:ss'); + }, + label(context): string | string[] { + let label = context.dataset.label || ''; + + if (label) { + label += ': '; + } + if (context.parsed.y !== null) { + label += getToolTipValue(context.parsed.y.toString(), yAxisUnit); + } + + return label; + }, + labelTextColor(labelData): Color { + if (labelData.datasetIndex === nearestDatasetIndex.current) { + return 'rgba(255, 255, 255, 1)'; + } + + return 'rgba(255, 255, 255, 0.75)'; + }, + }, + position: 'custom', + itemSort(item1, item2): number { + return item2.parsed.y - item1.parsed.y; + }, + }, + [dragSelectPluginId]: createDragSelectPluginOptions( + !!onDragSelect, + onDragSelect, + dragSelectColor, + ), + [intersectionCursorPluginId]: createIntersectionCursorPluginOptions( + !!onDragSelect, + currentTheme === 'dark' ? 'white' : 'black', + ), + }, + layout: { + padding: 0, + }, + scales: { + x: { + grid: { + display: true, + color: getGridColor(), + drawTicks: true, + }, + adapters: { + date: chartjsAdapter, + }, + time: { + unit: xAxisTimeUnit?.unitName || 'minute', + stepSize: xAxisTimeUnit?.stepSize || 1, + displayFormats: { + millisecond: 'HH:mm:ss', + second: 'HH:mm:ss', + minute: 'HH:mm', + hour: 'MM/dd HH:mm', + day: 'MM/dd', + week: 'MM/dd', + month: 'yy-MM', + year: 'yy', + }, + }, + type: 'time', + ticks: { color: getAxisLabelColor(currentTheme) }, + }, + y: { + display: true, + grid: { + display: true, + color: getGridColor(), + }, + ticks: { + color: getAxisLabelColor(currentTheme), + // Include a dollar sign in the ticks + callback(value): string { + return getYAxisFormattedValue(value.toString(), yAxisUnit); + }, + }, + }, + stacked: { + display: isStacked === undefined ? false : 'auto', + }, + }, + elements: { + line: { + tension: 0, + cubicInterpolationMode: 'monotone', + }, + point: { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + hoverBackgroundColor: (ctx: any): string => { + if (ctx?.element?.options?.borderColor) { + return ctx.element.options.borderColor; + } + return 'rgba(0,0,0,0.1)'; + }, + hoverRadius: 5, + }, + }, + onClick: (event, element, chart): void => { + if (onClickHandler) { + onClickHandler(event, element, chart, data); + } + }, + onHover: (event, _, chart): void => { + if (event.native) { + const interactions = chart.getElementsAtEventForMode( + event.native, + 'nearest', + { + intersect: false, + }, + true, + ); + + if (interactions[0]) { + // eslint-disable-next-line no-param-reassign + nearestDatasetIndex.current = interactions[0].datasetIndex; + } + } + }, +}); diff --git a/frontend/src/components/Graph/xAxisConfig.ts b/frontend/src/components/Graph/xAxisConfig.ts index ee794b6678..3fa0b00e08 100644 --- a/frontend/src/components/Graph/xAxisConfig.ts +++ b/frontend/src/components/Graph/xAxisConfig.ts @@ -4,20 +4,7 @@ import { useSelector } from 'react-redux'; import { AppState } from 'store/reducers'; import { GlobalReducer } from 'types/reducer/globalTime'; -interface IAxisTimeUintConfig { - unitName: TimeUnit; - multiplier: number; -} - -interface IAxisTimeConfig { - unitName: TimeUnit; - stepSize: number; -} - -export interface ITimeRange { - minTime: number | null; - maxTime: number | null; -} +import { IAxisTimeConfig, IAxisTimeUintConfig, ITimeRange } from './types'; export const TIME_UNITS: Record = { millisecond: 'millisecond', diff --git a/frontend/src/components/LogDetail/LogDetail.interfaces.ts b/frontend/src/components/LogDetail/LogDetail.interfaces.ts index 198e2abdcd..a67dfc10c8 100644 --- a/frontend/src/components/LogDetail/LogDetail.interfaces.ts +++ b/frontend/src/components/LogDetail/LogDetail.interfaces.ts @@ -1,9 +1,10 @@ +import { DrawerProps } from 'antd'; import { AddToQueryHOCProps } from 'components/Logs/AddToQueryHOC'; import { ActionItemProps } from 'container/LogDetailedView/ActionItem'; import { ILog } from 'types/api/logs/log'; export type LogDetailProps = { log: ILog | null; - onClose: () => void; } & Pick & - Pick; + Pick & + Pick; diff --git a/frontend/src/components/LogDetail/index.tsx b/frontend/src/components/LogDetail/index.tsx index 8ea0709fbd..b787322ca7 100644 --- a/frontend/src/components/LogDetail/index.tsx +++ b/frontend/src/components/LogDetail/index.tsx @@ -11,10 +11,6 @@ function LogDetail({ onAddToQuery, onClickActionItem, }: LogDetailProps): JSX.Element { - const onDrawerClose = (): void => { - onClose(); - }; - const items = useMemo( () => [ { @@ -43,7 +39,7 @@ function LogDetail({ title="Log Details" placement="right" closable - onClose={onDrawerClose} + onClose={onClose} open={log !== null} style={{ overscrollBehavior: 'contain' }} destroyOnClose diff --git a/frontend/src/components/Logs/ListLogView/index.tsx b/frontend/src/components/Logs/ListLogView/index.tsx index 91d0787a95..c5aa7a184d 100644 --- a/frontend/src/components/Logs/ListLogView/index.tsx +++ b/frontend/src/components/Logs/ListLogView/index.tsx @@ -1,9 +1,18 @@ import { blue, grey, orange } from '@ant-design/colors'; -import { CopyFilled, ExpandAltOutlined } from '@ant-design/icons'; +import { + CopyFilled, + ExpandAltOutlined, + LinkOutlined, + MonitorOutlined, +} from '@ant-design/icons'; import Convert from 'ansi-to-html'; import { Button, Divider, Row, Typography } from 'antd'; +import LogDetail from 'components/LogDetail'; +import LogsExplorerContext from 'container/LogsExplorerContext'; import dayjs from 'dayjs'; import dompurify from 'dompurify'; +import { useActiveLog } from 'hooks/logs/useActiveLog'; +import { useCopyLogLink } from 'hooks/logs/useCopyLogLink'; import { useNotifications } from 'hooks/useNotifications'; // utils import { FlatLogData } from 'lib/logs/flatLogData'; @@ -85,24 +94,39 @@ function LogSelectedField({ type ListLogViewProps = { logData: ILog; - onOpenDetailedView: (log: ILog) => void; selectedFields: IField[]; -} & Pick; +}; function ListLogView({ logData, selectedFields, - onOpenDetailedView, - onAddToQuery, }: ListLogViewProps): JSX.Element { const flattenLogData = useMemo(() => FlatLogData(logData), [logData]); const [, setCopy] = useCopyToClipboard(); const { notifications } = useNotifications(); + const { isHighlighted, isLogsExplorerPage, onLogCopy } = useCopyLogLink( + logData.id, + ); + const { + activeLog: activeContextLog, + onSetActiveLog: handleSetActiveContextLog, + onClearActiveLog: handleClearActiveContextLog, + } = useActiveLog(); + const { + activeLog, + onSetActiveLog, + onClearActiveLog, + onAddToQuery, + } = useActiveLog(); const handleDetailedView = useCallback(() => { - onOpenDetailedView(logData); - }, [logData, onOpenDetailedView]); + onSetActiveLog(logData); + }, [logData, onSetActiveLog]); + + const handleShowContext = useCallback(() => { + handleSetActiveContextLog(logData); + }, [logData, handleSetActiveContextLog]); const handleCopyJSON = (): void => { setCopy(JSON.stringify(logData, null, 2)); @@ -125,7 +149,7 @@ function ListLogView({ ); return ( - +
<> @@ -169,6 +193,42 @@ function ListLogView({ > Copy JSON + + {isLogsExplorerPage && ( + <> + + + + )} + + {activeContextLog && ( + + )} + ); diff --git a/frontend/src/components/Logs/ListLogView/styles.ts b/frontend/src/components/Logs/ListLogView/styles.ts index 313f5b9e0c..0db1baafe5 100644 --- a/frontend/src/components/Logs/ListLogView/styles.ts +++ b/frontend/src/components/Logs/ListLogView/styles.ts @@ -1,12 +1,17 @@ import { Card, Typography } from 'antd'; import styled from 'styled-components'; +import { getActiveLogBackground } from 'utils/logs'; -export const Container = styled(Card)` +export const Container = styled(Card)<{ + $isActiveLog: boolean; +}>` width: 100% !important; margin-bottom: 0.3rem; .ant-card-body { padding: 0.3rem 0.6rem; } + + ${({ $isActiveLog }): string => getActiveLogBackground($isActiveLog)} `; export const Text = styled(Typography.Text)` diff --git a/frontend/src/components/Logs/RawLogView/index.tsx b/frontend/src/components/Logs/RawLogView/index.tsx index 76d12c1a22..e926e73643 100644 --- a/frontend/src/components/Logs/RawLogView/index.tsx +++ b/frontend/src/components/Logs/RawLogView/index.tsx @@ -1,16 +1,32 @@ -import { ExpandAltOutlined } from '@ant-design/icons'; -// const Convert = require('ansi-to-html'); +import { + ExpandAltOutlined, + LinkOutlined, + MonitorOutlined, +} from '@ant-design/icons'; import Convert from 'ansi-to-html'; +import { Button, DrawerProps, Tooltip } from 'antd'; +import LogDetail from 'components/LogDetail'; +import LogsExplorerContext from 'container/LogsExplorerContext'; import dayjs from 'dayjs'; import dompurify from 'dompurify'; +import { useActiveLog } from 'hooks/logs/useActiveLog'; +import { useCopyLogLink } from 'hooks/logs/useCopyLogLink'; // hooks import { useIsDarkMode } from 'hooks/useDarkMode'; -import { useCallback, useMemo } from 'react'; +import { + KeyboardEvent, + MouseEvent, + MouseEventHandler, + useCallback, + useMemo, + useState, +} from 'react'; // interfaces import { ILog } from 'types/api/logs/log'; // styles import { + ActionButtonsWrapper, ExpandIconWrapper, RawLogContent, RawLogViewContainer, @@ -19,15 +35,34 @@ import { const convert = new Convert(); interface RawLogViewProps { + isActiveLog?: boolean; + isReadOnly?: boolean; data: ILog; linesPerRow: number; - onClickExpand: (log: ILog) => void; } function RawLogView(props: RawLogViewProps): JSX.Element { - const { data, linesPerRow, onClickExpand } = props; + const { isActiveLog = false, isReadOnly = false, data, linesPerRow } = props; + + const { isHighlighted, isLogsExplorerPage, onLogCopy } = useCopyLogLink( + data.id, + ); + const { + activeLog: activeContextLog, + onSetActiveLog: handleSetActiveContextLog, + onClearActiveLog: handleClearActiveContextLog, + } = useActiveLog(); + const { + activeLog, + onSetActiveLog, + onClearActiveLog, + onAddToQuery, + } = useActiveLog(); + + const [hasActionButtons, setHasActionButtons] = useState(false); const isDarkMode = useIsDarkMode(); + const isReadOnlyLog = !isLogsExplorerPage || isReadOnly; const text = useMemo( () => @@ -38,8 +73,43 @@ function RawLogView(props: RawLogViewProps): JSX.Element { ); const handleClickExpand = useCallback(() => { - onClickExpand(data); - }, [onClickExpand, data]); + if (activeContextLog || isReadOnly) return; + + onSetActiveLog(data); + }, [activeContextLog, isReadOnly, data, onSetActiveLog]); + + const handleCloseLogDetail: DrawerProps['onClose'] = useCallback( + ( + event: MouseEvent | KeyboardEvent, + ) => { + event.preventDefault(); + event.stopPropagation(); + + onClearActiveLog(); + }, + [onClearActiveLog], + ); + + const handleMouseEnter = useCallback(() => { + if (isReadOnlyLog) return; + + setHasActionButtons(true); + }, [isReadOnlyLog]); + + const handleMouseLeave = useCallback(() => { + if (isReadOnlyLog) return; + + setHasActionButtons(false); + }, [isReadOnlyLog]); + + const handleShowContext: MouseEventHandler = useCallback( + (event) => { + event.preventDefault(); + event.stopPropagation(); + handleSetActiveContextLog(data); + }, + [data, handleSetActiveContextLog], + ); const html = useMemo( () => ({ @@ -48,19 +118,69 @@ function RawLogView(props: RawLogViewProps): JSX.Element { [text], ); + const mouseActions = useMemo( + () => ({ onMouseEnter: handleMouseEnter, onMouseLeave: handleMouseLeave }), + [handleMouseEnter, handleMouseLeave], + ); + return ( - - - - + {!isReadOnly && ( + + + + )} + + + + {hasActionButtons && ( + + + + + + + + + + ); +} + +GraphManager.defaultProps = { + graphVisibilityStateHandler: undefined, +}; + +export default memo( + GraphManager, + (prevProps, nextProps) => + isEqual(prevProps.data, nextProps.data) && prevProps.name === nextProps.name, +); diff --git a/frontend/src/container/GridGraphLayout/Graph/FullView/TableRender/CustomCheckBox.tsx b/frontend/src/container/GridGraphLayout/Graph/FullView/TableRender/CustomCheckBox.tsx new file mode 100644 index 0000000000..22ae630bb8 --- /dev/null +++ b/frontend/src/container/GridGraphLayout/Graph/FullView/TableRender/CustomCheckBox.tsx @@ -0,0 +1,33 @@ +import { Checkbox, ConfigProvider } from 'antd'; +import { CheckboxChangeEvent } from 'antd/es/checkbox'; + +import { CheckBoxProps } from '../types'; + +function CustomCheckBox({ + data, + index, + graphVisibilityState, + checkBoxOnChangeHandler, +}: CheckBoxProps): JSX.Element { + const { datasets } = data; + + const onChangeHandler = (e: CheckboxChangeEvent): void => { + checkBoxOnChangeHandler(e, index); + }; + + return ( + + + + ); +} + +export default CustomCheckBox; diff --git a/frontend/src/container/GridGraphLayout/Graph/FullView/TableRender/GetCheckBox.tsx b/frontend/src/container/GridGraphLayout/Graph/FullView/TableRender/GetCheckBox.tsx new file mode 100644 index 0000000000..55485be5ad --- /dev/null +++ b/frontend/src/container/GridGraphLayout/Graph/FullView/TableRender/GetCheckBox.tsx @@ -0,0 +1,27 @@ +import { CheckboxChangeEvent } from 'antd/es/checkbox'; +import { ColumnType } from 'antd/es/table'; +import { ChartData } from 'chart.js'; + +import { DataSetProps } from '../types'; +import CustomCheckBox from './CustomCheckBox'; + +export const getCheckBox = ({ + data, + checkBoxOnChangeHandler, + graphVisibilityState, +}: GetCheckBoxProps): ColumnType => ({ + render: (index: number): JSX.Element => ( + + ), +}); + +interface GetCheckBoxProps { + data: ChartData; + checkBoxOnChangeHandler: (e: CheckboxChangeEvent, index: number) => void; + graphVisibilityState: boolean[]; +} diff --git a/frontend/src/container/GridGraphLayout/Graph/FullView/TableRender/GetLabel.tsx b/frontend/src/container/GridGraphLayout/Graph/FullView/TableRender/GetLabel.tsx new file mode 100644 index 0000000000..7fc5da2ab7 --- /dev/null +++ b/frontend/src/container/GridGraphLayout/Graph/FullView/TableRender/GetLabel.tsx @@ -0,0 +1,16 @@ +import { ColumnType } from 'antd/es/table'; + +import { DataSetProps } from '../types'; +import Label from './Label'; + +export const getLabel = ( + labelClickedHandler: (labelIndex: number) => void, +): ColumnType => ({ + render: (label: string, _, index): JSX.Element => ( +
+ + + + + +
+`; diff --git a/frontend/src/container/PipelinePage/tests/__snapshots__/PipelinePageLayout.test.tsx.snap b/frontend/src/container/PipelinePage/tests/__snapshots__/PipelinePageLayout.test.tsx.snap new file mode 100644 index 0000000000..fc3092c4f6 --- /dev/null +++ b/frontend/src/container/PipelinePage/tests/__snapshots__/PipelinePageLayout.test.tsx.snap @@ -0,0 +1,326 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`PipelinePage container test should render AddNewPipeline section 1`] = ``; + +exports[`PipelinePage container test should render PipelinePageLayout section 1`] = ` + + .c0.c0.c0 { + display: -webkit-box; + display: -webkit-flex; + display: -ms-flexbox; + display: flex; + -webkit-box-pack: end; + -webkit-justify-content: flex-end; + -ms-flex-pack: end; + justify-content: flex-end; + margin-bottom: 2rem; + -webkit-align-items: center; + -webkit-box-align: center; + -ms-flex-align: center; + align-items: center; +} + +.c1.c1.c1 { + margin-left: 1rem; +} + +
+ + + + +
+ + + + + + + + + + + + + + + + + + .c0 { + margin-top: 3rem; +} + +.c1 { + display: -webkit-box; + display: -webkit-flex; + display: -ms-flexbox; + display: flex; + gap: 0.5rem; + -webkit-box-pack: end; + -webkit-justify-content: flex-end; + -ms-flex-pack: end; + justify-content: flex-end; + color: #D89614; + margin: 0.125rem; + padding: 0.313rem; +} + +
+
+ Mode: + + Viewing + +
+ Configuration Version: 1 +
+
+
+
+
+
+
+
+ + + + + + + + + + + + + + + + + +
+ + + Pipeline Name + + Filters + + Last Edited + + Edited By +
+
+
+ + + + + + + + + +
+
+ No data +
+
+
+
+
+ +
+
+
+
+ +`; diff --git a/frontend/src/container/PipelinePage/tests/__snapshots__/PipelinesSearchSection.test.tsx.snap b/frontend/src/container/PipelinePage/tests/__snapshots__/PipelinesSearchSection.test.tsx.snap new file mode 100644 index 0000000000..17d488d290 --- /dev/null +++ b/frontend/src/container/PipelinePage/tests/__snapshots__/PipelinesSearchSection.test.tsx.snap @@ -0,0 +1,81 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`PipelinePage container test should render PipelinesSearchSection section 1`] = ` + + + + + + + + + + + + + + + + + + + +`; diff --git a/frontend/src/container/PipelinePage/tests/__snapshots__/TableExpandIcon.test.tsx.snap b/frontend/src/container/PipelinePage/tests/__snapshots__/TableExpandIcon.test.tsx.snap new file mode 100644 index 0000000000..a3ce5e3110 --- /dev/null +++ b/frontend/src/container/PipelinePage/tests/__snapshots__/TableExpandIcon.test.tsx.snap @@ -0,0 +1,26 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`PipelinePage container test should render TableExpandIcon section 1`] = ` + + + + + +`; diff --git a/frontend/src/container/PipelinePage/tests/__snapshots__/TagInput.test.tsx.snap b/frontend/src/container/PipelinePage/tests/__snapshots__/TagInput.test.tsx.snap new file mode 100644 index 0000000000..6a97ec9ad6 --- /dev/null +++ b/frontend/src/container/PipelinePage/tests/__snapshots__/TagInput.test.tsx.snap @@ -0,0 +1,32 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Pipeline Page should render TagInput section 1`] = ` + + .c0 { + display: -webkit-box; + display: -webkit-flex; + display: -ms-flexbox; + display: flex; + width: 100%; +} + +
+ + + + +
+
+`; diff --git a/frontend/src/container/PipelinePage/tests/__snapshots__/Tags.test.tsx.snap b/frontend/src/container/PipelinePage/tests/__snapshots__/Tags.test.tsx.snap new file mode 100644 index 0000000000..1fa7dd1d55 --- /dev/null +++ b/frontend/src/container/PipelinePage/tests/__snapshots__/Tags.test.tsx.snap @@ -0,0 +1,18 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`PipelinePage container test should render Tags section 1`] = ` + + + + server + + + app + + + +`; diff --git a/frontend/src/container/PipelinePage/tests/__snapshots__/ViewAction.test.tsx.snap b/frontend/src/container/PipelinePage/tests/__snapshots__/ViewAction.test.tsx.snap new file mode 100644 index 0000000000..c318633144 --- /dev/null +++ b/frontend/src/container/PipelinePage/tests/__snapshots__/ViewAction.test.tsx.snap @@ -0,0 +1,26 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`PipelinePage container test should render ViewAction section 1`] = ` + + + + + +`; diff --git a/frontend/src/container/PipelinePage/tests/utils.test.ts b/frontend/src/container/PipelinePage/tests/utils.test.ts new file mode 100644 index 0000000000..f433c422b9 --- /dev/null +++ b/frontend/src/container/PipelinePage/tests/utils.test.ts @@ -0,0 +1,88 @@ +import { pipelineMockData } from '../mocks/pipeline'; +import { + processorFields, + processorTypes, +} from '../PipelineListsView/AddNewProcessor/config'; +import { pipelineFields, processorColumns } from '../PipelineListsView/config'; +import { + getEditedDataSource, + getElementFromArray, + getRecordIndex, + getTableColumn, +} from '../PipelineListsView/utils'; + +describe('Utils testing of Pipeline Page', () => { + test('it should be check form field of add pipeline', () => { + expect(pipelineFields.length).toBe(3); + expect(pipelineFields.length).toBeGreaterThan(1); + }); + + test('it should be check processor types field of add pipeline', () => { + expect(processorTypes.length).toBeGreaterThan(1); + }); + + test('it should check form field of add processor', () => { + Object.keys(processorFields).forEach((key) => { + expect(processorFields[key].length).toBeGreaterThan(1); + }); + }); + + test('it should be check data length of pipeline', () => { + expect(pipelineMockData.length).toBe(2); + expect(pipelineMockData.length).toBeGreaterThan(0); + }); + + test('it should be return filtered data and perform deletion', () => { + const filterData = getElementFromArray( + pipelineMockData, + pipelineMockData[0], + 'id', + ); + expect(pipelineMockData).not.toEqual(filterData); + expect(pipelineMockData[0]).not.toEqual(filterData); + }); + + test('it should be return index data and perform deletion', () => { + const findRecordIndex = getRecordIndex( + pipelineMockData, + pipelineMockData[0], + 'id', + ); + expect(pipelineMockData).not.toEqual(findRecordIndex); + expect(pipelineMockData[0]).not.toEqual(findRecordIndex); + }); + + test('it should be return modified column data', () => { + const columnData = getTableColumn(processorColumns); + expect(processorColumns).not.toEqual(columnData); + expect(processorColumns.length).toEqual(columnData.length); + }); + + test('it should be return modified column data', () => { + const findRecordIndex = getRecordIndex( + pipelineMockData, + pipelineMockData[0], + 'name', + ); + const updatedPipelineData = { + ...pipelineMockData[findRecordIndex], + name: 'updated name', + description: 'changed description', + filter: 'value == test', + tags: ['test'], + }; + const editedData = getEditedDataSource( + pipelineMockData, + pipelineMockData[0], + 'name', + updatedPipelineData, + ); + expect(pipelineMockData).not.toEqual(editedData); + expect(pipelineMockData.length).toEqual(editedData.length); + expect(pipelineMockData[0].name).not.toEqual(editedData[0].name); + expect(pipelineMockData[0].description).not.toEqual( + editedData[0].description, + ); + expect(pipelineMockData[0].tags).not.toEqual(editedData[0].tags); + }); +}); diff --git a/frontend/src/container/QueryBuilder/components/Query/Query.tsx b/frontend/src/container/QueryBuilder/components/Query/Query.tsx index 7f8b4c1cdc..9f4fca9b12 100644 --- a/frontend/src/container/QueryBuilder/components/Query/Query.tsx +++ b/frontend/src/container/QueryBuilder/components/Query/Query.tsx @@ -210,16 +210,19 @@ export const Query = memo(function Query({ default: { return ( <> - - - - - - - - - - + {!filterConfigs?.limit?.isHidden && ( + + + + + + + + + + + )} + {!filterConfigs?.having?.isHidden && ( @@ -232,7 +235,6 @@ export const Query = memo(function Query({ )} - @@ -251,6 +253,7 @@ export const Query = memo(function Query({ panelType, isMetricsDataSource, query, + filterConfigs?.limit?.isHidden, filterConfigs?.having?.isHidden, handleChangeLimit, handleChangeHavingFilter, diff --git a/frontend/src/container/ServiceApplication/Columns/BaseColumnOptions.ts b/frontend/src/container/ServiceApplication/Columns/BaseColumnOptions.ts new file mode 100644 index 0000000000..7978073bab --- /dev/null +++ b/frontend/src/container/ServiceApplication/Columns/BaseColumnOptions.ts @@ -0,0 +1,36 @@ +import { ColumnsType } from 'antd/es/table'; +import { ServicesList } from 'types/api/metrics/getService'; + +import { + ColumnKey, + ColumnTitle, + ColumnWidth, + SORTING_ORDER, +} from './ColumnContants'; + +export const baseColumnOptions: ColumnsType = [ + { + title: ColumnTitle[ColumnKey.Application], + dataIndex: ColumnKey.Application, + width: ColumnWidth.Application, + key: ColumnKey.Application, + }, + { + dataIndex: ColumnKey.P99, + key: ColumnKey.P99, + width: ColumnWidth.P99, + defaultSortOrder: SORTING_ORDER, + }, + { + title: ColumnTitle[ColumnKey.ErrorRate], + dataIndex: ColumnKey.ErrorRate, + key: ColumnKey.ErrorRate, + width: 150, + }, + { + title: ColumnTitle[ColumnKey.Operations], + dataIndex: ColumnKey.Operations, + key: ColumnKey.Operations, + width: ColumnWidth.Operations, + }, +]; diff --git a/frontend/src/container/ServiceApplication/Columns/ColumnContants.ts b/frontend/src/container/ServiceApplication/Columns/ColumnContants.ts new file mode 100644 index 0000000000..b02c2d1fa0 --- /dev/null +++ b/frontend/src/container/ServiceApplication/Columns/ColumnContants.ts @@ -0,0 +1,24 @@ +export enum ColumnKey { + Application = 'serviceName', + P99 = 'p99', + ErrorRate = 'errorRate', + Operations = 'callRate', +} + +export const ColumnTitle: Record = { + [ColumnKey.Application]: 'Application', + [ColumnKey.P99]: 'P99 latency', + [ColumnKey.ErrorRate]: 'Error Rate (% of total)', + [ColumnKey.Operations]: 'Operations Per Second', +}; + +export enum ColumnWidth { + Application = 200, + P99 = 150, + ErrorRate = 150, + Operations = 150, +} + +export const SORTING_ORDER = 'descend'; + +export const SEARCH_PLACEHOLDER = 'Search by service'; diff --git a/frontend/src/container/ServiceApplication/Columns/GetColumnSearchProps.tsx b/frontend/src/container/ServiceApplication/Columns/GetColumnSearchProps.tsx new file mode 100644 index 0000000000..4257dc57ec --- /dev/null +++ b/frontend/src/container/ServiceApplication/Columns/GetColumnSearchProps.tsx @@ -0,0 +1,34 @@ +import { SearchOutlined } from '@ant-design/icons'; +import type { ColumnType } from 'antd/es/table'; +import ROUTES from 'constants/routes'; +import { routeConfig } from 'container/SideNav/config'; +import { getQueryString } from 'container/SideNav/helper'; +import { Link } from 'react-router-dom'; +import { ServicesList } from 'types/api/metrics/getService'; + +import { filterDropdown } from '../Filter/FilterDropdown'; +import { Name } from '../styles'; + +export const getColumnSearchProps = ( + dataIndex: keyof ServicesList, + search: string, +): ColumnType => ({ + filterDropdown, + filterIcon: , + onFilter: (value: string | number | boolean, record: ServicesList): boolean => + record[dataIndex] + .toString() + .toLowerCase() + .includes(value.toString().toLowerCase()), + render: (metrics: string): JSX.Element => { + const urlParams = new URLSearchParams(search); + const avialableParams = routeConfig[ROUTES.SERVICE_METRICS]; + const queryString = getQueryString(avialableParams, urlParams); + + return ( + + {metrics} + + ); + }, +}); diff --git a/frontend/src/container/ServiceApplication/Columns/ServiceColumn.ts b/frontend/src/container/ServiceApplication/Columns/ServiceColumn.ts new file mode 100644 index 0000000000..b0487b8915 --- /dev/null +++ b/frontend/src/container/ServiceApplication/Columns/ServiceColumn.ts @@ -0,0 +1,58 @@ +import type { ColumnsType, ColumnType } from 'antd/es/table'; +import { generatorResizeTableColumns } from 'components/TableRenderer/utils'; +import { ServicesList } from 'types/api/metrics/getService'; + +import { baseColumnOptions } from './BaseColumnOptions'; +import { ColumnKey, ColumnTitle } from './ColumnContants'; +import { getColumnSearchProps } from './GetColumnSearchProps'; + +export const getColumns = ( + search: string, + isMetricData: boolean, +): ColumnsType => { + const dynamicColumnOption: { + key: string; + columnOption: ColumnType; + }[] = [ + { + key: ColumnKey.Application, + columnOption: { + ...getColumnSearchProps('serviceName', search), + }, + }, + { + key: ColumnKey.P99, + columnOption: { + title: `${ColumnTitle[ColumnKey.P99]}${ + isMetricData ? ' (in ns)' : ' (in ms)' + }`, + sorter: (a: ServicesList, b: ServicesList): number => a.p99 - b.p99, + render: (value: number): string => { + if (Number.isNaN(value)) return '0.00'; + return isMetricData ? value.toFixed(2) : (value / 1000000).toFixed(2); + }, + }, + }, + { + key: ColumnKey.ErrorRate, + columnOption: { + sorter: (a: ServicesList, b: ServicesList): number => + a.errorRate - b.errorRate, + render: (value: number): string => value.toFixed(2), + }, + }, + { + key: ColumnKey.Operations, + columnOption: { + sorter: (a: ServicesList, b: ServicesList): number => + a.callRate - b.callRate, + render: (value: number): string => value.toFixed(2), + }, + }, + ]; + + return generatorResizeTableColumns({ + baseColumnOptions, + dynamicColumnOption, + }); +}; diff --git a/frontend/src/container/ServiceApplication/Filter/FilterDropdown.tsx b/frontend/src/container/ServiceApplication/Filter/FilterDropdown.tsx new file mode 100644 index 0000000000..1dc4a12d89 --- /dev/null +++ b/frontend/src/container/ServiceApplication/Filter/FilterDropdown.tsx @@ -0,0 +1,41 @@ +import { SearchOutlined } from '@ant-design/icons'; +import { Button, Card, Input, Space } from 'antd'; +import type { FilterDropdownProps } from 'antd/es/table/interface'; + +import { SEARCH_PLACEHOLDER } from '../Columns/ColumnContants'; + +export const filterDropdown = ({ + setSelectedKeys, + selectedKeys, + confirm, +}: FilterDropdownProps): JSX.Element => { + const handleSearch = (): void => { + confirm(); + }; + + const selectedKeysHandler = (e: React.ChangeEvent): void => { + setSelectedKeys(e.target.value ? [e.target.value] : []); + }; + + return ( + + + + + + + ); +}; diff --git a/frontend/src/container/ServiceApplication/ServiceMetrics/ServiceMetricTable.tsx b/frontend/src/container/ServiceApplication/ServiceMetrics/ServiceMetricTable.tsx new file mode 100644 index 0000000000..154cc4ab11 --- /dev/null +++ b/frontend/src/container/ServiceApplication/ServiceMetrics/ServiceMetricTable.tsx @@ -0,0 +1,67 @@ +import { ResizeTable } from 'components/ResizeTable'; +import { useGetQueriesRange } from 'hooks/queryBuilder/useGetQueriesRange'; +import { useNotifications } from 'hooks/useNotifications'; +import { useMemo } from 'react'; +import { useSelector } from 'react-redux'; +import { useLocation } from 'react-router-dom'; +import { AppState } from 'store/reducers'; +import { ServicesList } from 'types/api/metrics/getService'; +import { GlobalReducer } from 'types/reducer/globalTime'; + +import { getColumns } from '../Columns/ServiceColumn'; +import { ServiceMetricsTableProps } from '../types'; +import { getServiceListFromQuery } from '../utils'; + +function ServiceMetricTable({ + topLevelOperations, + queryRangeRequestData, +}: ServiceMetricsTableProps): JSX.Element { + const { minTime, maxTime, selectedTime: globalSelectedInterval } = useSelector< + AppState, + GlobalReducer + >((state) => state.globalTime); + + const { notifications } = useNotifications(); + + const queries = useGetQueriesRange(queryRangeRequestData, { + queryKey: [ + `GetMetricsQueryRange-${queryRangeRequestData[0].selectedTime}-${globalSelectedInterval}`, + maxTime, + minTime, + globalSelectedInterval, + ], + keepPreviousData: true, + enabled: true, + refetchOnMount: false, + onError: (error) => { + notifications.error({ + message: error.message, + }); + }, + }); + + const isLoading = queries.some((query) => query.isLoading); + const services: ServicesList[] = useMemo( + () => + getServiceListFromQuery({ + queries, + topLevelOperations, + isLoading, + }), + [isLoading, queries, topLevelOperations], + ); + + const { search } = useLocation(); + const tableColumns = useMemo(() => getColumns(search, true), [search]); + + return ( + + ); +} + +export default ServiceMetricTable; diff --git a/frontend/src/container/ServiceApplication/ServiceMetrics/ServiceMetricsApplication.tsx b/frontend/src/container/ServiceApplication/ServiceMetrics/ServiceMetricsApplication.tsx new file mode 100644 index 0000000000..4d5889b909 --- /dev/null +++ b/frontend/src/container/ServiceApplication/ServiceMetrics/ServiceMetricsApplication.tsx @@ -0,0 +1,36 @@ +import { useMemo } from 'react'; +import { useSelector } from 'react-redux'; +import { AppState } from 'store/reducers'; +import { GlobalReducer } from 'types/reducer/globalTime'; + +import { ServiceMetricsProps } from '../types'; +import { getQueryRangeRequestData } from '../utils'; +import ServiceMetricTable from './ServiceMetricTable'; + +function ServiceMetricsApplication({ + topLevelOperations, +}: ServiceMetricsProps): JSX.Element { + const { minTime, maxTime, selectedTime: globalSelectedInterval } = useSelector< + AppState, + GlobalReducer + >((state) => state.globalTime); + + const queryRangeRequestData = useMemo( + () => + getQueryRangeRequestData({ + topLevelOperations, + minTime, + maxTime, + globalSelectedInterval, + }), + [globalSelectedInterval, maxTime, minTime, topLevelOperations], + ); + return ( + + ); +} + +export default ServiceMetricsApplication; diff --git a/frontend/src/container/ServiceApplication/ServiceMetrics/ServiceMetricsQuery.ts b/frontend/src/container/ServiceApplication/ServiceMetrics/ServiceMetricsQuery.ts new file mode 100644 index 0000000000..7214a7c912 --- /dev/null +++ b/frontend/src/container/ServiceApplication/ServiceMetrics/ServiceMetricsQuery.ts @@ -0,0 +1,208 @@ +import { ServiceDataProps } from 'api/metrics/getTopLevelOperations'; +import { OPERATORS } from 'constants/queryBuilder'; +import { + DataType, + KeyOperationTableHeader, + MetricsType, + WidgetKeys, +} from 'container/MetricsApplication/constant'; +import { getQueryBuilderQuerieswithFormula } from 'container/MetricsApplication/MetricsPageQueries/MetricsPageQueriesFactory'; +import { BaseAutocompleteData } from 'types/api/queryBuilder/queryAutocompleteResponse'; +import { TagFilterItem } from 'types/api/queryBuilder/queryBuilderData'; +import { + DataSource, + MetricAggregateOperator, + QueryBuilderData, +} from 'types/common/queryBuilder'; + +export const serviceMetricsQuery = ( + topLevelOperation: [keyof ServiceDataProps, string[]], +): QueryBuilderData => { + const p99AutoCompleteData: BaseAutocompleteData = { + dataType: DataType.FLOAT64, + isColumn: true, + key: WidgetKeys.Signoz_latency_bucket, + type: null, + }; + + const errorRateAutoCompleteData: BaseAutocompleteData = { + dataType: DataType.FLOAT64, + isColumn: true, + key: WidgetKeys.SignozCallsTotal, + type: null, + }; + + const operationPrSecondAutoCompleteData: BaseAutocompleteData = { + dataType: DataType.FLOAT64, + isColumn: true, + key: WidgetKeys.SignozCallsTotal, + type: null, + }; + + const autocompleteData = [ + p99AutoCompleteData, + errorRateAutoCompleteData, + errorRateAutoCompleteData, + operationPrSecondAutoCompleteData, + ]; + + const p99AdditionalItems: TagFilterItem[] = [ + { + id: '', + key: { + dataType: DataType.STRING, + isColumn: false, + key: WidgetKeys.Service_name, + type: MetricsType.Resource, + }, + op: OPERATORS.IN, + value: [topLevelOperation[0].toString()], + }, + { + id: '', + key: { + dataType: DataType.STRING, + isColumn: false, + key: WidgetKeys.Operation, + type: MetricsType.Tag, + }, + op: OPERATORS.IN, + value: [...topLevelOperation[1]], + }, + ]; + + const errorRateAdditionalItemsA: TagFilterItem[] = [ + { + id: '', + key: { + dataType: DataType.STRING, + isColumn: false, + key: WidgetKeys.Service_name, + type: MetricsType.Resource, + }, + op: OPERATORS.IN, + value: [topLevelOperation[0].toString()], + }, + { + id: '', + key: { + dataType: DataType.INT64, + isColumn: false, + key: WidgetKeys.StatusCode, + type: MetricsType.Tag, + }, + op: OPERATORS.IN, + value: ['STATUS_CODE_ERROR'], + }, + { + id: '', + key: { + dataType: DataType.STRING, + isColumn: false, + key: WidgetKeys.Operation, + type: MetricsType.Tag, + }, + op: OPERATORS.IN, + value: [...topLevelOperation[1]], + }, + ]; + + const errorRateAdditionalItemsB: TagFilterItem[] = [ + { + id: '', + key: { + dataType: DataType.STRING, + isColumn: false, + key: WidgetKeys.Service_name, + type: MetricsType.Resource, + }, + op: OPERATORS.IN, + value: [topLevelOperation[0].toString()], + }, + { + id: '', + key: { + dataType: DataType.STRING, + isColumn: false, + key: WidgetKeys.Operation, + type: MetricsType.Tag, + }, + op: OPERATORS.IN, + value: [...topLevelOperation[1]], + }, + ]; + + const operationPrSecondAdditionalItems: TagFilterItem[] = [ + { + id: '', + key: { + dataType: DataType.STRING, + isColumn: false, + key: WidgetKeys.Service_name, + type: MetricsType.Resource, + }, + op: OPERATORS.IN, + value: [topLevelOperation[0].toString()], + }, + { + id: '', + key: { + dataType: DataType.STRING, + isColumn: false, + key: WidgetKeys.Operation, + type: MetricsType.Tag, + }, + op: OPERATORS.IN, + value: [...topLevelOperation[1]], + }, + ]; + + const additionalItems = [ + p99AdditionalItems, + errorRateAdditionalItemsA, + errorRateAdditionalItemsB, + operationPrSecondAdditionalItems, + ]; + + const aggregateOperators = [ + MetricAggregateOperator.HIST_QUANTILE_99, + MetricAggregateOperator.SUM_RATE, + MetricAggregateOperator.SUM_RATE, + MetricAggregateOperator.SUM_RATE, + ]; + + const disabled = [false, true, true, false]; + const legends = [ + KeyOperationTableHeader.P99, + KeyOperationTableHeader.ERROR_RATE, + KeyOperationTableHeader.ERROR_RATE, + KeyOperationTableHeader.OPERATION_PR_SECOND, + ]; + + const expressions = ['B*100/C']; + + const legendFormulas = ['Error Rate']; + + const groupBy: BaseAutocompleteData[] = [ + { + dataType: DataType.STRING, + isColumn: false, + key: WidgetKeys.Service_name, + type: MetricsType.Tag, + }, + ]; + + const dataSource = DataSource.METRICS; + + return getQueryBuilderQuerieswithFormula({ + autocompleteData, + additionalItems, + disabled, + legends, + aggregateOperators, + expressions, + legendFormulas, + groupBy, + dataSource, + }); +}; diff --git a/frontend/src/container/ServiceApplication/ServiceMetrics/index.tsx b/frontend/src/container/ServiceApplication/ServiceMetrics/index.tsx new file mode 100644 index 0000000000..740168f96c --- /dev/null +++ b/frontend/src/container/ServiceApplication/ServiceMetrics/index.tsx @@ -0,0 +1,59 @@ +import localStorageGet from 'api/browser/localstorage/get'; +import localStorageSet from 'api/browser/localstorage/set'; +import Spinner from 'components/Spinner'; +import { SKIP_ONBOARDING } from 'constants/onboarding'; +import useGetTopLevelOperations from 'hooks/useGetTopLevelOperations'; +import useResourceAttribute from 'hooks/useResourceAttribute'; +import { convertRawQueriesToTraceSelectedTags } from 'hooks/useResourceAttribute/utils'; +import { useMemo, useState } from 'react'; +import { QueryKey } from 'react-query'; +import { useSelector } from 'react-redux'; +import { AppState } from 'store/reducers'; +import { GlobalReducer } from 'types/reducer/globalTime'; +import { Tags } from 'types/reducer/trace'; + +import SkipOnBoardingModal from '../SkipOnBoardModal'; +import ServiceMetricsApplication from './ServiceMetricsApplication'; + +function ServicesUsingMetrics(): JSX.Element { + const { maxTime, minTime, selectedTime: globalSelectedInterval } = useSelector< + AppState, + GlobalReducer + >((state) => state.globalTime); + const { queries } = useResourceAttribute(); + const selectedTags = useMemo( + () => (convertRawQueriesToTraceSelectedTags(queries) as Tags[]) || [], + [queries], + ); + + const queryKey: QueryKey = [ + minTime, + maxTime, + selectedTags, + globalSelectedInterval, + ]; + const { data, isLoading, isError } = useGetTopLevelOperations(queryKey); + + const [skipOnboarding, setSkipOnboarding] = useState( + localStorageGet(SKIP_ONBOARDING) === 'true', + ); + + const onContinueClick = (): void => { + localStorageSet(SKIP_ONBOARDING, 'true'); + setSkipOnboarding(true); + }; + + const topLevelOperations = Object.entries(data || {}); + + if (isLoading === false && !skipOnboarding && isError === true) { + return ; + } + + if (isLoading) { + return ; + } + + return ; +} + +export default ServicesUsingMetrics; diff --git a/frontend/src/container/ServiceApplication/ServiceTraces/Service.test.tsx b/frontend/src/container/ServiceApplication/ServiceTraces/Service.test.tsx new file mode 100644 index 0000000000..2e94296d8d --- /dev/null +++ b/frontend/src/container/ServiceApplication/ServiceTraces/Service.test.tsx @@ -0,0 +1,50 @@ +import { render, screen, waitFor } from '@testing-library/react'; +import ROUTES from 'constants/routes'; +import { BrowserRouter } from 'react-router-dom'; + +import { services } from './__mocks__/getServices'; +import ServiceTraceTable from './ServiceTracesTable'; + +jest.mock('react-router-dom', () => ({ + ...jest.requireActual('react-router-dom'), + useLocation: (): { pathname: string } => ({ + pathname: `${process.env.FRONTEND_API_ENDPOINT}${ROUTES.APPLICATION}/`, + }), +})); + +describe('Metrics Component', () => { + it('renders without errors', async () => { + render( + + + , + ); + + await waitFor(() => { + expect(screen.getByText(/application/i)).toBeInTheDocument(); + expect(screen.getByText(/p99 latency \(in ms\)/i)).toBeInTheDocument(); + expect(screen.getByText(/error rate \(% of total\)/i)).toBeInTheDocument(); + expect(screen.getByText(/operations per second/i)).toBeInTheDocument(); + }); + }); + + it('renders if the data is loaded in the table', async () => { + render( + + + , + ); + + expect(screen.getByText('frontend')).toBeInTheDocument(); + }); + + it('renders no data when required conditions are met', async () => { + render( + + + , + ); + + expect(screen.getByText('No data')).toBeInTheDocument(); + }); +}); diff --git a/frontend/src/container/ServiceApplication/ServiceTraces/ServiceTracesTable.tsx b/frontend/src/container/ServiceApplication/ServiceTraces/ServiceTracesTable.tsx new file mode 100644 index 0000000000..b5c4f6e7a1 --- /dev/null +++ b/frontend/src/container/ServiceApplication/ServiceTraces/ServiceTracesTable.tsx @@ -0,0 +1,26 @@ +import { ResizeTable } from 'components/ResizeTable'; +import { useMemo } from 'react'; +import { useLocation } from 'react-router-dom'; + +import { getColumns } from '../Columns/ServiceColumn'; +import ServiceTableProps from '../types'; + +function ServiceTraceTable({ + services, + loading, +}: ServiceTableProps): JSX.Element { + const { search } = useLocation(); + + const tableColumns = useMemo(() => getColumns(search, false), [search]); + + return ( + + ); +} + +export default ServiceTraceTable; diff --git a/frontend/src/container/ServiceApplication/ServiceTraces/__mocks__/getServices.ts b/frontend/src/container/ServiceApplication/ServiceTraces/__mocks__/getServices.ts new file mode 100644 index 0000000000..c7ffdf0d46 --- /dev/null +++ b/frontend/src/container/ServiceApplication/ServiceTraces/__mocks__/getServices.ts @@ -0,0 +1,22 @@ +import { ServicesList } from 'types/api/metrics/getService'; + +export const services: ServicesList[] = [ + { + serviceName: 'frontend', + p99: 1261498140, + avgDuration: 768497850.9803921, + numCalls: 255, + callRate: 0.9444444444444444, + numErrors: 0, + errorRate: 0, + }, + { + serviceName: 'customer', + p99: 890150740.0000001, + avgDuration: 369612035.2941176, + numCalls: 255, + callRate: 0.9444444444444444, + numErrors: 0, + errorRate: 0, + }, +]; diff --git a/frontend/src/container/ServiceApplication/ServiceTraces/index.tsx b/frontend/src/container/ServiceApplication/ServiceTraces/index.tsx new file mode 100644 index 0000000000..370697af00 --- /dev/null +++ b/frontend/src/container/ServiceApplication/ServiceTraces/index.tsx @@ -0,0 +1,60 @@ +import localStorageGet from 'api/browser/localstorage/get'; +import localStorageSet from 'api/browser/localstorage/set'; +import { SKIP_ONBOARDING } from 'constants/onboarding'; +import useErrorNotification from 'hooks/useErrorNotification'; +import { useQueryService } from 'hooks/useQueryService'; +import useResourceAttribute from 'hooks/useResourceAttribute'; +import { convertRawQueriesToTraceSelectedTags } from 'hooks/useResourceAttribute/utils'; +import { useMemo, useState } from 'react'; +import { useSelector } from 'react-redux'; +import { AppState } from 'store/reducers'; +import { GlobalReducer } from 'types/reducer/globalTime'; +import { Tags } from 'types/reducer/trace'; + +import SkipOnBoardingModal from '../SkipOnBoardModal'; +import ServiceTraceTable from './ServiceTracesTable'; + +function ServiceTraces(): JSX.Element { + const { maxTime, minTime, selectedTime } = useSelector< + AppState, + GlobalReducer + >((state) => state.globalTime); + const { queries } = useResourceAttribute(); + const selectedTags = useMemo( + () => (convertRawQueriesToTraceSelectedTags(queries) as Tags[]) || [], + [queries], + ); + + const { data, error, isLoading, isError } = useQueryService({ + minTime, + maxTime, + selectedTime, + selectedTags, + }); + + useErrorNotification(error); + + const services = data || []; + + const [skipOnboarding, setSkipOnboarding] = useState( + localStorageGet(SKIP_ONBOARDING) === 'true', + ); + + const onContinueClick = (): void => { + localStorageSet(SKIP_ONBOARDING, 'true'); + setSkipOnboarding(true); + }; + + if ( + services.length === 0 && + isLoading === false && + !skipOnboarding && + isError === true + ) { + return ; + } + + return ; +} + +export default ServiceTraces; diff --git a/frontend/src/container/ServiceApplication/SkipOnBoardModal/index.tsx b/frontend/src/container/ServiceApplication/SkipOnBoardModal/index.tsx new file mode 100644 index 0000000000..aedc3d4e43 --- /dev/null +++ b/frontend/src/container/ServiceApplication/SkipOnBoardModal/index.tsx @@ -0,0 +1,48 @@ +import { Button, Typography } from 'antd'; +import Modal from 'components/Modal'; + +function SkipOnBoardingModal({ onContinueClick }: Props): JSX.Element { + return ( + + Continue without instrumentation + , + ]} + > + <> +