diff --git a/agent/collector/collector.go b/agent/collector/collector.go index 7989a728d3..2faecd7af0 100644 --- a/agent/collector/collector.go +++ b/agent/collector/collector.go @@ -9,11 +9,14 @@ import ( "time" "github.com/kubeshop/tracetest/agent/event" + "github.com/kubeshop/tracetest/agent/ui/dashboard/sensors" "github.com/kubeshop/tracetest/server/otlp" "go.opentelemetry.io/otel/trace" "go.uber.org/zap" ) +var activeCollector Collector + type Config struct { HTTPPort int GRPCPort int @@ -48,6 +51,12 @@ func WithObserver(observer event.Observer) CollectorOption { } } +func WithSensor(sensor sensors.Sensor) CollectorOption { + return func(ric *remoteIngesterConfig) { + ric.sensor = sensor + } +} + type collector struct { grpcServer stoppable httpServer stoppable @@ -60,6 +69,8 @@ type Collector interface { Statistics() Statistics ResetStatistics() + + SetSensor(sensors.Sensor) } // Stop implements stoppable. @@ -76,12 +87,21 @@ func (c *collector) ResetStatistics() { c.ingester.ResetStatistics() } +func (c *collector) SetSensor(sensor sensors.Sensor) { + c.ingester.SetSensor(sensor) +} + +func GetActiveCollector() Collector { + return activeCollector +} + func Start(ctx context.Context, config Config, tracer trace.Tracer, opts ...CollectorOption) (Collector, error) { ingesterConfig := remoteIngesterConfig{ URL: config.RemoteServerURL, Token: config.RemoteServerToken, logger: zap.NewNop(), observer: event.NewNopObserver(), + sensor: sensors.NewSensor(), } for _, opt := range opts { @@ -115,7 +135,8 @@ func Start(ctx context.Context, config Config, tracer trace.Tracer, opts ...Coll return nil, fmt.Errorf("could not start HTTP OTLP listener: %w", err) } - return &collector{grpcServer: grpcServer, httpServer: httpServer, ingester: ingester}, nil + activeCollector = &collector{grpcServer: grpcServer, httpServer: httpServer, ingester: ingester} + return activeCollector, nil } func onProcessTermination(callback func()) { diff --git a/agent/collector/ingester.go b/agent/collector/ingester.go index 56b98e8704..5a7e5ca287 100644 --- a/agent/collector/ingester.go +++ b/agent/collector/ingester.go @@ -8,6 +8,8 @@ import ( "time" "github.com/kubeshop/tracetest/agent/event" + "github.com/kubeshop/tracetest/agent/ui/dashboard/events" + "github.com/kubeshop/tracetest/agent/ui/dashboard/sensors" "github.com/kubeshop/tracetest/server/otlp" "go.opencensus.io/trace" pb "go.opentelemetry.io/proto/otlp/collector/trace/v1" @@ -27,6 +29,8 @@ type ingester interface { Statistics() Statistics ResetStatistics() + + SetSensor(sensors.Sensor) } func newForwardIngester(ctx context.Context, batchTimeout time.Duration, cfg remoteIngesterConfig, startRemoteServer bool) (ingester, error) { @@ -34,9 +38,11 @@ func newForwardIngester(ctx context.Context, batchTimeout time.Duration, cfg rem BatchTimeout: batchTimeout, RemoteIngester: cfg, buffer: &buffer{}, + traceIDs: make(map[string]bool, 0), done: make(chan bool), traceCache: cfg.traceCache, logger: cfg.logger, + sensor: cfg.sensor, } if startRemoteServer { @@ -63,9 +69,11 @@ type forwardIngester struct { RemoteIngester remoteIngesterConfig client pb.TraceServiceClient buffer *buffer + traceIDs map[string]bool done chan bool traceCache TraceCache logger *zap.Logger + sensor sensors.Sensor statistics Statistics } @@ -77,6 +85,7 @@ type remoteIngesterConfig struct { startRemoteServer bool logger *zap.Logger observer event.Observer + sensor sensors.Sensor } type buffer struct { @@ -92,6 +101,10 @@ func (i *forwardIngester) ResetStatistics() { i.statistics = Statistics{} } +func (i *forwardIngester) SetSensor(sensor sensors.Sensor) { + i.sensor = sensor +} + func (i *forwardIngester) Ingest(ctx context.Context, request *pb.ExportTraceServiceRequest, requestType otlp.RequestType) (*pb.ExportTraceServiceResponse, error) { spanCount := countSpans(request) i.buffer.mutex.Lock() @@ -100,6 +113,8 @@ func (i *forwardIngester) Ingest(ctx context.Context, request *pb.ExportTraceSer i.statistics.SpanCount += int64(spanCount) i.statistics.LastSpanTimestamp = time.Now() + i.sensor.Emit(events.SpanCountUpdated, i.statistics.SpanCount) + i.buffer.mutex.Unlock() i.logger.Debug("received spans", zap.Int("count", spanCount)) @@ -108,6 +123,7 @@ func (i *forwardIngester) Ingest(ctx context.Context, request *pb.ExportTraceSer // In case of OTLP datastore, those spans will be polled from this cache instead // of a real datastore i.cacheTestSpans(request.ResourceSpans) + i.sensor.Emit(events.TraceCountUpdated, len(i.traceIDs)) } return &pb.ExportTraceServiceResponse{ @@ -208,6 +224,7 @@ func (i *forwardIngester) cacheTestSpans(resourceSpans []*v1.ResourceSpans) { i.logger.Debug("caching test spans", zap.Int("count", len(spans))) for traceID, spans := range spans { + i.traceIDs[traceID] = true if _, ok := i.traceCache.Get(traceID); !ok { i.logger.Debug("traceID is not part of a test", zap.String("traceID", traceID)) // traceID is not part of a test diff --git a/agent/config/config.go b/agent/config/config.go index d6873d3311..751acabe1a 100644 --- a/agent/config/config.go +++ b/agent/config/config.go @@ -4,6 +4,7 @@ import ( "fmt" "os" "path" + "regexp" "strings" "github.com/spf13/viper" @@ -17,6 +18,11 @@ type Config struct { OTLPServer OtlpServer `mapstructure:"otlp_server"` } +func (c Config) APIEndpoint() string { + regex := regexp.MustCompile(":[0-9]+$") + return string(regex.ReplaceAll([]byte(c.ServerURL), []byte(""))) +} + type OtlpServer struct { GRPCPort int `mapstructure:"grpc_port"` HTTPPort int `mapstructure:"http_port"` diff --git a/agent/runner/runner.go b/agent/runner/runner.go index 6b753efd10..b04e60b5de 100644 --- a/agent/runner/runner.go +++ b/agent/runner/runner.go @@ -2,16 +2,20 @@ package runner import ( "context" + "errors" "fmt" "os" + "github.com/golang-jwt/jwt" agentConfig "github.com/kubeshop/tracetest/agent/config" + "github.com/kubeshop/tracetest/agent/event" "github.com/kubeshop/tracetest/agent/ui" "github.com/kubeshop/tracetest/cli/config" "github.com/kubeshop/tracetest/cli/pkg/resourcemanager" "go.uber.org/zap" + "go.uber.org/zap/zapcore" ) type Runner struct { @@ -20,6 +24,8 @@ type Runner struct { ui ui.ConsoleUI mode agentConfig.Mode logger *zap.Logger + loggerLevel *zap.AtomicLevel + claims jwt.MapClaims } func NewRunner(configurator config.Configurator, resources *resourcemanager.Registry, ui ui.ConsoleUI) *Runner { @@ -49,10 +55,21 @@ Once started, Tracetest Agent exposes OTLP ports 4317 and 4318 to ingest traces if enableLogging(flags.LogLevel) { var err error + atom := zap.NewAtomicLevel() logger, err = zap.NewDevelopment() if err != nil { return fmt.Errorf("could not create logger: %w", err) } + + logger = logger.WithOptions(zap.WrapCore(func(c zapcore.Core) zapcore.Core { + return zapcore.NewCore( + zapcore.NewJSONEncoder(zap.NewDevelopmentEncoderConfig()), + zapcore.Lock(os.Stdout), + atom, + ) + })) + + s.loggerLevel = &atom } s.logger = logger @@ -110,3 +127,42 @@ func (s *Runner) StartAgent(ctx context.Context, endpoint, agentApiKey, uiEndpoi func enableLogging(logLevel string) bool { return os.Getenv("TRACETEST_DEV") == "true" && logLevel == "debug" } + +func (s *Runner) authenticate(ctx context.Context, cfg agentConfig.Config, observer event.Observer) (*Session, jwt.MapClaims, error) { + isStarted := false + session := &Session{} + + var err error + + for !isStarted { + session, err = StartSession(ctx, cfg, observer, s.logger) + if err != nil && errors.Is(err, ErrOtlpServerStart) { + s.ui.Error("Tracetest Agent binds to the OpenTelemetry ports 4317 and 4318 which are used to receive trace information from your system. The agent tried to bind to these ports, but failed.") + shouldRetry := s.ui.Enter("Please stop the process currently listening on these ports and press enter to try again.") + + if !shouldRetry { + s.ui.Finish() + return nil, nil, err + } + + continue + } + + if err != nil { + return nil, nil, err + } + + isStarted = true + } + + claims, err := config.GetTokenClaims(session.Token) + if err != nil { + return nil, nil, err + } + s.claims = claims + return session, claims, nil +} + +func (s *Runner) getCurrentSessionClaims() jwt.MapClaims { + return s.claims +} diff --git a/agent/runner/runstrategy_dashboard.go b/agent/runner/runstrategy_dashboard.go new file mode 100644 index 0000000000..dbca28e206 --- /dev/null +++ b/agent/runner/runstrategy_dashboard.go @@ -0,0 +1,148 @@ +package runner + +import ( + "context" + "fmt" + "time" + + "github.com/kubeshop/tracetest/agent/collector" + agentConfig "github.com/kubeshop/tracetest/agent/config" + "github.com/kubeshop/tracetest/agent/event" + "github.com/kubeshop/tracetest/agent/proto" + "github.com/kubeshop/tracetest/agent/ui/dashboard" + "github.com/kubeshop/tracetest/agent/ui/dashboard/events" + "github.com/kubeshop/tracetest/agent/ui/dashboard/models" + "github.com/kubeshop/tracetest/agent/ui/dashboard/sensors" + "github.com/kubeshop/tracetest/server/version" + v1 "go.opentelemetry.io/proto/otlp/trace/v1" + "go.uber.org/zap" +) + +func (s *Runner) RunDashboardStrategy(ctx context.Context, cfg agentConfig.Config, uiEndpoint string, sensor sensors.Sensor) error { + // This prevents the agent logger from printing lots of messages + // and override the dashboard UI. + // By calling enableLogger() at the end of this function, the logger behavior is restored + enableLogger := s.disableLogger() + defer enableLogger() + + if collector := collector.GetActiveCollector(); collector != nil { + collector.SetSensor(sensor) + } + + claims := s.getCurrentSessionClaims() + if claims == nil { + return fmt.Errorf("not authenticated") + } + + // TODO: convert ids into names + return dashboard.StartDashboard(ctx, models.EnvironmentInformation{ + OrganizationID: claims["organization_id"].(string), + EnvironmentID: claims["environment_id"].(string), + AgentVersion: version.Version, + ServerEndpoint: uiEndpoint, + }, sensor) +} + +func (s *Runner) disableLogger() func() { + oldLevel := s.loggerLevel.Level() + s.loggerLevel.SetLevel(zap.PanicLevel) + + return func() { + s.loggerLevel.SetLevel(oldLevel) + } +} + +type dashboardObserver struct { + runs map[string]models.TestRun + sensor sensors.Sensor +} + +func (o *dashboardObserver) EndDataStoreConnection(*proto.DataStoreConnectionTestRequest, error) { + +} + +func (o *dashboardObserver) EndSpanReceive([]*v1.Span, error) { + +} + +func (o *dashboardObserver) EndStopRequest(*proto.StopRequest, error) { + +} + +func (o *dashboardObserver) EndTracePoll(*proto.PollingRequest, error) { + +} + +func (o *dashboardObserver) EndTriggerExecution(*proto.TriggerRequest, error) { + +} + +func (o *dashboardObserver) Error(error) { +} + +func (o *dashboardObserver) StartDataStoreConnection(*proto.DataStoreConnectionTestRequest) { +} + +func (o *dashboardObserver) StartSpanReceive([]*v1.Span) { +} + +func (o *dashboardObserver) StartStopRequest(request *proto.StopRequest) { + model := o.getRun(request.TestID, request.RunID) + model.Status = "Stopped by user" + + o.setRun(model) + o.sensor.Emit(events.UpdatedTestRun, model) +} + +func (o *dashboardObserver) StartTracePoll(request *proto.PollingRequest) { + model := o.getRun(request.TestID, request.RunID) + model.Status = "Awaiting Trace" + + o.setRun(model) + o.sensor.Emit(events.UpdatedTestRun, model) +} + +func (o *dashboardObserver) StartTriggerExecution(request *proto.TriggerRequest) { + model := o.getRun(request.TestID, request.RunID) + model.TestID = request.TestID + model.RunID = fmt.Sprintf("%d", request.RunID) + model.Type = request.Trigger.Type + model.Endpoint = getEndpoint(request) + model.Name = "" + model.Status = "Triggering" + model.Started = time.Now() + + o.setRun(model) + o.sensor.Emit(events.NewTestRun, model) +} + +func (o *dashboardObserver) getRun(testID string, runID int32) models.TestRun { + if model, ok := o.runs[fmt.Sprintf("%s-%d", testID, runID)]; ok { + return model + } + + return models.TestRun{TestID: testID, RunID: fmt.Sprintf("%d", runID)} +} + +func (o *dashboardObserver) setRun(model models.TestRun) { + o.runs[fmt.Sprintf("%s-%s", model.TestID, model.RunID)] = model +} + +func getEndpoint(request *proto.TriggerRequest) string { + switch request.Trigger.Type { + case "http": + return request.Trigger.Http.Url + case "grpc": + return fmt.Sprintf("%s/%s", request.Trigger.Grpc.Address, request.Trigger.Grpc.Service) + case "kafka": + return request.Trigger.Kafka.Topic + case "traceID": + return request.Trigger.TraceID.Id + default: + return "" + } +} + +func newDashboardObserver(sensor sensors.Sensor) event.Observer { + return &dashboardObserver{sensor: sensor, runs: make(map[string]models.TestRun)} +} diff --git a/agent/runner/runstrategy_desktop.go b/agent/runner/runstrategy_desktop.go index 9805850680..be64e6e7c2 100644 --- a/agent/runner/runstrategy_desktop.go +++ b/agent/runner/runstrategy_desktop.go @@ -2,11 +2,10 @@ package runner import ( "context" - "errors" "fmt" agentConfig "github.com/kubeshop/tracetest/agent/config" - "github.com/kubeshop/tracetest/cli/config" + "github.com/kubeshop/tracetest/agent/ui/dashboard/sensors" consoleUI "github.com/kubeshop/tracetest/agent/ui" ) @@ -14,33 +13,9 @@ import ( func (s *Runner) RunDesktopStrategy(ctx context.Context, cfg agentConfig.Config, uiEndpoint string) error { s.ui.Infof("Starting Agent with name %s...", cfg.Name) - isStarted := false - session := &Session{} - - var err error - - for !isStarted { - session, err = StartSession(ctx, cfg, nil, s.logger) - if err != nil && errors.Is(err, ErrOtlpServerStart) { - s.ui.Error("Tracetest Agent binds to the OpenTelemetry ports 4317 and 4318 which are used to receive trace information from your system. The agent tried to bind to these ports, but failed.") - shouldRetry := s.ui.Enter("Please stop the process currently listening on these ports and press enter to try again.") - - if !shouldRetry { - s.ui.Finish() - return err - } - - continue - } - - if err != nil { - return err - } - - isStarted = true - } - - claims, err := config.GetTokenClaims(session.Token) + sensor := sensors.NewSensor() + dashboardObserver := newDashboardObserver(sensor) + session, claims, err := s.authenticate(ctx, cfg, dashboardObserver) if err != nil { return err } @@ -48,19 +23,32 @@ func (s *Runner) RunDesktopStrategy(ctx context.Context, cfg agentConfig.Config, isOpen := true message := `Agent is started! Leave the terminal open so tests can be run and traces gathered from this environment. You can` - options := []consoleUI.Option{{ - Text: "Open Tracetest in a browser to this environment", - Fn: func(_ consoleUI.ConsoleUI) { - s.ui.OpenBrowser(fmt.Sprintf("%sorganizations/%s/environments/%s", uiEndpoint, claims["organization_id"], claims["environment_id"])) + options := []consoleUI.Option{ + { + Text: "Open Tracetest in a browser to this environment", + Fn: func(_ consoleUI.ConsoleUI) { + s.ui.OpenBrowser(fmt.Sprintf("%sorganizations/%s/environments/%s", uiEndpoint, claims["organization_id"], claims["environment_id"])) + }, }, - }, { - Text: "Stop this agent", - Fn: func(_ consoleUI.ConsoleUI) { - isOpen = false - session.Close() - s.ui.Finish() + { + Text: "(Experimental) Open Dashboard", + Fn: func(ui consoleUI.ConsoleUI) { + sensor.Reset() + err := s.RunDashboardStrategy(ctx, cfg, uiEndpoint, sensor) + if err != nil { + fmt.Println(err.Error()) + } + }, }, - }} + { + Text: "Stop this agent", + Fn: func(_ consoleUI.ConsoleUI) { + isOpen = false + session.Close() + s.claims = nil + s.ui.Finish() + }, + }} for isOpen { selected := s.ui.Select(message, options, 0) diff --git a/agent/ui/dashboard/components/header.go b/agent/ui/dashboard/components/header.go new file mode 100644 index 0000000000..85dadd3c3b --- /dev/null +++ b/agent/ui/dashboard/components/header.go @@ -0,0 +1,187 @@ +package components + +import ( + "fmt" + "time" + + "github.com/kubeshop/tracetest/agent/ui/dashboard/events" + "github.com/kubeshop/tracetest/agent/ui/dashboard/models" + "github.com/kubeshop/tracetest/agent/ui/dashboard/sensors" + "github.com/kubeshop/tracetest/agent/ui/dashboard/styles" + "github.com/rivo/tview" +) + +type HeaderData struct { + Context AgentContext + Metrics AgentMetrics + Message BannerMessage +} + +type AgentContext struct { + OrganizationName string + EnvironmentName string + LastUsedTracingBackend string +} + +type BannerMessage struct { + Text string + Type string +} + +type AgentMetrics struct { + Uptime time.Duration + TestRuns int64 + Traces int64 + Spans int64 +} + +type Header struct { + *tview.Flex + + renderScheduler RenderScheduler + sensor sensors.Sensor + data HeaderData + + messageBanner *MessageBanner + + organizationTableCell *tview.TableCell + environmentTableCell *tview.TableCell + agentVersionTableCell *tview.TableCell + + uptimeTableCell *tview.TableCell + runsTableCell *tview.TableCell + tracesTableCell *tview.TableCell + spansTableCell *tview.TableCell +} + +func NewHeader(renderScheduler RenderScheduler, sensor sensors.Sensor) *Header { + h := &Header{ + Flex: tview.NewFlex(), + renderScheduler: renderScheduler, + sensor: sensor, + messageBanner: NewMessageBanner(renderScheduler), + organizationTableCell: tview.NewTableCell("").SetStyle(styles.MetricValueStyle), + environmentTableCell: tview.NewTableCell("").SetStyle(styles.MetricValueStyle), + agentVersionTableCell: tview.NewTableCell("").SetStyle(styles.MetricValueStyle), + uptimeTableCell: tview.NewTableCell("0s").SetStyle(styles.MetricValueStyle), + runsTableCell: tview.NewTableCell("0").SetStyle(styles.MetricValueStyle), + tracesTableCell: tview.NewTableCell("0").SetStyle(styles.MetricValueStyle), + spansTableCell: tview.NewTableCell("0").SetStyle(styles.MetricValueStyle), + } + + h.draw() + + return h +} + +func (h *Header) draw() { + h.Clear() + + // This flex layout represents the two information boxes we see on the interface. They are aligned + // in the Column orientation (take a look at CSS's flex direction). + // Each one fills 50% of the available space. (each one takes `proportion=1` + // and total proporsion of all elements is 2, so 1/2 for each element) + flex := tview.NewFlex() + + flex.SetDirection(tview.FlexColumn). + AddItem(h.getEnvironmentInformationTable(), 0, 1, false). + AddItem(h.getMetricsTable(), 0, 1, false) + + // Then we have this flex for stacking the MessageBanner and the previous flex layout together in a different + // orientation. The banner will be on top of the flex layout. + h.Flex.SetDirection(tview.FlexRow).AddItem(h.messageBanner, 0, 0, false).AddItem(flex, 0, 8, false) + + h.setupSensors() +} + +func (h *Header) onDataChange() { + h.renderScheduler.Render(func() { + h.uptimeTableCell.SetText(h.data.Metrics.Uptime.String()) + h.runsTableCell.SetText(fmt.Sprintf("%d", h.data.Metrics.TestRuns)) + h.tracesTableCell.SetText(fmt.Sprintf("%d", h.data.Metrics.Traces)) + h.spansTableCell.SetText(fmt.Sprintf("%d", h.data.Metrics.Spans)) + }) +} + +func (h *Header) getEnvironmentInformationTable() tview.Primitive { + table := tview.NewTable() + table.SetBackgroundColor(styles.HeaderBackgroundColor) + table.SetBorder(true).SetTitle("Environment").SetTitleColor(styles.HighlighColor) + table.SetCell(0, 0, tview.NewTableCell("Organization: ").SetStyle(styles.MetricNameStyle)) + table.SetCell(0, 1, h.organizationTableCell) + table.SetCell(1, 0, tview.NewTableCell("Environment: ").SetStyle(styles.MetricNameStyle)) + table.SetCell(1, 1, h.environmentTableCell) + table.SetCell(2, 0, tview.NewTableCell("Last Tracing Backend: ").SetStyle(styles.MetricNameStyle)) + table.SetCell(2, 1, tview.NewTableCell("").SetStyle(styles.MetricValueStyle)) + table.SetCell(3, 0, tview.NewTableCell("Version: ").SetStyle(styles.MetricNameStyle)) + table.SetCell(3, 1, h.agentVersionTableCell) + table.SetBorderPadding(1, 1, 2, 1) + + return table +} + +func (h *Header) getMetricsTable() tview.Primitive { + table := tview.NewTable() + table.SetBackgroundColor(styles.HeaderBackgroundColor) + table.SetBorder(true).SetTitle("Tracetest Metrics").SetTitleColor(styles.HighlighColor) + table.SetCell(0, 0, tview.NewTableCell("Uptime: ").SetStyle(styles.MetricNameStyle)) + table.SetCell(0, 1, h.uptimeTableCell) + table.SetCell(1, 0, tview.NewTableCell("Runs: ").SetStyle(styles.MetricNameStyle)) + table.SetCell(1, 1, h.runsTableCell) + table.SetCell(2, 0, tview.NewTableCell("Traces: ").SetStyle(styles.MetricNameStyle)) + table.SetCell(2, 1, h.tracesTableCell) + table.SetCell(3, 0, tview.NewTableCell("Spans: ").SetStyle(styles.MetricNameStyle)) + table.SetCell(3, 1, h.spansTableCell) + table.SetBorderPadding(1, 1, 2, 1) + + return table +} + +func (h *Header) showMessageBanner() { + h.Flex.ResizeItem(h.messageBanner, 0, 4) +} + +func (h *Header) hideMessageBanner() { + h.Flex.ResizeItem(h.messageBanner, 0, 0) +} + +func (h *Header) setupSensors() { + h.sensor.On(events.TimeChanged, func(e sensors.Event) { + var uptime time.Duration + e.Unmarshal(&uptime) + + h.data.Metrics.Uptime = uptime + h.onDataChange() + }) + + h.sensor.On(events.EnvironmentStart, func(e sensors.Event) { + var environment models.EnvironmentInformation + e.Unmarshal(&environment) + + h.environmentTableCell.SetText(environment.EnvironmentID) + h.organizationTableCell.SetText(environment.OrganizationID) + h.agentVersionTableCell.SetText(environment.AgentVersion) + }) + + h.sensor.On(events.SpanCountUpdated, func(e sensors.Event) { + var count int64 + e.Unmarshal(&count) + + h.data.Metrics.Spans = count + h.onDataChange() + }) + + h.sensor.On(events.TraceCountUpdated, func(e sensors.Event) { + var count int + e.Unmarshal(&count) + + h.data.Metrics.Traces = int64(count) + h.onDataChange() + }) + + h.sensor.On(events.NewTestRun, func(e sensors.Event) { + h.data.Metrics.TestRuns++ + + h.onDataChange() + }) +} diff --git a/agent/ui/dashboard/components/message_banner.go b/agent/ui/dashboard/components/message_banner.go new file mode 100644 index 0000000000..e8508c984b --- /dev/null +++ b/agent/ui/dashboard/components/message_banner.go @@ -0,0 +1,43 @@ +package components + +import ( + "github.com/kubeshop/tracetest/agent/ui/dashboard/events" + "github.com/kubeshop/tracetest/agent/ui/dashboard/styles" + "github.com/rivo/tview" +) + +type MessageBanner struct { + *tview.TextView + + renderScheduler RenderScheduler +} + +func NewMessageBanner(renderScheduler RenderScheduler) *MessageBanner { + banner := &MessageBanner{ + TextView: tview.NewTextView(), + renderScheduler: renderScheduler, + } + + banner.TextView.SetBackgroundColor(styles.HeaderBackgroundColor) + banner.TextView.SetMaxLines(5) + banner.TextView.SetTextAlign(tview.AlignCenter).SetTextAlign(tview.AlignCenter) + banner.TextView.SetWrap(true) + banner.TextView.SetWordWrap(true) + banner.SetBorderPadding(1, 0, 0, 0) + banner.SetText("") + + return banner +} + +func (b *MessageBanner) SetMessage(text string, messageType events.MessageType) { + if messageType == events.Warning { + b.SetBackgroundColor(styles.WarningMessageBackgroundColor) + b.SetTextColor(styles.WarningMessageForegroundColor) + } + + if messageType == events.Error { + b.SetBackgroundColor(styles.ErrorMessageBackgroundColor) + b.SetTextColor(styles.ErrorMessageForegroundColor) + } + b.TextView.SetText(text) +} diff --git a/agent/ui/dashboard/components/render_scheduler.go b/agent/ui/dashboard/components/render_scheduler.go new file mode 100644 index 0000000000..8db6ecd74a --- /dev/null +++ b/agent/ui/dashboard/components/render_scheduler.go @@ -0,0 +1,19 @@ +package components + +import "github.com/rivo/tview" + +type RenderScheduler interface { + Render(f func()) +} + +type appRenderScheduler struct { + app *tview.Application +} + +func (s *appRenderScheduler) Render(f func()) { + s.app.QueueUpdateDraw(f) +} + +func NewRenderScheduler(app *tview.Application) RenderScheduler { + return &appRenderScheduler{app: app} +} diff --git a/agent/ui/dashboard/components/test_run_list.go b/agent/ui/dashboard/components/test_run_list.go new file mode 100644 index 0000000000..35bf7c9e50 --- /dev/null +++ b/agent/ui/dashboard/components/test_run_list.go @@ -0,0 +1,92 @@ +package components + +import ( + "strings" + "time" + + "github.com/kubeshop/tracetest/agent/ui/dashboard/events" + "github.com/kubeshop/tracetest/agent/ui/dashboard/models" + "github.com/kubeshop/tracetest/agent/ui/dashboard/sensors" + "github.com/kubeshop/tracetest/agent/ui/dashboard/styles" + "github.com/rivo/tview" +) + +var headers = []string{ + "Name", + "Type", + "Endpoint", + "Status", + "Age", +} + +type TestRunList struct { + *tview.Table + + testRuns []models.TestRun + sensor sensors.Sensor + renderScheduler RenderScheduler +} + +func NewTestRunList(renderScheduler RenderScheduler, sensor sensors.Sensor) *TestRunList { + list := &TestRunList{ + Table: tview.NewTable(), + renderScheduler: renderScheduler, + sensor: sensor, + } + + for i, header := range headers { + header = strings.ToUpper(header) + headerCell := tview.NewTableCell(header).SetStyle(styles.MetricNameStyle).SetExpansion(1).SetAlign(tview.AlignLeft) + list.Table.SetCell(0, i, headerCell) + list.Table.SetFixed(1, len(headers)) + } + + list.SetBorder(true).SetTitleColor(styles.HighlighColor).SetTitle("Test runs").SetBorderPadding(2, 0, 0, 0) + list.SetSelectedStyle(styles.SelectedListItem) + list.renderRuns() + + list.SetSelectable(true, false) + list.Select(0, 0) + list.SetSelectedFunc(func(row, column int) { + // ignore the header which is the first row + if row == 0 { + return + } + + selectedRow := row - 1 + run := list.testRuns[selectedRow] + list.sensor.Emit(events.SelectedTestRun, run) + }) + + list.setupSensors() + + return list +} + +func (l *TestRunList) SetTestRuns(runs []models.TestRun) { + l.testRuns = runs + l.renderScheduler.Render(func() { + l.renderRuns() + }) +} + +func (l *TestRunList) setupSensors() { + l.sensor.On(events.TimeChanged, func(e sensors.Event) { + for i, run := range l.testRuns { + run.When = time.Since(run.Started).Round(time.Second) + l.testRuns[i] = run + } + + l.renderRuns() + }) +} + +func (l *TestRunList) renderRuns() { + for i, run := range l.testRuns { + l.Table.SetCell(i+1, 0, tview.NewTableCell(run.Name).SetStyle(styles.MetricValueStyle).SetExpansion(1).SetAlign(tview.AlignLeft)) + l.Table.SetCell(i+1, 1, tview.NewTableCell(run.Type).SetStyle(styles.MetricValueStyle).SetExpansion(1).SetAlign(tview.AlignLeft)) + l.Table.SetCell(i+1, 2, tview.NewTableCell(run.Endpoint).SetStyle(styles.MetricValueStyle).SetExpansion(1).SetAlign(tview.AlignLeft)) + l.Table.SetCell(i+1, 3, tview.NewTableCell(run.Status).SetStyle(styles.MetricValueStyle).SetExpansion(1).SetAlign(tview.AlignLeft)) + l.Table.SetCell(i+1, 4, tview.NewTableCell(run.When.String()).SetStyle(styles.MetricValueStyle).SetExpansion(1).SetAlign(tview.AlignLeft)) + } +} diff --git a/agent/ui/dashboard/dashboard.go b/agent/ui/dashboard/dashboard.go new file mode 100644 index 0000000000..1701a2bac2 --- /dev/null +++ b/agent/ui/dashboard/dashboard.go @@ -0,0 +1,57 @@ +package dashboard + +import ( + "context" + "fmt" + "time" + + "github.com/gdamore/tcell/v2" + "github.com/kubeshop/tracetest/agent/ui/dashboard/components" + "github.com/kubeshop/tracetest/agent/ui/dashboard/events" + "github.com/kubeshop/tracetest/agent/ui/dashboard/models" + "github.com/kubeshop/tracetest/agent/ui/dashboard/pages" + "github.com/kubeshop/tracetest/agent/ui/dashboard/sensors" + "github.com/kubeshop/tracetest/agent/ui/dashboard/styles" + "github.com/rivo/tview" +) + +type Dashboard struct{} + +func startUptimeCounter(sensor sensors.Sensor) { + ticker := time.NewTicker(time.Second) + start := time.Now() + go func() { + for { + select { + case <-ticker.C: + sensor.Emit(events.TimeChanged, time.Since(start).Round(time.Second)) + } + } + }() +} + +func StartDashboard(ctx context.Context, environment models.EnvironmentInformation, sensor sensors.Sensor) error { + app := tview.NewApplication() + tview.Styles.PrimitiveBackgroundColor = styles.HeaderBackgroundColor + renderScheduler := components.NewRenderScheduler(app) + sensor.Emit(events.EnvironmentStart, environment) + + startUptimeCounter(sensor) + + router := NewRouter() + router.AddAndSwitchToPage("home", pages.NewTestRunPage(renderScheduler, sensor)) + + app.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey { + switch event.Key() { + case tcell.KeyCtrlC, tcell.KeyEsc: + app.Stop() + } + return event + }) + + if err := app.SetRoot(router, true).SetFocus(router).Run(); err != nil { + return fmt.Errorf("failed to start dashboard: %w", err) + } + + return nil +} diff --git a/agent/ui/dashboard/events/errors.go b/agent/ui/dashboard/events/errors.go new file mode 100644 index 0000000000..6f7966dd61 --- /dev/null +++ b/agent/ui/dashboard/events/errors.go @@ -0,0 +1,8 @@ +package events + +type MessageType string + +var ( + Warning MessageType = "warning" + Error MessageType = "error" +) diff --git a/agent/ui/dashboard/events/events.go b/agent/ui/dashboard/events/events.go new file mode 100644 index 0000000000..a4e8942d16 --- /dev/null +++ b/agent/ui/dashboard/events/events.go @@ -0,0 +1,14 @@ +package events + +var ( + TimeChanged = "time_changed" + NewTestRun = "new_test_run" + UpdatedTestRun = "updated_test_run" + + EnvironmentStart = "environment_start" + + SpanCountUpdated = "span_count_updated" + TraceCountUpdated = "trace_count_updated" + + SelectedTestRun = "selected_test_run" +) diff --git a/agent/ui/dashboard/main/main.go b/agent/ui/dashboard/main/main.go new file mode 100644 index 0000000000..aa72ce105d --- /dev/null +++ b/agent/ui/dashboard/main/main.go @@ -0,0 +1,22 @@ +package main + +import ( + "context" + "fmt" + + "github.com/kubeshop/tracetest/agent/ui/dashboard" + "github.com/kubeshop/tracetest/agent/ui/dashboard/models" + "github.com/kubeshop/tracetest/agent/ui/dashboard/sensors" +) + +func main() { + err := dashboard.StartDashboard(context.Background(), models.EnvironmentInformation{ + OrganizationID: "Ana", + EnvironmentID: "Empregada", + AgentVersion: "0.15.5", + }, sensors.NewSensor()) + + if err != nil { + fmt.Println(err.Error()) + } +} diff --git a/agent/ui/dashboard/models/environment.go b/agent/ui/dashboard/models/environment.go new file mode 100644 index 0000000000..8ed7e879a7 --- /dev/null +++ b/agent/ui/dashboard/models/environment.go @@ -0,0 +1,8 @@ +package models + +type EnvironmentInformation struct { + OrganizationID string + EnvironmentID string + AgentVersion string + ServerEndpoint string +} diff --git a/agent/ui/dashboard/models/test_runs.go b/agent/ui/dashboard/models/test_runs.go new file mode 100644 index 0000000000..9664b39f45 --- /dev/null +++ b/agent/ui/dashboard/models/test_runs.go @@ -0,0 +1,14 @@ +package models + +import "time" + +type TestRun struct { + TestID string + RunID string + Name string + Type string + Endpoint string + Status string + Started time.Time + When time.Duration +} diff --git a/agent/ui/dashboard/pages/test_runs_page.go b/agent/ui/dashboard/pages/test_runs_page.go new file mode 100644 index 0000000000..10d019a567 --- /dev/null +++ b/agent/ui/dashboard/pages/test_runs_page.go @@ -0,0 +1,108 @@ +package pages + +import ( + "fmt" + + "github.com/kubeshop/tracetest/agent/ui" + "github.com/kubeshop/tracetest/agent/ui/dashboard/components" + "github.com/kubeshop/tracetest/agent/ui/dashboard/events" + "github.com/kubeshop/tracetest/agent/ui/dashboard/models" + "github.com/kubeshop/tracetest/agent/ui/dashboard/sensors" + "github.com/rivo/tview" +) + +const maxTestRuns = 25 + +type TestRunPage struct { + *tview.Grid + + header *components.Header + testRunList *components.TestRunList + + renderScheduler components.RenderScheduler + testRuns []models.TestRun +} + +func NewTestRunPage(renderScheduler components.RenderScheduler, sensor sensors.Sensor) *TestRunPage { + p := &TestRunPage{ + Grid: tview.NewGrid(), + renderScheduler: renderScheduler, + testRuns: make([]models.TestRun, 0, 30), + } + + p.header = components.NewHeader(renderScheduler, sensor) + p.testRunList = components.NewTestRunList(renderScheduler, sensor) + + p.Grid. + // We gonna use 4 lines (it could be 2, but there's a limitation in tview that only allow + // lines of height 30 or less. So I had to convert the previous line of height 90 to 3 lines of height 30) + SetRows(10, 30, 30, 30). + // 3 rows, two columns of size 30 and the middle column fills the rest of the screen. + SetColumns(30, 0, 30). + + // Header starts at (row,column) (0,0) and fills 1 row and 3 columns + AddItem(p.header, 0, 0, 1, 3, 0, 0, false). + // TestRunList starts at (1,0) and fills 2 rows and 3 columns + AddItem(p.testRunList, 1, 0, 2, 3, 0, 0, true) + + sensor.On(events.NewTestRun, func(e sensors.Event) { + var testRun models.TestRun + err := e.Unmarshal(&testRun) + if err != nil { + fmt.Println(err.Error()) + return + } + + if len(p.testRuns) < maxTestRuns { + p.testRuns = append(p.testRuns, testRun) + } else { + p.testRuns = append(p.testRuns[1:], testRun) + } + + p.testRunList.SetTestRuns(p.testRuns) + }) + + sensor.On(events.UpdatedTestRun, func(e sensors.Event) { + var testRun models.TestRun + err := e.Unmarshal(&testRun) + if err != nil { + fmt.Println(err.Error()) + return + } + + for i, run := range p.testRuns { + if run.TestID == testRun.TestID && run.RunID == testRun.RunID { + p.testRuns[i] = testRun + } + } + + p.testRunList.SetTestRuns(p.testRuns) + }) + + sensor.On(events.EnvironmentStart, func(e sensors.Event) { + var environment models.EnvironmentInformation + e.Unmarshal(&environment) + + sensor.On(events.SelectedTestRun, func(e sensors.Event) { + var run models.TestRun + e.Unmarshal(&run) + + endpoint := fmt.Sprintf( + "%s/organizations/%s/environments/%s/test/%s/run/%s", + environment.ServerEndpoint, + environment.OrganizationID, + environment.EnvironmentID, + run.TestID, + run.RunID, + ) + + ui.DefaultUI.OpenBrowser(endpoint) + }) + }) + + return p +} + +func (p *TestRunPage) Focus(delegate func(p tview.Primitive)) { + delegate(p.testRunList) +} diff --git a/agent/ui/dashboard/router.go b/agent/ui/dashboard/router.go new file mode 100644 index 0000000000..5c78f3d8ee --- /dev/null +++ b/agent/ui/dashboard/router.go @@ -0,0 +1,23 @@ +package dashboard + +import ( + "github.com/rivo/tview" +) + +type Router struct { + *tview.Pages +} + +func NewRouter() *Router { + return &Router{ + Pages: tview.NewPages(), + } +} + +func (r *Router) AddPage(name string, page tview.Primitive) { + r.Pages.AddPage(name, page, true, false) +} + +func (r *Router) AddAndSwitchToPage(name string, page tview.Primitive) { + r.Pages.AddAndSwitchToPage(name, page, true) +} diff --git a/agent/ui/dashboard/sensors/sensor.go b/agent/ui/dashboard/sensors/sensor.go new file mode 100644 index 0000000000..3f46a31c1e --- /dev/null +++ b/agent/ui/dashboard/sensors/sensor.go @@ -0,0 +1,72 @@ +package sensors + +import ( + "fmt" + + "github.com/fluidtruck/deepcopy" +) + +type Sensor interface { + On(string, func(Event)) + Emit(string, interface{}) + Reset() +} + +type Event struct { + Name string + data interface{} +} + +func (e *Event) Unmarshal(target interface{}) error { + err := deepcopy.DeepCopy(e.data, target) + if err != nil { + return fmt.Errorf("could not unmarshal event into target: %w", err) + } + + return nil +} + +type sensor struct { + listeners map[string][]func(Event) + lastEvent map[string]Event +} + +func NewSensor() Sensor { + return &sensor{ + listeners: make(map[string][]func(Event)), + lastEvent: make(map[string]Event), + } +} + +func (r *sensor) Reset() { + r.listeners = make(map[string][]func(Event)) + r.lastEvent = make(map[string]Event) +} + +func (r *sensor) On(eventName string, cb func(Event)) { + var slice []func(Event) + if existingSlice, ok := r.listeners[eventName]; ok { + slice = existingSlice + } else { + slice = make([]func(Event), 0) + } + r.listeners[eventName] = append(slice, cb) + + if event, ok := r.lastEvent[eventName]; ok { + cb(event) + } +} + +func (r *sensor) Emit(eventName string, event interface{}) { + listeners := r.listeners[eventName] + e := Event{ + Name: eventName, + data: event, + } + + r.lastEvent[eventName] = e + + for _, listener := range listeners { + listener(e) + } +} diff --git a/agent/ui/dashboard/styles/styles.go b/agent/ui/dashboard/styles/styles.go new file mode 100644 index 0000000000..f335a27af5 --- /dev/null +++ b/agent/ui/dashboard/styles/styles.go @@ -0,0 +1,30 @@ +package styles + +import "github.com/gdamore/tcell/v2" + +var ( + HeaderBackgroundColor = tcell.NewRGBColor(18, 18, 18) + HeaderLogoColor = tcell.NewRGBColor(253, 166, 34) + + ErrorMessageBackgroundColor = tcell.NewRGBColor(102, 0, 0) + ErrorMessageForegroundColor = tcell.NewRGBColor(255, 255, 255) + WarningMessageBackgroundColor = tcell.NewRGBColor(227, 149, 30) + WarningMessageForegroundColor = tcell.NewRGBColor(0, 0, 0) + + TableSelectionColor = tcell.NewRGBColor(0, 0, 255) + + HighlighColor = tcell.NewRGBColor(253, 166, 34) + + MetricNameStyle = tcell.Style{}. + Foreground(HighlighColor). + Bold(true) + + MetricValueStyle = tcell.Style{}. + Foreground(tcell.NewRGBColor(255, 255, 255)). + Bold(true) + + SelectedListItem = tcell.Style{}. + Foreground(tcell.NewRGBColor(255, 255, 255)). + Background(tcell.NewRGBColor(114, 159, 207)). + Bold(true) +) diff --git a/agent/workers/trigger.go b/agent/workers/trigger.go index 7496442406..7ef4d2236b 100644 --- a/agent/workers/trigger.go +++ b/agent/workers/trigger.go @@ -8,6 +8,7 @@ import ( "github.com/kubeshop/tracetest/agent/collector" "github.com/kubeshop/tracetest/agent/event" "github.com/kubeshop/tracetest/agent/proto" + "github.com/kubeshop/tracetest/agent/ui/dashboard/sensors" agentTrigger "github.com/kubeshop/tracetest/agent/workers/trigger" "github.com/kubeshop/tracetest/server/executor" "github.com/kubeshop/tracetest/server/pkg/id" @@ -23,6 +24,7 @@ type TriggerWorker struct { registry *agentTrigger.Registry traceCache collector.TraceCache observer event.Observer + sensor sensors.Sensor stoppableProcessRunner StoppableProcessRunner } @@ -46,6 +48,12 @@ func WithTriggerObserver(observer event.Observer) TriggerOption { } } +func WithSensor(sensor sensors.Sensor) TriggerOption { + return func(tw *TriggerWorker) { + tw.sensor = sensor + } +} + func NewTriggerWorker(client *client.Client, opts ...TriggerOption) *TriggerWorker { // TODO: use a real tracer tracer := trace.NewNoopTracerProvider().Tracer("noop") diff --git a/go.mod b/go.mod index eacdc7a30a..e481c05224 100644 --- a/go.mod +++ b/go.mod @@ -112,6 +112,8 @@ require ( github.com/elastic/elastic-transport-go/v8 v8.0.0-20211216131617-bbee439d559c // indirect github.com/fatih/color v1.13.0 // indirect github.com/felixge/httpsnoop v1.0.2 // indirect + github.com/gdamore/encoding v1.0.0 // indirect + github.com/gdamore/tcell/v2 v2.7.0 // indirect github.com/go-logr/logr v1.2.4 // indirect github.com/go-logr/stdr v1.2.2 // indirect github.com/go-redis/redis/v7 v7.4.1 // indirect @@ -140,6 +142,7 @@ require ( github.com/kylelemons/godebug v1.1.0 // indirect github.com/lib/pq v1.10.5 // indirect github.com/lithammer/fuzzysearch v1.1.8 // indirect + github.com/lucasb-eyer/go-colorful v1.2.0 // indirect github.com/magiconair/properties v1.8.7 // indirect github.com/mattn/go-colorable v0.1.12 // indirect github.com/mattn/go-isatty v0.0.14 // indirect @@ -159,6 +162,7 @@ require ( github.com/pkg/browser v0.0.0-20210911075715-681adbf594b8 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect github.com/rcrowley/go-metrics v0.0.0-20201227073835-cf1acfcdf475 // indirect + github.com/rivo/tview v0.0.0-20240122063236-8526c9fe1b54 // indirect github.com/rivo/uniseg v0.4.4 // indirect github.com/russross/blackfriday/v2 v2.1.0 // indirect github.com/segmentio/backo-go v1.0.0 // indirect diff --git a/go.sum b/go.sum index 66419fb86f..890bb6d9ef 100644 --- a/go.sum +++ b/go.sum @@ -631,6 +631,12 @@ github.com/fullstorydev/grpcurl v1.8.6/go.mod h1:WhP7fRQdhxz2TkL97u+TCb505sxfH78 github.com/gabriel-vasile/mimetype v1.3.1/go.mod h1:fA8fi6KUiG7MgQQ+mEWotXoEOvmxRtOJlERCzSmRvr8= github.com/gabriel-vasile/mimetype v1.4.0/go.mod h1:fA8fi6KUiG7MgQQ+mEWotXoEOvmxRtOJlERCzSmRvr8= github.com/garyburd/redigo v0.0.0-20150301180006-535138d7bcd7/go.mod h1:NR3MbYisc3/PwhQ00EMzDiPmrwpPxAn5GI05/YaO1SY= +github.com/gdamore/encoding v1.0.0 h1:+7OoQ1Bc6eTm5niUzBa0Ctsh6JbMW6Ra+YNuAtDBdko= +github.com/gdamore/encoding v1.0.0/go.mod h1:alR0ol34c49FCSBLjhosxzcPHQbf2trDkoo5dl+VrEg= +github.com/gdamore/tcell/v2 v2.6.1-0.20231203215052-2917c3801e73 h1:SeDV6ZUSVlTAUUPdMzPXgMyj96z+whQJRRUff8dIeic= +github.com/gdamore/tcell/v2 v2.6.1-0.20231203215052-2917c3801e73/go.mod h1:pwzJMyH4Hd0AZMJkWQ+/g01dDvYWEvmJuaiRU71Xl8k= +github.com/gdamore/tcell/v2 v2.7.0 h1:I5LiGTQuwrysAt1KS9wg1yFfOI3arI3ucFrxtd/xqaA= +github.com/gdamore/tcell/v2 v2.7.0/go.mod h1:hl/KtAANGBecfIPxk+FzKvThTqI84oplgbPEmVX60b8= github.com/getkin/kin-openapi v0.53.0/go.mod h1:7Yn5whZr5kJi6t+kShccXS8ae1APpYTW6yheSwk8Yi4= github.com/getsentry/raven-go v0.2.0/go.mod h1:KungGk8q33+aIAZUIVWZDr2OfAEBsO49PX4NzFV5kcQ= github.com/ghodss/yaml v0.0.0-20150909031657-73d445a93680/go.mod h1:4dBDuWmgqj2HViK6kFavaiC9ZROes6MMH2rRYeMEF04= @@ -1290,6 +1296,8 @@ github.com/linode/linodego v1.2.1/go.mod h1:x/7+BoaKd4unViBmS2umdjYyVAmpFtBtEXZ0 github.com/linuxkit/virtsock v0.0.0-20201010232012-f8cee7dfc7a3/go.mod h1:3r6x7q95whyfWQpmGZTu3gk3v2YkMi05HEzl7Tf7YEo= github.com/lithammer/fuzzysearch v1.1.8 h1:/HIuJnjHuXS8bKaiTMeeDlW2/AyIWk2brx1V8LFgLN4= github.com/lithammer/fuzzysearch v1.1.8/go.mod h1:IdqeyBClc3FFqSzYq/MXESsS4S0FsZ5ajtkr5xPLts4= +github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY= +github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= github.com/lyft/protoc-gen-star v0.5.3/go.mod h1:V0xaHgaf5oCCqmcxYcWiDfTiKsZsRc87/1qhoTACD8w= github.com/lyft/protoc-gen-validate v0.0.13/go.mod h1:XbGvPuh87YZc5TdIa2/I4pLk0QoUACkjt2znoq26NVQ= github.com/magiconair/properties v1.8.0/go.mod h1:PppfXfuXeibc/6YijjN8zIbojt8czPbwD3XqdrwzmxQ= @@ -1337,6 +1345,7 @@ github.com/mattn/go-runewidth v0.0.2/go.mod h1:LwmH8dsx7+W8Uxz3IHJYH5QSwggIsqBzp github.com/mattn/go-runewidth v0.0.3/go.mod h1:LwmH8dsx7+W8Uxz3IHJYH5QSwggIsqBzpuz5H//U1FU= github.com/mattn/go-runewidth v0.0.12/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk= github.com/mattn/go-runewidth v0.0.13/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= +github.com/mattn/go-runewidth v0.0.14/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= github.com/mattn/go-runewidth v0.0.15 h1:UNAjwbU9l54TA3KzvqLGxwWjHmMgBUVhBiTjelZgg3U= github.com/mattn/go-runewidth v0.0.15/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= github.com/mattn/go-shellwords v1.0.3/go.mod h1:3xCvwCdWdlDJUrvuMn7Wuy9eWs4pE8vqg+NOMyg4B2o= @@ -1644,8 +1653,11 @@ github.com/remyoudompheng/bigfft v0.0.0-20190728182440-6a916e37a237/go.mod h1:qq github.com/remyoudompheng/bigfft v0.0.0-20200410134404-eec4a21b6bb0/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= github.com/retailnext/hllpp v1.0.1-0.20180308014038-101a6d2f8b52/go.mod h1:RDpi1RftBQPUCDRw6SmxeaREsAaRKnOclghuzp/WRzc= github.com/rhnvrm/simples3 v0.6.1/go.mod h1:Y+3vYm2V7Y4VijFoJHHTrja6OgPrJ2cBti8dPGkC3sA= +github.com/rivo/tview v0.0.0-20240122063236-8526c9fe1b54 h1:O2sPgzemzBPoeLuVrIyyNPwFxWqgh/AuAOfd65OIqMc= +github.com/rivo/tview v0.0.0-20240122063236-8526c9fe1b54/go.mod h1:c0SPlNPXkM+/Zgjn/0vD3W0Ds1yxstN7lpquqLDpWCg= github.com/rivo/uniseg v0.1.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= +github.com/rivo/uniseg v0.4.3/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= github.com/rivo/uniseg v0.4.4 h1:8TfxU8dW6PdqD27gjM8MVNuicgxIjxpm4K7x4jp8sis= github.com/rivo/uniseg v0.4.4/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= github.com/rogpeppe/fastuuid v0.0.0-20150106093220-6724a57986af/go.mod h1:XWv6SoW27p1b0cqNHllgS5HIMJraePCO15w5zCzIWYg= @@ -2396,6 +2408,8 @@ golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBc golang.org/x/sys v0.0.0-20220908164124-27713097b956/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.9.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.11.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.15.0 h1:h48lPFYpsTvQJZF4EKyI4aLHaev3CxivZmv7yZig9pc= golang.org/x/sys v0.15.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/term v0.0.0-20201117132131-f5c789dd3221/go.mod h1:Nr5EML6q2oocZ2LXRh80K7BxOlk5/8JxuGnuhpl+muw= @@ -2405,6 +2419,7 @@ golang.org/x/term v0.0.0-20210615171337-6886f2dfbf5b/go.mod h1:jbD1KX2456YbFQfuX golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= golang.org/x/term v0.1.0/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= +golang.org/x/term v0.9.0/go.mod h1:M6DEAAIenWoTxdKrOltXcmDY3rSplQUkrvaDU5FcQyo= golang.org/x/term v0.15.0 h1:y/Oo/a/q3IXu26lQgl04j/gjuBDOBlx7X6Om1j2CPW4= golang.org/x/term v0.15.0/go.mod h1:BDl952bC7+uMoWR75FIrCDx79TPU9oHkTZ9yRbYOrX0= golang.org/x/text v0.0.0-20160726164857-2910a502d2bf/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= @@ -2421,6 +2436,7 @@ golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= golang.org/x/text v0.4.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= +golang.org/x/text v0.12.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE= golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ= golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= golang.org/x/time v0.0.0-20180412165947-fbb02b2291d2/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= diff --git a/web/src/components/RunDetailTrace/Visualization.tsx b/web/src/components/RunDetailTrace/Visualization.tsx index 00b889975c..67f8efa7c6 100644 --- a/web/src/components/RunDetailTrace/Visualization.tsx +++ b/web/src/components/RunDetailTrace/Visualization.tsx @@ -9,7 +9,7 @@ import TraceSelectors from 'selectors/Trace.selectors'; import TestRunService from 'services/TestRun.service'; import Trace from 'models/Trace.model'; import {TTestRunState} from 'types/TestRun.types'; -import TimelineV2 from 'components/Visualization/components/Timeline/Timeline'; +import Timeline from 'components/Visualization/components/Timeline'; import {VisualizationType} from './RunDetailTrace'; import TraceDAG from './TraceDAG'; @@ -58,7 +58,7 @@ const Visualization = ({isDAGDisabled, runEvents, runState, trace, trace: {spans onNavigateToSpan={onNavigateToSpan} /> ) : ( - 1 - viewEnd ? 'left' : 'right'; +} + +interface IProps extends IPropsComponent { + span: Span; +} + +const BaseSpanNode = ({index, node, span, style}: IProps) => { + const {collapsedSpans, getScale, matchedSpans, onSpanCollapse, onSpanClick, selectedSpan} = useTimeline(); + const {start: viewStart, end: viewEnd} = getScale(span.startTime, span.endTime); + const hintSide = getHintSide(viewStart, viewEnd); + const isSelected = selectedSpan === node.data.id; + const isMatched = matchedSpans.includes(node.data.id); + const isCollapsed = collapsedSpans.includes(node.data.id); + + return ( +
+ onSpanClick(node.data.id)} + $isEven={index % 2 === 0} + $isMatched={isMatched} + $isSelected={isSelected} + > + + + + + {span.name} + + + + + + + + {span.duration} + + + +
+ ); +}; + +export default BaseSpanNode; diff --git a/web/src/components/Visualization/components/Timeline/BaseSpanNode/ConnectorV2.tsx b/web/src/components/Visualization/components/Timeline/BaseSpanNode/ConnectorV2.tsx new file mode 100644 index 0000000000..cb332a3f84 --- /dev/null +++ b/web/src/components/Visualization/components/Timeline/BaseSpanNode/ConnectorV2.tsx @@ -0,0 +1,56 @@ +import {BaseLeftPaddingV2} from 'constants/Timeline.constants'; +import * as S from '../TimelineV2.styled'; + +interface IProps { + hasParent: boolean; + id: string; + isCollapsed: boolean; + nodeDepth: number; + onCollapse(id: string): void; + totalChildren: number; +} + +const Connector = ({hasParent, id, isCollapsed, nodeDepth, onCollapse, totalChildren}: IProps) => { + const leftPadding = nodeDepth * BaseLeftPaddingV2; + + return ( + + {hasParent && ( + <> + + + + )} + + {totalChildren > 0 ? ( + <> + {!isCollapsed && } + + + {totalChildren} + + { + event.stopPropagation(); + onCollapse(id); + }} + /> + + ) : ( + + )} + + {new Array(nodeDepth).fill(0).map((_, index) => { + return ; + })} + + ); +}; + +export default Connector; diff --git a/web/src/components/Visualization/components/Timeline/SpanNodeFactoryV2.tsx b/web/src/components/Visualization/components/Timeline/SpanNodeFactoryV2.tsx new file mode 100644 index 0000000000..9e6aed970c --- /dev/null +++ b/web/src/components/Visualization/components/Timeline/SpanNodeFactoryV2.tsx @@ -0,0 +1,30 @@ +import {NodeTypesEnum} from 'constants/Visualization.constants'; +import {TNode} from 'types/Timeline.types'; +// import TestSpanNode from './TestSpanNode/TestSpanNode'; +import TraceSpanNode from './TraceSpanNode/TraceSpanNodeV2'; + +export interface IPropsComponent { + index: number; + node: TNode; + style: React.CSSProperties; +} + +const ComponentMap: Record React.ReactElement> = { + [NodeTypesEnum.TestSpan]: TraceSpanNode, + [NodeTypesEnum.TraceSpan]: TraceSpanNode, +}; + +interface IProps { + data: TNode[]; + index: number; + style: React.CSSProperties; +} + +const SpanNodeFactory = ({data, ...props}: IProps) => { + const node = data[props.index]; + const Component = ComponentMap[node.type]; + + return ; +}; + +export default SpanNodeFactory; diff --git a/web/src/components/Visualization/components/Timeline/TimelineV2.styled.ts b/web/src/components/Visualization/components/Timeline/TimelineV2.styled.ts new file mode 100644 index 0000000000..827d7b88a3 --- /dev/null +++ b/web/src/components/Visualization/components/Timeline/TimelineV2.styled.ts @@ -0,0 +1,150 @@ +import {Typography} from 'antd'; +import {SemanticGroupNames, SemanticGroupNamesToColor} from 'constants/SemanticGroupNames.constants'; +import styled, {css} from 'styled-components'; + +export const Container = styled.div` + padding: 50px 24px 0 24px; +`; + +export const Row = styled.div<{$isEven: boolean; $isMatched: boolean; $isSelected: boolean}>` + background-color: ${({theme, $isEven}) => ($isEven ? theme.color.background : theme.color.white)}; + display: grid; + grid-template-columns: 300px 1fr; + grid-template-rows: 32px; + padding: 0px 16px; + + :hover { + background-color: ${({theme}) => theme.color.backgroundInteractive}; + } + + ${({$isMatched}) => + $isMatched && + css` + background-color: ${({theme}) => theme.color.alertYellow}; + `}; + + ${({$isSelected}) => + $isSelected && + css` + background: rgba(97, 23, 94, 0.1); + + :hover { + background: rgba(97, 23, 94, 0.1); + } + `}; +`; + +export const Col = styled.div` + display: grid; + grid-template-columns: 1fr 8px; +`; + +export const ColDuration = styled.div` + overflow: hidden; + position: relative; +`; + +export const Header = styled.div` + align-items: center; + display: flex; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +`; + +export const NameContainer = styled.div` + overflow: hidden; + text-overflow: ellipsis; +`; + +export const Separator = styled.div` + border-left: 1px solid rgb(222, 227, 236); + cursor: ew-resize; + height: 32px; + padding: 0px 3px; + width: 1px; +`; + +export const Title = styled(Typography.Text)` + color: ${({theme}) => theme.color.text}; + font-size: ${({theme}) => theme.size.sm}; + font-weight: 400; +`; + +export const Connector = styled.svg` + flex-shrink: 0; + overflow: hidden; + overflow-clip-margin: content-box; +`; + +export const SpanBar = styled.div<{$type: SemanticGroupNames}>` + background-color: ${({$type}) => SemanticGroupNamesToColor[$type]}; + border-radius: 3px; + height: 18px; + min-width: 2px; + position: absolute; + top: 7px; +`; + +export const SpanBarLabel = styled.div<{$side: 'left' | 'right'}>` + color: ${({theme}) => theme.color.textSecondary}; + font-size: ${({theme}) => theme.size.xs}; + padding: 1px 4px 0 4px; + position: absolute; + + ${({$side}) => + $side === 'left' + ? css` + right: 100%; + ` + : css` + left: 100%; + `}; +`; + +export const TextConnector = styled.text<{$isActive?: boolean}>` + fill: ${({theme, $isActive}) => ($isActive ? theme.color.white : theme.color.text)}; + font-size: ${({theme}) => theme.size.xs}; +`; + +export const CircleDot = styled.circle` + fill: ${({theme}) => theme.color.textSecondary}; + stroke-width: 2; + stroke: ${({theme}) => theme.color.white}; +`; + +export const LineBase = styled.line` + stroke: ${({theme}) => theme.color.borderLight}; +`; + +export const RectBase = styled.rect<{$isActive?: boolean}>` + fill: ${({theme, $isActive}) => ($isActive ? theme.color.primary : theme.color.white)}; + stroke: ${({theme}) => theme.color.textSecondary}; +`; + +export const RectBaseTransparent = styled(RectBase)` + cursor: pointer; + fill: transparent; +`; + +export const HeaderRow = styled.div` + background-color: ${({theme}) => theme.color.white}; + display: grid; + grid-template-columns: 300px 1fr; + grid-template-rows: 32px; + padding: 0px 16px; +`; + +export const HeaderContent = styled.div` + align-items: center; + display: flex; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +`; + +export const HeaderTitle = styled(Typography.Title)` + && { + margin: 0; + } +`; diff --git a/web/src/components/Visualization/components/Timeline/TimelineV2.tsx b/web/src/components/Visualization/components/Timeline/TimelineV2.tsx new file mode 100644 index 0000000000..b3c72c9078 --- /dev/null +++ b/web/src/components/Visualization/components/Timeline/TimelineV2.tsx @@ -0,0 +1,37 @@ +import {NodeTypesEnum} from 'constants/Visualization.constants'; +import Span from 'models/Span.model'; +import {useRef} from 'react'; +import {FixedSizeList as List} from 'react-window'; +import NavigationWrapper from './NavigationWrapper'; +import TimelineProvider from './Timeline.provider'; +import ListWrapper from './ListWrapper'; + +export interface IProps { + nodeType: NodeTypesEnum; + spans: Span[]; + onNavigate(spanId: string): void; + onClick(spanId: string): void; + matchedSpans: string[]; + selectedSpan: string; +} + +const Timeline = ({nodeType, spans, onClick, onNavigate, matchedSpans, selectedSpan}: IProps) => { + const listRef = useRef(null); + + return ( + + + + + ); +}; + +export default Timeline; diff --git a/web/src/components/Visualization/components/Timeline/TraceSpanNode/TraceSpanNodeV2.tsx b/web/src/components/Visualization/components/Timeline/TraceSpanNode/TraceSpanNodeV2.tsx new file mode 100644 index 0000000000..539eda5ae2 --- /dev/null +++ b/web/src/components/Visualization/components/Timeline/TraceSpanNode/TraceSpanNodeV2.tsx @@ -0,0 +1,19 @@ +import useSpanData from 'hooks/useSpanData'; +// import Header from './Header'; +import BaseSpanNode from '../BaseSpanNode/BaseSpanNodeV2'; +import {IPropsComponent} from '../SpanNodeFactoryV2'; + +const TraceSpanNode = (props: IPropsComponent) => { + const {node} = props; + const {span} = useSpanData(node.data.id); + + return ( + } + span={span} + /> + ); +}; + +export default TraceSpanNode;