Merge pull request #3256 from SigNoz/release/v0.25.1

Release/v0.25.1
This commit is contained in:
Ankit Nayan 2023-08-03 01:00:19 +05:30 committed by GitHub
commit 558352e43b
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
243 changed files with 11040 additions and 1418 deletions

View File

@ -1,40 +1,75 @@
<p align="center">
<img src="https://res.cloudinary.com/dcv3epinx/image/upload/v1618904450/signoz-images/LogoGithub_sigfbu.svg" alt="SigNoz-logo" width="240" />
<p align="center">Überwache deine Anwendungen und behebe Probleme in deinen bereitgestellten Anwendungen. SigNoz ist eine Open Source Alternative zu DataDog, New Relic, etc.</p>
</p>
<p align="center">
<img alt="Downloads" src="https://img.shields.io/docker/pulls/signoz/frontend?label=Downloads"> </a>
<img alt="Downloads" src="https://img.shields.io/docker/pulls/signoz/query-service?label=Downloads"> </a>
<img alt="GitHub issues" src="https://img.shields.io/github/issues/signoz/signoz"> </a>
<a href="https://twitter.com/intent/tweet?text=Monitor%20your%20applications%20and%20troubleshoot%20problems%20with%20SigNoz,%20an%20open-source%20alternative%20to%20DataDog,%20NewRelic.&url=https://signoz.io/&via=SigNozHQ&hashtags=opensource,signoz,observability">
<img alt="tweet" src="https://img.shields.io/twitter/url/http/shields.io.svg?style=social"> </a>
<a href="https://twitter.com/intent/tweet?text=Monitor%20your%20applications%20and%20troubleshoot%20problems%20with%20SigNoz,%20an%20open-source%20alternative%20to%20DataDog,%20NewRelic.&url=https://signoz.io/&via=SigNozHQ&hashtags=opensource,signoz,observability">
<img alt="tweet" src="https://img.shields.io/twitter/url/http/shields.io.svg?style=social"> </a>
</p>
<h3 align="center">
<a href="https://signoz.io/docs"><b>Dokumentation</b></a> &bull;
<a href="https://github.com/SigNoz/signoz/blob/develop/README.md"><b>Readme auf Englisch </b></a> &bull;
<a href="https://github.com/SigNoz/signoz/blob/develop/README.zh-cn.md"><b>ReadMe auf Chinesisch</b></a> &bull;
<a href="https://github.com/SigNoz/signoz/blob/develop/README.pt-br.md"><b>ReadMe auf Portugiesisch</b></a> &bull;
<a href="https://signoz.io/slack"><b>Slack Community</b></a> &bull;
<a href="https://twitter.com/SigNozHQ"><b>Twitter</b></a>
<a href="https://twitter.com/SigNozHq"><b>Twitter</b></a>
</h3>
##
##
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
<img width="2068" alt="distributed_tracing_2 2" src="https://user-images.githubusercontent.com/83692067/226536447-bae58321-6a22-4ed3-af80-e3e964cb3489.png">
<img width="2068" alt="distributed_tracing_1" src="https://user-images.githubusercontent.com/83692067/226536462-939745b6-4f9d-45a6-8016-814837e7f7b4.png">
### Log Verwaltung
<img width="2068" alt="logs_management" src="https://user-images.githubusercontent.com/83692067/226536482-b8a5c4af-b69c-43d5-969c-338bd5eaf1a5.png">
### Infrastruktur Überwachung
<img width="2068" alt="infrastructure_monitoring" src="https://user-images.githubusercontent.com/83692067/226536496-f38c4dbf-e03c-4158-8be0-32d4a61158c7.png">
### Exceptions Monitoring
![exceptions_light](https://user-images.githubusercontent.com/83692067/226637967-4188d024-3ac9-4799-be95-f5ea9c45436f.png)
### Alarme
<img width="2068" alt="alerts_management" src="https://user-images.githubusercontent.com/83692067/226536548-2c81e2e8-c12d-47e8-bad7-c6be79055def.png">
![SigNoz Feature](https://signoz-public.s3.us-east-2.amazonaws.com/signoz_hero_github.png)
<br /><br />
<img align="left" src="https://signoz-public.s3.us-east-2.amazonaws.com/Contributing.svg" width="50px" />
## Werde Teil unserer Slack Community
@ -42,20 +77,22 @@ Sag Hi zu uns auf [Slack](https://signoz.io/slack) 👋
<br /><br />
<img align="left" src="https://signoz-public.s3.us-east-2.amazonaws.com/Features.svg" width="50px" />
## 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.
<br /><br />
<img align="left" src="https://signoz-public.s3.us-east-2.amazonaws.com/WhatsCool.svg" width="50px" />
## 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/
<br /><br />
<img align="left" src="https://signoz-public.s3.us-east-2.amazonaws.com/Philosophy.svg" width="50px" />
## 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.
<p>&nbsp </p>
### 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.
<br /><br />
<img align="left" src="https://signoz-public.s3.us-east-2.amazonaws.com/UseSigNoz.svg" width="50px" />
## 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
<p>&nbsp </p>
### 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.
<p>&nbsp </p>
### 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)
<p>&nbsp </p>
### 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)
<br /><br />
<img align="left" src="https://signoz-public.s3.us-east-2.amazonaws.com/Contributors.svg" width="50px" />
## 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)
<br /><br />
<img align="left" src="https://signoz-public.s3.us-east-2.amazonaws.com/DevelopingLocally.svg" width="50px" />
## Dokumentation
Du findest unsere Dokumentation unter https://signoz.io/docs/. Falls etwas unverständlich ist oder fehlt, öffne gerne ein Github Issue mit dem Label `documentation` oder schreib uns über den Community Slack Channel.
<br /><br />
<img align="left" src="https://signoz-public.s3.us-east-2.amazonaws.com/Contributing.svg" width="50px" />
## 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!
<a href="https://github.com/signoz/signoz/graphs/contributors">
<img src="https://contrib.rocks/image?repo=signoz/signoz" />
</a>

View File

@ -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

View File

@ -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:

View File

@ -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

View File

@ -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
}

View File

@ -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()
}
}
}

View File

@ -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
}

View File

@ -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: ['<rootDir>jest.setup.ts'],
testPathIgnorePatterns: ['/node_modules/', '/public/'],
moduleDirectories: ['node_modules', 'src'],

View File

@ -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",

View File

@ -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"
}

View File

@ -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"
}

View File

@ -12,6 +12,7 @@
"routes": {
"general": "General",
"alert_channels": "Alert Channels",
"all_errors": "All Exceptions"
"all_errors": "All Exceptions",
"pipelines": "Pipelines"
}
}

View File

@ -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"
}

View File

@ -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"
}

View File

@ -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"
}

View File

@ -12,6 +12,7 @@
"routes": {
"general": "General",
"alert_channels": "Alert Channels",
"all_errors": "All Exceptions"
"all_errors": "All Exceptions",
"pipelines": "Pipelines"
}
}

View File

@ -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'),
);

View File

@ -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 {

View File

@ -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);

View File

@ -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<SuccessResponse<Pipeline> | 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;

View File

@ -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<SuccessResponse<Pipeline> | 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;

View File

@ -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<HTMLTableRowElement>(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 (
<tr
ref={ref}
className={className}
style={{ ...style }}
// eslint-disable-next-line react/jsx-props-no-spreading
{...restProps}
/>
);
}
interface DraggableTableRowProps
extends React.HTMLAttributes<HTMLTableRowElement> {
index: number;
moveRow: (dragIndex: number, hoverIndex: number) => void;
}
export default DraggableTableRow;

View File

@ -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(
<Provider store={store}>
<I18nextProvider i18n={i18n}>
<Table
components={{
body: {
row: DraggableTableRow,
},
}}
pagination={false}
/>
</I18nextProvider>
</Provider>,
);
expect(asFragment()).toMatchSnapshot();
});
});

View File

@ -0,0 +1,103 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`DraggableTableRow Snapshot test should render DraggableTableRow 1`] = `
<DocumentFragment>
<div
class="ant-table-wrapper css-dev-only-do-not-override-1i536d8"
>
<div
class="ant-spin-nested-loading css-dev-only-do-not-override-1i536d8"
>
<div
class="ant-spin-container"
>
<div
class="ant-table ant-table-empty"
>
<div
class="ant-table-container"
>
<div
class="ant-table-content"
>
<table
style="table-layout: auto;"
>
<colgroup />
<thead
class="ant-table-thead"
>
<tr>
<th
class="ant-table-cell"
/>
</tr>
</thead>
<tbody
class="ant-table-tbody"
>
<tr
class="ant-table-placeholder"
>
<td
class="ant-table-cell"
>
<div
class="css-dev-only-do-not-override-1i536d8 ant-empty ant-empty-normal"
>
<div
class="ant-empty-image"
>
<svg
height="41"
viewBox="0 0 64 41"
width="64"
xmlns="http://www.w3.org/2000/svg"
>
<g
fill="none"
fill-rule="evenodd"
transform="translate(0 1)"
>
<ellipse
cx="32"
cy="33"
fill="#f5f5f5"
rx="32"
ry="7"
/>
<g
fill-rule="nonzero"
stroke="#d9d9d9"
>
<path
d="M55 12.76L44.854 1.258C44.367.474 43.656 0 42.907 0H21.093c-.749 0-1.46.474-1.947 1.257L9 12.761V22h46v-9.24z"
/>
<path
d="M41.613 15.931c0-1.605.994-2.93 2.227-2.931H55v18.137C55 33.26 53.68 35 52.05 35h-40.1C10.32 35 9 33.259 9 31.137V13h11.16c1.233 0 2.227 1.323 2.227 2.928v.022c0 1.605 1.005 2.901 2.237 2.901h14.752c1.232 0 2.237-1.308 2.237-2.913v-.007z"
fill="#fafafa"
/>
</g>
</g>
</svg>
</div>
<div
class="ant-empty-description"
>
No data
</div>
</div>
</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
</div>
</div>
</div>
</DocumentFragment>
`;
exports[`PipelinePage container test should render AddNewPipeline section 1`] = `<DocumentFragment />`;

View File

@ -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 });
});
});

View File

@ -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(),
};
}

View File

@ -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<ChartType> => ({
item.datasetIndex,
!chart.isDatasetVisible(item.datasetIndex),
);
eventEmitter.emit(Events.UPDATE_GRAPH_MANAGER_TABLE, {
name: id,
index: item.datasetIndex,
});
}
chart.update();
};

View File

@ -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 | number>(null);
const chartRef = useRef<HTMLCanvasElement>(null);
const isDarkMode = useIsDarkMode();
const Graph = forwardRef<ToggleGraphProps | undefined, GraphProps>(
(
{
animate = true,
data,
type,
title,
isStacked,
onClickHandler,
name,
yAxisUnit = 'short',
forceReRender,
staticLine,
containerHeight,
onDragSelect,
dragSelectColor,
},
ref,
): JSX.Element => {
const nearestDatasetIndex = useRef<null | number>(null);
const chartRef = useRef<HTMLCanvasElement>(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<Chart>();
const getGridColor = useCallback(() => {
if (currentTheme === undefined) {
return 'rgba(231,233,237,0.1)';
}
const lineChartRef = useRef<Chart>();
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 (
<div style={{ height: containerHeight }}>
<canvas ref={chartRef} />
<LegendsContainer id={name} />
</div>
);
}
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 (
<div style={{ height: containerHeight }}>
<canvas ref={chartRef} />
<LegendsContainer id={name} />
</div>
);
},
);
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),
);

View File

@ -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<ToggleGraphProps | undefined>;
}
export interface IAxisTimeUintConfig {
unitName: TimeUnit;
multiplier: number;
}
export interface IAxisTimeConfig {
unitName: TimeUnit;
stepSize: number;
}
export interface ITimeRange {
minTime: number | null;
maxTime: number | null;
}

View File

@ -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<Chart | undefined>,
): 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<number | null>,
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;
}
}
},
});

View File

@ -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<TimeUnit, TimeUnit> = {
millisecond: 'millisecond',

View File

@ -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<AddToQueryHOCProps, 'onAddToQuery'> &
Pick<ActionItemProps, 'onClickActionItem'>;
Pick<ActionItemProps, 'onClickActionItem'> &
Pick<DrawerProps, 'onClose'>;

View File

@ -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

View File

@ -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<AddToQueryHOCProps, 'onAddToQuery'>;
};
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 (
<Container>
<Container $isActiveLog={isHighlighted}>
<div>
<LogContainer>
<>
@ -169,6 +193,42 @@ function ListLogView({
>
Copy JSON
</Button>
{isLogsExplorerPage && (
<>
<Button
size="small"
type="text"
onClick={handleShowContext}
style={{ color: grey[1] }}
icon={<MonitorOutlined />}
>
Show in Context
</Button>
<Button
size="small"
type="text"
onClick={onLogCopy}
style={{ color: grey[1] }}
icon={<LinkOutlined />}
>
Copy Link
</Button>
</>
)}
{activeContextLog && (
<LogsExplorerContext
log={activeContextLog}
onClose={handleClearActiveContextLog}
/>
)}
<LogDetail
log={activeLog}
onClose={onClearActiveLog}
onAddToQuery={onAddToQuery}
onClickActionItem={onAddToQuery}
/>
</Row>
</Container>
);

View File

@ -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)`

View File

@ -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<boolean>(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<Element, globalThis.MouseEvent> | KeyboardEvent<Element>,
) => {
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<HTMLElement> = 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 (
<RawLogViewContainer
onClick={handleClickExpand}
wrap={false}
align="middle"
$isDarkMode={isDarkMode}
$isReadOnly={isReadOnly}
$isActiveLog={isHighlighted}
// eslint-disable-next-line react/jsx-props-no-spreading
{...mouseActions}
>
<ExpandIconWrapper flex="30px">
<ExpandAltOutlined />
</ExpandIconWrapper>
<RawLogContent linesPerRow={linesPerRow} dangerouslySetInnerHTML={html} />
{!isReadOnly && (
<ExpandIconWrapper flex="30px">
<ExpandAltOutlined />
</ExpandIconWrapper>
)}
<RawLogContent
$isReadOnly={isReadOnly}
$isActiveLog={isActiveLog}
linesPerRow={linesPerRow}
dangerouslySetInnerHTML={html}
/>
{hasActionButtons && (
<ActionButtonsWrapper>
<Tooltip title="Show Context">
<Button
size="small"
icon={<MonitorOutlined />}
onClick={handleShowContext}
/>
</Tooltip>
<Tooltip title="Copy Link">
<Button size="small" icon={<LinkOutlined />} onClick={onLogCopy} />
</Tooltip>
</ActionButtonsWrapper>
)}
{activeContextLog && (
<LogsExplorerContext
log={activeContextLog}
onClose={handleClearActiveContextLog}
/>
)}
<LogDetail
log={activeLog}
onClose={handleCloseLogDetail}
onAddToQuery={onAddToQuery}
onClickActionItem={onAddToQuery}
/>
</RawLogViewContainer>
);
}
RawLogView.defaultProps = {
isActiveLog: false,
isReadOnly: false,
};
export default RawLogView;

View File

@ -1,8 +1,14 @@
import { blue } from '@ant-design/colors';
import { Col, Row } from 'antd';
import { Col, Row, Space } from 'antd';
import styled from 'styled-components';
import { getActiveLogBackground, getDefaultLogBackground } from 'utils/logs';
export const RawLogViewContainer = styled(Row)<{ $isDarkMode: boolean }>`
export const RawLogViewContainer = styled(Row)<{
$isDarkMode: boolean;
$isReadOnly: boolean;
$isActiveLog: boolean;
}>`
position: relative;
width: 100%;
font-weight: 700;
font-size: 0.625rem;
@ -10,10 +16,12 @@ export const RawLogViewContainer = styled(Row)<{ $isDarkMode: boolean }>`
transition: background-color 0.2s ease-in;
&:hover {
background-color: ${({ $isDarkMode }): string =>
$isDarkMode ? 'rgba(255,255,255,0.1)' : 'rgba(0, 0, 0, 0.1)'};
}
${({ $isActiveLog }): string => getActiveLogBackground($isActiveLog)}
${({ $isReadOnly, $isDarkMode, $isActiveLog }): string =>
$isActiveLog
? getActiveLogBackground()
: getDefaultLogBackground($isReadOnly, $isDarkMode)}
`;
export const ExpandIconWrapper = styled(Col)`
@ -25,6 +33,8 @@ export const ExpandIconWrapper = styled(Col)`
interface RawLogContentProps {
linesPerRow: number;
$isReadOnly: boolean;
$isActiveLog: boolean;
}
export const RawLogContent = styled.div<RawLogContentProps>`
@ -42,5 +52,17 @@ export const RawLogContent = styled.div<RawLogContentProps>`
font-size: 1rem;
line-height: 2rem;
cursor: ${(props): string =>
props.$isActiveLog || props.$isReadOnly ? 'initial' : 'pointer'};
${(props): string =>
props.$isReadOnly && !props.$isActiveLog ? 'padding: 0 1.5rem;' : ''}
`;
export const ActionButtonsWrapper = styled(Space)`
position: absolute;
transform: translate(-50%, -50%);
top: 50%;
right: 0;
cursor: pointer;
`;

View File

@ -10,7 +10,6 @@ export type LogsTableViewProps = {
logs: ILog[];
fields: IField[];
linesPerRow: number;
onClickExpand: (log: ILog) => void;
};
export type UseTableViewResult = {
@ -20,4 +19,12 @@ export type UseTableViewResult = {
export type UseTableViewProps = {
appendTo?: 'center' | 'end';
onOpenLogsContext?: (log: ILog) => void;
onClickExpand?: (log: ILog) => void;
} & LogsTableViewProps;
export type ActionsColumnProps = {
logId: string;
logs: ILog[];
onOpenLogsContext?: (log: ILog) => void;
};

View File

@ -1,16 +1,22 @@
import { ExpandAltOutlined } from '@ant-design/icons';
import {
ExpandAltOutlined,
LinkOutlined,
MonitorOutlined,
} from '@ant-design/icons';
import Convert from 'ansi-to-html';
import { Typography } from 'antd';
import { Button, Space, Typography } from 'antd';
import { ColumnsType } from 'antd/es/table';
import dayjs from 'dayjs';
import dompurify from 'dompurify';
import { useCopyLogLink } from 'hooks/logs/useCopyLogLink';
import { FlatLogData } from 'lib/logs/flatLogData';
import { useMemo } from 'react';
import { useCallback, useMemo } from 'react';
import { ExpandIconWrapper } from '../RawLogView/styles';
import { defaultCellStyle, defaultTableStyle } from './config';
import { TableBodyContent } from './styles';
import {
ActionsColumnProps,
ColumnTypeRender,
UseTableViewProps,
UseTableViewResult,
@ -18,19 +24,60 @@ import {
const convert = new Convert();
function ActionsColumn({
logId,
logs,
onOpenLogsContext,
}: ActionsColumnProps): JSX.Element {
const currentLog = useMemo(() => logs.find(({ id }) => id === logId), [
logs,
logId,
]);
const { onLogCopy } = useCopyLogLink(currentLog?.id);
const handleShowContext = useCallback(() => {
if (!onOpenLogsContext || !currentLog) return;
onOpenLogsContext(currentLog);
}, [currentLog, onOpenLogsContext]);
return (
<Space>
<Button
size="small"
onClick={handleShowContext}
icon={<MonitorOutlined />}
/>
<Button size="small" onClick={onLogCopy} icon={<LinkOutlined />} />
</Space>
);
}
export const useTableView = (props: UseTableViewProps): UseTableViewResult => {
const {
logs,
fields,
linesPerRow,
onClickExpand,
appendTo = 'center',
onOpenLogsContext,
onClickExpand,
} = props;
const { isLogsExplorerPage } = useCopyLogLink();
const flattenLogData = useMemo(() => logs.map((log) => FlatLogData(log)), [
logs,
]);
const handleClickExpand = useCallback(
(index: number): void => {
if (!onClickExpand) return;
onClickExpand(logs[index]);
},
[logs, onClickExpand],
);
const columns: ColumnsType<Record<string, unknown>> = useMemo(() => {
const fieldColumns: ColumnsType<Record<string, unknown>> = fields
.filter((e) => e.name !== 'id')
@ -63,7 +110,7 @@ export const useTableView = (props: UseTableViewProps): UseTableViewResult => {
children: (
<ExpandIconWrapper
onClick={(): void => {
onClickExpand(logs[index]);
handleClickExpand(index);
}}
>
<ExpandAltOutlined />
@ -106,8 +153,34 @@ export const useTableView = (props: UseTableViewProps): UseTableViewResult => {
}),
},
...(appendTo === 'end' ? fieldColumns : []),
...(isLogsExplorerPage
? ([
{
title: 'actions',
dataIndex: 'actions',
key: 'actions',
render: (_, log): ColumnTypeRender<Record<string, unknown>> => ({
children: (
<ActionsColumn
logId={(log.id as unknown) as string}
logs={logs}
onOpenLogsContext={onOpenLogsContext}
/>
),
}),
},
] as ColumnsType<Record<string, unknown>>)
: []),
];
}, [fields, appendTo, linesPerRow, onClickExpand, logs]);
}, [
logs,
fields,
appendTo,
linesPerRow,
isLogsExplorerPage,
handleClickExpand,
onOpenLogsContext,
]);
return { columns, dataSource: flattenLogData };
};

View File

@ -0,0 +1,22 @@
import { ColumnType } from 'antd/es/table';
import { ColumnsType } from 'antd/lib/table';
export const generatorResizeTableColumns = <T>({
baseColumnOptions,
dynamicColumnOption,
}: GeneratorResizeTableColumnsProp<T>): ColumnsType<T> =>
baseColumnOptions.map((config: ColumnType<T>) => {
const { key } = config;
const extraConfig = dynamicColumnOption.find(
(dynamicConfigItem) => dynamicConfigItem.key === key,
);
return {
...config,
...extraConfig?.columnOption,
};
});
interface GeneratorResizeTableColumnsProp<T> {
baseColumnOptions: ColumnsType<T>;
dynamicColumnOption: { key: string; columnOption: ColumnType<T> }[];
}

View File

@ -0,0 +1,4 @@
export enum Events {
UPDATE_GRAPH_VISIBILITY_STATE = 'UPDATE_GRAPH_VISIBILITY_STATE',
UPDATE_GRAPH_MANAGER_TABLE = 'UPDATE_GRAPH_MANAGER_TABLE',
}

View File

@ -8,6 +8,7 @@ export enum LOCALSTORAGE {
LOGS_LINES_PER_ROW = 'LOGS_LINES_PER_ROW',
LOGS_LIST_OPTIONS = 'LOGS_LIST_OPTIONS',
TRACES_LIST_OPTIONS = 'TRACES_LIST_OPTIONS',
GRAPH_VISIBILITY_STATES = 'GRAPH_VISIBILITY_STATES',
TRACES_LIST_COLUMNS = 'TRACES_LIST_COLUMNS',
LOGS_LIST_COLUMNS = 'LOGS_LIST_COLUMNS',
}

View File

@ -16,4 +16,6 @@ export enum QueryParams {
widgetId = 'widgetId',
order = 'order',
q = 'q',
activeLogId = 'activeLogId',
timeRange = 'timeRange',
}

View File

@ -32,6 +32,8 @@ const ROUTES = {
HOME_PAGE: '/',
PASSWORD_RESET: '/password-reset',
LIST_LICENSES: '/licenses',
TRACE_EXPLORER: '/trace-explorer',
PIPELINES: '/pipelines',
};
export default ROUTES;

View File

@ -38,11 +38,20 @@ const themeColors = {
whiteCream: '#ffffffd5',
white: '#ffffff',
black: '#000000',
darkGrey: '#262626',
lightBlack: '#141414',
lightgrey: '#ddd',
lightWhite: '#ffffffd9',
borderLightGrey: '#d9d9d9',
borderDarkGrey: '#424242',
gainsboro: '#DBDBDB',
navyBlue: '#1668DC',
lightSkyBlue: '#8DCFF8',
neroBlack: '#1d1d1d',
snowWhite: '#fafafa',
gamboge: '#D89614',
bckgGrey: '#1d1d1d',
lightBlue: '#177ddc',
};
export { themeColors };

View File

@ -6,7 +6,8 @@ import Header from 'container/Header';
import SideNav from 'container/SideNav';
import TopNav from 'container/TopNav';
import { useNotifications } from 'hooks/useNotifications';
import { ReactNode, useEffect, useRef } from 'react';
import { ReactNode, useEffect, useMemo, useRef } from 'react';
import { Helmet } from 'react-helmet-async';
import { useTranslation } from 'react-i18next';
import { useQueries } from 'react-query';
import { useDispatch, useSelector } from 'react-redux';
@ -25,13 +26,14 @@ import {
import AppReducer from 'types/reducer/app';
import { ChildrenContainer, Layout } from './styles';
import { getRouteKey } from './utils';
function AppLayout(props: AppLayoutProps): JSX.Element {
const { isLoggedIn, user } = useSelector<AppState, AppReducer>(
(state) => state.app,
);
const { pathname } = useLocation();
const { t } = useTranslation();
const { t } = useTranslation(['titles']);
const [
getUserVersionResponse,
@ -226,8 +228,15 @@ function AppLayout(props: AppLayoutProps): JSX.Element {
const isToDisplayLayout = isLoggedIn;
const routeKey = useMemo(() => getRouteKey(pathname), [pathname]);
const pageTitle = t(routeKey);
return (
<Layout>
<Helmet>
<title>{pageTitle}</title>
</Helmet>
{isToDisplayLayout && <Header />}
<Layout>
{isToDisplayLayout && <SideNav />}

View File

@ -0,0 +1,9 @@
import ROUTES from 'constants/routes';
export function getRouteKey(pathname: string): string {
const [routeKey] = Object.entries(ROUTES).find(
([, value]) => value === pathname,
) || ['DEFAULT'];
return routeKey;
}

View File

@ -1,5 +1,5 @@
import { InfoCircleOutlined } from '@ant-design/icons';
import { StaticLineProps } from 'components/Graph';
import { StaticLineProps } from 'components/Graph/types';
import Spinner from 'components/Spinner';
import { initialQueriesMap, PANEL_TYPES } from 'constants/queryBuilder';
import GridPanelSwitch from 'container/GridPanelSwitch';

View File

@ -0,0 +1,207 @@
import { Button, Input } from 'antd';
import { CheckboxChangeEvent } from 'antd/es/checkbox';
import { ResizeTable } from 'components/ResizeTable';
import { Events } from 'constants/events';
import { useNotifications } from 'hooks/useNotifications';
import isEqual from 'lodash-es/isEqual';
import { memo, useCallback, useEffect, useMemo, useState } from 'react';
import { eventEmitter } from 'utils/getEventEmitter';
import { getGraphVisibilityStateOnDataChange } from '../utils';
import {
FilterTableAndSaveContainer,
FilterTableContainer,
SaveCancelButtonContainer,
SaveContainer,
} from './styles';
import { getGraphManagerTableColumns } from './TableRender/GraphManagerColumns';
import { ExtendedChartDataset, GraphManagerProps } from './types';
import {
getDefaultTableDataSet,
saveLegendEntriesToLocalStorage,
} from './utils';
function GraphManager({
data,
name,
yAxisUnit,
onToggleModelHandler,
}: GraphManagerProps): JSX.Element {
const {
graphVisibilityStates: localstoredVisibilityStates,
legendEntry,
} = useMemo(
() =>
getGraphVisibilityStateOnDataChange({
data,
isExpandedName: false,
name,
}),
[data, name],
);
const [graphVisibilityState, setGraphVisibilityState] = useState<boolean[]>(
localstoredVisibilityStates,
);
const [tableDataSet, setTableDataSet] = useState<ExtendedChartDataset[]>(
getDefaultTableDataSet(data),
);
const { notifications } = useNotifications();
// useEffect for updating graph visibility state on data change
useEffect(() => {
const newGraphVisibilityStates = Array<boolean>(data.datasets.length).fill(
true,
);
data.datasets.forEach((dataset, i) => {
const index = legendEntry.findIndex(
(entry) => entry.label === dataset.label,
);
if (index !== -1) {
newGraphVisibilityStates[i] = legendEntry[index].show;
}
});
eventEmitter.emit(Events.UPDATE_GRAPH_VISIBILITY_STATE, {
name,
graphVisibilityStates: newGraphVisibilityStates,
});
setGraphVisibilityState(newGraphVisibilityStates);
}, [data, name, legendEntry]);
// useEffect for listening to events event graph legend is clicked
useEffect(() => {
const eventListener = eventEmitter.on(
Events.UPDATE_GRAPH_MANAGER_TABLE,
(data) => {
if (data.name === name) {
const newGraphVisibilityStates = graphVisibilityState;
newGraphVisibilityStates[data.index] = !newGraphVisibilityStates[
data.index
];
eventEmitter.emit(Events.UPDATE_GRAPH_VISIBILITY_STATE, {
name,
graphVisibilityStates: newGraphVisibilityStates,
});
setGraphVisibilityState([...newGraphVisibilityStates]);
}
},
);
return (): void => {
eventListener.off(Events.UPDATE_GRAPH_MANAGER_TABLE);
};
}, [graphVisibilityState, name]);
const checkBoxOnChangeHandler = useCallback(
(e: CheckboxChangeEvent, index: number): void => {
graphVisibilityState[index] = e.target.checked;
setGraphVisibilityState([...graphVisibilityState]);
eventEmitter.emit(Events.UPDATE_GRAPH_VISIBILITY_STATE, {
name,
graphVisibilityStates: [...graphVisibilityState],
});
},
[graphVisibilityState, name],
);
const labelClickedHandler = useCallback(
(labelIndex: number): void => {
const newGraphVisibilityStates = Array<boolean>(data.datasets.length).fill(
false,
);
newGraphVisibilityStates[labelIndex] = true;
setGraphVisibilityState([...newGraphVisibilityStates]);
eventEmitter.emit(Events.UPDATE_GRAPH_VISIBILITY_STATE, {
name,
graphVisibilityStates: newGraphVisibilityStates,
});
},
[data.datasets.length, name],
);
const columns = useMemo(
() =>
getGraphManagerTableColumns({
data,
checkBoxOnChangeHandler,
graphVisibilityState,
labelClickedHandler,
yAxisUnit,
}),
[
checkBoxOnChangeHandler,
data,
graphVisibilityState,
labelClickedHandler,
yAxisUnit,
],
);
const filterHandler = useCallback(
(event: React.ChangeEvent<HTMLInputElement>): void => {
const value = event.target.value.toString().toLowerCase();
const updatedDataSet = tableDataSet.map((item) => {
if (item.label?.toLocaleLowerCase().includes(value)) {
return { ...item, show: true };
}
return { ...item, show: false };
});
setTableDataSet(updatedDataSet);
},
[tableDataSet],
);
const saveHandler = useCallback((): void => {
saveLegendEntriesToLocalStorage({
data,
graphVisibilityState,
name,
});
notifications.success({
message: 'The updated graphs & legends are saved',
});
if (onToggleModelHandler) {
onToggleModelHandler();
}
}, [data, graphVisibilityState, name, notifications, onToggleModelHandler]);
const dataSource = tableDataSet.filter((item) => item.show);
return (
<FilterTableAndSaveContainer>
<FilterTableContainer>
<Input onChange={filterHandler} placeholder="Filter Series" />
<ResizeTable
columns={columns}
dataSource={dataSource}
rowKey="index"
pagination={false}
scroll={{ y: 240 }}
/>
</FilterTableContainer>
<SaveContainer>
<SaveCancelButtonContainer>
<Button type="default" onClick={onToggleModelHandler}>
Cancel
</Button>
</SaveCancelButtonContainer>
<SaveCancelButtonContainer>
<Button onClick={saveHandler} type="primary">
Save
</Button>
</SaveCancelButtonContainer>
</SaveContainer>
</FilterTableAndSaveContainer>
);
}
GraphManager.defaultProps = {
graphVisibilityStateHandler: undefined,
};
export default memo(
GraphManager,
(prevProps, nextProps) =>
isEqual(prevProps.data, nextProps.data) && prevProps.name === nextProps.name,
);

View File

@ -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 (
<ConfigProvider
theme={{
token: {
colorPrimary: datasets[index].borderColor?.toString(),
colorBorder: datasets[index].borderColor?.toString(),
colorBgContainer: datasets[index].borderColor?.toString(),
},
}}
>
<Checkbox onChange={onChangeHandler} checked={graphVisibilityState[index]} />
</ConfigProvider>
);
}
export default CustomCheckBox;

View File

@ -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<DataSetProps> => ({
render: (index: number): JSX.Element => (
<CustomCheckBox
data={data}
index={index}
checkBoxOnChangeHandler={checkBoxOnChangeHandler}
graphVisibilityState={graphVisibilityState}
/>
),
});
interface GetCheckBoxProps {
data: ChartData;
checkBoxOnChangeHandler: (e: CheckboxChangeEvent, index: number) => void;
graphVisibilityState: boolean[];
}

View File

@ -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<DataSetProps> => ({
render: (label: string, _, index): JSX.Element => (
<Label
label={label}
labelIndex={index}
labelClickedHandler={labelClickedHandler}
/>
),
});

View File

@ -0,0 +1,80 @@
import { CheckboxChangeEvent } from 'antd/es/checkbox';
import { ColumnType } from 'antd/es/table';
import { ChartData } from 'chart.js';
import { ColumnsKeyAndDataIndex, ColumnsTitle } from '../contants';
import { DataSetProps } from '../types';
import { getGraphManagerTableHeaderTitle } from '../utils';
import { getCheckBox } from './GetCheckBox';
import { getLabel } from './GetLabel';
export const getGraphManagerTableColumns = ({
data,
checkBoxOnChangeHandler,
graphVisibilityState,
labelClickedHandler,
yAxisUnit,
}: GetGraphManagerTableColumnsProps): ColumnType<DataSetProps>[] => [
{
title: '',
width: 50,
dataIndex: ColumnsKeyAndDataIndex.Index,
key: ColumnsKeyAndDataIndex.Index,
...getCheckBox({
checkBoxOnChangeHandler,
graphVisibilityState,
data,
}),
},
{
title: ColumnsTitle[ColumnsKeyAndDataIndex.Label],
width: 300,
dataIndex: ColumnsKeyAndDataIndex.Label,
key: ColumnsKeyAndDataIndex.Label,
...getLabel(labelClickedHandler),
},
{
title: getGraphManagerTableHeaderTitle(
ColumnsTitle[ColumnsKeyAndDataIndex.Avg],
yAxisUnit,
),
width: 90,
dataIndex: ColumnsKeyAndDataIndex.Avg,
key: ColumnsKeyAndDataIndex.Avg,
},
{
title: getGraphManagerTableHeaderTitle(
ColumnsTitle[ColumnsKeyAndDataIndex.Sum],
yAxisUnit,
),
width: 90,
dataIndex: ColumnsKeyAndDataIndex.Sum,
key: ColumnsKeyAndDataIndex.Sum,
},
{
title: getGraphManagerTableHeaderTitle(
ColumnsTitle[ColumnsKeyAndDataIndex.Max],
yAxisUnit,
),
width: 90,
dataIndex: ColumnsKeyAndDataIndex.Max,
key: ColumnsKeyAndDataIndex.Max,
},
{
title: getGraphManagerTableHeaderTitle(
ColumnsTitle[ColumnsKeyAndDataIndex.Min],
yAxisUnit,
),
width: 90,
dataIndex: ColumnsKeyAndDataIndex.Min,
key: ColumnsKeyAndDataIndex.Min,
},
];
interface GetGraphManagerTableColumnsProps {
data: ChartData;
checkBoxOnChangeHandler: (e: CheckboxChangeEvent, index: number) => void;
labelClickedHandler: (labelIndex: number) => void;
graphVisibilityState: boolean[];
yAxisUnit?: string;
}

View File

@ -0,0 +1,21 @@
import { LabelContainer } from '../styles';
import { LabelProps } from '../types';
import { getAbbreviatedLabel } from '../utils';
function Label({
labelClickedHandler,
labelIndex,
label,
}: LabelProps): JSX.Element {
const onClickHandler = (): void => {
labelClickedHandler(labelIndex);
};
return (
<LabelContainer type="button" onClick={onClickHandler}>
{getAbbreviatedLabel(label)}
</LabelContainer>
);
}
export default Label;

View File

@ -0,0 +1,29 @@
import { PanelTypeAndGraphManagerVisibilityProps } from './types';
export enum ColumnsKeyAndDataIndex {
Index = 'index',
Legend = 'legend',
Label = 'label',
Avg = 'avg',
Sum = 'sum',
Max = 'max',
Min = 'min',
}
export const ColumnsTitle = {
[ColumnsKeyAndDataIndex.Index]: 'Index',
[ColumnsKeyAndDataIndex.Label]: 'Label',
[ColumnsKeyAndDataIndex.Avg]: 'Avg',
[ColumnsKeyAndDataIndex.Sum]: 'Sum',
[ColumnsKeyAndDataIndex.Max]: 'Max',
[ColumnsKeyAndDataIndex.Min]: 'Min',
};
export const PANEL_TYPES_VS_FULL_VIEW_TABLE: PanelTypeAndGraphManagerVisibilityProps = {
TIME_SERIES: true,
VALUE: false,
TABLE: false,
LIST: false,
TRACE: false,
EMPTY_WIDGET: false,
};

View File

@ -1,5 +1,5 @@
import { Button } from 'antd';
import { GraphOnClickHandler } from 'components/Graph';
import { ToggleGraphProps } from 'components/Graph/types';
import Spinner from 'components/Spinner';
import TimePreference from 'components/TimePreferenceDropDown';
import GridPanelSwitch from 'container/GridPanelSwitch';
@ -9,15 +9,20 @@ import {
} from 'container/NewWidget/RightContainer/timeItems';
import { useGetQueryRange } from 'hooks/queryBuilder/useGetQueryRange';
import { useStepInterval } from 'hooks/queryBuilder/useStepInterval';
import { useChartMutable } from 'hooks/useChartMutable';
import { getDashboardVariables } from 'lib/dashbaordVariables/getDashboardVariables';
import getChartData from 'lib/getChartData';
import { useCallback, useMemo, useState } from 'react';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useSelector } from 'react-redux';
import { AppState } from 'store/reducers';
import { Widgets } from 'types/api/dashboard/getAll';
import { GlobalReducer } from 'types/reducer/globalTime';
import { TimeContainer } from './styles';
import { toggleGraphsVisibilityInChart } from '../utils';
import { PANEL_TYPES_VS_FULL_VIEW_TABLE } from './contants';
import GraphManager from './GraphManager';
import { GraphContainer, TimeContainer } from './styles';
import { FullViewProps } from './types';
import { getIsGraphLegendToggleAvailable } from './utils';
function FullView({
widget,
@ -27,6 +32,8 @@ function FullView({
yAxisUnit,
onDragSelect,
isDependedDataLoaded = false,
graphsVisibilityStates,
onToggleModelHandler,
}: FullViewProps): JSX.Element {
const { selectedTime: globalSelectedTime } = useSelector<
AppState,
@ -39,6 +46,22 @@ function FullView({
[widget],
);
const canModifyChart = useChartMutable({
panelType: widget.panelTypes,
panelTypeAndGraphManagerVisibility: PANEL_TYPES_VS_FULL_VIEW_TABLE,
});
const lineChartRef = useRef<ToggleGraphProps>();
useEffect(() => {
if (graphsVisibilityStates && canModifyChart && lineChartRef.current) {
toggleGraphsVisibilityInChart({
graphsVisibilityStates,
lineChartRef,
});
}
}, [graphsVisibilityStates, canModifyChart]);
const [selectedTime, setSelectedTime] = useState<timePreferance>({
name: getSelectedTime()?.name || '',
enum: widget?.timePreferance || 'GLOBAL_TIME',
@ -78,7 +101,11 @@ function FullView({
[response],
);
if (response.status === 'idle' || response.status === 'loading') {
const isGraphLegendToggleAvailable = getIsGraphLegendToggleAvailable(
widget.panelTypes,
);
if (response.isFetching) {
return <Spinner height="100%" size="large" tip="Loading..." />;
}
@ -101,33 +128,35 @@ function FullView({
</TimeContainer>
)}
<GridPanelSwitch
panelType={widget.panelTypes}
data={chartDataSet}
isStacked={widget.isStacked}
opacity={widget.opacity}
title={widget.title}
onClickHandler={onClickHandler}
name={name}
yAxisUnit={yAxisUnit}
onDragSelect={onDragSelect}
panelData={response.data?.payload.data.newResult.data.result || []}
query={widget.query}
/>
<GraphContainer isGraphLegendToggleAvailable={isGraphLegendToggleAvailable}>
<GridPanelSwitch
panelType={widget.panelTypes}
data={chartDataSet}
isStacked={widget.isStacked}
opacity={widget.opacity}
title={widget.title}
onClickHandler={onClickHandler}
name={name}
yAxisUnit={yAxisUnit}
onDragSelect={onDragSelect}
panelData={response.data?.payload.data.newResult.data.result || []}
query={widget.query}
ref={lineChartRef}
/>
</GraphContainer>
{canModifyChart && (
<GraphManager
data={chartDataSet}
name={name}
yAxisUnit={yAxisUnit}
onToggleModelHandler={onToggleModelHandler}
/>
)}
</>
);
}
interface FullViewProps {
widget: Widgets;
fullViewOptions?: boolean;
onClickHandler?: GraphOnClickHandler;
name: string;
yAxisUnit?: string;
onDragSelect?: (start: number, end: number) => void;
isDependedDataLoaded?: boolean;
}
FullView.defaultProps = {
fullViewOptions: undefined,
onClickHandler: undefined,
@ -136,4 +165,6 @@ FullView.defaultProps = {
isDependedDataLoaded: undefined,
};
FullView.displayName = 'FullView';
export default FullView;

View File

@ -1,6 +1,9 @@
import { PANEL_TYPES } from 'constants/queryBuilder';
import { themeColors } from 'constants/theme';
import styled, { css, FlattenSimpleInterpolation } from 'styled-components';
import { GraphContainerProps } from './types';
interface Props {
$panelType: PANEL_TYPES;
}
@ -22,3 +25,36 @@ export const TimeContainer = styled.div<Props>`
`
: css``}
`;
export const GraphContainer = styled.div<GraphContainerProps>`
height: ${({ isGraphLegendToggleAvailable }): string =>
isGraphLegendToggleAvailable ? '50%' : '100%'};
`;
export const FilterTableAndSaveContainer = styled.div`
margin-top: 1.875rem;
display: flex;
align-items: flex-end;
`;
export const FilterTableContainer = styled.div`
flex-basis: 80%;
`;
export const SaveContainer = styled.div`
flex-basis: 20%;
display: flex;
justify-content: flex-end;
`;
export const SaveCancelButtonContainer = styled.span`
margin: 0 0.313rem;
`;
export const LabelContainer = styled.button`
max-width: 18.75rem;
cursor: pointer;
border: none;
background-color: transparent;
color: ${themeColors.white};
`;

View File

@ -0,0 +1,77 @@
import { CheckboxChangeEvent } from 'antd/es/checkbox';
import { ChartData, ChartDataset } from 'chart.js';
import { GraphOnClickHandler } from 'components/Graph/types';
import { PANEL_TYPES } from 'constants/queryBuilder';
import { Widgets } from 'types/api/dashboard/getAll';
export interface DataSetProps {
index: number;
data: number | null;
label: string;
borderWidth: number;
spanGaps: boolean;
animations: boolean;
borderColor: string;
showLine: boolean;
pointRadius: number;
}
export interface LegendEntryProps {
label: string;
show: boolean;
}
export type ExtendedChartDataset = ChartDataset & {
show: boolean;
sum: number;
avg: number;
min: number;
max: number;
};
export type PanelTypeAndGraphManagerVisibilityProps = Record<
keyof typeof PANEL_TYPES,
boolean
>;
export interface LabelProps {
labelClickedHandler: (labelIndex: number) => void;
labelIndex: number;
label: string;
}
export interface GraphManagerProps {
data: ChartData;
name: string;
yAxisUnit?: string;
onToggleModelHandler?: () => void;
}
export interface CheckBoxProps {
data: ChartData;
index: number;
graphVisibilityState: boolean[];
checkBoxOnChangeHandler: (e: CheckboxChangeEvent, index: number) => void;
}
export interface FullViewProps {
widget: Widgets;
fullViewOptions?: boolean;
onClickHandler?: GraphOnClickHandler;
name: string;
yAxisUnit?: string;
onDragSelect?: (start: number, end: number) => void;
isDependedDataLoaded?: boolean;
graphsVisibilityStates?: boolean[];
onToggleModelHandler?: GraphManagerProps['onToggleModelHandler'];
}
export interface SaveLegendEntriesToLocalStoreProps {
data: ChartData;
graphVisibilityState: boolean[];
name: string;
}
export interface GraphContainerProps {
isGraphLegendToggleAvailable: boolean;
}

View File

@ -0,0 +1,123 @@
import { ChartData, ChartDataset } from 'chart.js';
import { LOCALSTORAGE } from 'constants/localStorage';
import { PANEL_TYPES } from 'constants/queryBuilder';
import {
ExtendedChartDataset,
LegendEntryProps,
SaveLegendEntriesToLocalStoreProps,
} from './types';
function convertToTwoDecimalsOrZero(value: number): number {
if (
typeof value === 'number' &&
!Number.isNaN(value) &&
value !== Infinity &&
value !== -Infinity
) {
const result = value ? value.toFixed(20).match(/^-?\d*\.?0*\d{0,2}/) : null;
return result ? parseFloat(result[0]) : 0;
}
return 0;
}
export const getDefaultTableDataSet = (
data: ChartData,
): ExtendedChartDataset[] =>
data.datasets.map(
(item: ChartDataset): ExtendedChartDataset => {
if (item.data.length === 0) {
return {
...item,
show: true,
sum: 0,
avg: 0,
max: 0,
min: 0,
};
}
return {
...item,
show: true,
sum: convertToTwoDecimalsOrZero(
(item.data as number[]).reduce((a, b) => a + b, 0),
),
avg: convertToTwoDecimalsOrZero(
(item.data as number[]).reduce((a, b) => a + b, 0) / item.data.length,
),
max: convertToTwoDecimalsOrZero(Math.max(...(item.data as number[]))),
min: convertToTwoDecimalsOrZero(Math.min(...(item.data as number[]))),
};
},
);
export const getAbbreviatedLabel = (label: string): string => {
let newLabel = label;
if (label.length > 30) {
newLabel = `${label.substring(0, 30)}...`;
}
return newLabel;
};
export const showAllDataSet = (data: ChartData): LegendEntryProps[] =>
data.datasets.map(
(item): LegendEntryProps => ({
label: item.label || '',
show: true,
}),
);
export const saveLegendEntriesToLocalStorage = ({
data,
graphVisibilityState,
name,
}: SaveLegendEntriesToLocalStoreProps): void => {
const newLegendEntry = {
name,
dataIndex: data.datasets.map(
(item, index): LegendEntryProps => ({
label: item.label || '',
show: graphVisibilityState[index],
}),
),
};
let existingEntries: { name: string; dataIndex: LegendEntryProps[] }[] = [];
try {
existingEntries = JSON.parse(
localStorage.getItem(LOCALSTORAGE.GRAPH_VISIBILITY_STATES) || '[]',
);
} catch (error) {
console.error('Error parsing LEGEND_GRAPH from local storage', error);
}
const entryIndex = existingEntries.findIndex((entry) => entry.name === name);
if (entryIndex >= 0) {
existingEntries[entryIndex] = newLegendEntry;
} else {
existingEntries = [...existingEntries, newLegendEntry];
}
try {
localStorage.setItem(
LOCALSTORAGE.GRAPH_VISIBILITY_STATES,
JSON.stringify(existingEntries),
);
} catch (error) {
console.error('Error setting LEGEND_GRAPH to local storage', error);
}
};
export const getIsGraphLegendToggleAvailable = (
panelType: PANEL_TYPES,
): boolean => panelType === PANEL_TYPES.TIME_SERIES;
export const getGraphManagerTableHeaderTitle = (
title: string,
yAxisUnit?: string,
): string => {
const yAxisUnitText = yAxisUnit ? `(in ${yAxisUnit})` : '';
return `${title} ${yAxisUnitText}`;
};

View File

@ -0,0 +1,62 @@
import { mockTestData } from './__mock__/mockChartData';
import { mocklegendEntryResult } from './__mock__/mockLegendEntryData';
import { showAllDataSet } from './FullView/utils';
import { getGraphVisibilityStateOnDataChange } from './utils';
describe('getGraphVisibilityStateOnDataChange', () => {
beforeEach(() => {
const localStorageMock = {
getItem: jest.fn(),
};
Object.defineProperty(window, 'localStorage', { value: localStorageMock });
});
it('should return the correct visibility state and legend entry', () => {
// Mock the localStorage behavior
const mockLocalStorageData = [
{
name: 'exampleexpanded',
dataIndex: [
{ label: 'customer', show: true },
{ label: 'demo-app', show: false },
],
},
];
jest
.spyOn(window.localStorage, 'getItem')
.mockReturnValue(JSON.stringify(mockLocalStorageData));
const result1 = getGraphVisibilityStateOnDataChange({
data: mockTestData,
isExpandedName: true,
name: 'example',
});
expect(result1.graphVisibilityStates).toEqual([true, false]);
expect(result1.legendEntry).toEqual(mocklegendEntryResult);
const result2 = getGraphVisibilityStateOnDataChange({
data: mockTestData,
isExpandedName: false,
name: 'example',
});
expect(result2.graphVisibilityStates).toEqual(
Array(mockTestData.datasets.length).fill(true),
);
expect(result2.legendEntry).toEqual(showAllDataSet(mockTestData));
});
it('should return default values if localStorage data is not available', () => {
// Mock the localStorage behavior to return null
jest.spyOn(window.localStorage, 'getItem').mockReturnValue(null);
const result = getGraphVisibilityStateOnDataChange({
data: mockTestData,
isExpandedName: true,
name: 'example',
});
expect(result.graphVisibilityStates).toEqual(
Array(mockTestData.datasets.length).fill(true),
);
expect(result.legendEntry).toEqual(showAllDataSet(mockTestData));
});
});

View File

@ -0,0 +1,336 @@
import { Typography } from 'antd';
import { ToggleGraphProps } from 'components/Graph/types';
import { Events } from 'constants/events';
import GridPanelSwitch from 'container/GridPanelSwitch';
import { useChartMutable } from 'hooks/useChartMutable';
import { useNotifications } from 'hooks/useNotifications';
import history from 'lib/history';
import { isEmpty, isEqual } from 'lodash-es';
import {
Dispatch,
memo,
SetStateAction,
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from 'react';
import { useTranslation } from 'react-i18next';
import { connect, useSelector } from 'react-redux';
import { bindActionCreators } from 'redux';
import { ThunkDispatch } from 'redux-thunk';
import { DeleteWidget } from 'store/actions/dashboard/deleteWidget';
import { AppState } from 'store/reducers';
import AppActions from 'types/actions';
import AppReducer from 'types/reducer/app';
import DashboardReducer from 'types/reducer/dashboards';
import { eventEmitter } from 'utils/getEventEmitter';
import { v4 } from 'uuid';
import { UpdateDashboard } from '../utils';
import WidgetHeader from '../WidgetHeader';
import FullView from './FullView';
import { PANEL_TYPES_VS_FULL_VIEW_TABLE } from './FullView/contants';
import { FullViewContainer, Modal } from './styles';
import { DispatchProps, WidgetGraphComponentProps } from './types';
import {
getGraphVisibilityStateOnDataChange,
toggleGraphsVisibilityInChart,
} from './utils';
function WidgetGraphComponent({
enableModel,
enableWidgetHeader,
data,
widget,
queryResponse,
errorMessage,
name,
yAxisUnit,
layout = [],
deleteWidget,
setLayout,
onDragSelect,
onClickHandler,
allowClone = true,
allowDelete = true,
allowEdit = true,
}: WidgetGraphComponentProps): JSX.Element {
const [deleteModal, setDeleteModal] = useState(false);
const [modal, setModal] = useState<boolean>(false);
const [hovered, setHovered] = useState(false);
const { notifications } = useNotifications();
const { t } = useTranslation(['common']);
const { graphVisibilityStates: localstoredVisibilityStates } = useMemo(
() =>
getGraphVisibilityStateOnDataChange({
data,
isExpandedName: true,
name,
}),
[data, name],
);
const [graphsVisibilityStates, setGraphsVisilityStates] = useState<boolean[]>(
localstoredVisibilityStates,
);
const { dashboards } = useSelector<AppState, DashboardReducer>(
(state) => state.dashboards,
);
const [selectedDashboard] = dashboards;
const canModifyChart = useChartMutable({
panelType: widget.panelTypes,
panelTypeAndGraphManagerVisibility: PANEL_TYPES_VS_FULL_VIEW_TABLE,
});
const lineChartRef = useRef<ToggleGraphProps>();
// Updating the visibility state of the graph on data change according to global time range
useEffect(() => {
if (canModifyChart) {
const newGraphVisibilityState = getGraphVisibilityStateOnDataChange({
data,
isExpandedName: true,
name,
});
setGraphsVisilityStates(newGraphVisibilityState.graphVisibilityStates);
}
}, [canModifyChart, data, name]);
useEffect(() => {
const eventListener = eventEmitter.on(
Events.UPDATE_GRAPH_VISIBILITY_STATE,
(data) => {
if (data.name === `${name}expanded` && canModifyChart) {
setGraphsVisilityStates([...data.graphVisibilityStates]);
}
},
);
return (): void => {
eventListener.off(Events.UPDATE_GRAPH_VISIBILITY_STATE);
};
}, [canModifyChart, name]);
useEffect(() => {
if (canModifyChart && lineChartRef.current) {
toggleGraphsVisibilityInChart({
graphsVisibilityStates,
lineChartRef,
});
}
}, [graphsVisibilityStates, canModifyChart]);
const { featureResponse } = useSelector<AppState, AppReducer>(
(state) => state.app,
);
const onToggleModal = useCallback(
(func: Dispatch<SetStateAction<boolean>>) => {
func((value) => !value);
},
[],
);
const onDeleteHandler = useCallback(() => {
const isEmptyWidget = widget?.id === 'empty' || isEmpty(widget);
const widgetId = isEmptyWidget ? layout[0].i : widget?.id;
featureResponse
.refetch()
.then(() => {
deleteWidget({ widgetId, setLayout });
onToggleModal(setDeleteModal);
})
.catch(() => {
notifications.error({
message: t('common:something_went_wrong'),
});
});
}, [
widget,
layout,
featureResponse,
deleteWidget,
setLayout,
onToggleModal,
notifications,
t,
]);
const onCloneHandler = async (): Promise<void> => {
const uuid = v4();
const layout = [
{
i: uuid,
w: 6,
x: 0,
h: 2,
y: 0,
},
...(selectedDashboard.data.layout || []),
];
if (widget) {
await UpdateDashboard(
{
data: selectedDashboard.data,
generateWidgetId: uuid,
graphType: widget?.panelTypes,
selectedDashboard,
layout,
widgetData: widget,
isRedirected: false,
},
notifications,
).then(() => {
notifications.success({
message: 'Panel cloned successfully, redirecting to new copy.',
});
setTimeout(() => {
history.push(
`${history.location.pathname}/new?graphType=${widget?.panelTypes}&widgetId=${uuid}`,
);
}, 1500);
});
}
};
const handleOnView = (): void => {
onToggleModal(setModal);
};
const handleOnDelete = (): void => {
onToggleModal(setDeleteModal);
};
const onDeleteModelHandler = (): void => {
onToggleModal(setDeleteModal);
};
const onToggleModelHandler = (): void => {
onToggleModal(setModal);
};
const getModals = (): JSX.Element => (
<>
<Modal
destroyOnClose
onCancel={onDeleteModelHandler}
open={deleteModal}
title="Delete"
height="10vh"
onOk={onDeleteHandler}
centered
>
<Typography>Are you sure you want to delete this widget</Typography>
</Modal>
<Modal
title="View"
footer={[]}
centered
open={modal}
onCancel={onToggleModelHandler}
width="85%"
destroyOnClose
>
<FullViewContainer>
<FullView
name={`${name}expanded`}
widget={widget}
yAxisUnit={yAxisUnit}
graphsVisibilityStates={graphsVisibilityStates}
onToggleModelHandler={onToggleModelHandler}
/>
</FullViewContainer>
</Modal>
</>
);
return (
<span
onMouseOver={(): void => {
setHovered(true);
}}
onFocus={(): void => {
setHovered(true);
}}
onMouseOut={(): void => {
setHovered(false);
}}
onBlur={(): void => {
setHovered(false);
}}
>
{enableModel && getModals()}
{!isEmpty(widget) && data && (
<>
{enableWidgetHeader && (
<div className="drag-handle">
<WidgetHeader
parentHover={hovered}
title={widget?.title}
widget={widget}
onView={handleOnView}
onDelete={handleOnDelete}
onClone={onCloneHandler}
queryResponse={queryResponse}
errorMessage={errorMessage}
allowClone={allowClone}
allowDelete={allowDelete}
allowEdit={allowEdit}
/>
</div>
)}
<GridPanelSwitch
panelType={widget.panelTypes}
data={data}
isStacked={widget.isStacked}
opacity={widget.opacity}
title={' '}
name={name}
yAxisUnit={yAxisUnit}
onClickHandler={onClickHandler}
onDragSelect={onDragSelect}
panelData={[]}
query={widget.query}
ref={lineChartRef}
/>
</>
)}
</span>
);
}
WidgetGraphComponent.defaultProps = {
yAxisUnit: undefined,
layout: undefined,
setLayout: undefined,
onDragSelect: undefined,
onClickHandler: undefined,
allowDelete: true,
allowClone: true,
allowEdit: true,
};
const mapDispatchToProps = (
dispatch: ThunkDispatch<unknown, unknown, AppActions>,
): DispatchProps => ({
deleteWidget: bindActionCreators(DeleteWidget, dispatch),
});
export default connect(
null,
mapDispatchToProps,
)(
memo(
WidgetGraphComponent,
(prevProps, nextProps) =>
isEqual(prevProps.data, nextProps.data) && prevProps.name === nextProps.name,
),
);

View File

@ -0,0 +1,15 @@
import { ChartData } from 'chart.js';
export const mockTestData: ChartData = {
labels: ['test1', 'test2'],
datasets: [
{
label: 'customer',
data: [481.60377358490564, 730.0000000000002],
},
{
label: 'demo-app',
data: [4471.4285714285725],
},
],
};

View File

@ -0,0 +1,12 @@
import { LegendEntryProps } from '../FullView/types';
export const mocklegendEntryResult: LegendEntryProps[] = [
{
label: 'customer',
show: true,
},
{
label: 'demo-app',
show: false,
},
];

View File

@ -1,56 +1,25 @@
import { Typography } from 'antd';
import { ChartData } from 'chart.js';
import { GraphOnClickHandler } from 'components/Graph';
import Spinner from 'components/Spinner';
import { UpdateDashboard } from 'container/GridGraphLayout/utils';
import GridPanelSwitch from 'container/GridPanelSwitch';
import { useGetQueryRange } from 'hooks/queryBuilder/useGetQueryRange';
import { useStepInterval } from 'hooks/queryBuilder/useStepInterval';
import { useNotifications } from 'hooks/useNotifications';
import usePreviousValue from 'hooks/usePreviousValue';
import { getDashboardVariables } from 'lib/dashbaordVariables/getDashboardVariables';
import getChartData from 'lib/getChartData';
import history from 'lib/history';
import isEmpty from 'lodash-es/isEmpty';
import {
Dispatch,
memo,
SetStateAction,
useCallback,
useMemo,
useState,
} from 'react';
import { Layout } from 'react-grid-layout';
import { useTranslation } from 'react-i18next';
import { memo, useMemo, useState } from 'react';
import { useInView } from 'react-intersection-observer';
import { connect, useSelector } from 'react-redux';
import { bindActionCreators } from 'redux';
import { ThunkDispatch } from 'redux-thunk';
import {
DeleteWidget,
DeleteWidgetProps,
} from 'store/actions/dashboard/deleteWidget';
import { useSelector } from 'react-redux';
import { AppState } from 'store/reducers';
import AppActions from 'types/actions';
import { Widgets } from 'types/api/dashboard/getAll';
import AppReducer from 'types/reducer/app';
import DashboardReducer from 'types/reducer/dashboards';
import { GlobalReducer } from 'types/reducer/globalTime';
import {
getSelectedDashboard,
getSelectedDashboardVariable,
} from 'utils/dashboard/selectedDashboard';
import { v4 } from 'uuid';
import { getSelectedDashboardVariable } from 'utils/dashboard/selectedDashboard';
import { LayoutProps } from '..';
import EmptyWidget from '../EmptyWidget';
import WidgetHeader from '../WidgetHeader';
import FullView from './FullView';
import { FullViewContainer, Modal } from './styles';
import { GridCardGraphProps } from './types';
import WidgetGraphComponent from './WidgetGraphComponent';
function GridCardGraph({
widget,
deleteWidget,
name,
yAxisUnit,
layout = [],
@ -62,33 +31,26 @@ function GridCardGraph({
allowEdit,
isQueryEnabled,
}: GridCardGraphProps): JSX.Element {
const { isAddWidget } = useSelector<AppState, DashboardReducer>(
(state) => state.dashboards,
);
const { ref: graphRef, inView: isGraphVisible } = useInView({
threshold: 0,
triggerOnce: true,
initialInView: false,
});
const { notifications } = useNotifications();
const { t } = useTranslation(['common']);
const [errorMessage, setErrorMessage] = useState<string | undefined>('');
const [hovered, setHovered] = useState(false);
const [modal, setModal] = useState(false);
const [deleteModal, setDeleteModal] = useState(false);
const { minTime, maxTime, selectedTime: globalSelectedInterval } = useSelector<
AppState,
GlobalReducer
>((state) => state.globalTime);
const { featureResponse } = useSelector<AppState, AppReducer>(
(state) => state.app,
);
const { dashboards } = useSelector<AppState, DashboardReducer>(
(state) => state.dashboards,
);
const selectedDashboard = getSelectedDashboard(dashboards);
const variables = getSelectedDashboardVariable(dashboards);
const updatedQuery = useStepInterval(widget?.query);
@ -116,7 +78,7 @@ function GridCardGraph({
variables,
],
keepPreviousData: true,
enabled: isGraphVisible && !isEmptyWidget && isQueryEnabled,
enabled: isGraphVisible && !isEmptyWidget && isQueryEnabled && !isAddWidget,
refetchOnMount: false,
onError: (error) => {
setErrorMessage(error.message);
@ -138,153 +100,31 @@ function GridCardGraph({
const prevChartDataSetRef = usePreviousValue<ChartData>(chartData);
const onToggleModal = useCallback(
(func: Dispatch<SetStateAction<boolean>>) => {
func((value) => !value);
},
[],
);
const onDeleteHandler = useCallback(() => {
const widgetId = isEmptyWidget ? layout[0].i : widget?.id;
featureResponse
.refetch()
.then(() => {
deleteWidget({ widgetId, setLayout });
onToggleModal(setDeleteModal);
})
.catch(() => {
notifications.error({
message: t('common:something_went_wrong'),
});
});
}, [
isEmptyWidget,
widget?.id,
layout,
featureResponse,
deleteWidget,
setLayout,
onToggleModal,
notifications,
t,
]);
const onCloneHandler = async (): Promise<void> => {
const uuid = v4();
const layout = [
{
i: uuid,
w: 6,
x: 0,
h: 2,
y: 0,
},
...(selectedDashboard?.data.layout || []),
];
if (widget && selectedDashboard) {
await UpdateDashboard(
{
data: selectedDashboard.data,
generateWidgetId: uuid,
graphType: widget?.panelTypes,
selectedDashboard,
layout,
widgetData: widget,
isRedirected: false,
},
notifications,
).then(() => {
notifications.success({
message: 'Panel cloned successfully, redirecting to new copy.',
});
setTimeout(() => {
history.push(
`${history.location.pathname}/new?graphType=${widget?.panelTypes}&widgetId=${uuid}`,
);
}, 1500);
});
}
};
const getModals = (): JSX.Element => (
<>
<Modal
destroyOnClose
onCancel={(): void => onToggleModal(setDeleteModal)}
open={deleteModal}
title="Delete"
height="10vh"
onOk={onDeleteHandler}
centered
>
<Typography>Are you sure you want to delete this widget</Typography>
</Modal>
<Modal
title="View"
footer={[]}
centered
open={modal}
onCancel={(): void => onToggleModal(setModal)}
width="85%"
destroyOnClose
>
<FullViewContainer>
<FullView name={`${name}expanded`} widget={widget} yAxisUnit={yAxisUnit} />
</FullViewContainer>
</Modal>
</>
);
const handleOnView = (): void => {
onToggleModal(setModal);
};
const handleOnDelete = (): void => {
onToggleModal(setDeleteModal);
};
const isEmptyLayout = widget?.id === 'empty' || isEmpty(widget);
if (queryResponse.isRefetching) {
return <Spinner height="20vh" tip="Loading..." />;
}
if (queryResponse.isError && !isEmptyLayout) {
return (
<span ref={graphRef}>
{getModals()}
{!isEmpty(widget) && prevChartDataSetRef && (
<>
<div className="drag-handle">
<WidgetHeader
parentHover={hovered}
title={widget?.title}
widget={widget}
onView={handleOnView}
onDelete={handleOnDelete}
onClone={onCloneHandler}
queryResponse={queryResponse}
errorMessage={errorMessage}
allowClone={allowClone}
allowDelete={allowDelete}
allowEdit={allowEdit}
/>
</div>
<GridPanelSwitch
panelType={widget?.panelTypes}
data={prevChartDataSetRef}
isStacked={widget?.isStacked}
opacity={widget?.opacity}
title={' '}
name={name}
yAxisUnit={yAxisUnit}
onClickHandler={onClickHandler}
panelData={[]}
query={widget.query}
/>
</>
<WidgetGraphComponent
enableModel
enableWidgetHeader
widget={widget}
queryResponse={queryResponse}
errorMessage={errorMessage}
data={prevChartDataSetRef}
name={name}
yAxisUnit={yAxisUnit}
layout={layout}
setLayout={setLayout}
allowClone={allowClone}
allowDelete={allowDelete}
allowEdit={allowEdit}
/>
)}
</span>
);
@ -294,35 +134,22 @@ function GridCardGraph({
return (
<span ref={graphRef}>
{!isEmpty(widget) && prevChartDataSetRef?.labels ? (
<>
<div className="drag-handle">
<WidgetHeader
parentHover={hovered}
title={widget?.title}
widget={widget}
onView={handleOnView}
onDelete={handleOnDelete}
onClone={onCloneHandler}
queryResponse={queryResponse}
errorMessage={errorMessage}
allowClone={allowClone}
allowDelete={allowDelete}
allowEdit={allowEdit}
/>
</div>
<GridPanelSwitch
panelType={widget.panelTypes}
data={prevChartDataSetRef}
isStacked={widget.isStacked}
opacity={widget.opacity}
title={' '}
name={name}
yAxisUnit={yAxisUnit}
onClickHandler={onClickHandler}
panelData={[]}
query={widget.query}
/>
</>
<WidgetGraphComponent
enableModel={false}
enableWidgetHeader
widget={widget}
queryResponse={queryResponse}
errorMessage={errorMessage}
data={prevChartDataSetRef}
name={name}
yAxisUnit={yAxisUnit}
layout={layout}
setLayout={setLayout}
allowClone={allowClone}
allowDelete={allowDelete}
allowEdit={allowEdit}
onClickHandler={onClickHandler}
/>
) : (
<Spinner height="20vh" tip="Loading..." />
)}
@ -331,54 +158,21 @@ function GridCardGraph({
}
return (
<span
ref={graphRef}
onMouseOver={(): void => {
setHovered(true);
}}
onFocus={(): void => {
setHovered(true);
}}
onMouseOut={(): void => {
setHovered(false);
}}
onBlur={(): void => {
setHovered(false);
}}
>
{!isEmptyLayout && (
<div className="drag-handle">
<WidgetHeader
parentHover={hovered}
title={widget?.title}
widget={widget}
onView={handleOnView}
onDelete={handleOnDelete}
onClone={onCloneHandler}
queryResponse={queryResponse}
errorMessage={errorMessage}
allowClone={allowClone}
allowDelete={allowDelete}
allowEdit={allowEdit}
/>
</div>
)}
{!isEmptyLayout && getModals()}
<span ref={graphRef}>
{!isEmpty(widget) && !!queryResponse.data?.payload && (
<GridPanelSwitch
panelType={widget.panelTypes}
<WidgetGraphComponent
enableModel={!isEmptyLayout}
enableWidgetHeader={!isEmptyLayout}
widget={widget}
queryResponse={queryResponse}
errorMessage={errorMessage}
data={chartData}
isStacked={widget.isStacked}
opacity={widget.opacity}
title={' '} // `empty title to accommodate absolutely positioned widget header
name={name}
yAxisUnit={yAxisUnit}
onDragSelect={onDragSelect}
onClickHandler={onClickHandler}
panelData={queryResponse.data?.payload.data.newResult.data.result || []}
query={widget.query}
allowClone={allowClone}
allowDelete={allowDelete}
allowEdit={allowEdit}
/>
)}
@ -387,28 +181,6 @@ function GridCardGraph({
);
}
interface DispatchProps {
deleteWidget: ({
widgetId,
}: DeleteWidgetProps) => (dispatch: Dispatch<AppActions>) => void;
}
interface GridCardGraphProps extends DispatchProps {
widget: Widgets;
name: string;
yAxisUnit: string | undefined;
// eslint-disable-next-line react/require-default-props
layout?: Layout[];
// eslint-disable-next-line react/require-default-props
setLayout?: Dispatch<SetStateAction<LayoutProps[]>>;
onDragSelect?: (start: number, end: number) => void;
onClickHandler?: GraphOnClickHandler;
allowDelete?: boolean;
allowClone?: boolean;
allowEdit?: boolean;
isQueryEnabled?: boolean;
}
GridCardGraph.defaultProps = {
onDragSelect: undefined,
onClickHandler: undefined,
@ -418,10 +190,4 @@ GridCardGraph.defaultProps = {
isQueryEnabled: true,
};
const mapDispatchToProps = (
dispatch: ThunkDispatch<unknown, unknown, AppActions>,
): DispatchProps => ({
deleteWidget: bindActionCreators(DeleteWidget, dispatch),
});
export default connect(null, mapDispatchToProps)(memo(GridCardGraph));
export default memo(GridCardGraph);

View File

@ -0,0 +1,71 @@
import { ChartData } from 'chart.js';
import { GraphOnClickHandler, ToggleGraphProps } from 'components/Graph/types';
import { Dispatch, MutableRefObject, SetStateAction } from 'react';
import { Layout } from 'react-grid-layout';
import { UseQueryResult } from 'react-query';
import { DeleteWidgetProps } from 'store/actions/dashboard/deleteWidget';
import AppActions from 'types/actions';
import { ErrorResponse, SuccessResponse } from 'types/api';
import { Widgets } from 'types/api/dashboard/getAll';
import { MetricRangePayloadProps } from 'types/api/metrics/getQueryRange';
import { LayoutProps } from '..';
import { LegendEntryProps } from './FullView/types';
export interface GraphVisibilityLegendEntryProps {
graphVisibilityStates: boolean[];
legendEntry: LegendEntryProps[];
}
export interface DispatchProps {
deleteWidget: ({
widgetId,
}: DeleteWidgetProps) => (dispatch: Dispatch<AppActions>) => void;
}
export interface WidgetGraphComponentProps extends DispatchProps {
enableModel: boolean;
enableWidgetHeader: boolean;
widget: Widgets;
queryResponse: UseQueryResult<
SuccessResponse<MetricRangePayloadProps> | ErrorResponse
>;
errorMessage: string | undefined;
data: ChartData;
name: string;
yAxisUnit?: string;
layout?: Layout[];
setLayout?: Dispatch<SetStateAction<LayoutProps[]>>;
onDragSelect?: (start: number, end: number) => void;
onClickHandler?: GraphOnClickHandler;
allowDelete?: boolean;
allowClone?: boolean;
allowEdit?: boolean;
}
export interface GridCardGraphProps {
widget: Widgets;
name: string;
yAxisUnit: string | undefined;
// eslint-disable-next-line react/require-default-props
layout?: Layout[];
// eslint-disable-next-line react/require-default-props
setLayout?: Dispatch<SetStateAction<LayoutProps[]>>;
onDragSelect?: (start: number, end: number) => void;
onClickHandler?: GraphOnClickHandler;
allowDelete?: boolean;
allowClone?: boolean;
allowEdit?: boolean;
isQueryEnabled?: boolean;
}
export interface GetGraphVisibilityStateOnLegendClickProps {
data: ChartData;
isExpandedName: boolean;
name: string;
}
export interface ToggleGraphsVisibilityInChartProps {
graphsVisibilityStates: GraphVisibilityLegendEntryProps['graphVisibilityStates'];
lineChartRef: MutableRefObject<ToggleGraphProps | undefined>;
}

View File

@ -0,0 +1,66 @@
import { LOCALSTORAGE } from 'constants/localStorage';
import { LegendEntryProps } from './FullView/types';
import { showAllDataSet } from './FullView/utils';
import {
GetGraphVisibilityStateOnLegendClickProps,
GraphVisibilityLegendEntryProps,
ToggleGraphsVisibilityInChartProps,
} from './types';
export const getGraphVisibilityStateOnDataChange = ({
data,
isExpandedName,
name,
}: GetGraphVisibilityStateOnLegendClickProps): GraphVisibilityLegendEntryProps => {
const visibilityStateAndLegendEntry: GraphVisibilityLegendEntryProps = {
graphVisibilityStates: Array(data.datasets.length).fill(true),
legendEntry: showAllDataSet(data),
};
if (localStorage.getItem(LOCALSTORAGE.GRAPH_VISIBILITY_STATES) !== null) {
const legendGraphFromLocalStore = localStorage.getItem(
LOCALSTORAGE.GRAPH_VISIBILITY_STATES,
);
let legendFromLocalStore: {
name: string;
dataIndex: LegendEntryProps[];
}[] = [];
try {
legendFromLocalStore = JSON.parse(legendGraphFromLocalStore || '[]');
} catch (error) {
console.error(
'Error parsing GRAPH_VISIBILITY_STATES from local storage',
error,
);
}
const newGraphVisibilityStates = Array(data.datasets.length).fill(true);
legendFromLocalStore.forEach((item) => {
const newName = isExpandedName ? `${name}expanded` : name;
if (item.name === newName) {
visibilityStateAndLegendEntry.legendEntry = item.dataIndex;
data.datasets.forEach((datasets, i) => {
const index = item.dataIndex.findIndex(
(dataKey) => dataKey.label === datasets.label,
);
if (index !== -1) {
newGraphVisibilityStates[i] = item.dataIndex[index].show;
}
});
visibilityStateAndLegendEntry.graphVisibilityStates = newGraphVisibilityStates;
}
});
}
return visibilityStateAndLegendEntry;
};
export const toggleGraphsVisibilityInChart = ({
graphsVisibilityStates,
lineChartRef,
}: ToggleGraphsVisibilityInChartProps): void => {
graphsVisibilityStates?.forEach((showLegendData, index) => {
lineChartRef?.current?.toggleGraph(index, showLegendData);
});
};

View File

@ -8,6 +8,7 @@ import { useSelector } from 'react-redux';
import { AppState } from 'store/reducers';
import { Widgets } from 'types/api/dashboard/getAll';
import AppReducer from 'types/reducer/app';
import DashboardReducer from 'types/reducer/dashboards';
import { LayoutProps, State } from '.';
import {
@ -28,6 +29,9 @@ function GraphLayout({
widgets,
setLayout,
}: GraphLayoutProps): JSX.Element {
const { isAddWidget } = useSelector<AppState, DashboardReducer>(
(state) => state.dashboards,
);
const { role } = useSelector<AppState, AppReducer>((state) => state.app);
const isDarkMode = useIsDarkMode();
@ -53,7 +57,7 @@ function GraphLayout({
{addPanelPermission && (
<Button
loading={addPanelLoading}
disabled={addPanelLoading}
disabled={addPanelLoading || isAddWidget}
onClick={onAddPanelHandler}
icon={<PlusOutlined />}
>

View File

@ -1,7 +1,7 @@
import { themeColors } from 'constants/theme';
import { CSSProperties } from 'react';
const positionCss: CSSProperties['position'] = 'fixed';
const positionCss: CSSProperties['position'] = 'absolute';
export const spinnerStyles = { position: positionCss, right: '0.5rem' };
export const tooltipStyles = {

View File

@ -1,73 +1,85 @@
import { ToggleGraphProps } from 'components/Graph/types';
import { PANEL_TYPES_COMPONENT_MAP } from 'constants/panelTypes';
import { PANEL_TYPES } from 'constants/queryBuilder';
import { GRID_TABLE_CONFIG } from 'container/GridTableComponent/config';
import { FC, memo, useMemo } from 'react';
import { FC, forwardRef, memo, useMemo } from 'react';
import { GridPanelSwitchProps, PropsTypePropsMap } from './types';
function GridPanelSwitch({
panelType,
data,
title,
isStacked,
onClickHandler,
name,
yAxisUnit,
staticLine,
onDragSelect,
panelData,
query,
}: GridPanelSwitchProps): JSX.Element | null {
const currentProps: PropsTypePropsMap = useMemo(() => {
const result: PropsTypePropsMap = {
[PANEL_TYPES.TIME_SERIES]: {
type: 'line',
data,
title,
isStacked,
onClickHandler,
name,
yAxisUnit,
staticLine,
onDragSelect,
},
[PANEL_TYPES.VALUE]: {
title,
data,
yAxisUnit,
},
[PANEL_TYPES.TABLE]: { ...GRID_TABLE_CONFIG, data: panelData, query },
[PANEL_TYPES.LIST]: null,
[PANEL_TYPES.TRACE]: null,
[PANEL_TYPES.EMPTY_WIDGET]: null,
};
const GridPanelSwitch = forwardRef<
ToggleGraphProps | undefined,
GridPanelSwitchProps
>(
(
{
panelType,
data,
title,
isStacked,
onClickHandler,
name,
yAxisUnit,
staticLine,
onDragSelect,
panelData,
query,
},
ref,
): JSX.Element | null => {
const currentProps: PropsTypePropsMap = useMemo(() => {
const result: PropsTypePropsMap = {
[PANEL_TYPES.TIME_SERIES]: {
type: 'line',
data,
title,
isStacked,
onClickHandler,
name,
yAxisUnit,
staticLine,
onDragSelect,
ref,
},
[PANEL_TYPES.VALUE]: {
title,
data,
yAxisUnit,
},
[PANEL_TYPES.TABLE]: { ...GRID_TABLE_CONFIG, data: panelData, query },
[PANEL_TYPES.LIST]: null,
[PANEL_TYPES.TRACE]: null,
[PANEL_TYPES.EMPTY_WIDGET]: null,
};
return result;
}, [
data,
isStacked,
name,
onClickHandler,
onDragSelect,
staticLine,
title,
yAxisUnit,
panelData,
query,
]);
return result;
}, [
data,
isStacked,
name,
onClickHandler,
onDragSelect,
staticLine,
title,
yAxisUnit,
panelData,
query,
ref,
]);
const Component = PANEL_TYPES_COMPONENT_MAP[panelType] as FC<
PropsTypePropsMap[typeof panelType]
>;
const componentProps = useMemo(() => currentProps[panelType], [
panelType,
currentProps,
]);
const Component = PANEL_TYPES_COMPONENT_MAP[panelType] as FC<
PropsTypePropsMap[typeof panelType]
>;
const componentProps = useMemo(() => currentProps[panelType], [
panelType,
currentProps,
]);
if (!Component || !componentProps) return null;
if (!Component || !componentProps) return null;
// eslint-disable-next-line react/jsx-props-no-spreading
return <Component {...componentProps} />;
},
);
// eslint-disable-next-line react/jsx-props-no-spreading
return <Component {...componentProps} />;
}
GridPanelSwitch.displayName = 'GridPanelSwitch';
export default memo(GridPanelSwitch);

View File

@ -3,7 +3,7 @@ import {
GraphOnClickHandler,
GraphProps,
StaticLineProps,
} from 'components/Graph';
} from 'components/Graph/types';
import { GridTableComponentProps } from 'container/GridTableComponent/types';
import { GridValueComponentProps } from 'container/GridValueComponent/types';
import { Query } from 'types/api/queryBuilder/queryBuilderData';

View File

@ -0,0 +1,36 @@
import { Button, Typography } from 'antd';
import { FILTERS } from 'container/QueryBuilder/filters/OrderByFilter/config';
import { ShowButtonWrapper } from './styles';
interface ShowButtonProps {
isLoading: boolean;
isDisabled: boolean;
order: string;
onClick: () => void;
}
function ShowButton({
isLoading,
isDisabled,
order,
onClick,
}: ShowButtonProps): JSX.Element {
return (
<ShowButtonWrapper>
<Typography>
Showing 10 lines {order === FILTERS.ASC ? 'after' : 'before'} match
</Typography>
<Button
size="small"
disabled={isLoading || isDisabled}
loading={isLoading}
onClick={onClick}
>
Show 10 more lines
</Button>
</ShowButtonWrapper>
);
}
export default ShowButton;

View File

@ -0,0 +1,9 @@
import { OrderByPayload } from 'types/api/queryBuilder/queryBuilderData';
export const INITIAL_PAGE_SIZE = 5;
export const LOGS_MORE_PAGE_SIZE = 10;
export const getOrderByTimestamp = (order: string): OrderByPayload => ({
columnName: 'timestamp',
order,
});

View File

@ -0,0 +1,198 @@
import RawLogView from 'components/Logs/RawLogView';
import Spinner from 'components/Spinner';
import { PANEL_TYPES } from 'constants/queryBuilder';
import { FILTERS } from 'container/QueryBuilder/filters/OrderByFilter/config';
import { useGetExplorerQueryRange } from 'hooks/queryBuilder/useGetExplorerQueryRange';
import { useIsDarkMode } from 'hooks/useDarkMode';
import { memo, useCallback, useEffect, useMemo, useState } from 'react';
import { Virtuoso } from 'react-virtuoso';
import { SuccessResponse } from 'types/api';
import { ILog } from 'types/api/logs/log';
import { MetricRangePayloadProps } from 'types/api/metrics/getQueryRange';
import { Query, TagFilter } from 'types/api/queryBuilder/queryBuilderData';
import {
getOrderByTimestamp,
INITIAL_PAGE_SIZE,
LOGS_MORE_PAGE_SIZE,
} from './configs';
import ShowButton from './ShowButton';
import { EmptyText, ListContainer } from './styles';
import { getRequestData } from './utils';
interface LogsContextListProps {
isEdit: boolean;
query: Query;
log: ILog;
order: string;
filters: TagFilter | null;
}
function LogsContextList({
isEdit,
query,
log,
order,
filters,
}: LogsContextListProps): JSX.Element {
const isDarkMode = useIsDarkMode();
const [logs, setLogs] = useState<ILog[]>([]);
const [page, setPage] = useState<number>(1);
const firstLog = useMemo(() => logs[0], [logs]);
const lastLog = useMemo(() => logs[logs.length - 1], [logs]);
const orderByTimestamp = useMemo(() => getOrderByTimestamp(order), [order]);
const logsMorePageSize = useMemo(() => (page - 1) * LOGS_MORE_PAGE_SIZE, [
page,
]);
const pageSize = useMemo(
() => (page <= 1 ? INITIAL_PAGE_SIZE : logsMorePageSize + INITIAL_PAGE_SIZE),
[page, logsMorePageSize],
);
const isDisabledFetch = useMemo(() => logs.length < pageSize, [
logs.length,
pageSize,
]);
const currentStagedQueryData = useMemo(() => {
if (!query || query.builder.queryData.length !== 1) return null;
return query.builder.queryData[0];
}, [query]);
const initialLogsRequest = useMemo(
() =>
getRequestData({
stagedQueryData: currentStagedQueryData,
query,
log,
orderByTimestamp,
page,
}),
[currentStagedQueryData, page, log, query, orderByTimestamp],
);
const [requestData, setRequestData] = useState<Query | null>(
initialLogsRequest,
);
const handleSuccess = useCallback(
(data: SuccessResponse<MetricRangePayloadProps, unknown>) => {
const currentData = data?.payload.data.newResult.data.result || [];
if (currentData.length > 0 && currentData[0].list) {
const currentLogs: ILog[] = currentData[0].list.map((item) => ({
...item.data,
timestamp: item.timestamp,
}));
if (order === FILTERS.ASC) {
const reversedCurrentLogs = currentLogs.reverse();
setLogs((prevLogs) => [...reversedCurrentLogs, ...prevLogs]);
} else {
setLogs((prevLogs) => [...prevLogs, ...currentLogs]);
}
}
},
[order],
);
const { isError, isFetching } = useGetExplorerQueryRange(
requestData,
PANEL_TYPES.LIST,
{
keepPreviousData: true,
onSuccess: handleSuccess,
},
);
const handleShowNextLines = useCallback(() => {
if (isDisabledFetch) return;
const log = order === FILTERS.ASC ? firstLog : lastLog;
const newRequestData = getRequestData({
stagedQueryData: currentStagedQueryData,
query,
log,
orderByTimestamp,
page: page + 1,
pageSize: LOGS_MORE_PAGE_SIZE,
});
setPage((prevPage) => prevPage + 1);
setRequestData(newRequestData);
}, [
query,
firstLog,
lastLog,
page,
order,
currentStagedQueryData,
isDisabledFetch,
orderByTimestamp,
]);
useEffect(() => {
if (!isEdit) return;
const newRequestData = getRequestData({
stagedQueryData: currentStagedQueryData,
query,
log,
orderByTimestamp,
page: 1,
});
setPage(1);
setLogs([]);
setRequestData(newRequestData);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [filters]);
const getItemContent = useCallback(
(_: number, log: ILog): JSX.Element => (
<RawLogView isReadOnly key={log.id} data={log} linesPerRow={1} />
),
[],
);
return (
<>
{order === FILTERS.ASC && (
<ShowButton
isLoading={isFetching}
isDisabled={isDisabledFetch}
order={order}
onClick={handleShowNextLines}
/>
)}
<ListContainer $isDarkMode={isDarkMode}>
{((!logs.length && !isFetching) || isError) && (
<EmptyText>No Data</EmptyText>
)}
{isFetching && <Spinner size="large" height="10rem" />}
<Virtuoso
initialTopMostItemIndex={0}
data={logs}
itemContent={getItemContent}
followOutput={order === FILTERS.DESC}
/>
</ListContainer>
{order === FILTERS.DESC && (
<ShowButton
isLoading={isFetching}
isDisabled={isDisabledFetch}
order={order}
onClick={handleShowNextLines}
/>
)}
</>
);
}
export default memo(LogsContextList);

View File

@ -0,0 +1,25 @@
import { Space, Typography } from 'antd';
import { themeColors } from 'constants/theme';
import styled from 'styled-components';
export const ListContainer = styled.div<{ $isDarkMode: boolean }>`
position: relative;
margin: 0 -1.5rem;
height: 10rem;
overflow-y: scroll;
background-color: ${({ $isDarkMode }): string =>
$isDarkMode ? themeColors.darkGrey : themeColors.lightgrey};
`;
export const ShowButtonWrapper = styled(Space)`
margin: 0.625rem 0;
`;
export const EmptyText = styled(Typography)`
padding: 0 1.5rem;
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
`;

View File

@ -0,0 +1,52 @@
import { getPaginationQueryData } from 'lib/newQueryBuilder/getPaginationQueryData';
import { ILog } from 'types/api/logs/log';
import {
IBuilderQuery,
OrderByPayload,
Query,
} from 'types/api/queryBuilder/queryBuilderData';
import { INITIAL_PAGE_SIZE } from './configs';
type GetRequestDataProps = {
query: Query | null;
stagedQueryData: IBuilderQuery | null;
log: ILog;
orderByTimestamp: OrderByPayload;
page: number;
pageSize?: number;
};
export const getRequestData = ({
query,
stagedQueryData,
log,
orderByTimestamp,
page,
pageSize = INITIAL_PAGE_SIZE,
}: GetRequestDataProps): Query | null => {
if (!query) return null;
const paginateData = getPaginationQueryData({
currentStagedQueryData: stagedQueryData,
listItemId: log ? log.id : null,
orderByTimestamp,
page,
pageSize,
});
const data: Query = {
...query,
builder: {
...query.builder,
queryData: query.builder.queryData.map((item) => ({
...item,
...paginateData,
pageSize,
orderBy: [orderByTimestamp],
})),
},
};
return data;
};

View File

@ -0,0 +1,109 @@
import { EditFilled } from '@ant-design/icons';
import { Typography } from 'antd';
import Modal from 'antd/es/modal/Modal';
import RawLogView from 'components/Logs/RawLogView';
import LogsContextList from 'container/LogsContextList';
import { FILTERS } from 'container/QueryBuilder/filters/OrderByFilter/config';
import QueryBuilderSearch from 'container/QueryBuilder/filters/QueryBuilderSearch';
import { useIsDarkMode } from 'hooks/useDarkMode';
import { memo, useCallback, useMemo, useState } from 'react';
import { Query, TagFilter } from 'types/api/queryBuilder/queryBuilderData';
import { EditButton, TitleWrapper } from './styles';
import { LogsExplorerContextProps } from './types';
import useInitialQuery from './useInitialQuery';
function LogsExplorerContext({
log,
onClose,
}: LogsExplorerContextProps): JSX.Element | null {
const initialContextQuery = useInitialQuery(log);
const [contextQuery, setContextQuery] = useState<Query>(initialContextQuery);
const [filters, setFilters] = useState<TagFilter | null>(null);
const [isEdit, setIsEdit] = useState<boolean>(false);
const isDarkMode = useIsDarkMode();
const handleClickEditButton = useCallback(
() => setIsEdit((prevValue) => !prevValue),
[],
);
const handleSearch = useCallback(
(tagFilters: TagFilter): void => {
const tagFiltersLength = tagFilters.items.length;
if (
(!tagFiltersLength && (!filters || !filters.items.length)) ||
tagFiltersLength === filters?.items.length
)
return;
const nextQuery: Query = {
...contextQuery,
builder: {
...contextQuery.builder,
queryData: contextQuery.builder.queryData.map((item) => ({
...item,
filters: tagFilters,
})),
},
};
setFilters(tagFilters);
setContextQuery(nextQuery);
},
[contextQuery, filters],
);
const contextListParams = useMemo(
() => ({ log, isEdit, filters, query: contextQuery }),
[isEdit, log, filters, contextQuery],
);
return (
<Modal
centered
destroyOnClose
open
width={816}
onCancel={onClose}
onOk={onClose}
footer={null}
title={
<TitleWrapper block>
<Typography>Logs Context</Typography>
<EditButton
$isDarkMode={isDarkMode}
size="small"
type="text"
icon={<EditFilled />}
onClick={handleClickEditButton}
/>
</TitleWrapper>
}
>
{isEdit && (
<QueryBuilderSearch
query={contextQuery?.builder.queryData[0]}
onChange={handleSearch}
/>
)}
<LogsContextList
order={FILTERS.ASC}
// eslint-disable-next-line react/jsx-props-no-spreading
{...contextListParams}
/>
<RawLogView isActiveLog isReadOnly data={log} linesPerRow={1} />
<LogsContextList
order={FILTERS.DESC}
// eslint-disable-next-line react/jsx-props-no-spreading
{...contextListParams}
/>
</Modal>
);
}
export default memo(LogsExplorerContext);

View File

@ -0,0 +1,30 @@
import { Button, Space } from 'antd';
import { themeColors } from 'constants/theme';
import styled from 'styled-components';
import getAlphaColor from 'utils/getAlphaColor';
export const TitleWrapper = styled(Space.Compact)`
justify-content: space-between;
align-items: center;
`;
export const EditButton = styled(Button)<{ $isDarkMode: boolean }>`
margin-right: 0.938rem;
width: 1.375rem !important;
height: 1.375rem;
position: absolute;
top: 1rem;
right: 1.563rem;
padding: 0;
border-radius: 0.125rem;
border-start-start-radius: 0.125rem !important;
border-end-start-radius: 0.125rem !important;
color: ${({ $isDarkMode }): string =>
$isDarkMode
? getAlphaColor(themeColors.white)[45]
: getAlphaColor(themeColors.black)[45]};
`;

View File

@ -0,0 +1,6 @@
import { ILog } from 'types/api/logs/log';
export interface LogsExplorerContextProps {
log: ILog;
onClose: VoidFunction;
}

View File

@ -0,0 +1,36 @@
import { initialQueriesMap, PANEL_TYPES } from 'constants/queryBuilder';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import { ILog } from 'types/api/logs/log';
import { Query } from 'types/api/queryBuilder/queryBuilderData';
import { DataSource } from 'types/common/queryBuilder';
import { getFiltersFromResources } from './utils';
const useInitialQuery = (log: ILog): Query => {
const { updateAllQueriesOperators } = useQueryBuilder();
const resourcesFilters = getFiltersFromResources(log.resources_string);
const updatedAllQueriesOperator = updateAllQueriesOperators(
initialQueriesMap.logs,
PANEL_TYPES.LIST,
DataSource.LOGS,
);
const data: Query = {
...updatedAllQueriesOperator,
builder: {
...updatedAllQueriesOperator.builder,
queryData: updatedAllQueriesOperator.builder.queryData.map((item) => ({
...item,
filters: {
...item.filters,
items: [...item.filters.items, ...resourcesFilters],
},
})),
},
};
return data;
};
export default useInitialQuery;

View File

@ -0,0 +1,22 @@
import { OPERATORS } from 'constants/queryBuilder';
import { ILog } from 'types/api/logs/log';
import { TagFilterItem } from 'types/api/queryBuilder/queryBuilderData';
import { v4 as uuid } from 'uuid';
export const getFiltersFromResources = (
resources: ILog['resources_string'],
): TagFilterItem[] =>
Object.keys(resources).map((key: string) => {
const resourceValue = resources[key] as string;
return {
id: uuid(),
key: {
key,
dataType: 'string',
type: 'resource',
isColumn: false,
},
op: OPERATORS['='],
value: resourceValue,
};
});

View File

@ -1,3 +1,4 @@
import Spinner from 'components/Spinner';
import { dragColumnParams } from 'hooks/useDragColumns/configs';
import ReactDragListView from 'react-drag-listview';
import { TableComponents } from 'react-virtuoso';
@ -5,13 +6,18 @@ import { TableComponents } from 'react-virtuoso';
import { TableStyled } from './styles';
interface LogsCustomTableProps {
isLoading?: boolean;
handleDragEnd: (fromIndex: number, toIndex: number) => void;
}
export const LogsCustomTable = ({
isLoading,
handleDragEnd,
}: LogsCustomTableProps): TableComponents['Table'] =>
function CustomTable({ style, children }): JSX.Element {
if (isLoading) {
return <Spinner height="35px" tip="Getting Logs" />;
}
return (
<ReactDragListView.DragColumn
// eslint-disable-next-line react/jsx-props-no-spreading

View File

@ -1,6 +1,6 @@
import { CSSProperties } from 'react';
export const infinityDefaultStyles: CSSProperties = {
height: 'auto',
width: '100%',
overflowX: 'scroll',
};

View File

@ -1,16 +1,26 @@
import LogDetail from 'components/LogDetail';
import { ColumnTypeRender } from 'components/Logs/TableView/types';
import { useTableView } from 'components/Logs/TableView/useTableView';
import { LOCALSTORAGE } from 'constants/localStorage';
import LogsExplorerContext from 'container/LogsExplorerContext';
import { useActiveLog } from 'hooks/logs/useActiveLog';
import { useCopyLogLink } from 'hooks/logs/useCopyLogLink';
import useDragColumns from 'hooks/useDragColumns';
import { getDraggedColumns } from 'hooks/useDragColumns/utils';
import {
cloneElement,
forwardRef,
ReactElement,
ReactNode,
useCallback,
useMemo,
} from 'react';
import { TableComponents, TableVirtuoso } from 'react-virtuoso';
import {
TableComponents,
TableVirtuoso,
TableVirtuosoHandle,
} from 'react-virtuoso';
import { ILog } from 'types/api/logs/log';
import { infinityDefaultStyles } from './config';
import { LogsCustomTable } from './LogsCustomTable';
@ -22,105 +32,151 @@ import {
import { InfinityTableProps } from './types';
// eslint-disable-next-line react/function-component-definition
const CustomTableRow: TableComponents['TableRow'] = ({
const CustomTableRow: TableComponents<ILog>['TableRow'] = ({
children,
context,
...props
// eslint-disable-next-line react/jsx-props-no-spreading
}) => <TableRowStyled {...props}>{children}</TableRowStyled>;
function InfinityTable({
tableViewProps,
infitiyTableProps,
}: InfinityTableProps): JSX.Element | null {
const { onEndReached } = infitiyTableProps;
const { dataSource, columns } = useTableView(tableViewProps);
const { draggedColumns, onDragColumns } = useDragColumns<
Record<string, unknown>
>(LOCALSTORAGE.LOGS_LIST_COLUMNS);
const tableColumns = useMemo(
() => getDraggedColumns<Record<string, unknown>>(columns, draggedColumns),
[columns, draggedColumns],
);
const handleDragEnd = useCallback(
(fromIndex: number, toIndex: number) =>
onDragColumns(tableColumns, fromIndex, toIndex),
[tableColumns, onDragColumns],
);
const itemContent = useCallback(
(index: number, log: Record<string, unknown>): JSX.Element => (
<>
{tableColumns.map((column) => {
if (!column.render) return <td>Empty</td>;
const element: ColumnTypeRender<Record<string, unknown>> = column.render(
log[column.key as keyof Record<string, unknown>],
log,
index,
);
const elementWithChildren = element as Exclude<
ColumnTypeRender<Record<string, unknown>>,
ReactNode
>;
const children = elementWithChildren.children as ReactElement;
const props = elementWithChildren.props as Record<string, unknown>;
return (
<TableCellStyled key={column.key}>
{cloneElement(children, props)}
</TableCellStyled>
);
})}
</>
),
[tableColumns],
);
const tableHeader = useCallback(
() => (
<tr>
{tableColumns.map((column) => {
const isDragColumn = column.key !== 'expand';
return (
<TableHeaderCellStyled
isDragColumn={isDragColumn}
key={column.key}
// eslint-disable-next-line react/jsx-props-no-spreading
{...(isDragColumn && { className: 'dragHandler' })}
>
{column.title as string}
</TableHeaderCellStyled>
);
})}
</tr>
),
[tableColumns],
);
}) => {
const { isHighlighted } = useCopyLogLink(props.item.id);
return (
<TableVirtuoso
style={infinityDefaultStyles}
data={dataSource}
components={{
// eslint-disable-next-line react/jsx-props-no-spreading
Table: LogsCustomTable({ handleDragEnd }),
// TODO: fix it in the future
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
TableRow: CustomTableRow,
}}
itemContent={itemContent}
fixedHeaderContent={tableHeader}
endReached={onEndReached}
totalCount={dataSource.length}
/>
<TableRowStyled
$isActiveLog={isHighlighted}
// eslint-disable-next-line react/jsx-props-no-spreading
{...props}
>
{children}
</TableRowStyled>
);
}
};
const InfinityTable = forwardRef<TableVirtuosoHandle, InfinityTableProps>(
function InfinityTableView(
{ isLoading, tableViewProps, infitiyTableProps },
ref,
): JSX.Element | null {
const {
activeLog: activeContextLog,
onSetActiveLog: handleSetActiveContextLog,
onClearActiveLog: handleClearActiveContextLog,
} = useActiveLog();
const {
activeLog,
onSetActiveLog,
onClearActiveLog,
onAddToQuery,
} = useActiveLog();
const { onEndReached } = infitiyTableProps;
const { dataSource, columns } = useTableView({
...tableViewProps,
onClickExpand: onSetActiveLog,
onOpenLogsContext: handleSetActiveContextLog,
});
const { draggedColumns, onDragColumns } = useDragColumns<
Record<string, unknown>
>(LOCALSTORAGE.LOGS_LIST_COLUMNS);
const tableColumns = useMemo(
() => getDraggedColumns<Record<string, unknown>>(columns, draggedColumns),
[columns, draggedColumns],
);
const handleDragEnd = useCallback(
(fromIndex: number, toIndex: number) =>
onDragColumns(tableColumns, fromIndex, toIndex),
[tableColumns, onDragColumns],
);
const itemContent = useCallback(
(index: number, log: Record<string, unknown>): JSX.Element => (
<>
{tableColumns.map((column) => {
if (!column.render) return <td>Empty</td>;
const element: ColumnTypeRender<Record<string, unknown>> = column.render(
log[column.key as keyof Record<string, unknown>],
log,
index,
);
const elementWithChildren = element as Exclude<
ColumnTypeRender<Record<string, unknown>>,
ReactNode
>;
const children = elementWithChildren.children as ReactElement;
const props = elementWithChildren.props as Record<string, unknown>;
return (
<TableCellStyled key={column.key}>
{cloneElement(children, props)}
</TableCellStyled>
);
})}
</>
),
[tableColumns],
);
const tableHeader = useCallback(
() => (
<tr>
{tableColumns.map((column) => {
const isDragColumn = column.key !== 'expand';
return (
<TableHeaderCellStyled
isDragColumn={isDragColumn}
key={column.key}
// eslint-disable-next-line react/jsx-props-no-spreading
{...(isDragColumn && { className: 'dragHandler' })}
>
{column.title as string}
</TableHeaderCellStyled>
);
})}
</tr>
),
[tableColumns],
);
return (
<>
<TableVirtuoso
useWindowScroll
ref={ref}
style={infinityDefaultStyles}
data={dataSource}
components={{
// eslint-disable-next-line react/jsx-props-no-spreading
Table: LogsCustomTable({ isLoading, handleDragEnd }),
// TODO: fix it in the future
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
TableRow: CustomTableRow,
}}
itemContent={itemContent}
fixedHeaderContent={tableHeader}
endReached={onEndReached}
totalCount={dataSource.length}
/>
{activeContextLog && (
<LogsExplorerContext
log={activeContextLog}
onClose={handleClearActiveContextLog}
/>
)}
<LogDetail
log={activeLog}
onClose={onClearActiveLog}
onAddToQuery={onAddToQuery}
onClickActionItem={onAddToQuery}
/>
</>
);
},
);
export default InfinityTable;

View File

@ -1,5 +1,6 @@
import { themeColors } from 'constants/theme';
import styled from 'styled-components';
import { getActiveLogBackground } from 'utils/logs';
interface TableHeaderCellStyledProps {
isDragColumn: boolean;
@ -22,10 +23,19 @@ export const TableCellStyled = styled.td`
background-color: ${themeColors.lightBlack};
`;
export const TableRowStyled = styled.tr`
export const TableRowStyled = styled.tr<{
$isActiveLog: boolean;
}>`
td {
${({ $isActiveLog }): string => getActiveLogBackground($isActiveLog)}
}
&:hover {
${TableCellStyled} {
background-color: #1d1d1d;
${({ $isActiveLog }): string =>
$isActiveLog
? getActiveLogBackground()
: `background-color: ${themeColors.bckgGrey};`}
}
}
`;
@ -33,7 +43,7 @@ export const TableRowStyled = styled.tr`
export const TableHeaderCellStyled = styled.th<TableHeaderCellStyledProps>`
padding: 0.5rem;
border-inline-end: 1px solid rgba(253, 253, 253, 0.12);
background-color: #1d1d1d;
background-color: ${themeColors.bckgGrey};
${({ isDragColumn }): string => (isDragColumn ? 'cursor: col-resize;' : '')}
&:first-child {

View File

@ -1,7 +1,8 @@
import { UseTableViewProps } from 'components/Logs/TableView/types';
export type InfinityTableProps = {
tableViewProps: UseTableViewProps;
isLoading?: boolean;
tableViewProps: Omit<UseTableViewProps, 'onOpenLogsContext' | 'onClickExpand'>;
infitiyTableProps: {
onEndReached: (index: number) => void;
};

View File

@ -1,4 +1,3 @@
import { AddToQueryHOCProps } from 'components/Logs/AddToQueryHOC';
import { ILog } from 'types/api/logs/log';
import { IBuilderQuery } from 'types/api/queryBuilder/queryBuilderData';
@ -7,6 +6,4 @@ export type LogsExplorerListProps = {
currentStagedQueryData: IBuilderQuery | null;
logs: ILog[];
onEndReached: (index: number) => void;
onExpand: (log: ILog) => void;
onOpenDetailedView: (log: ILog) => void;
} & Pick<AddToQueryHOCProps, 'onAddToQuery'>;
};

View File

@ -8,10 +8,11 @@ import ExplorerControlPanel from 'container/ExplorerControlPanel';
import { Heading } from 'container/LogsTable/styles';
import { useOptionsMenu } from 'container/OptionsMenu';
import { contentStyle } from 'container/Trace/Search/config';
import { useCopyLogLink } from 'hooks/logs/useCopyLogLink';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import useFontFaceObserver from 'hooks/useFontObserver';
import { memo, useCallback, useMemo } from 'react';
import { Virtuoso } from 'react-virtuoso';
import { memo, useCallback, useEffect, useMemo, useRef } from 'react';
import { Virtuoso, VirtuosoHandle } from 'react-virtuoso';
// interfaces
import { ILog } from 'types/api/logs/log';
import { DataSource, StringOperators } from 'types/common/queryBuilder';
@ -29,13 +30,13 @@ function LogsExplorerList({
isLoading,
currentStagedQueryData,
logs,
onOpenDetailedView,
onEndReached,
onExpand,
onAddToQuery,
}: LogsExplorerListProps): JSX.Element {
const ref = useRef<VirtuosoHandle>(null);
const { initialDataSource } = useQueryBuilder();
const { activeLogId } = useCopyLogLink();
const { options, config } = useOptionsMenu({
storageKey: LOCALSTORAGE.LOGS_LIST_OPTIONS,
dataSource: initialDataSource || DataSource.METRICS,
@ -43,6 +44,11 @@ function LogsExplorerList({
currentStagedQueryData?.aggregateOperator || StringOperators.NOOP,
});
const activeLogIndex = useMemo(
() => logs.findIndex(({ id }) => id === activeLogId),
[logs, activeLogId],
);
useFontFaceObserver(
[
{
@ -65,35 +71,27 @@ function LogsExplorerList({
(_: number, log: ILog): JSX.Element => {
if (options.format === 'raw') {
return (
<RawLogView
key={log.id}
data={log}
linesPerRow={options.maxLines}
onClickExpand={onExpand}
/>
<RawLogView key={log.id} data={log} linesPerRow={options.maxLines} />
);
}
return (
<ListLogView
key={log.id}
logData={log}
selectedFields={selectedFields}
onOpenDetailedView={onOpenDetailedView}
onAddToQuery={onAddToQuery}
/>
<ListLogView key={log.id} logData={log} selectedFields={selectedFields} />
);
},
[
options.format,
options.maxLines,
selectedFields,
onOpenDetailedView,
onAddToQuery,
onExpand,
],
[options.format, options.maxLines, selectedFields],
);
useEffect(() => {
if (!activeLogId || activeLogIndex < 0) return;
ref?.current?.scrollToIndex({
index: activeLogIndex,
align: 'start',
behavior: 'smooth',
});
}, [activeLogId, activeLogIndex]);
const renderContent = useMemo(() => {
const components = isLoading
? {
@ -104,11 +102,12 @@ function LogsExplorerList({
if (options.format === 'table') {
return (
<InfinityTableView
ref={ref}
isLoading={isLoading}
tableViewProps={{
logs,
fields: selectedFields,
linesPerRow: options.maxLines,
onClickExpand: onExpand,
appendTo: 'end',
}}
infitiyTableProps={{ onEndReached }}
@ -119,6 +118,7 @@ function LogsExplorerList({
return (
<Card style={{ width: '100%' }} bodyStyle={{ ...contentStyle }}>
<Virtuoso
ref={ref}
useWindowScroll
data={logs}
endReached={onEndReached}
@ -136,7 +136,6 @@ function LogsExplorerList({
onEndReached,
getItemContent,
selectedFields,
onExpand,
]);
return (

View File

@ -1,13 +1,10 @@
import { TabsProps } from 'antd';
import LogDetail from 'components/LogDetail';
import TabLabel from 'components/TabLabel';
import { QueryParams } from 'constants/query';
import {
initialAutocompleteData,
initialQueriesMap,
OPERATORS,
PANEL_TYPES,
QueryBuilderKeys,
} from 'constants/queryBuilder';
import { queryParamNamesMap } from 'constants/queryBuilderQueryNames';
import ROUTES from 'constants/routes';
@ -21,25 +18,20 @@ import { SIGNOZ_VALUE } from 'container/QueryBuilder/filters/OrderByFilter/const
import TimeSeriesView from 'container/TimeSeriesView/TimeSeriesView';
import { useUpdateDashboard } from 'hooks/dashboard/useUpdateDashboard';
import { addEmptyWidgetInDashboardJSONWithQuery } from 'hooks/dashboard/utils';
import { LogTimeRange } from 'hooks/logs/types';
import { useCopyLogLink } from 'hooks/logs/useCopyLogLink';
import { useGetExplorerQueryRange } from 'hooks/queryBuilder/useGetExplorerQueryRange';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import useAxiosError from 'hooks/useAxiosError';
import { useNotifications } from 'hooks/useNotifications';
import useUrlQueryData from 'hooks/useUrlQueryData';
import { chooseAutocompleteFromCustomValue } from 'lib/newQueryBuilder/chooseAutocompleteFromCustomValue';
import { getPaginationQueryData } from 'lib/newQueryBuilder/getPaginationQueryData';
import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useQueryClient } from 'react-query';
import { useSelector } from 'react-redux';
import { generatePath, useHistory } from 'react-router-dom';
import { AppState } from 'store/reducers';
import { SuccessResponse } from 'types/api';
import { Dashboard } from 'types/api/dashboard/getAll';
import { ILog } from 'types/api/logs/log';
import {
BaseAutocompleteData,
IQueryAutocompleteResponse,
} from 'types/api/queryBuilder/queryAutocompleteResponse';
import {
IBuilderQuery,
OrderByPayload,
@ -47,7 +39,6 @@ import {
} from 'types/api/queryBuilder/queryBuilderData';
import { DataSource, StringOperators } from 'types/common/queryBuilder';
import { GlobalReducer } from 'types/reducer/globalTime';
import { v4 as uuid } from 'uuid';
import { ActionsWrapper, TabsStyled } from './LogsExplorerViews.styled';
@ -55,8 +46,7 @@ function LogsExplorerViews(): JSX.Element {
const { notifications } = useNotifications();
const history = useHistory();
const queryClient = useQueryClient();
const { activeLogId, timeRange, onTimeRangeChange } = useCopyLogLink();
const { queryData: pageSize } = useUrlQueryData(
queryParamNamesMap.pageSize,
DEFAULT_PER_PAGE_VALUE,
@ -79,7 +69,6 @@ function LogsExplorerViews(): JSX.Element {
} = useQueryBuilder();
// State
const [activeLog, setActiveLog] = useState<ILog | null>(null);
const [page, setPage] = useState<number>(1);
const [logs, setLogs] = useState<ILog[]>([]);
const [requestData, setRequestData] = useState<Query | null>(null);
@ -167,16 +156,16 @@ function LogsExplorerViews(): JSX.Element {
keepPreviousData: true,
enabled: !isLimit,
},
{
...(timeRange &&
activeLogId &&
!logs.length && {
start: timeRange.start,
end: timeRange.end,
}),
},
);
const handleSetActiveLog = useCallback((nextActiveLog: ILog) => {
setActiveLog(nextActiveLog);
}, []);
const handleClearActiveLog = useCallback(() => {
setActiveLog(null);
}, []);
const getUpdateQuery = useCallback(
(newPanelType: PANEL_TYPES): Query => {
let query = updateAllQueriesOperators(
@ -245,51 +234,6 @@ function LogsExplorerViews(): JSX.Element {
[currentStagedQueryData, orderByTimestamp],
);
const handleAddToQuery = useCallback(
(fieldKey: string, fieldValue: string, operator: string): void => {
const keysAutocomplete: BaseAutocompleteData[] =
queryClient.getQueryData<SuccessResponse<IQueryAutocompleteResponse>>(
[QueryBuilderKeys.GET_AGGREGATE_KEYS],
{ exact: false },
)?.payload.attributeKeys || [];
const existAutocompleteKey = chooseAutocompleteFromCustomValue(
keysAutocomplete,
fieldKey,
);
const currentOperator =
Object.keys(OPERATORS).find((op) => op === operator) || '';
const nextQuery: Query = {
...currentQuery,
builder: {
...currentQuery.builder,
queryData: currentQuery.builder.queryData.map((item) => ({
...item,
filters: {
...item.filters,
items: [
...item.filters.items.filter(
(item) => item.key?.id !== existAutocompleteKey.id,
),
{
id: uuid(),
key: existAutocompleteKey,
op: currentOperator,
value: fieldValue,
},
],
},
})),
},
};
redirectWithQueryBuilderData(nextQuery);
},
[currentQuery, queryClient, redirectWithQueryBuilderData],
);
const handleEndReached = useCallback(
(index: number) => {
if (isLimit) return;
@ -397,14 +341,24 @@ function LogsExplorerViews(): JSX.Element {
}, [panelType, isMultipleQueries, isGroupByExist, handleChangeView]);
useEffect(() => {
const currentParams = data?.params as Omit<LogTimeRange, 'pageSize'>;
const currentData = data?.payload.data.newResult.data.result || [];
if (currentData.length > 0 && currentData[0].list) {
const currentLogs: ILog[] = currentData[0].list.map((item) => ({
...item.data,
timestamp: item.timestamp,
}));
setLogs((prevLogs) => [...prevLogs, ...currentLogs]);
const newLogs = [...logs, ...currentLogs];
setLogs(newLogs);
onTimeRangeChange({
start: currentParams?.start,
end: timeRange?.end || currentParams?.end,
pageSize: newLogs.length,
});
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [data]);
useEffect(() => {
@ -415,14 +369,28 @@ function LogsExplorerViews(): JSX.Element {
const newRequestData = getRequestData(stagedQuery, {
page: 1,
log: null,
pageSize,
pageSize:
timeRange?.pageSize && activeLogId ? timeRange?.pageSize : pageSize,
});
setLogs([]);
setPage(1);
setRequestData(newRequestData);
currentMinTimeRef.current = minTime;
if (!activeLogId) {
onTimeRangeChange(null);
}
}
}, [stagedQuery, requestData, getRequestData, pageSize, minTime]);
}, [
stagedQuery,
requestData,
getRequestData,
pageSize,
minTime,
timeRange,
activeLogId,
onTimeRangeChange,
]);
const tabsItems: TabsProps['items'] = useMemo(
() => [
@ -441,10 +409,7 @@ function LogsExplorerViews(): JSX.Element {
isLoading={isFetching}
currentStagedQueryData={currentStagedQueryData}
logs={logs}
onOpenDetailedView={handleSetActiveLog}
onEndReached={handleEndReached}
onExpand={handleSetActiveLog}
onAddToQuery={handleAddToQuery}
/>
),
},
@ -472,9 +437,7 @@ function LogsExplorerViews(): JSX.Element {
isFetching,
currentStagedQueryData,
logs,
handleSetActiveLog,
handleEndReached,
handleAddToQuery,
data,
isError,
],
@ -524,12 +487,6 @@ function LogsExplorerViews(): JSX.Element {
onChange={handleChangeView}
destroyInactiveTabPane
/>
<LogDetail
log={activeLog}
onClose={handleClearActiveLog}
onAddToQuery={handleAddToQuery}
onClickActionItem={handleAddToQuery}
/>
<GoToTop />
</>

View File

@ -4,18 +4,13 @@ import ListLogView from 'components/Logs/ListLogView';
import RawLogView from 'components/Logs/RawLogView';
import LogsTableView from 'components/Logs/TableView';
import Spinner from 'components/Spinner';
import ROUTES from 'constants/routes';
import { contentStyle } from 'container/Trace/Search/config';
import useFontFaceObserver from 'hooks/useFontObserver';
import { getGeneratedFilterQueryString } from 'lib/getGeneratedFilterQueryString';
import { memo, useCallback, useMemo } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import { useHistory } from 'react-router-dom';
import { useSelector } from 'react-redux';
import { Virtuoso } from 'react-virtuoso';
import { AppState } from 'store/reducers';
// interfaces
import { SET_DETAILED_LOG_DATA } from 'types/actions/logs';
import { ILog } from 'types/api/logs/log';
import { ILogsReducer } from 'types/reducer/logs';
// styles
@ -26,15 +21,10 @@ export type LogViewMode = 'raw' | 'table' | 'list';
type LogsTableProps = {
viewMode: LogViewMode;
linesPerRow: number;
onClickExpand: (logData: ILog) => void;
};
function LogsTable(props: LogsTableProps): JSX.Element {
const { viewMode, onClickExpand, linesPerRow } = props;
const history = useHistory();
const dispatch = useDispatch();
const { viewMode, linesPerRow } = props;
useFontFaceObserver(
[
@ -52,7 +42,6 @@ function LogsTable(props: LogsTableProps): JSX.Element {
const {
logs,
fields: { selected },
searchFilter: { queryString },
isLoading,
liveTail,
} = useSelector<AppState, ILogsReducer>((state) => state.logs);
@ -67,75 +56,23 @@ function LogsTable(props: LogsTableProps): JSX.Element {
liveTail,
]);
const handleOpenDetailedView = useCallback(
(logData: ILog) => {
dispatch({
type: SET_DETAILED_LOG_DATA,
payload: logData,
});
},
[dispatch],
);
const handleAddToQuery = useCallback(
(fieldKey: string, fieldValue: string, operator: string) => {
const updatedQueryString = getGeneratedFilterQueryString(
fieldKey,
fieldValue,
operator,
queryString,
);
history.replace(`${ROUTES.LOGS}?q=${updatedQueryString}`);
},
[history, queryString],
);
const getItemContent = useCallback(
(index: number): JSX.Element => {
const log = logs[index];
if (viewMode === 'raw') {
return (
<RawLogView
key={log.id}
data={log}
linesPerRow={linesPerRow}
onClickExpand={onClickExpand}
/>
);
return <RawLogView key={log.id} data={log} linesPerRow={linesPerRow} />;
}
return (
<ListLogView
key={log.id}
logData={log}
selectedFields={selected}
onOpenDetailedView={handleOpenDetailedView}
onAddToQuery={handleAddToQuery}
/>
);
return <ListLogView key={log.id} logData={log} selectedFields={selected} />;
},
[
logs,
viewMode,
selected,
linesPerRow,
onClickExpand,
handleOpenDetailedView,
handleAddToQuery,
],
[logs, viewMode, selected, linesPerRow],
);
const renderContent = useMemo(() => {
if (viewMode === 'table') {
return (
<LogsTableView
logs={logs}
fields={selected}
linesPerRow={linesPerRow}
onClickExpand={onClickExpand}
/>
<LogsTableView logs={logs} fields={selected} linesPerRow={linesPerRow} />
);
}
@ -148,7 +85,7 @@ function LogsTable(props: LogsTableProps): JSX.Element {
/>
</Card>
);
}, [getItemContent, linesPerRow, logs, onClickExpand, selected, viewMode]);
}, [getItemContent, linesPerRow, logs, selected, viewMode]);
if (isLoading) {
return <Spinner height={20} tip="Getting Logs" />;

View File

@ -8,7 +8,7 @@ import {
} from 'types/common/queryBuilder';
import { DataType, FORMULA, MetricsType, WidgetKeys } from '../constant';
import { IServiceName } from '../Tabs/types';
import { DatabaseCallProps, DatabaseCallsRPSProps } from '../types';
import {
getQueryBuilderQueries,
getQueryBuilderQuerieswithFormula,
@ -103,8 +103,8 @@ export const databaseCallsAvgDuration = ({
const legends = ['', ''];
const disabled = [true, true];
const legendFormula = 'Average Duration';
const expression = FORMULA.DATABASE_CALLS_AVG_DURATION;
const legendFormulas = ['Average Duration'];
const expressions = [FORMULA.DATABASE_CALLS_AVG_DURATION];
const aggregateOperators = [
MetricAggregateOperator.SUM,
MetricAggregateOperator.SUM,
@ -116,18 +116,9 @@ export const databaseCallsAvgDuration = ({
additionalItems,
legends,
disabled,
expression,
legendFormula,
expressions,
legendFormulas,
aggregateOperators,
dataSource,
});
};
interface DatabaseCallsRPSProps extends DatabaseCallProps {
legend: '{{db_system}}';
}
interface DatabaseCallProps {
servicename: IServiceName['servicename'];
tagFilterItems: TagFilterItem[];
}

View File

@ -83,22 +83,18 @@ export const externalCallErrorPercent = ({
},
...tagFilterItems,
];
const legendFormulas = [legend];
const expressions = [FORMULA.ERROR_PERCENTAGE];
const disabled = [true, true];
const autocompleteData = [autocompleteDataA, autocompleteDataB];
const legendFormula = legend;
const expression = FORMULA.ERROR_PERCENTAGE;
const autocompleteData: BaseAutocompleteData[] = [
autocompleteDataA,
autocompleteDataB,
const additionalItems = [additionalItemsA, additionalItemsB];
const aggregateOperators = [
MetricAggregateOperator.SUM,
MetricAggregateOperator.SUM,
];
const additionalItems: TagFilterItem[][] = [
additionalItemsA,
additionalItemsB,
];
const legends = Array(2).fill(legend);
const aggregateOperators = Array(2).fill(MetricAggregateOperator.SUM);
const disabled = Array(2).fill(true);
const legends = [legend, legend];
const dataSource = DataSource.METRICS;
return getQueryBuilderQuerieswithFormula({
@ -107,8 +103,8 @@ export const externalCallErrorPercent = ({
legends,
groupBy,
disabled,
expression,
legendFormula,
expressions,
legendFormulas,
aggregateOperators,
dataSource,
});
@ -130,11 +126,10 @@ export const externalCallDuration = ({
key: WidgetKeys.SignozExternalCallLatencyCount,
type: null,
};
const expression = FORMULA.DATABASE_CALLS_AVG_DURATION;
const legendFormula = 'Average Duration';
const expressions = [FORMULA.DATABASE_CALLS_AVG_DURATION];
const legendFormulas = ['Average Duration'];
const legend = '';
const disabled = Array(2).fill(true);
const disabled = [true, true];
const additionalItemsA: TagFilterItem[] = [
{
id: '',
@ -150,28 +145,25 @@ export const externalCallDuration = ({
...tagFilterItems,
];
const autocompleteData: BaseAutocompleteData[] = [
autocompleteDataA,
autocompleteDataB,
];
const autocompleteData = [autocompleteDataA, autocompleteDataB];
const additionalItems: TagFilterItem[][] = [
additionalItemsA,
additionalItemsA,
const additionalItems = [additionalItemsA, additionalItemsA];
const legends = [legend, legend];
const aggregateOperators = [
MetricAggregateOperator.SUM,
MetricAggregateOperator.SUM,
];
const legends = Array(2).fill(legend);
const aggregateOperators = Array(2).fill(MetricAggregateOperator.SUM);
const dataSource = DataSource.METRICS;
return getQueryBuilderQuerieswithFormula({
autocompleteData,
additionalItems,
legends,
disabled,
expression,
legendFormula,
expressions,
legendFormulas,
aggregateOperators,
dataSource: DataSource.METRICS,
dataSource,
});
};
@ -234,8 +226,8 @@ export const externalCallDurationByAddress = ({
key: WidgetKeys.SignozExternalCallLatencyCount,
type: null,
};
const expression = FORMULA.DATABASE_CALLS_AVG_DURATION;
const legendFormula = legend;
const expressions = [FORMULA.DATABASE_CALLS_AVG_DURATION];
const legendFormulas = [legend];
const disabled = [true, true];
const additionalItemsA: TagFilterItem[] = [
{
@ -252,18 +244,13 @@ export const externalCallDurationByAddress = ({
...tagFilterItems,
];
const autocompleteData: BaseAutocompleteData[] = [
autocompleteDataA,
autocompleteDataB,
const autocompleteData = [autocompleteDataA, autocompleteDataB];
const additionalItems = [additionalItemsA, additionalItemsA];
const legends = [legend, legend];
const aggregateOperators = [
MetricAggregateOperator.SUM,
MetricAggregateOperator.SUM,
];
const additionalItems: TagFilterItem[][] = [
additionalItemsA,
additionalItemsA,
];
const legends = Array(2).fill(legend);
const aggregateOperators = Array(2).fill(MetricAggregateOperator.SUM_RATE);
const dataSource = DataSource.METRICS;
return getQueryBuilderQuerieswithFormula({
@ -272,8 +259,8 @@ export const externalCallDurationByAddress = ({
legends,
groupBy,
disabled,
expression,
legendFormula,
expressions,
legendFormulas,
aggregateOperators,
dataSource,
});

View File

@ -67,18 +67,16 @@ export const getQueryBuilderQuerieswithFormula = ({
legends,
groupBy = [],
disabled,
expression,
legendFormula,
expressions,
legendFormulas,
aggregateOperators,
dataSource,
}: BuilderQuerieswithFormulaProps): QueryBuilderData => ({
queryFormulas: [
{
...initialFormulaBuilderFormValues,
expression,
legend: legendFormula,
},
],
queryFormulas: expressions.map((expression, index) => ({
...initialFormulaBuilderFormValues,
expression,
legend: legendFormulas[index],
})),
queryData: autocompleteData.map((_, index) => ({
...initialQueryBuilderFormValuesMap.metrics,
aggregateOperator: aggregateOperators[index],

View File

@ -224,8 +224,8 @@ export const errorPercentage = ({
const additionalItems = [additionalItemsA, additionalItemsB];
const legends = [GraphTitle.ERROR_PERCENTAGE];
const disabled = [true, true];
const expression = FORMULA.ERROR_PERCENTAGE;
const legendFormula = GraphTitle.ERROR_PERCENTAGE;
const expressions = [FORMULA.ERROR_PERCENTAGE];
const legendFormulas = [GraphTitle.ERROR_PERCENTAGE];
const aggregateOperators = [
MetricAggregateOperator.SUM_RATE,
MetricAggregateOperator.SUM_RATE,
@ -237,8 +237,8 @@ export const errorPercentage = ({
additionalItems,
legends,
disabled,
expression,
legendFormula,
expressions,
legendFormulas,
aggregateOperators,
dataSource,
});

View File

@ -124,8 +124,8 @@ export const topOperationQueries = ({
MetricAggregateOperator.SUM_RATE,
MetricAggregateOperator.SUM_RATE,
];
const expression = 'D*100/E';
const legendFormula = GraphTitle.ERROR_PERCENTAGE;
const expressions = ['D*100/E'];
const legendFormulas = [GraphTitle.ERROR_PERCENTAGE];
const dataSource = DataSource.METRICS;
return getQueryBuilderQuerieswithFormula({
@ -134,8 +134,8 @@ export const topOperationQueries = ({
disabled,
legends,
aggregateOperators,
expression,
legendFormula,
expressions,
legendFormulas,
dataSource,
groupBy,
});

View File

@ -4,12 +4,13 @@ import { topOperationQueries } from 'container/MetricsApplication/MetricsPageQue
import { QueryTable } from 'container/QueryTable';
import { useGetQueryRange } from 'hooks/queryBuilder/useGetQueryRange';
import { useStepInterval } from 'hooks/queryBuilder/useStepInterval';
import { useNotifications } from 'hooks/useNotifications';
import useResourceAttribute from 'hooks/useResourceAttribute';
import { convertRawQueriesToTraceSelectedTags } from 'hooks/useResourceAttribute/utils';
import { getDashboardVariables } from 'lib/dashbaordVariables/getDashboardVariables';
import { RowData } from 'lib/query/createTableColumnsFromQuery';
import { isEmpty } from 'lodash-es';
import { ReactNode, useMemo, useState } from 'react';
import { ReactNode, useMemo } from 'react';
import { useSelector } from 'react-redux';
import { useParams } from 'react-router-dom';
import { AppState } from 'store/reducers';
@ -18,18 +19,19 @@ import { GlobalReducer } from 'types/reducer/globalTime';
import { v4 as uuid } from 'uuid';
import { IServiceName } from '../types';
import { title } from './config';
import ColumnWithLink from './TableRenderer/ColumnWithLink';
import { getTableColumnRenderer } from './TableRenderer/TableColumnRenderer';
function TopOperationMetrics(): JSX.Element {
const { servicename } = useParams<IServiceName>();
const [errorMessage, setErrorMessage] = useState<string | undefined>('');
const { notifications } = useNotifications();
const { minTime, maxTime, selectedTime: globalSelectedInterval } = useSelector<
AppState,
GlobalReducer
>((state) => state.globalTime);
const { queries } = useResourceAttribute();
const selectedTraceTags = JSON.stringify(
@ -80,7 +82,7 @@ function TopOperationMetrics(): JSX.Element {
enabled: !isEmptyWidget,
refetchOnMount: false,
onError: (error) => {
setErrorMessage(error.message);
notifications.error({ message: error.message });
},
},
);
@ -104,13 +106,8 @@ function TopOperationMetrics(): JSX.Element {
[servicename, minTime, maxTime, selectedTraceTags],
);
if (errorMessage) {
return <div>{errorMessage}</div>;
}
return (
<QueryTable
title={title}
query={updatedQuery}
queryTableData={queryTableData}
loading={isLoading}

View File

@ -36,8 +36,8 @@ export interface BuilderQuerieswithFormulaProps {
legends: string[];
disabled: boolean[];
groupBy?: BaseAutocompleteData[];
expression: string;
legendFormula: string;
expressions: string[];
legendFormulas: string[];
additionalItems: TagFilterItem[][];
aggregateOperators: MetricAggregateOperator[];
dataSource: DataSource;

View File

@ -34,6 +34,7 @@ export enum KeyOperationTableHeader {
P99 = 'P99',
NUM_OF_CALLS = 'Number of Calls',
ERROR_RATE = 'Error Rate',
OPERATION_PR_SECOND = 'Op/s',
}
export enum DataType {

View File

@ -1,4 +1,7 @@
import { Widgets } from 'types/api/dashboard/getAll';
import { TagFilterItem } from 'types/api/queryBuilder/queryBuilderData';
import { IServiceName } from './Tabs/types';
export interface GetWidgetQueryBuilderProps {
query: Widgets['query'];
@ -13,3 +16,12 @@ export interface NavigateToTraceProps {
maxTime: number;
selectedTraceTags: string;
}
export interface DatabaseCallsRPSProps extends DatabaseCallProps {
legend: '{{db_system}}';
}
export interface DatabaseCallProps {
servicename: IServiceName['servicename'];
tagFilterItems: TagFilterItem[];
}

View File

@ -5,7 +5,7 @@ import query from 'api/dashboard/variables/query';
import { commaValuesParser } from 'lib/dashbaordVariables/customCommaValuesParser';
import sortValues from 'lib/dashbaordVariables/sortVariableValues';
import map from 'lodash-es/map';
import { memo, useCallback, useEffect, useState } from 'react';
import { memo, useCallback, useEffect, useMemo, useState } from 'react';
import { IDashboardVariable } from 'types/api/dashboard/getAll';
import { variablePropsToPayloadVariables } from '../utils';
@ -24,6 +24,16 @@ interface VariableItemProps {
onAllSelectedUpdate: (name: string, arg1: boolean) => void;
lastUpdatedVar: string;
}
const getSelectValue = (
selectedValue: IDashboardVariable['selectedValue'],
): string | string[] => {
if (Array.isArray(selectedValue)) {
return selectedValue.map((item) => item.toString());
}
return selectedValue?.toString() || '';
};
function VariableItem({
variableData,
existingVariables,
@ -141,9 +151,15 @@ function VariableItem({
}
};
const { selectedValue } = variableData;
const selectedValueStringified = useMemo(() => getSelectValue(selectedValue), [
selectedValue,
]);
const selectValue = variableData.allSelected
? 'ALL'
: variableData.selectedValue?.toString() || '';
: selectedValueStringified;
const mode =
variableData.multiSelect && !variableData.allSelected
? 'multiple'

View File

@ -0,0 +1,14 @@
import { IconDataSpan } from 'container/PipelinePage/styles';
import { getDeploymentStage, getDeploymentStageIcon } from './utils';
function DeploymentStage(deployStatus: string): JSX.Element {
return (
<>
{getDeploymentStageIcon(deployStatus)}
<IconDataSpan>{getDeploymentStage(deployStatus)}</IconDataSpan>
</>
);
}
export default DeploymentStage;

Some files were not shown because too many files have changed in this diff Show More